feat(admin): ingest аудита из apps и users 1:1 с Sheet журнала
Добавлен POST /api/v1/ingest/audit, фильтры source_app/user_id, last_login_at; таблица пользователей по solution-users-1 с журналом в Sheet. Co-authored-by: Cursor <[email protected]>
This commit is contained in:
@@ -28,6 +28,7 @@ import type { AppConfig } from './config.js'
|
|||||||
import { authRoutes } from './routes/auth.js'
|
import { authRoutes } from './routes/auth.js'
|
||||||
import { adminRoutes } from './routes/admin.js'
|
import { adminRoutes } from './routes/admin.js'
|
||||||
import { auditAdminRoutes } from './routes/audit.js'
|
import { auditAdminRoutes } from './routes/audit.js'
|
||||||
|
import { auditIngestRoutes } from './routes/ingest-audit.js'
|
||||||
import { startAuditRetentionJob } from './services/audit-retention.js'
|
import { startAuditRetentionJob } from './services/audit-retention.js'
|
||||||
|
|
||||||
declare module 'fastify' {
|
declare module 'fastify' {
|
||||||
@@ -119,6 +120,7 @@ export async function buildApp(opts: {
|
|||||||
await app.register(authRoutes)
|
await app.register(authRoutes)
|
||||||
await app.register(adminRoutes)
|
await app.register(adminRoutes)
|
||||||
await app.register(auditAdminRoutes)
|
await app.register(auditAdminRoutes)
|
||||||
|
await app.register(auditIngestRoutes)
|
||||||
|
|
||||||
if (process.env.NODE_ENV !== 'test') {
|
if (process.env.NODE_ENV !== 'test') {
|
||||||
const stopRetention = startAuditRetentionJob(app)
|
const stopRetention = startAuditRetentionJob(app)
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ export const configSchema = z.object({
|
|||||||
staticDir: z.string().optional(),
|
staticDir: z.string().optional(),
|
||||||
logLevel: z.string().default('info'),
|
logLevel: z.string().default('info'),
|
||||||
isProd: z.boolean(),
|
isProd: z.boolean(),
|
||||||
|
auditIngestSecret: z.string().min(8).optional(),
|
||||||
})
|
})
|
||||||
|
|
||||||
export type AppConfig = z.infer<typeof configSchema>
|
export type AppConfig = z.infer<typeof configSchema>
|
||||||
@@ -26,6 +27,9 @@ export type AppConfig = z.infer<typeof configSchema>
|
|||||||
export function loadConfig(env: NodeJS.ProcessEnv = process.env): AppConfig {
|
export function loadConfig(env: NodeJS.ProcessEnv = process.env): AppConfig {
|
||||||
const isProd = env.NODE_ENV === 'production'
|
const isProd = env.NODE_ENV === 'production'
|
||||||
const jwtSecret = env.JWT_SECRET ?? (isProd ? '' : 'dev-secret-change-me')
|
const jwtSecret = env.JWT_SECRET ?? (isProd ? '' : 'dev-secret-change-me')
|
||||||
|
const auditIngestSecret =
|
||||||
|
env.AUDIT_INGEST_SECRET ??
|
||||||
|
(isProd ? undefined : 'dev-audit-ingest-secret')
|
||||||
|
|
||||||
return configSchema.parse({
|
return configSchema.parse({
|
||||||
databaseUrl: env.DATABASE_URL ?? 'sqlite:data/app.db',
|
databaseUrl: env.DATABASE_URL ?? 'sqlite:data/app.db',
|
||||||
@@ -41,6 +45,9 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env): AppConfig {
|
|||||||
serverPort: env.SERVER_PORT ?? 8080,
|
serverPort: env.SERVER_PORT ?? 8080,
|
||||||
staticDir: env.STATIC_DIR || undefined,
|
staticDir: env.STATIC_DIR || undefined,
|
||||||
logLevel: env.LOG_LEVEL ?? 'info',
|
logLevel: env.LOG_LEVEL ?? 'info',
|
||||||
isProd: boolFromEnv(env.NODE_ENV === 'production' ? 'true' : undefined, false) || isProd,
|
isProd:
|
||||||
|
boolFromEnv(env.NODE_ENV === 'production' ? 'true' : undefined, false) ||
|
||||||
|
isProd,
|
||||||
|
auditIngestSecret,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ function mapUser(
|
|||||||
disabled: user.disabled,
|
disabled: user.disabled,
|
||||||
apps,
|
apps,
|
||||||
permissions,
|
permissions,
|
||||||
|
last_login_at: user.lastLoginAt ?? null,
|
||||||
created_at: user.createdAt,
|
created_at: user.createdAt,
|
||||||
updated_at: user.updatedAt,
|
updated_at: user.updatedAt,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,7 +25,14 @@ export async function auditAdminRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
error: { code: 'VALIDATION_ERROR', message: 'Некорректные параметры' },
|
error: { code: 'VALIDATION_ERROR', message: 'Некорректные параметры' },
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
return listAudit(app.db, parsed.data)
|
const q = parsed.data
|
||||||
|
return listAudit(app.db, {
|
||||||
|
action: q.action,
|
||||||
|
severity: q.severity,
|
||||||
|
userId: q.user_id,
|
||||||
|
sourceApp: q.source_app,
|
||||||
|
limit: q.limit,
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
app.get('/api/v1/admin/audit/settings', async () => ({
|
app.get('/api/v1/admin/audit/settings', async () => ({
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
getUserById,
|
getUserById,
|
||||||
listUsers,
|
listUsers,
|
||||||
revokeRefreshSession,
|
revokeRefreshSession,
|
||||||
|
touchLastLogin,
|
||||||
} from '@authportal/db'
|
} from '@authportal/db'
|
||||||
import { requireAuth } from '../plugins/auth-guards.js'
|
import { requireAuth } from '../plugins/auth-guards.js'
|
||||||
import { issueAccessToken } from '../lib/issue-access-token.js'
|
import { issueAccessToken } from '../lib/issue-access-token.js'
|
||||||
@@ -82,6 +83,7 @@ export async function authRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
Date.now() + app.config.refreshTtlDays * 24 * 60 * 60 * 1000,
|
Date.now() + app.config.refreshTtlDays * 24 * 60 * 60 * 1000,
|
||||||
)
|
)
|
||||||
createRefreshSession(app.db, user.id, refreshRaw, refreshExpires)
|
createRefreshSession(app.db, user.id, refreshRaw, refreshExpires)
|
||||||
|
touchLastLogin(app.db, user.id)
|
||||||
|
|
||||||
reply.header(
|
reply.header(
|
||||||
'Set-Cookie',
|
'Set-Cookie',
|
||||||
|
|||||||
@@ -0,0 +1,77 @@
|
|||||||
|
import type { FastifyInstance } from 'fastify'
|
||||||
|
import { appendAudit } from '@authportal/db'
|
||||||
|
import {
|
||||||
|
ingestAuditRequestSchema,
|
||||||
|
type AuditSourceApp,
|
||||||
|
} from '@authportal/shared'
|
||||||
|
import { timingSafeEqual } from 'node:crypto'
|
||||||
|
|
||||||
|
function secretsEqual(a: string, b: string): boolean {
|
||||||
|
const ba = Buffer.from(a)
|
||||||
|
const bb = Buffer.from(b)
|
||||||
|
if (ba.length !== bb.length) return false
|
||||||
|
return timingSafeEqual(ba, bb)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function auditIngestRoutes(app: FastifyInstance): Promise<void> {
|
||||||
|
app.post(
|
||||||
|
'/api/v1/ingest/audit',
|
||||||
|
{
|
||||||
|
config: { rateLimit: { max: 120, timeWindow: '1 minute' } },
|
||||||
|
},
|
||||||
|
async (request, reply) => {
|
||||||
|
const secret = app.config.auditIngestSecret
|
||||||
|
if (!secret) {
|
||||||
|
return reply.status(503).send({
|
||||||
|
error: {
|
||||||
|
code: 'UNAVAILABLE',
|
||||||
|
message: 'Audit ingest не настроен',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const auth = request.headers.authorization ?? ''
|
||||||
|
const token = auth.startsWith('Bearer ') ? auth.slice(7).trim() : ''
|
||||||
|
if (!token || !secretsEqual(token, secret)) {
|
||||||
|
return reply.status(401).send({
|
||||||
|
error: { code: 'UNAUTHORIZED', message: 'Неверный ingest secret' },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = ingestAuditRequestSchema.safeParse(request.body)
|
||||||
|
if (!parsed.success) {
|
||||||
|
return reply.status(400).send({
|
||||||
|
error: { code: 'VALIDATION_ERROR', message: 'Некорректные данные' },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
let accepted = 0
|
||||||
|
let duplicates = 0
|
||||||
|
for (const event of parsed.data.events) {
|
||||||
|
try {
|
||||||
|
const inserted = appendAudit(app.db, {
|
||||||
|
eventId: event.event_id,
|
||||||
|
sourceApp: event.source_app as AuditSourceApp,
|
||||||
|
action: event.action,
|
||||||
|
severity: event.severity,
|
||||||
|
actorUserId: event.actor_user_id,
|
||||||
|
actorEmail: event.actor_email,
|
||||||
|
actorName: event.actor_name,
|
||||||
|
targetType: event.target_type,
|
||||||
|
targetId: event.target_id,
|
||||||
|
summary: event.summary,
|
||||||
|
details: event.details,
|
||||||
|
ip: event.ip,
|
||||||
|
createdAt: event.created_at,
|
||||||
|
})
|
||||||
|
if (inserted) accepted += 1
|
||||||
|
else duplicates += 1
|
||||||
|
} catch (err) {
|
||||||
|
app.log.warn({ err, event_id: event.event_id }, 'ingest append failed')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { accepted, duplicates }
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -11,6 +11,7 @@ async function buildTestApp() {
|
|||||||
ADMIN_PASSWORD: 'adminpass',
|
ADMIN_PASSWORD: 'adminpass',
|
||||||
DATABASE_URL: 'sqlite::memory:',
|
DATABASE_URL: 'sqlite::memory:',
|
||||||
NODE_ENV: 'test',
|
NODE_ENV: 'test',
|
||||||
|
AUDIT_INGEST_SECRET: 'dev-audit-ingest-secret',
|
||||||
})
|
})
|
||||||
return buildApp({ config, databaseUrl: 'sqlite::memory:' })
|
return buildApp({ config, databaseUrl: 'sqlite::memory:' })
|
||||||
}
|
}
|
||||||
@@ -26,6 +27,65 @@ async function adminToken(app: Awaited<ReturnType<typeof buildTestApp>>) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe('audit log API', () => {
|
describe('audit log API', () => {
|
||||||
|
it('ingests external events with secret and dedupes by event_id', async () => {
|
||||||
|
const app = await buildTestApp()
|
||||||
|
const denied = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/v1/ingest/audit',
|
||||||
|
payload: { events: [] },
|
||||||
|
})
|
||||||
|
expect(denied.statusCode).toBe(401)
|
||||||
|
|
||||||
|
const eventId = 'evt-test-1'
|
||||||
|
const ok = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/v1/ingest/audit',
|
||||||
|
headers: { authorization: 'Bearer dev-audit-ingest-secret' },
|
||||||
|
payload: {
|
||||||
|
events: [
|
||||||
|
{
|
||||||
|
event_id: eventId,
|
||||||
|
source_app: 'vps',
|
||||||
|
action: 'vps.vps.create',
|
||||||
|
summary: 'Создан VPS',
|
||||||
|
actor_email: '[email protected]',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
expect(ok.statusCode).toBe(200)
|
||||||
|
expect(ok.json()).toMatchObject({ accepted: 1, duplicates: 0 })
|
||||||
|
|
||||||
|
const dup = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/v1/ingest/audit',
|
||||||
|
headers: { authorization: 'Bearer dev-audit-ingest-secret' },
|
||||||
|
payload: {
|
||||||
|
events: [
|
||||||
|
{
|
||||||
|
event_id: eventId,
|
||||||
|
source_app: 'vps',
|
||||||
|
action: 'vps.vps.create',
|
||||||
|
summary: 'Создан VPS',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
expect(dup.json()).toMatchObject({ accepted: 0, duplicates: 1 })
|
||||||
|
|
||||||
|
const token = await adminToken(app)
|
||||||
|
const list = await app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: '/api/v1/admin/audit?source_app=vps',
|
||||||
|
headers: { authorization: `Bearer ${token}` },
|
||||||
|
})
|
||||||
|
expect(list.statusCode).toBe(200)
|
||||||
|
const entries = list.json() as { source_app: string; action: string }[]
|
||||||
|
expect(entries.some((e) => e.action === 'vps.vps.create')).toBe(true)
|
||||||
|
|
||||||
|
await app.close()
|
||||||
|
})
|
||||||
|
|
||||||
it('records login and lists for admin', async () => {
|
it('records login and lists for admin', async () => {
|
||||||
const app = await buildTestApp()
|
const app = await buildTestApp()
|
||||||
const token = await adminToken(app)
|
const token = await adminToken(app)
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,4 +1,4 @@
|
|||||||
import type { AuditLogEntry, AuditSeverity } from '@authportal/shared'
|
import type { AuditLogEntry, AuditSeverity, AuditSourceApp } from '@authportal/shared'
|
||||||
import {
|
import {
|
||||||
KeyRoundIcon,
|
KeyRoundIcon,
|
||||||
LogInIcon,
|
LogInIcon,
|
||||||
@@ -32,6 +32,18 @@ export const RANGE_OPTIONS = [
|
|||||||
|
|
||||||
export type AuditRange = (typeof RANGE_OPTIONS)[number]['value']
|
export type AuditRange = (typeof RANGE_OPTIONS)[number]['value']
|
||||||
|
|
||||||
|
export const SOURCE_APP_OPTIONS: {
|
||||||
|
value: AuditSourceApp | 'all'
|
||||||
|
label: string
|
||||||
|
}[] = [
|
||||||
|
{ value: 'all', label: 'Все приложения' },
|
||||||
|
{ value: 'portal', label: 'Auth Portal' },
|
||||||
|
{ value: 'vps', label: 'VPS Tracker' },
|
||||||
|
{ value: 'cfdm', label: 'CFDM' },
|
||||||
|
{ value: 'bgp', label: 'EvoBGP' },
|
||||||
|
{ value: 'fw', label: 'EvoFirewall' },
|
||||||
|
]
|
||||||
|
|
||||||
export const severityVariant: Record<AuditSeverity, BadgeProps['variant']> = {
|
export const severityVariant: Record<AuditSeverity, BadgeProps['variant']> = {
|
||||||
info: 'success-outline',
|
info: 'success-outline',
|
||||||
warning: 'warning-outline',
|
warning: 'warning-outline',
|
||||||
@@ -97,6 +109,14 @@ export function matchesFilter(entry: AuditLogEntry, filter: AuditFilterId) {
|
|||||||
return actionMeta(entry.action).filter === filter
|
return actionMeta(entry.action).filter === filter
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function matchesSourceApp(
|
||||||
|
entry: AuditLogEntry,
|
||||||
|
source: AuditSourceApp | 'all',
|
||||||
|
) {
|
||||||
|
if (source === 'all') return true
|
||||||
|
return entry.source_app === source
|
||||||
|
}
|
||||||
|
|
||||||
export function matchesRange(entry: AuditLogEntry, range: AuditRange) {
|
export function matchesRange(entry: AuditLogEntry, range: AuditRange) {
|
||||||
if (range === 'all') return true
|
if (range === 'all') return true
|
||||||
const ms =
|
const ms =
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
* Docs: https://reui.io/blocks · Timeline: https://reui.io/docs/components/base/timeline
|
* Docs: https://reui.io/blocks · Timeline: https://reui.io/docs/components/base/timeline
|
||||||
*/
|
*/
|
||||||
import { useMemo, useState } from 'react'
|
import { useMemo, useState } from 'react'
|
||||||
import type { AuditLogEntry } from '@authportal/shared'
|
import type { AuditLogEntry, AuditSourceApp } from '@authportal/shared'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
import {
|
import {
|
||||||
CalendarIcon,
|
CalendarIcon,
|
||||||
@@ -56,6 +56,7 @@ import { Tabs, TabsList, TabsTrigger } from '@authportal/ui/components/tabs'
|
|||||||
import {
|
import {
|
||||||
AUDIT_FILTER_OPTIONS,
|
AUDIT_FILTER_OPTIONS,
|
||||||
RANGE_OPTIONS,
|
RANGE_OPTIONS,
|
||||||
|
SOURCE_APP_OPTIONS,
|
||||||
actionMeta,
|
actionMeta,
|
||||||
exportAuditCsv,
|
exportAuditCsv,
|
||||||
formatEventTime,
|
formatEventTime,
|
||||||
@@ -63,6 +64,7 @@ import {
|
|||||||
initials,
|
initials,
|
||||||
matchesFilter,
|
matchesFilter,
|
||||||
matchesRange,
|
matchesRange,
|
||||||
|
matchesSourceApp,
|
||||||
severityDotClass,
|
severityDotClass,
|
||||||
severityLabel,
|
severityLabel,
|
||||||
severityVariant,
|
severityVariant,
|
||||||
@@ -227,81 +229,128 @@ function EventRow({
|
|||||||
export function AuditLogTimeline({
|
export function AuditLogTimeline({
|
||||||
entries,
|
entries,
|
||||||
totalCount,
|
totalCount,
|
||||||
|
compact = false,
|
||||||
|
lockedUserId,
|
||||||
|
sourceAppFilter,
|
||||||
|
onSourceAppFilterChange,
|
||||||
}: {
|
}: {
|
||||||
entries: AuditLogEntry[]
|
entries: AuditLogEntry[]
|
||||||
totalCount: number
|
totalCount: number
|
||||||
|
compact?: boolean
|
||||||
|
lockedUserId?: string
|
||||||
|
sourceAppFilter?: AuditSourceApp | 'all'
|
||||||
|
onSourceAppFilterChange?: (v: AuditSourceApp | 'all') => void
|
||||||
}) {
|
}) {
|
||||||
const [filter, setFilter] = useState<AuditFilterId>('all')
|
const [filter, setFilter] = useState<AuditFilterId>('all')
|
||||||
const [range, setRange] = useState<AuditRange>('7d')
|
const [range, setRange] = useState<AuditRange>(compact ? 'all' : '7d')
|
||||||
|
const [localSource, setLocalSource] = useState<AuditSourceApp | 'all'>(
|
||||||
|
sourceAppFilter ?? 'all',
|
||||||
|
)
|
||||||
|
const source = sourceAppFilter ?? localSource
|
||||||
|
const setSource = onSourceAppFilterChange ?? setLocalSource
|
||||||
|
|
||||||
const visible = useMemo(
|
const visible = useMemo(
|
||||||
() =>
|
() =>
|
||||||
entries.filter(
|
entries.filter(
|
||||||
(e) => matchesFilter(e, filter) && matchesRange(e, range),
|
(e) =>
|
||||||
|
matchesFilter(e, filter) &&
|
||||||
|
matchesRange(e, range) &&
|
||||||
|
matchesSourceApp(e, source),
|
||||||
),
|
),
|
||||||
[entries, filter, range],
|
[entries, filter, range, source],
|
||||||
)
|
)
|
||||||
|
|
||||||
const days = useMemo(() => groupByDay(visible), [visible])
|
const days = useMemo(() => groupByDay(visible), [visible])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="w-full" aria-labelledby="audit-log-title">
|
<section className="w-full" aria-labelledby="audit-log-title">
|
||||||
<div className="mb-6 flex flex-col gap-4">
|
<div className={cn('flex flex-col gap-4', compact ? 'mb-4' : 'mb-6')}>
|
||||||
<div className="flex flex-wrap items-end justify-between gap-3">
|
{!compact ? (
|
||||||
<div className="flex min-w-0 flex-col gap-1">
|
<div className="flex flex-wrap items-end justify-between gap-3">
|
||||||
<h1
|
<div className="flex min-w-0 flex-col gap-1">
|
||||||
id="audit-log-title"
|
<h1
|
||||||
className="text-xl font-semibold tracking-tight"
|
id="audit-log-title"
|
||||||
>
|
className="text-xl font-semibold tracking-tight"
|
||||||
Журнал аудита
|
>
|
||||||
</h1>
|
Журнал аудита
|
||||||
<p className="text-muted-foreground text-sm leading-5">
|
</h1>
|
||||||
{totalCount} событий · показано {visible.length}
|
<p className="text-muted-foreground text-sm leading-5">
|
||||||
</p>
|
{totalCount} событий · показано {visible.length}
|
||||||
</div>
|
{lockedUserId ? ' · пользователь' : ''}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="flex shrink-0 items-center gap-2">
|
<div className="flex shrink-0 flex-wrap items-center gap-2">
|
||||||
<Select
|
<Select
|
||||||
value={range}
|
value={source}
|
||||||
onValueChange={(value) => {
|
onValueChange={(value) => {
|
||||||
if (value) setRange(value as AuditRange)
|
if (value) setSource(value as AuditSourceApp | 'all')
|
||||||
}}
|
}}
|
||||||
items={[...RANGE_OPTIONS]}
|
items={[...SOURCE_APP_OPTIONS]}
|
||||||
>
|
>
|
||||||
<SelectTrigger size="sm" className="w-44">
|
<SelectTrigger size="sm" className="w-40">
|
||||||
<CalendarIcon
|
<SelectValue />
|
||||||
className="text-muted-foreground size-4"
|
</SelectTrigger>
|
||||||
aria-hidden="true"
|
<SelectContent align="end" alignItemWithTrigger={false}>
|
||||||
/>
|
<SelectGroup>
|
||||||
<SelectValue />
|
{SOURCE_APP_OPTIONS.map((option) => (
|
||||||
</SelectTrigger>
|
<SelectItem key={option.value} value={option.value}>
|
||||||
<SelectContent align="end" alignItemWithTrigger={false}>
|
{option.label}
|
||||||
<SelectGroup>
|
</SelectItem>
|
||||||
{RANGE_OPTIONS.map((option) => (
|
))}
|
||||||
<SelectItem key={option.value} value={option.value}>
|
</SelectGroup>
|
||||||
{option.label}
|
</SelectContent>
|
||||||
</SelectItem>
|
</Select>
|
||||||
))}
|
|
||||||
</SelectGroup>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
|
|
||||||
<Button
|
<Select
|
||||||
size="sm"
|
value={range}
|
||||||
type="button"
|
onValueChange={(value) => {
|
||||||
variant="outline"
|
if (value) setRange(value as AuditRange)
|
||||||
onClick={() => {
|
}}
|
||||||
exportAuditCsv(visible)
|
items={[...RANGE_OPTIONS]}
|
||||||
toast.success('CSV экспортирован', {
|
>
|
||||||
description: `${visible.length} событий`,
|
<SelectTrigger size="sm" className="w-44">
|
||||||
})
|
<CalendarIcon
|
||||||
}}
|
className="text-muted-foreground size-4"
|
||||||
>
|
aria-hidden="true"
|
||||||
<DownloadIcon aria-hidden="true" />
|
/>
|
||||||
<span className="hidden sm:inline">Export CSV</span>
|
<SelectValue />
|
||||||
</Button>
|
</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>
|
</div>
|
||||||
</div>
|
) : (
|
||||||
|
<p
|
||||||
|
id="audit-log-title"
|
||||||
|
className="text-muted-foreground text-sm leading-5"
|
||||||
|
>
|
||||||
|
{visible.length} из {totalCount} событий
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
<Tabs
|
<Tabs
|
||||||
value={filter}
|
value={filter}
|
||||||
|
|||||||
@@ -0,0 +1,172 @@
|
|||||||
|
/**
|
||||||
|
* Create user Sheet — auth-portal admin.
|
||||||
|
* Preview: https://reui.io/preview/base/solution-users-1
|
||||||
|
*/
|
||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import type { CreateUserRequest } from '@authportal/shared'
|
||||||
|
import { XIcon } from 'lucide-react'
|
||||||
|
import {
|
||||||
|
Alert,
|
||||||
|
AlertDescription,
|
||||||
|
AlertTitle,
|
||||||
|
} from '@/components/reui/alert'
|
||||||
|
import { Button } from '@authportal/ui/components/button'
|
||||||
|
import { Checkbox } from '@authportal/ui/components/checkbox'
|
||||||
|
import { Field, FieldGroup, FieldLabel } from '@authportal/ui/components/field'
|
||||||
|
import { Input } from '@authportal/ui/components/input'
|
||||||
|
import {
|
||||||
|
Sheet,
|
||||||
|
SheetClose,
|
||||||
|
SheetContent,
|
||||||
|
SheetDescription,
|
||||||
|
SheetFooter,
|
||||||
|
SheetHeader,
|
||||||
|
SheetTitle,
|
||||||
|
} from '@authportal/ui/components/sheet'
|
||||||
|
|
||||||
|
const mutedIconButtonClassName = 'text-muted-foreground hover:text-foreground'
|
||||||
|
|
||||||
|
export function CreateUserSheet({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
pending,
|
||||||
|
error,
|
||||||
|
onSubmit,
|
||||||
|
}: {
|
||||||
|
open: boolean
|
||||||
|
onOpenChange: (open: boolean) => void
|
||||||
|
pending: boolean
|
||||||
|
error: string | null
|
||||||
|
onSubmit: (values: CreateUserRequest) => void
|
||||||
|
}) {
|
||||||
|
const [isAdmin, setIsAdmin] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) setIsAdmin(false)
|
||||||
|
}, [open])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Sheet open={open} onOpenChange={onOpenChange}>
|
||||||
|
<SheetContent
|
||||||
|
side="right"
|
||||||
|
showCloseButton={false}
|
||||||
|
className="inset-y-4 right-4 left-auto flex h-[calc(100svh-2rem)] w-[min(24rem,calc(100vw-2rem))] max-w-none flex-col gap-0 overflow-hidden rounded-xl p-0 outline-none sm:max-w-none"
|
||||||
|
>
|
||||||
|
<SheetHeader className="shrink-0 gap-0 p-0">
|
||||||
|
<div className="flex min-h-12 items-center justify-between gap-2 border-b px-4">
|
||||||
|
<SheetTitle className="min-w-0 truncate text-base font-semibold">
|
||||||
|
Новый пользователь
|
||||||
|
</SheetTitle>
|
||||||
|
<SheetClose
|
||||||
|
render={
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-sm"
|
||||||
|
aria-label="Закрыть"
|
||||||
|
className={mutedIconButtonClassName}
|
||||||
|
>
|
||||||
|
<XIcon aria-hidden="true" />
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<SheetDescription className="sr-only">
|
||||||
|
Создание учётной записи портала
|
||||||
|
</SheetDescription>
|
||||||
|
</SheetHeader>
|
||||||
|
|
||||||
|
<form
|
||||||
|
id="create-user-form"
|
||||||
|
className="flex min-h-0 flex-1 flex-col"
|
||||||
|
onSubmit={(e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
const fd = new FormData(e.currentTarget)
|
||||||
|
onSubmit({
|
||||||
|
email: String(fd.get('email') ?? ''),
|
||||||
|
name: String(fd.get('name') ?? ''),
|
||||||
|
password: String(fd.get('password') ?? ''),
|
||||||
|
is_admin: isAdmin,
|
||||||
|
apps: [],
|
||||||
|
permissions: [],
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="min-h-0 flex-1 overflow-y-auto px-4 py-5">
|
||||||
|
<FieldGroup>
|
||||||
|
<Field>
|
||||||
|
<FieldLabel htmlFor="create-name">Имя</FieldLabel>
|
||||||
|
<Input
|
||||||
|
id="create-name"
|
||||||
|
name="name"
|
||||||
|
autoComplete="name"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field>
|
||||||
|
<FieldLabel htmlFor="create-email">Email</FieldLabel>
|
||||||
|
<Input
|
||||||
|
id="create-email"
|
||||||
|
name="email"
|
||||||
|
type="email"
|
||||||
|
autoComplete="email"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field>
|
||||||
|
<FieldLabel htmlFor="create-password">Пароль</FieldLabel>
|
||||||
|
<Input
|
||||||
|
id="create-password"
|
||||||
|
name="password"
|
||||||
|
type="password"
|
||||||
|
autoComplete="new-password"
|
||||||
|
minLength={6}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field orientation="horizontal">
|
||||||
|
<Checkbox
|
||||||
|
id="create-is-admin"
|
||||||
|
checked={isAdmin}
|
||||||
|
onCheckedChange={(v) => setIsAdmin(v === true)}
|
||||||
|
/>
|
||||||
|
<FieldLabel htmlFor="create-is-admin" className="font-normal">
|
||||||
|
Администратор портала
|
||||||
|
</FieldLabel>
|
||||||
|
</Field>
|
||||||
|
</FieldGroup>
|
||||||
|
|
||||||
|
{error ? (
|
||||||
|
<Alert variant="destructive" className="mt-5">
|
||||||
|
<AlertTitle>Ошибка</AlertTitle>
|
||||||
|
<AlertDescription>{error}</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<SheetFooter className="bg-background shrink-0 border-t">
|
||||||
|
<div className="flex w-full gap-2">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
className="min-w-0 flex-1"
|
||||||
|
disabled={pending}
|
||||||
|
onClick={() => onOpenChange(false)}
|
||||||
|
>
|
||||||
|
Отмена
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
form="create-user-form"
|
||||||
|
className="min-w-0 flex-1"
|
||||||
|
disabled={pending}
|
||||||
|
>
|
||||||
|
{pending ? 'Создание…' : 'Создать'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</SheetFooter>
|
||||||
|
</form>
|
||||||
|
</SheetContent>
|
||||||
|
</Sheet>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -13,3 +13,6 @@ export { QuickActionGrid, type QuickActionItem } from './quick-action-grid'
|
|||||||
export type { ResourcePageProps, ResourcePageTab } from './resource-page'
|
export type { ResourcePageProps, ResourcePageTab } from './resource-page'
|
||||||
export { AppSwitcherAdminEditor } from './app-switcher-admin-editor'
|
export { AppSwitcherAdminEditor } from './app-switcher-admin-editor'
|
||||||
export { UserAccessSheet } from './user-access-sheet'
|
export { UserAccessSheet } from './user-access-sheet'
|
||||||
|
export { UserAuditSheet } from './user-audit-sheet'
|
||||||
|
export { CreateUserSheet } from './create-user-sheet'
|
||||||
|
export { AdminUsersGrid, type AdminUsersGridProps } from './admin-users-grid'
|
||||||
|
|||||||
@@ -0,0 +1,109 @@
|
|||||||
|
/**
|
||||||
|
* User-scoped audit Sheet — chrome DNA solution-users-1 MemberDetailSheet.
|
||||||
|
* Preview: https://reui.io/preview/base/solution-users-1
|
||||||
|
* Timeline: https://reui.io/preview/base/solution-users-6
|
||||||
|
*/
|
||||||
|
import { useQuery } from '@tanstack/react-query'
|
||||||
|
import type { AdminUser } from '@authportal/shared'
|
||||||
|
import { XIcon } from 'lucide-react'
|
||||||
|
import { AuditLogTimeline } from '@/components/reui-kit/audit-log-timeline'
|
||||||
|
import { auditQueryOptions } from '@/queries/audit'
|
||||||
|
import { ApiError } from '@/lib/api-client'
|
||||||
|
import { Button } from '@authportal/ui/components/button'
|
||||||
|
import { ScrollArea } from '@authportal/ui/components/scroll-area'
|
||||||
|
import { Skeleton } from '@authportal/ui/components/skeleton'
|
||||||
|
import {
|
||||||
|
Sheet,
|
||||||
|
SheetClose,
|
||||||
|
SheetContent,
|
||||||
|
SheetDescription,
|
||||||
|
SheetHeader,
|
||||||
|
SheetTitle,
|
||||||
|
} from '@authportal/ui/components/sheet'
|
||||||
|
|
||||||
|
const mutedIconButtonClassName = 'text-muted-foreground hover:text-foreground'
|
||||||
|
|
||||||
|
export function UserAuditSheet({
|
||||||
|
user,
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
}: {
|
||||||
|
user: AdminUser | null
|
||||||
|
open: boolean
|
||||||
|
onOpenChange: (open: boolean) => void
|
||||||
|
}) {
|
||||||
|
const userId = user?.id
|
||||||
|
const { data: entries = [], isLoading, error, refetch } = useQuery({
|
||||||
|
...auditQueryOptions({ userId, limit: 200 }),
|
||||||
|
enabled: open && Boolean(userId),
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Sheet open={open} onOpenChange={onOpenChange}>
|
||||||
|
<SheetContent
|
||||||
|
side="right"
|
||||||
|
showCloseButton={false}
|
||||||
|
className="inset-y-4 right-4 left-auto flex h-[calc(100svh-2rem)] w-[min(36rem,calc(100vw-2rem))] max-w-none flex-col gap-0 overflow-hidden rounded-xl p-0 outline-none sm:max-w-none"
|
||||||
|
>
|
||||||
|
<SheetHeader className="shrink-0 gap-0 p-0">
|
||||||
|
<div className="flex min-h-12 items-center justify-between gap-2 border-b px-4">
|
||||||
|
<SheetTitle className="min-w-0 truncate text-base font-semibold">
|
||||||
|
Журнал: {user?.name ?? '…'}
|
||||||
|
</SheetTitle>
|
||||||
|
<SheetClose
|
||||||
|
render={
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-sm"
|
||||||
|
aria-label="Закрыть"
|
||||||
|
className={mutedIconButtonClassName}
|
||||||
|
>
|
||||||
|
<XIcon aria-hidden="true" />
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<SheetDescription className="text-muted-foreground border-b px-4 py-2 text-sm">
|
||||||
|
{user?.email ?? 'События пользователя (актор или цель)'}
|
||||||
|
</SheetDescription>
|
||||||
|
</SheetHeader>
|
||||||
|
|
||||||
|
<ScrollArea className="min-h-0 flex-1">
|
||||||
|
<div className="p-4">
|
||||||
|
{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: 5 }).map((_, i) => (
|
||||||
|
<Skeleton key={i} className="h-14 w-full" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<AuditLogTimeline
|
||||||
|
entries={entries}
|
||||||
|
totalCount={entries.length}
|
||||||
|
compact
|
||||||
|
lockedUserId={userId}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</ScrollArea>
|
||||||
|
</SheetContent>
|
||||||
|
</Sheet>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,15 +1,44 @@
|
|||||||
import { queryOptions } from '@tanstack/react-query'
|
import { queryOptions } from '@tanstack/react-query'
|
||||||
import type { AuditLogEntry, AuditSettings } from '@authportal/shared'
|
import type {
|
||||||
|
AuditLogEntry,
|
||||||
|
AuditSettings,
|
||||||
|
AuditSourceApp,
|
||||||
|
} from '@authportal/shared'
|
||||||
import { api } from '@/lib/api-client'
|
import { api } from '@/lib/api-client'
|
||||||
|
|
||||||
export const auditQueryKey = ['admin', 'audit'] as const
|
export const auditQueryKey = ['admin', 'audit'] as const
|
||||||
export const auditSettingsQueryKey = ['admin', 'audit', 'settings'] as const
|
export const auditSettingsQueryKey = ['admin', 'audit', 'settings'] as const
|
||||||
|
|
||||||
export const auditQueryOptions = queryOptions({
|
export function auditListQueryKey(opts?: {
|
||||||
queryKey: auditQueryKey,
|
userId?: string
|
||||||
queryFn: () =>
|
sourceApp?: AuditSourceApp | 'all'
|
||||||
api.get<AuditLogEntry[]>('/api/v1/admin/audit?limit=200'),
|
}) {
|
||||||
})
|
return [
|
||||||
|
...auditQueryKey,
|
||||||
|
'list',
|
||||||
|
opts?.userId ?? null,
|
||||||
|
opts?.sourceApp ?? 'all',
|
||||||
|
] as const
|
||||||
|
}
|
||||||
|
|
||||||
|
export function auditQueryOptions(opts?: {
|
||||||
|
userId?: string
|
||||||
|
sourceApp?: AuditSourceApp
|
||||||
|
limit?: number
|
||||||
|
}) {
|
||||||
|
const params = new URLSearchParams()
|
||||||
|
params.set('limit', String(opts?.limit ?? 200))
|
||||||
|
if (opts?.userId) params.set('user_id', opts.userId)
|
||||||
|
if (opts?.sourceApp) params.set('source_app', opts.sourceApp)
|
||||||
|
return queryOptions({
|
||||||
|
queryKey: auditListQueryKey({
|
||||||
|
userId: opts?.userId,
|
||||||
|
sourceApp: opts?.sourceApp ?? 'all',
|
||||||
|
}),
|
||||||
|
queryFn: () =>
|
||||||
|
api.get<AuditLogEntry[]>(`/api/v1/admin/audit?${params.toString()}`),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
export const auditSettingsQueryOptions = queryOptions({
|
export const auditSettingsQueryOptions = queryOptions({
|
||||||
queryKey: auditSettingsQueryKey,
|
queryKey: auditSettingsQueryKey,
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ function AdminAuditPage() {
|
|||||||
isLoading,
|
isLoading,
|
||||||
error,
|
error,
|
||||||
refetch,
|
refetch,
|
||||||
} = useQuery(auditQueryOptions)
|
} = useQuery(auditQueryOptions())
|
||||||
const { data: settings } = useQuery(auditSettingsQueryOptions)
|
const { data: settings } = useQuery(auditSettingsQueryOptions)
|
||||||
|
|
||||||
const saveMutation = useMutation({
|
const saveMutation = useMutation({
|
||||||
|
|||||||
@@ -1,453 +1,179 @@
|
|||||||
/**
|
/**
|
||||||
* Admin users directory — DNA solution-users-1.
|
* Admin users directory — thin wiring over AdminUsersGrid.
|
||||||
* Preview: https://reui.io/preview/base/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 { useCallback, useState } from 'react'
|
||||||
import { createFileRoute } from '@tanstack/react-router'
|
import { createFileRoute } from '@tanstack/react-router'
|
||||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||||
import {
|
import { toast } from 'sonner'
|
||||||
getCoreRowModel,
|
|
||||||
getFilteredRowModel,
|
|
||||||
getPaginationRowModel,
|
|
||||||
getSortedRowModel,
|
|
||||||
useReactTable,
|
|
||||||
type ColumnDef,
|
|
||||||
type SortingState,
|
|
||||||
} from '@tanstack/react-table'
|
|
||||||
import {
|
|
||||||
CircleDotIcon,
|
|
||||||
MailIcon,
|
|
||||||
ShieldCheckIcon,
|
|
||||||
UserIcon,
|
|
||||||
XIcon,
|
|
||||||
} from 'lucide-react'
|
|
||||||
import type { AdminUser, CreateUserRequest } from '@authportal/shared'
|
import type { AdminUser, CreateUserRequest } from '@authportal/shared'
|
||||||
import { PageShell } from '@/components/page-shell'
|
import { PageShell } from '@/components/page-shell'
|
||||||
|
import { AdminUsersGrid } from '@/components/reui-kit/admin-users-grid'
|
||||||
|
import { CreateUserSheet } from '@/components/reui-kit/create-user-sheet'
|
||||||
import { UserAccessSheet } from '@/components/reui-kit/user-access-sheet'
|
import { UserAccessSheet } from '@/components/reui-kit/user-access-sheet'
|
||||||
import {
|
import { UserAuditSheet } from '@/components/reui-kit/user-audit-sheet'
|
||||||
Alert,
|
|
||||||
AlertDescription,
|
|
||||||
AlertTitle,
|
|
||||||
} from '@/components/reui/alert'
|
|
||||||
import { Badge } from '@/components/reui/badge'
|
|
||||||
import {
|
|
||||||
Frame,
|
|
||||||
FrameDescription,
|
|
||||||
FrameFooter,
|
|
||||||
FrameHeader,
|
|
||||||
FramePanel,
|
|
||||||
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'
|
|
||||||
import { Checkbox } from '@authportal/ui/components/checkbox'
|
|
||||||
import {
|
|
||||||
Sheet,
|
|
||||||
SheetClose,
|
|
||||||
SheetContent,
|
|
||||||
SheetDescription,
|
|
||||||
SheetFooter,
|
|
||||||
SheetHeader,
|
|
||||||
SheetTitle,
|
|
||||||
} from '@authportal/ui/components/sheet'
|
|
||||||
import {
|
|
||||||
Tabs,
|
|
||||||
TabsList,
|
|
||||||
TabsTrigger,
|
|
||||||
} from '@authportal/ui/components/tabs'
|
|
||||||
import { Skeleton } from '@authportal/ui/components/skeleton'
|
|
||||||
import { api, ApiError } from '@/lib/api-client'
|
import { api, ApiError } from '@/lib/api-client'
|
||||||
import { usersQueryKey, usersQueryOptions } from '@/queries/auth'
|
import { usersQueryKey, usersQueryOptions } from '@/queries/auth'
|
||||||
|
import { Button } from '@authportal/ui/components/button'
|
||||||
const mutedIconButtonClassName = 'text-muted-foreground hover:text-foreground'
|
|
||||||
|
|
||||||
export const Route = createFileRoute('/_auth/admin/')({
|
export const Route = createFileRoute('/_auth/admin/')({
|
||||||
component: AdminUsersPage,
|
component: AdminUsersPage,
|
||||||
})
|
})
|
||||||
|
|
||||||
type TabId = 'all' | 'admins' | 'disabled'
|
|
||||||
|
|
||||||
function getActiveFilters(filters: Filter[]) {
|
|
||||||
return filters.filter((filter) => {
|
|
||||||
const { values } = filter
|
|
||||||
if (!values || values.length === 0) return false
|
|
||||||
if (
|
|
||||||
values.every((value) => typeof value === 'string' && value.trim() === '')
|
|
||||||
)
|
|
||||||
return false
|
|
||||||
if (values.every((value) => value === null || value === undefined))
|
|
||||||
return false
|
|
||||||
if (values.every((value) => Array.isArray(value) && value.length === 0))
|
|
||||||
return false
|
|
||||||
return true
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
function applyFiltersToUsers(data: AdminUser[], filters: Filter[]): AdminUser[] {
|
|
||||||
const active = getActiveFilters(filters)
|
|
||||||
let result = [...data]
|
|
||||||
for (const filter of active) {
|
|
||||||
const { field, operator, values } = filter
|
|
||||||
result = result.filter((item) => {
|
|
||||||
let fieldValue: string | boolean = ''
|
|
||||||
if (field === 'name') fieldValue = item.name
|
|
||||||
else if (field === 'email') fieldValue = item.email
|
|
||||||
else if (field === 'role') fieldValue = item.is_admin ? 'admin' : 'user'
|
|
||||||
else if (field === 'status')
|
|
||||||
fieldValue = item.disabled ? 'disabled' : 'active'
|
|
||||||
|
|
||||||
switch (operator) {
|
|
||||||
case 'is':
|
|
||||||
return values.includes(fieldValue)
|
|
||||||
case 'is_not':
|
|
||||||
return !values.includes(fieldValue)
|
|
||||||
case 'is_any_of':
|
|
||||||
return values.some((v) => fieldValue === v)
|
|
||||||
case 'is_not_any_of':
|
|
||||||
return !values.some((v) => fieldValue === v)
|
|
||||||
case 'contains': {
|
|
||||||
const tokens = values.map((v) => String(v).trim()).filter(Boolean)
|
|
||||||
if (tokens.length === 0) return true
|
|
||||||
return tokens.some((token) =>
|
|
||||||
String(fieldValue).toLowerCase().includes(token.toLowerCase()),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
case 'not_contains':
|
|
||||||
return !values.some((v) =>
|
|
||||||
String(fieldValue).toLowerCase().includes(String(v).toLowerCase()),
|
|
||||||
)
|
|
||||||
case 'starts_with':
|
|
||||||
return values.some((v) =>
|
|
||||||
String(fieldValue)
|
|
||||||
.toLowerCase()
|
|
||||||
.startsWith(String(v).toLowerCase()),
|
|
||||||
)
|
|
||||||
case 'empty':
|
|
||||||
return fieldValue === '' || fieldValue == null
|
|
||||||
case 'not_empty':
|
|
||||||
return fieldValue !== '' && fieldValue != null
|
|
||||||
default:
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
function createDefaultFilters(): Filter[] {
|
|
||||||
return [createFilter('name', 'contains', [''])]
|
|
||||||
}
|
|
||||||
|
|
||||||
function userInitials(name: string, email: string) {
|
|
||||||
const parts = name.trim().split(/\s+/).filter(Boolean)
|
|
||||||
if (parts.length >= 2) {
|
|
||||||
return `${parts[0]![0] ?? ''}${parts[1]![0] ?? ''}`.toUpperCase()
|
|
||||||
}
|
|
||||||
return (name || email).slice(0, 2).toUpperCase()
|
|
||||||
}
|
|
||||||
|
|
||||||
function AdminUsersPage() {
|
function AdminUsersPage() {
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
const { data: users = [], isLoading, error, refetch } = useQuery(usersQueryOptions)
|
const { data: users = [], isLoading, error, refetch } = useQuery(
|
||||||
const [tab, setTab] = useState<TabId>('all')
|
usersQueryOptions,
|
||||||
const [filters, setFilters] = useState<Filter[]>(createDefaultFilters)
|
)
|
||||||
const [sorting, setSorting] = useState<SortingState>([
|
|
||||||
{ id: 'name', desc: false },
|
|
||||||
])
|
|
||||||
const [createOpen, setCreateOpen] = useState(false)
|
const [createOpen, setCreateOpen] = useState(false)
|
||||||
const [accessUserId, setAccessUserId] = useState<string | null>(null)
|
const [accessUserId, setAccessUserId] = useState<string | null>(null)
|
||||||
|
const [auditUser, setAuditUser] = useState<AdminUser | null>(null)
|
||||||
|
|
||||||
const filterFields: FilterFieldConfig[] = useMemo(
|
const invalidateUsers = useCallback(async () => {
|
||||||
() => [
|
await queryClient.invalidateQueries({ queryKey: usersQueryKey })
|
||||||
{
|
}, [queryClient])
|
||||||
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)
|
|
||||||
return applyFiltersToUsers(rows, filters)
|
|
||||||
}, [users, tab, filters])
|
|
||||||
|
|
||||||
const counts = useMemo(
|
|
||||||
() => ({
|
|
||||||
all: users.length,
|
|
||||||
admins: users.filter((u) => u.is_admin).length,
|
|
||||||
disabled: users.filter((u) => u.disabled).length,
|
|
||||||
}),
|
|
||||||
[users],
|
|
||||||
)
|
|
||||||
|
|
||||||
const columns = useMemo<ColumnDef<AdminUser, unknown>[]>(
|
|
||||||
() => [
|
|
||||||
{
|
|
||||||
accessorKey: 'name',
|
|
||||||
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',
|
|
||||||
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">
|
|
||||||
Админ
|
|
||||||
</Badge>
|
|
||||||
) : (
|
|
||||||
<Badge variant="secondary" size="sm">
|
|
||||||
Пользователь
|
|
||||||
</Badge>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: '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',
|
|
||||||
accessorFn: (row) => (row.disabled ? 0 : 1),
|
|
||||||
header: ({ column }) => (
|
|
||||||
<DataGridColumnHeader title="Статус" column={column} />
|
|
||||||
),
|
|
||||||
cell: ({ row }) =>
|
|
||||||
row.original.disabled ? (
|
|
||||||
<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" className="gap-1.5">
|
|
||||||
<span className="bg-success size-1.5 shrink-0 rounded-full" />
|
|
||||||
Активен
|
|
||||||
</Badge>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'actions',
|
|
||||||
enableSorting: false,
|
|
||||||
header: '',
|
|
||||||
cell: ({ row }) => (
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant="outline"
|
|
||||||
onClick={() => setAccessUserId(row.original.id)}
|
|
||||||
>
|
|
||||||
Права
|
|
||||||
</Button>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
[],
|
|
||||||
)
|
|
||||||
|
|
||||||
const table = useReactTable({
|
|
||||||
data: tabFiltered,
|
|
||||||
columns,
|
|
||||||
getRowId: (row) => row.id,
|
|
||||||
state: { sorting },
|
|
||||||
onSortingChange: setSorting,
|
|
||||||
getCoreRowModel: getCoreRowModel(),
|
|
||||||
getFilteredRowModel: getFilteredRowModel(),
|
|
||||||
getSortedRowModel: getSortedRowModel(),
|
|
||||||
getPaginationRowModel: getPaginationRowModel(),
|
|
||||||
initialState: { pagination: { pageSize: 10 } },
|
|
||||||
})
|
|
||||||
|
|
||||||
const createMutation = useMutation({
|
const createMutation = useMutation({
|
||||||
mutationFn: (body: CreateUserRequest) =>
|
mutationFn: (body: CreateUserRequest) =>
|
||||||
api.post<AdminUser>('/api/v1/admin/users', body),
|
api.post<AdminUser>('/api/v1/admin/users', body),
|
||||||
onSuccess: async (user) => {
|
onSuccess: async (user) => {
|
||||||
await queryClient.invalidateQueries({ queryKey: usersQueryKey })
|
await invalidateUsers()
|
||||||
setCreateOpen(false)
|
setCreateOpen(false)
|
||||||
setAccessUserId(user.id)
|
setAccessUserId(user.id)
|
||||||
|
toast.success('Пользователь создан')
|
||||||
|
},
|
||||||
|
onError: (err) => {
|
||||||
|
toast.error(
|
||||||
|
err instanceof ApiError ? err.message : 'Не удалось создать пользователя',
|
||||||
|
)
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const patchMutation = useMutation({
|
||||||
|
mutationFn: ({
|
||||||
|
id,
|
||||||
|
...body
|
||||||
|
}: {
|
||||||
|
id: string
|
||||||
|
is_admin?: boolean
|
||||||
|
disabled?: boolean
|
||||||
|
}) => api.patch<AdminUser>(`/api/v1/admin/users/${id}`, body),
|
||||||
|
onSuccess: async () => {
|
||||||
|
await invalidateUsers()
|
||||||
|
},
|
||||||
|
onError: (err) => {
|
||||||
|
toast.error(
|
||||||
|
err instanceof ApiError ? err.message : 'Не удалось обновить пользователя',
|
||||||
|
)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const deleteMutation = useMutation({
|
||||||
|
mutationFn: (id: string) => api.delete(`/api/v1/admin/users/${id}`),
|
||||||
|
onSuccess: async () => {
|
||||||
|
await invalidateUsers()
|
||||||
|
toast.success('Пользователь удалён')
|
||||||
|
},
|
||||||
|
onError: (err) => {
|
||||||
|
toast.error(
|
||||||
|
err instanceof ApiError ? err.message : 'Не удалось удалить пользователя',
|
||||||
|
)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const handleDeactivate = useCallback(
|
||||||
|
(user: AdminUser) => {
|
||||||
|
patchMutation.mutate(
|
||||||
|
{ id: user.id, disabled: true },
|
||||||
|
{
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.message('Пользователь отключён', {
|
||||||
|
description: `${user.email} больше не может войти.`,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
},
|
||||||
|
[patchMutation],
|
||||||
|
)
|
||||||
|
|
||||||
|
const handleDelete = useCallback(
|
||||||
|
(user: AdminUser) => {
|
||||||
|
deleteMutation.mutate(user.id)
|
||||||
|
},
|
||||||
|
[deleteMutation],
|
||||||
|
)
|
||||||
|
|
||||||
|
const handleBulkSetRole = useCallback(
|
||||||
|
(userIds: string[], isAdmin: boolean) => {
|
||||||
|
Promise.all(
|
||||||
|
userIds.map((id) =>
|
||||||
|
api.patch<AdminUser>(`/api/v1/admin/users/${id}`, {
|
||||||
|
is_admin: isAdmin,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.then(async () => {
|
||||||
|
await invalidateUsers()
|
||||||
|
toast.success(
|
||||||
|
`Роль обновлена для ${userIds.length} пользовател${userIds.length === 1 ? 'я' : 'ей'}`,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
toast.error(
|
||||||
|
err instanceof ApiError ? err.message : 'Ошибка массового обновления',
|
||||||
|
)
|
||||||
|
})
|
||||||
|
},
|
||||||
|
[invalidateUsers],
|
||||||
|
)
|
||||||
|
|
||||||
|
const handleBulkDeactivate = useCallback(
|
||||||
|
(userIds: string[]) => {
|
||||||
|
Promise.all(
|
||||||
|
userIds.map((id) =>
|
||||||
|
api.patch<AdminUser>(`/api/v1/admin/users/${id}`, { disabled: true }),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.then(async () => {
|
||||||
|
await invalidateUsers()
|
||||||
|
toast.message('Пользователи отключены', {
|
||||||
|
description: `${userIds.length} учётных записей.`,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
toast.error(
|
||||||
|
err instanceof ApiError ? err.message : 'Ошибка массового отключения',
|
||||||
|
)
|
||||||
|
})
|
||||||
|
},
|
||||||
|
[invalidateUsers],
|
||||||
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PageShell>
|
<PageShell>
|
||||||
<Frame dense className="w-full">
|
{error ? (
|
||||||
<FrameHeader className="flex-row items-start justify-between gap-4">
|
<div className="flex flex-col gap-2">
|
||||||
<div className="flex flex-col gap-px">
|
<p className="text-destructive text-sm">
|
||||||
<FrameTitle>Пользователи</FrameTitle>
|
{error instanceof ApiError ? error.message : 'Ошибка загрузки'}
|
||||||
<FrameDescription>
|
</p>
|
||||||
Управление доступом к приложениям и разделам
|
<Button variant="outline" size="sm" className="w-fit" onClick={() => refetch()}>
|
||||||
</FrameDescription>
|
Повторить
|
||||||
</div>
|
</Button>
|
||||||
<Button onClick={() => setCreateOpen(true)}>Создать</Button>
|
</div>
|
||||||
</FrameHeader>
|
) : (
|
||||||
|
<AdminUsersGrid
|
||||||
<FramePanel className="flex flex-col gap-4 p-0">
|
users={users}
|
||||||
<div className="flex flex-col gap-3 border-b px-4 pt-3">
|
isLoading={isLoading}
|
||||||
<Tabs value={tab} onValueChange={(v) => setTab(v as TabId)}>
|
onCreateClick={() => setCreateOpen(true)}
|
||||||
<TabsList variant="line" className="gap-5">
|
onOpenAudit={setAuditUser}
|
||||||
{(
|
onOpenAccess={(user) => setAccessUserId(user.id)}
|
||||||
[
|
onDeactivate={handleDeactivate}
|
||||||
['all', 'Все', counts.all],
|
onDelete={handleDelete}
|
||||||
['admins', 'Админы', counts.admins],
|
onBulkSetRole={handleBulkSetRole}
|
||||||
['disabled', 'Отключённые', counts.disabled],
|
onBulkDeactivate={handleBulkDeactivate}
|
||||||
] as const
|
/>
|
||||||
).map(([id, label, count]) => (
|
)}
|
||||||
<TabsTrigger
|
|
||||||
key={id}
|
|
||||||
value={id}
|
|
||||||
className="text-muted-foreground hover:text-foreground h-auto gap-2 px-0 pb-3"
|
|
||||||
>
|
|
||||||
<span>{label}</span>
|
|
||||||
<span className="bg-muted text-muted-foreground rounded-md px-1.5 py-0.5 text-xs tabular-nums">
|
|
||||||
{count}
|
|
||||||
</span>
|
|
||||||
</TabsTrigger>
|
|
||||||
))}
|
|
||||||
</TabsList>
|
|
||||||
</Tabs>
|
|
||||||
<div className="pb-3">
|
|
||||||
<Filters
|
|
||||||
filters={filters}
|
|
||||||
fields={filterFields}
|
|
||||||
onChange={setFilters}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{error ? (
|
|
||||||
<div className="flex flex-col gap-2 p-4">
|
|
||||||
<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-2 p-4">
|
|
||||||
{Array.from({ length: 5 }).map((_, i) => (
|
|
||||||
<Skeleton key={i} className="h-10 w-full" />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
) : tabFiltered.length === 0 ? (
|
|
||||||
<div className="text-muted-foreground flex flex-col items-start gap-3 p-6 text-sm">
|
|
||||||
<p>Нет пользователей по текущему фильтру.</p>
|
|
||||||
<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={tabFiltered.length}
|
|
||||||
tableLayout={{ dense: true, width: 'auto' }}
|
|
||||||
>
|
|
||||||
<div className="relative">
|
|
||||||
<DataGridTable />
|
|
||||||
</div>
|
|
||||||
</DataGrid>
|
|
||||||
)}
|
|
||||||
</FramePanel>
|
|
||||||
{!isLoading && tabFiltered.length > 0 ? (
|
|
||||||
<FrameFooter className="border-t">
|
|
||||||
<DataGrid table={table} recordCount={tabFiltered.length}>
|
|
||||||
<DataGridPagination />
|
|
||||||
</DataGrid>
|
|
||||||
</FrameFooter>
|
|
||||||
) : null}
|
|
||||||
</Frame>
|
|
||||||
|
|
||||||
<CreateUserSheet
|
<CreateUserSheet
|
||||||
open={createOpen}
|
open={createOpen}
|
||||||
@@ -470,151 +196,14 @@ function AdminUsersPage() {
|
|||||||
if (!next) setAccessUserId(null)
|
if (!next) setAccessUserId(null)
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<UserAuditSheet
|
||||||
|
user={auditUser}
|
||||||
|
open={Boolean(auditUser)}
|
||||||
|
onOpenChange={(next) => {
|
||||||
|
if (!next) setAuditUser(null)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</PageShell>
|
</PageShell>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function CreateUserSheet({
|
|
||||||
open,
|
|
||||||
onOpenChange,
|
|
||||||
pending,
|
|
||||||
error,
|
|
||||||
onSubmit,
|
|
||||||
}: {
|
|
||||||
open: boolean
|
|
||||||
onOpenChange: (open: boolean) => void
|
|
||||||
pending: boolean
|
|
||||||
error: string | null
|
|
||||||
onSubmit: (values: CreateUserRequest) => void
|
|
||||||
}) {
|
|
||||||
const [isAdmin, setIsAdmin] = useState(false)
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!open) setIsAdmin(false)
|
|
||||||
}, [open])
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Sheet open={open} onOpenChange={onOpenChange}>
|
|
||||||
<SheetContent
|
|
||||||
side="right"
|
|
||||||
showCloseButton={false}
|
|
||||||
className="inset-y-4 right-4 left-auto flex h-[calc(100svh-2rem)] w-[min(24rem,calc(100vw-2rem))] max-w-none flex-col gap-0 overflow-hidden rounded-xl p-0 outline-none sm:max-w-none"
|
|
||||||
>
|
|
||||||
<SheetHeader className="shrink-0 gap-0 p-0">
|
|
||||||
<div className="flex min-h-12 items-center justify-between gap-2 border-b px-4">
|
|
||||||
<SheetTitle className="min-w-0 truncate text-base font-semibold">
|
|
||||||
Новый пользователь
|
|
||||||
</SheetTitle>
|
|
||||||
<SheetClose
|
|
||||||
render={
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="ghost"
|
|
||||||
size="icon-sm"
|
|
||||||
aria-label="Закрыть"
|
|
||||||
className={mutedIconButtonClassName}
|
|
||||||
>
|
|
||||||
<XIcon aria-hidden="true" />
|
|
||||||
</Button>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<SheetDescription className="sr-only">
|
|
||||||
Создание учётной записи портала
|
|
||||||
</SheetDescription>
|
|
||||||
</SheetHeader>
|
|
||||||
|
|
||||||
<form
|
|
||||||
id="create-user-form"
|
|
||||||
className="flex min-h-0 flex-1 flex-col"
|
|
||||||
onSubmit={(e) => {
|
|
||||||
e.preventDefault()
|
|
||||||
const fd = new FormData(e.currentTarget)
|
|
||||||
onSubmit({
|
|
||||||
email: String(fd.get('email') ?? ''),
|
|
||||||
name: String(fd.get('name') ?? ''),
|
|
||||||
password: String(fd.get('password') ?? ''),
|
|
||||||
is_admin: isAdmin,
|
|
||||||
apps: [],
|
|
||||||
permissions: [],
|
|
||||||
})
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div className="min-h-0 flex-1 overflow-y-auto px-4 py-5">
|
|
||||||
<FieldGroup>
|
|
||||||
<Field>
|
|
||||||
<FieldLabel htmlFor="create-name">Имя</FieldLabel>
|
|
||||||
<Input
|
|
||||||
id="create-name"
|
|
||||||
name="name"
|
|
||||||
autoComplete="name"
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</Field>
|
|
||||||
<Field>
|
|
||||||
<FieldLabel htmlFor="create-email">Email</FieldLabel>
|
|
||||||
<Input
|
|
||||||
id="create-email"
|
|
||||||
name="email"
|
|
||||||
type="email"
|
|
||||||
autoComplete="email"
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</Field>
|
|
||||||
<Field>
|
|
||||||
<FieldLabel htmlFor="create-password">Пароль</FieldLabel>
|
|
||||||
<Input
|
|
||||||
id="create-password"
|
|
||||||
name="password"
|
|
||||||
type="password"
|
|
||||||
autoComplete="new-password"
|
|
||||||
minLength={6}
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</Field>
|
|
||||||
<Field orientation="horizontal">
|
|
||||||
<Checkbox
|
|
||||||
id="create-is-admin"
|
|
||||||
checked={isAdmin}
|
|
||||||
onCheckedChange={(v) => setIsAdmin(v === true)}
|
|
||||||
/>
|
|
||||||
<FieldLabel htmlFor="create-is-admin" className="font-normal">
|
|
||||||
Администратор портала
|
|
||||||
</FieldLabel>
|
|
||||||
</Field>
|
|
||||||
</FieldGroup>
|
|
||||||
|
|
||||||
{error ? (
|
|
||||||
<Alert variant="destructive" className="mt-5">
|
|
||||||
<AlertTitle>Ошибка</AlertTitle>
|
|
||||||
<AlertDescription>{error}</AlertDescription>
|
|
||||||
</Alert>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<SheetFooter className="bg-background shrink-0 border-t">
|
|
||||||
<div className="flex w-full gap-2">
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
className="min-w-0 flex-1"
|
|
||||||
disabled={pending}
|
|
||||||
onClick={() => onOpenChange(false)}
|
|
||||||
>
|
|
||||||
Отмена
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
type="submit"
|
|
||||||
form="create-user-form"
|
|
||||||
className="min-w-0 flex-1"
|
|
||||||
disabled={pending}
|
|
||||||
>
|
|
||||||
{pending ? 'Создание…' : 'Создать'}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</SheetFooter>
|
|
||||||
</form>
|
|
||||||
</SheetContent>
|
|
||||||
</Sheet>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
# Audit ingest — интеграция приложений с Auth Portal
|
||||||
|
|
||||||
|
Центральный журнал: `GET /api/v1/admin/audit` (только admin).
|
||||||
|
Приложения пишут **локально** и дублируют события в portal.
|
||||||
|
|
||||||
|
## Endpoint
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /api/v1/ingest/audit
|
||||||
|
Authorization: Bearer <AUDIT_INGEST_SECRET>
|
||||||
|
Content-Type: application/json
|
||||||
|
```
|
||||||
|
|
||||||
|
### Body
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"events": [
|
||||||
|
{
|
||||||
|
"event_id": "uuid-unique-per-event",
|
||||||
|
"source_app": "vps",
|
||||||
|
"action": "vps.vps.create",
|
||||||
|
"severity": "info",
|
||||||
|
"actor_user_id": "…",
|
||||||
|
"actor_email": "[email protected]",
|
||||||
|
"actor_name": "Name",
|
||||||
|
"target_type": "app_resource",
|
||||||
|
"target_id": "entity-id",
|
||||||
|
"summary": "Человекочитаемое описание",
|
||||||
|
"details": { "entity": "vps", "diff": {} },
|
||||||
|
"ip": "1.2.3.4",
|
||||||
|
"created_at": "2026-07-21T00:00:00.000Z"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- `events`: 1–50 за запрос
|
||||||
|
- `source_app`: `vps` | `cfdm` | `bgp` | `fw` (не `portal`)
|
||||||
|
- `event_id`: идемпотентность (дубликаты → `duplicates++`)
|
||||||
|
- Ответ: `{ "accepted": N, "duplicates": M }`
|
||||||
|
|
||||||
|
## Env
|
||||||
|
|
||||||
|
| Где | Переменная |
|
||||||
|
|-----|------------|
|
||||||
|
| auth-portal | `AUDIT_INGEST_SECRET` |
|
||||||
|
| apps (vps / cfdm / bgp / fw) | `AUTH_PORTAL_URL` + `AUTH_AUDIT_INGEST_SECRET` (тот же секрет) |
|
||||||
|
|
||||||
|
Dev default secret: `dev-audit-ingest-secret`.
|
||||||
|
|
||||||
|
## Dual-write
|
||||||
|
|
||||||
|
1. Записать в локальный `audit_log`
|
||||||
|
2. Fire-and-forget `POST` в portal (timeout ~2s)
|
||||||
|
3. Ошибка push **не** должна ломать CRUD
|
||||||
|
|
||||||
|
## Фильтры admin UI
|
||||||
|
|
||||||
|
- `/admin/audit` — source_app + type tabs + range
|
||||||
|
- Клик по пользователю на `/admin` — Sheet с событиями, где `actor_user_id` или `target_id` = user id
|
||||||
|
|
||||||
|
Preview: [solution-users-1](https://reui.io/preview/base/solution-users-1) · [solution-users-6](https://reui.io/preview/base/solution-users-6)
|
||||||
@@ -100,6 +100,10 @@ pnpm --filter web dev
|
|||||||
|
|
||||||
`CURRENT_APP_ID = cfdm`. Если в JWT есть `apps[]` — в меню только пересечение с каталогом.
|
`CURRENT_APP_ID = cfdm`. Если в JWT есть `apps[]` — в меню только пересечение с каталогом.
|
||||||
|
|
||||||
|
## Audit ingest
|
||||||
|
|
||||||
|
Dual-write локального журнала в portal: [`integrate-audit-ingest.md`](./integrate-audit-ingest.md) (`source_app: cfdm`).
|
||||||
|
|
||||||
## UI аккаунта
|
## UI аккаунта
|
||||||
|
|
||||||
SidebarFooter → **NavUser** ([app-shell-1](https://reui.io/preview/base/app-shell-1)): Настройки, Тема, Выйти → `AUTH_PORTAL_URL/logout`.
|
SidebarFooter → **NavUser** ([app-shell-1](https://reui.io/preview/base/app-shell-1)): Настройки, Тема, Выйти → `AUTH_PORTAL_URL/logout`.
|
||||||
|
|||||||
@@ -89,6 +89,10 @@ VITE_AUTH_PORTAL_URL=http://localhost:5175
|
|||||||
|
|
||||||
Очистить локальный JWT → `AUTH_PORTAL_URL/logout` (не `/?return_to=`).
|
Очистить локальный JWT → `AUTH_PORTAL_URL/logout` (не `/?return_to=`).
|
||||||
|
|
||||||
|
## Audit ingest
|
||||||
|
|
||||||
|
Dual-write локального журнала в portal: [`integrate-audit-ingest.md`](./integrate-audit-ingest.md) (`source_app: bgp`).
|
||||||
|
|
||||||
## Troubleshooting
|
## Troubleshooting
|
||||||
|
|
||||||
| Симптом | Причина |
|
| Симптом | Причина |
|
||||||
|
|||||||
@@ -24,3 +24,7 @@ App id: **`fw`**.
|
|||||||
## App env
|
## App env
|
||||||
|
|
||||||
См. EvoFirewall `docs/integrate-auth-portal.md`.
|
См. EvoFirewall `docs/integrate-auth-portal.md`.
|
||||||
|
|
||||||
|
## Audit ingest
|
||||||
|
|
||||||
|
Dual-write локального журнала в portal: [`integrate-audit-ingest.md`](./integrate-audit-ingest.md) (`source_app: fw`).
|
||||||
|
|||||||
@@ -107,6 +107,10 @@ pnpm --filter web dev # :5173
|
|||||||
|
|
||||||
`CURRENT_APP_ID = vps`. Фильтр меню по JWT `apps[]` при наличии claims.
|
`CURRENT_APP_ID = vps`. Фильтр меню по JWT `apps[]` при наличии claims.
|
||||||
|
|
||||||
|
## Audit ingest
|
||||||
|
|
||||||
|
Dual-write локального журнала в portal: [`integrate-audit-ingest.md`](./integrate-audit-ingest.md) (`source_app: vps`).
|
||||||
|
|
||||||
## Troubleshooting
|
## Troubleshooting
|
||||||
|
|
||||||
| Симптом | Причина |
|
| Симптом | Причина |
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import { and, desc, eq, lt, sql } from 'drizzle-orm'
|
import { and, desc, eq, lt, or, sql } from 'drizzle-orm'
|
||||||
import { randomUUID } from 'node:crypto'
|
import { randomUUID } from 'node:crypto'
|
||||||
import {
|
import {
|
||||||
DEFAULT_AUDIT_RETENTION_DAYS,
|
DEFAULT_AUDIT_RETENTION_DAYS,
|
||||||
type AuditLogEntry,
|
type AuditLogEntry,
|
||||||
type AuditSeverity,
|
type AuditSeverity,
|
||||||
|
type AuditSourceApp,
|
||||||
type AuditTargetType,
|
type AuditTargetType,
|
||||||
} from '@authportal/shared'
|
} from '@authportal/shared'
|
||||||
import type { AppDb } from './index.js'
|
import type { AppDb } from './index.js'
|
||||||
@@ -12,6 +13,8 @@ import { auditLog, portalSettings } from './schema/index.js'
|
|||||||
const SETTINGS_ID = 'main'
|
const SETTINGS_ID = 'main'
|
||||||
|
|
||||||
export type AppendAuditInput = {
|
export type AppendAuditInput = {
|
||||||
|
eventId?: string | null
|
||||||
|
sourceApp?: AuditSourceApp
|
||||||
action: string
|
action: string
|
||||||
severity?: AuditSeverity
|
severity?: AuditSeverity
|
||||||
actorUserId?: string | null
|
actorUserId?: string | null
|
||||||
@@ -22,6 +25,7 @@ export type AppendAuditInput = {
|
|||||||
summary: string
|
summary: string
|
||||||
details?: Record<string, unknown> | null
|
details?: Record<string, unknown> | null
|
||||||
ip?: string | null
|
ip?: string | null
|
||||||
|
createdAt?: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
function mapRow(row: typeof auditLog.$inferSelect): AuditLogEntry {
|
function mapRow(row: typeof auditLog.$inferSelect): AuditLogEntry {
|
||||||
@@ -35,6 +39,8 @@ function mapRow(row: typeof auditLog.$inferSelect): AuditLogEntry {
|
|||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
id: row.id,
|
id: row.id,
|
||||||
|
event_id: row.eventId,
|
||||||
|
source_app: (row.sourceApp as AuditSourceApp) || 'portal',
|
||||||
action: row.action,
|
action: row.action,
|
||||||
severity: row.severity as AuditSeverity,
|
severity: row.severity as AuditSeverity,
|
||||||
actor_user_id: row.actorUserId,
|
actor_user_id: row.actorUserId,
|
||||||
@@ -49,12 +55,28 @@ function mapRow(row: typeof auditLog.$inferSelect): AuditLogEntry {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Append an audit event. Callers should catch/swallow DB errors. */
|
/**
|
||||||
export function appendAudit(db: AppDb, input: AppendAuditInput): void {
|
* Append an audit event.
|
||||||
const now = new Date().toISOString()
|
* @returns true if inserted, false if duplicate event_id
|
||||||
|
*/
|
||||||
|
export function appendAudit(db: AppDb, input: AppendAuditInput): boolean {
|
||||||
|
const now = input.createdAt ?? new Date().toISOString()
|
||||||
|
const eventId = input.eventId ?? null
|
||||||
|
|
||||||
|
if (eventId) {
|
||||||
|
const existing = db
|
||||||
|
.select({ id: auditLog.id })
|
||||||
|
.from(auditLog)
|
||||||
|
.where(eq(auditLog.eventId, eventId))
|
||||||
|
.get()
|
||||||
|
if (existing) return false
|
||||||
|
}
|
||||||
|
|
||||||
db.insert(auditLog)
|
db.insert(auditLog)
|
||||||
.values({
|
.values({
|
||||||
id: randomUUID(),
|
id: randomUUID(),
|
||||||
|
eventId,
|
||||||
|
sourceApp: input.sourceApp ?? 'portal',
|
||||||
action: input.action,
|
action: input.action,
|
||||||
severity: input.severity ?? 'info',
|
severity: input.severity ?? 'info',
|
||||||
actorUserId: input.actorUserId ?? null,
|
actorUserId: input.actorUserId ?? null,
|
||||||
@@ -68,16 +90,32 @@ export function appendAudit(db: AppDb, input: AppendAuditInput): void {
|
|||||||
createdAt: now,
|
createdAt: now,
|
||||||
})
|
})
|
||||||
.run()
|
.run()
|
||||||
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
export function listAudit(
|
export function listAudit(
|
||||||
db: AppDb,
|
db: AppDb,
|
||||||
opts: { action?: string; severity?: AuditSeverity; limit?: number } = {},
|
opts: {
|
||||||
|
action?: string
|
||||||
|
severity?: AuditSeverity
|
||||||
|
userId?: string
|
||||||
|
sourceApp?: AuditSourceApp
|
||||||
|
limit?: number
|
||||||
|
} = {},
|
||||||
): AuditLogEntry[] {
|
): AuditLogEntry[] {
|
||||||
const limit = opts.limit ?? 200
|
const limit = opts.limit ?? 200
|
||||||
const conditions = []
|
const conditions = []
|
||||||
if (opts.action) conditions.push(eq(auditLog.action, opts.action))
|
if (opts.action) conditions.push(eq(auditLog.action, opts.action))
|
||||||
if (opts.severity) conditions.push(eq(auditLog.severity, opts.severity))
|
if (opts.severity) conditions.push(eq(auditLog.severity, opts.severity))
|
||||||
|
if (opts.sourceApp) conditions.push(eq(auditLog.sourceApp, opts.sourceApp))
|
||||||
|
if (opts.userId) {
|
||||||
|
conditions.push(
|
||||||
|
or(
|
||||||
|
eq(auditLog.actorUserId, opts.userId),
|
||||||
|
eq(auditLog.targetId, opts.userId),
|
||||||
|
)!,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
const rows =
|
const rows =
|
||||||
conditions.length > 0
|
conditions.length > 0
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ export function migrateSchema(sqlite: Sqlite): void {
|
|||||||
password_hash TEXT NOT NULL,
|
password_hash TEXT NOT NULL,
|
||||||
is_admin INTEGER NOT NULL DEFAULT 0,
|
is_admin INTEGER NOT NULL DEFAULT 0,
|
||||||
disabled INTEGER NOT NULL DEFAULT 0,
|
disabled INTEGER NOT NULL DEFAULT 0,
|
||||||
|
last_login_at TEXT,
|
||||||
created_at TEXT NOT NULL,
|
created_at TEXT NOT NULL,
|
||||||
updated_at TEXT NOT NULL
|
updated_at TEXT NOT NULL
|
||||||
);
|
);
|
||||||
@@ -69,6 +70,8 @@ export function migrateSchema(sqlite: Sqlite): void {
|
|||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS audit_log (
|
CREATE TABLE IF NOT EXISTS audit_log (
|
||||||
id TEXT PRIMARY KEY NOT NULL,
|
id TEXT PRIMARY KEY NOT NULL,
|
||||||
|
event_id TEXT,
|
||||||
|
source_app TEXT NOT NULL DEFAULT 'portal',
|
||||||
action TEXT NOT NULL,
|
action TEXT NOT NULL,
|
||||||
severity TEXT NOT NULL,
|
severity TEXT NOT NULL,
|
||||||
actor_user_id TEXT,
|
actor_user_id TEXT,
|
||||||
@@ -87,17 +90,45 @@ export function migrateSchema(sqlite: Sqlite): void {
|
|||||||
CREATE INDEX IF NOT EXISTS idx_refresh_sessions_user ON refresh_sessions(user_id);
|
CREATE INDEX IF NOT EXISTS idx_refresh_sessions_user ON refresh_sessions(user_id);
|
||||||
CREATE INDEX IF NOT EXISTS idx_audit_log_created ON audit_log(created_at);
|
CREATE INDEX IF NOT EXISTS idx_audit_log_created ON audit_log(created_at);
|
||||||
CREATE INDEX IF NOT EXISTS idx_audit_log_action ON audit_log(action);
|
CREATE INDEX IF NOT EXISTS idx_audit_log_action ON audit_log(action);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_audit_log_actor ON audit_log(actor_user_id, created_at);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_audit_log_target ON audit_log(target_id, created_at);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_audit_log_source ON audit_log(source_app, created_at);
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_audit_log_event_id ON audit_log(event_id) WHERE event_id IS NOT NULL;
|
||||||
`)
|
`)
|
||||||
|
|
||||||
// Existing DBs created before audit_retention_days
|
const settingsCols = sqlite
|
||||||
const cols = sqlite
|
|
||||||
.prepare(`PRAGMA table_info(portal_settings)`)
|
.prepare(`PRAGMA table_info(portal_settings)`)
|
||||||
.all() as Array<{ name: string }>
|
.all() as Array<{ name: string }>
|
||||||
if (!cols.some((c) => c.name === 'audit_retention_days')) {
|
if (!settingsCols.some((c) => c.name === 'audit_retention_days')) {
|
||||||
sqlite.exec(
|
sqlite.exec(
|
||||||
`ALTER TABLE portal_settings ADD COLUMN audit_retention_days INTEGER NOT NULL DEFAULT 90`,
|
`ALTER TABLE portal_settings ADD COLUMN audit_retention_days INTEGER NOT NULL DEFAULT 90`,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const userCols = sqlite
|
||||||
|
.prepare(`PRAGMA table_info(users)`)
|
||||||
|
.all() as Array<{ name: string }>
|
||||||
|
if (!userCols.some((c) => c.name === 'last_login_at')) {
|
||||||
|
sqlite.exec(`ALTER TABLE users ADD COLUMN last_login_at TEXT`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const auditCols = sqlite
|
||||||
|
.prepare(`PRAGMA table_info(audit_log)`)
|
||||||
|
.all() as Array<{ name: string }>
|
||||||
|
if (!auditCols.some((c) => c.name === 'event_id')) {
|
||||||
|
sqlite.exec(`ALTER TABLE audit_log ADD COLUMN event_id TEXT`)
|
||||||
|
}
|
||||||
|
if (!auditCols.some((c) => c.name === 'source_app')) {
|
||||||
|
sqlite.exec(
|
||||||
|
`ALTER TABLE audit_log ADD COLUMN source_app TEXT NOT NULL DEFAULT 'portal'`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
sqlite.exec(`
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_audit_log_actor ON audit_log(actor_user_id, created_at);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_audit_log_target ON audit_log(target_id, created_at);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_audit_log_source ON audit_log(source_app, created_at);
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_audit_log_event_id ON audit_log(event_id) WHERE event_id IS NOT NULL;
|
||||||
|
`)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function healthCheck(sqlite: Sqlite): void {
|
export function healthCheck(sqlite: Sqlite): void {
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ export const users = sqliteTable('users', {
|
|||||||
passwordHash: text('password_hash').notNull(),
|
passwordHash: text('password_hash').notNull(),
|
||||||
isAdmin: integer('is_admin', { mode: 'boolean' }).notNull().default(false),
|
isAdmin: integer('is_admin', { mode: 'boolean' }).notNull().default(false),
|
||||||
disabled: integer('disabled', { mode: 'boolean' }).notNull().default(false),
|
disabled: integer('disabled', { mode: 'boolean' }).notNull().default(false),
|
||||||
|
lastLoginAt: text('last_login_at'),
|
||||||
createdAt: text('created_at').notNull(),
|
createdAt: text('created_at').notNull(),
|
||||||
updatedAt: text('updated_at').notNull(),
|
updatedAt: text('updated_at').notNull(),
|
||||||
})
|
})
|
||||||
@@ -46,6 +47,8 @@ export const portalSettings = sqliteTable('portal_settings', {
|
|||||||
|
|
||||||
export const auditLog = sqliteTable('audit_log', {
|
export const auditLog = sqliteTable('audit_log', {
|
||||||
id: text('id').primaryKey(),
|
id: text('id').primaryKey(),
|
||||||
|
eventId: text('event_id'),
|
||||||
|
sourceApp: text('source_app').notNull().default('portal'),
|
||||||
action: text('action').notNull(),
|
action: text('action').notNull(),
|
||||||
severity: text('severity').notNull(),
|
severity: text('severity').notNull(),
|
||||||
actorUserId: text('actor_user_id'),
|
actorUserId: text('actor_user_id'),
|
||||||
|
|||||||
@@ -130,6 +130,14 @@ export function setUserAccess(
|
|||||||
.run()
|
.run()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function touchLastLogin(db: AppDb, userId: string): void {
|
||||||
|
const now = new Date().toISOString()
|
||||||
|
db.update(users)
|
||||||
|
.set({ lastLoginAt: now, updatedAt: now })
|
||||||
|
.where(eq(users.id, userId))
|
||||||
|
.run()
|
||||||
|
}
|
||||||
|
|
||||||
export function createRefreshSession(
|
export function createRefreshSession(
|
||||||
db: AppDb,
|
db: AppDb,
|
||||||
userId: string,
|
userId: string,
|
||||||
|
|||||||
@@ -4,16 +4,27 @@ export const AUDIT_SEVERITIES = ['info', 'warning', 'critical'] as const
|
|||||||
export type AuditSeverity = (typeof AUDIT_SEVERITIES)[number]
|
export type AuditSeverity = (typeof AUDIT_SEVERITIES)[number]
|
||||||
export const auditSeveritySchema = z.enum(AUDIT_SEVERITIES)
|
export const auditSeveritySchema = z.enum(AUDIT_SEVERITIES)
|
||||||
|
|
||||||
|
export const AUDIT_SOURCE_APPS = [
|
||||||
|
'portal',
|
||||||
|
'vps',
|
||||||
|
'cfdm',
|
||||||
|
'bgp',
|
||||||
|
'fw',
|
||||||
|
] as const
|
||||||
|
export type AuditSourceApp = (typeof AUDIT_SOURCE_APPS)[number]
|
||||||
|
export const auditSourceAppSchema = z.enum(AUDIT_SOURCE_APPS)
|
||||||
|
|
||||||
export const AUDIT_TARGET_TYPES = [
|
export const AUDIT_TARGET_TYPES = [
|
||||||
'user',
|
'user',
|
||||||
'settings',
|
'settings',
|
||||||
'session',
|
'session',
|
||||||
'system',
|
'system',
|
||||||
|
'app_resource',
|
||||||
] as const
|
] as const
|
||||||
export type AuditTargetType = (typeof AUDIT_TARGET_TYPES)[number]
|
export type AuditTargetType = (typeof AUDIT_TARGET_TYPES)[number]
|
||||||
export const auditTargetTypeSchema = z.enum(AUDIT_TARGET_TYPES)
|
export const auditTargetTypeSchema = z.enum(AUDIT_TARGET_TYPES)
|
||||||
|
|
||||||
/** Well-known action keys written by the API. */
|
/** Well-known portal-native action keys. */
|
||||||
export const AUDIT_ACTIONS = [
|
export const AUDIT_ACTIONS = [
|
||||||
'auth.login',
|
'auth.login',
|
||||||
'auth.login_failed',
|
'auth.login_failed',
|
||||||
@@ -31,6 +42,8 @@ export const auditActionSchema = z.enum(AUDIT_ACTIONS)
|
|||||||
|
|
||||||
export const auditLogEntrySchema = z.object({
|
export const auditLogEntrySchema = z.object({
|
||||||
id: z.string(),
|
id: z.string(),
|
||||||
|
event_id: z.string().nullable(),
|
||||||
|
source_app: auditSourceAppSchema,
|
||||||
action: z.string(),
|
action: z.string(),
|
||||||
severity: auditSeveritySchema,
|
severity: auditSeveritySchema,
|
||||||
actor_user_id: z.string().nullable(),
|
actor_user_id: z.string().nullable(),
|
||||||
@@ -48,6 +61,8 @@ export type AuditLogEntry = z.infer<typeof auditLogEntrySchema>
|
|||||||
export const auditListQuerySchema = z.object({
|
export const auditListQuerySchema = z.object({
|
||||||
action: z.string().optional(),
|
action: z.string().optional(),
|
||||||
severity: auditSeveritySchema.optional(),
|
severity: auditSeveritySchema.optional(),
|
||||||
|
user_id: z.string().optional(),
|
||||||
|
source_app: auditSourceAppSchema.optional(),
|
||||||
limit: z.coerce.number().int().min(1).max(500).default(200),
|
limit: z.coerce.number().int().min(1).max(500).default(200),
|
||||||
})
|
})
|
||||||
export type AuditListQuery = z.infer<typeof auditListQuerySchema>
|
export type AuditListQuery = z.infer<typeof auditListQuerySchema>
|
||||||
@@ -73,3 +88,31 @@ export const auditPurgeResponseSchema = z.object({
|
|||||||
retention_days: z.number().int(),
|
retention_days: z.number().int(),
|
||||||
})
|
})
|
||||||
export type AuditPurgeResponse = z.infer<typeof auditPurgeResponseSchema>
|
export type AuditPurgeResponse = z.infer<typeof auditPurgeResponseSchema>
|
||||||
|
|
||||||
|
export const ingestAuditEventSchema = z.object({
|
||||||
|
event_id: z.string().min(1).max(128),
|
||||||
|
source_app: z.enum(['vps', 'cfdm', 'bgp', 'fw']),
|
||||||
|
action: z.string().min(1).max(200),
|
||||||
|
severity: auditSeveritySchema.optional(),
|
||||||
|
actor_user_id: z.string().nullable().optional(),
|
||||||
|
actor_email: z.string().email().nullable().optional(),
|
||||||
|
actor_name: z.string().nullable().optional(),
|
||||||
|
target_type: auditTargetTypeSchema.nullable().optional(),
|
||||||
|
target_id: z.string().nullable().optional(),
|
||||||
|
summary: z.string().min(1).max(500),
|
||||||
|
details: z.record(z.string(), z.unknown()).nullable().optional(),
|
||||||
|
ip: z.string().nullable().optional(),
|
||||||
|
created_at: z.string().optional(),
|
||||||
|
})
|
||||||
|
export type IngestAuditEvent = z.infer<typeof ingestAuditEventSchema>
|
||||||
|
|
||||||
|
export const ingestAuditRequestSchema = z.object({
|
||||||
|
events: z.array(ingestAuditEventSchema).min(1).max(50),
|
||||||
|
})
|
||||||
|
export type IngestAuditRequest = z.infer<typeof ingestAuditRequestSchema>
|
||||||
|
|
||||||
|
export const ingestAuditResponseSchema = z.object({
|
||||||
|
accepted: z.number().int().nonnegative(),
|
||||||
|
duplicates: z.number().int().nonnegative(),
|
||||||
|
})
|
||||||
|
export type IngestAuditResponse = z.infer<typeof ingestAuditResponseSchema>
|
||||||
|
|||||||
@@ -370,6 +370,7 @@ export const adminUserSchema = z.object({
|
|||||||
disabled: z.boolean(),
|
disabled: z.boolean(),
|
||||||
apps: z.array(appIdSchema),
|
apps: z.array(appIdSchema),
|
||||||
permissions: z.array(z.string()),
|
permissions: z.array(z.string()),
|
||||||
|
last_login_at: z.string().nullable(),
|
||||||
created_at: z.string(),
|
created_at: z.string(),
|
||||||
updated_at: z.string(),
|
updated_at: z.string(),
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user