feat: refine admin audit filtering (#495)

* feat: refine admin audit filtering

Agent-Profile: https://agent-kanban.dev/agents/b0abe6cd7aeba133

* test: add audit filter spec scenarios

Agent-Profile: https://agent-kanban.dev/agents/b0abe6cd7aeba133

* chore: refresh openapi client

Agent-Profile: https://agent-kanban.dev/agents/b0abe6cd7aeba133

---------

Co-authored-by: Noah Reed <noah-reed@mails.agent-kanban.dev>
This commit is contained in:
agent-kanban[bot]
2026-07-04 10:21:45 -04:00
committed by GitHub
parent bc15bc3617
commit 7ccaba2f8b
17 changed files with 1057 additions and 184 deletions
+40 -6
View File
@@ -3497,12 +3497,14 @@ type UpdateAnnouncementJSONBodyStatus string
// ListAuditEventsParams defines parameters for ListAuditEvents.
type ListAuditEventsParams struct {
Page *int `form:"page,omitempty" json:"page,omitempty"`
PageSize *int `form:"pageSize,omitempty" json:"pageSize,omitempty"`
OrgId *string `form:"orgId,omitempty" json:"orgId,omitempty"`
UserId *string `form:"userId,omitempty" json:"userId,omitempty"`
Action *string `form:"action,omitempty" json:"action,omitempty"`
TargetType *string `form:"targetType,omitempty" json:"targetType,omitempty"`
Page *int `form:"page,omitempty" json:"page,omitempty"`
PageSize *int `form:"pageSize,omitempty" json:"pageSize,omitempty"`
OrgId *string `form:"orgId,omitempty" json:"orgId,omitempty"`
UserId *string `form:"userId,omitempty" json:"userId,omitempty"`
Action *string `form:"action,omitempty" json:"action,omitempty"`
TargetType *string `form:"targetType,omitempty" json:"targetType,omitempty"`
CreatedFrom *time.Time `form:"createdFrom,omitempty" json:"createdFrom,omitempty"`
CreatedTo *time.Time `form:"createdTo,omitempty" json:"createdTo,omitempty"`
}
// UpsertAuthProviderJSONBody defines parameters for UpsertAuthProvider.
@@ -15212,6 +15214,30 @@ func NewListAuditEventsRequest(server string, params *ListAuditEventsParams) (*h
}
if params.CreatedFrom != nil {
if queryFrag, err := runtime.StyleParamWithOptions("form", true, "createdFrom", *params.CreatedFrom, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationQuery, Type: "string", Format: "date-time"}); err != nil {
return nil, err
} else {
for _, qp := range strings.Split(queryFrag, "&") {
rawQueryFragments = append(rawQueryFragments, qp)
}
}
}
if params.CreatedTo != nil {
if queryFrag, err := runtime.StyleParamWithOptions("form", true, "createdTo", *params.CreatedTo, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationQuery, Type: "string", Format: "date-time"}); err != nil {
return nil, err
} else {
for _, qp := range strings.Split(queryFrag, "&") {
rawQueryFragments = append(rawQueryFragments, qp)
}
}
}
if encoded := queryValues.Encode(); encoded != "" {
rawQueryFragments = append(rawQueryFragments, encoded)
}
@@ -25277,6 +25303,7 @@ type ListAuditEventsResponse struct {
Body []byte
HTTPResponse *http.Response
JSON200 *AuditEventPage
JSON400 *Error
}
// Status returns HTTPResponse.Status
@@ -39837,6 +39864,13 @@ func ParseListAuditEventsResponse(rsp *http.Response) (*ListAuditEventsResponse,
}
response.JSON200 = &dest
case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 400:
var dest Error
if err := json.Unmarshal(bodyBytes, &dest); err != nil {
return nil, err
}
response.JSON400 = &dest
}
return response, nil
+3 -1
View File
@@ -1,4 +1,4 @@
import { and, count, desc, eq } from 'drizzle-orm'
import { and, count, desc, eq, gte, lte } from 'drizzle-orm'
import { nanoid } from 'nanoid'
import { organization, user } from '../../db/auth-schema'
import { activityEvents } from '../../db/schema'
@@ -76,6 +76,8 @@ export function createActivityRepo(db: Database): ActivityRepo {
opts.userId ? eq(activityEvents.userId, opts.userId) : undefined,
opts.action ? eq(activityEvents.action, opts.action) : undefined,
opts.targetType ? eq(activityEvents.targetType, opts.targetType) : undefined,
opts.createdFrom ? gte(activityEvents.createdAt, opts.createdFrom) : undefined,
opts.createdTo ? lte(activityEvents.createdAt, opts.createdTo) : undefined,
].filter(Boolean) as Parameters<typeof and>
const whereClause = filters.length > 0 ? and(...filters) : undefined
@@ -198,6 +198,85 @@ describe('GET /api/site/audit-events — licensed admin', () => {
expect(body.items[0].action).toBe('upload')
})
it('filters by action and created-at range [spec: audit/filter-action-created-range]', async () => {
const { app, db } = await createTestApp()
await seedProLicense(db)
const headers = await adminHeaders(app)
const { activityEvents } = await import('../../db/schema.js')
await db.insert(activityEvents).values([
{
id: 'evt-range-match',
orgId: 'org-1',
userId: 'u1',
action: 'upload',
targetType: 'file',
targetId: null,
targetName: 'match.pdf',
metadata: null,
createdAt: new Date('2026-02-15T12:00:00.000Z'),
},
{
id: 'evt-range-wrong-action',
orgId: 'org-1',
userId: 'u1',
action: 'delete',
targetType: 'file',
targetId: null,
targetName: 'wrong-action.pdf',
metadata: null,
createdAt: new Date('2026-02-15T12:00:00.000Z'),
},
{
id: 'evt-range-too-old',
orgId: 'org-1',
userId: 'u1',
action: 'upload',
targetType: 'file',
targetId: null,
targetName: 'too-old.pdf',
metadata: null,
createdAt: new Date('2026-01-31T23:59:59.000Z'),
},
{
id: 'evt-range-too-new',
orgId: 'org-1',
userId: 'u1',
action: 'upload',
targetType: 'file',
targetId: null,
targetName: 'too-new.pdf',
metadata: null,
createdAt: new Date('2026-03-01T00:00:01.000Z'),
},
])
const query = new URLSearchParams({
action: 'upload',
createdFrom: '2026-02-01T00:00:00.000Z',
createdTo: '2026-03-01T00:00:00.000Z',
})
const res = await app.request(`/api/site/audit-events?${query}`, { headers })
const body = (await res.json()) as { items: Array<{ id: string }>; total: number }
expect(body.total).toBe(1)
expect(body.items.map((item) => item.id)).toEqual(['evt-range-match'])
})
it('rejects an inverted created-at range [spec: audit/filter-created-range-validation]', async () => {
const { app, db } = await createTestApp()
await seedProLicense(db)
const headers = await adminHeaders(app)
const query = new URLSearchParams({
createdFrom: '2026-03-01T00:00:00.000Z',
createdTo: '2026-02-01T00:00:00.000Z',
})
const res = await app.request(`/api/site/audit-events?${query}`, { headers })
const body = (await res.json()) as { error: { details: Array<{ reason: string }> } }
expect(res.status).toBe(400)
expect(body.error.details[0]?.reason).toBe('INVALID_TIME_RANGE')
})
it('filters by targetType [spec: audit/filter-target-type]', async () => {
const { app, db } = await createTestApp()
await seedProLicense(db)
+14 -3
View File
@@ -4,8 +4,9 @@ import { requireAdmin } from '../../middleware/auth'
import type { Env } from '../../middleware/platform'
import { requireFeature } from '../../middleware/require-feature'
import type { AdminAuditEventWithOrg } from '../../usecases/ports'
import { badRequest } from '../../usecases/ports'
import { listAuditEvents } from '../../usecases/site/audit'
import { jsonContent } from '../openapi'
import { errorResponse, jsonContent } from '../openapi'
const auditEventSchema = z
.object({
@@ -36,6 +37,8 @@ const listAuditQuerySchema = pageQuerySchema.extend({
userId: z.string().optional(),
action: z.string().optional(),
targetType: z.string().optional(),
createdFrom: z.string().datetime().optional(),
createdTo: z.string().datetime().optional(),
})
const listRoute = createRoute({
@@ -46,11 +49,17 @@ const listRoute = createRoute({
path: '/',
middleware: [requireAdmin, requireFeature('audit_log')] as const,
request: { query: listAuditQuerySchema },
responses: { 200: jsonContent(auditPageSchema, 'Audit events') },
responses: { 200: jsonContent(auditPageSchema, 'Audit events'), 400: errorResponse('Invalid query') },
})
export const adminAudit = new OpenAPIHono<Env>().openapi(listRoute, async (c) => {
const { page, pageSize, orgId, userId, action, targetType } = c.req.valid('query')
const { page, pageSize, orgId, userId, action, targetType, createdFrom, createdTo } = c.req.valid('query')
const createdFromDate = createdFrom ? new Date(createdFrom) : undefined
const createdToDate = createdTo ? new Date(createdTo) : undefined
if (createdFromDate && createdToDate && createdFromDate > createdToDate) {
throw badRequest('createdFrom must be before createdTo', 'INVALID_TIME_RANGE')
}
const result = await listAuditEvents(c.get('deps'), {
page,
pageSize,
@@ -58,6 +67,8 @@ export const adminAudit = new OpenAPIHono<Env>().openapi(listRoute, async (c) =>
userId,
action,
targetType,
createdFrom: createdFromDate,
createdTo: createdToDate,
})
return c.json({ ...result, items: result.items.map(toAuditEventDTO) }, 200)
})
+2
View File
@@ -39,6 +39,8 @@ export interface ListAdminAuditOpts {
userId?: string
action?: string
targetType?: string
createdFrom?: Date
createdTo?: Date
}
export interface ListActivityByTargetOpts {
+2
View File
@@ -7,6 +7,8 @@ export const listAdminAuditQuerySchema = z.object({
userId: z.string().optional(),
action: z.string().optional(),
targetType: z.string().optional(),
createdFrom: z.string().datetime().optional(),
createdTo: z.string().datetime().optional(),
})
export type ListAdminAuditQuery = z.infer<typeof listAdminAuditQuerySchema>
+12
View File
@@ -50,6 +50,18 @@ Feature: Audit log
When an admin filters by action
Then only events of that action are returned
@audit/filter-action-created-range @api
Scenario: Events can be filtered by action and creation time range
Given recorded events of several action types across several creation times
When an admin filters by action and created-at range
Then only matching events within that range are returned
@audit/filter-created-range-validation @api
Scenario: Inverted audit creation time ranges are rejected
Given an admin audit log request with createdFrom after createdTo
When the admin reads the audit log
Then the API responds 400 invalid_time_range
@audit/filter-target-type @api
Scenario: Events can be filtered by target type
Given recorded events on several target types
+238
View File
@@ -0,0 +1,238 @@
import { useTranslation } from 'react-i18next'
import { Button } from '@/components/ui/button'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import type { AdminAuditFilter } from '@/lib/api'
export const AUDIT_DEFAULT_PAGE_SIZE = 20
export const AUDIT_PAGE_SIZE_OPTIONS = [20, 50, 100]
export const AUDIT_FILTER_ALL = 'all'
export const AUDIT_EVENT_ACTIONS = [
'upload',
'create',
'delete',
'rename',
'move',
'restore',
'replace',
'save_from_share',
'upload_confirm',
'upload_cancel',
'object_copy',
'object_purge',
'moved_from_org',
'copied_from_org',
'moved_to_org',
'batch_trash',
'batch_purge',
'trash_empty',
'share_create',
'share_revoke',
'share_download',
'team_invite_link_create',
'team_member_join',
'team_member_remove',
'team_member_role_update',
'team_settings_update',
'team_delete',
'team_logo_update',
'team_logo_delete',
'system_option_set',
'system_option_delete',
'storage_create',
'storage_update',
'storage_delete',
'quota_update',
'quota_entitlement_grant',
'quota_entitlement_update',
'quota_entitlement_revoke',
'quota_order_increase',
'quota_order_decrease',
'invite_code_generate',
'invite_code_delete',
'site_invitation_create',
'site_invitation_revoke',
'user_disable',
'user_enable',
'user_delete',
'license_pair',
'license_refresh',
'license_disconnect',
'branding_update',
'branding_reset',
'download_task_created',
'download_task_assigned',
'download_task_queued',
'download_task_started',
'download_task_ingesting',
'download_task_completed',
'download_task_failed',
'download_task_canceled',
'download_task_suspended',
'download_task_paused',
'download_task_interrupted',
'download_task_pause_requested',
'download_task_resume_requested',
'download_task_cancel_requested',
'download_task_retry_requested',
'download_task_restart_requested',
'download_task_deleted',
'download_task_error',
'download_task_billing_suspended',
'download_resolve_started',
'download_resolve_completed',
'download_completed',
'download_ingest_started',
'download_ingest_completed',
'download_seeding_started',
'download_seeding_stopped',
'download_stale_requeued',
'download_stale_control_resolved',
]
export type AuditTimeRange = 'all' | '24h' | '7d' | '30d' | '90d'
const TIME_RANGE_MS: Record<Exclude<AuditTimeRange, 'all'>, number> = {
'24h': 24 * 60 * 60 * 1000,
'7d': 7 * 24 * 60 * 60 * 1000,
'30d': 30 * 24 * 60 * 60 * 1000,
'90d': 90 * 24 * 60 * 60 * 1000,
}
const TIME_RANGE_KEYS: Record<AuditTimeRange, string> = {
all: 'admin.audit.allTime',
'24h': 'admin.audit.last24Hours',
'7d': 'admin.audit.last7Days',
'30d': 'admin.audit.last30Days',
'90d': 'admin.audit.last90Days',
}
export function auditActionToFilter(action: string): string | undefined {
return action === AUDIT_FILTER_ALL ? undefined : action
}
export function auditTimeRangeToFilter(
timeRange: AuditTimeRange,
now = new Date(),
): Pick<AdminAuditFilter, 'createdFrom' | 'createdTo'> {
if (timeRange === 'all') return {}
return {
createdFrom: new Date(now.getTime() - TIME_RANGE_MS[timeRange]).toISOString(),
createdTo: now.toISOString(),
}
}
interface AuditLogFiltersProps {
action: string
timeRange: AuditTimeRange
disabled?: boolean
onActionChange: (value: string) => void
onTimeRangeChange: (value: AuditTimeRange) => void
}
export function AuditLogFilters({
action,
timeRange,
disabled = false,
onActionChange,
onTimeRangeChange,
}: AuditLogFiltersProps) {
const { t } = useTranslation()
return (
<div className="flex flex-col gap-3 sm:flex-row sm:items-end">
<div className="grid gap-1.5 text-sm">
<span className="text-xs font-medium text-muted-foreground">{t('admin.audit.eventType')}</span>
<Select value={action} onValueChange={onActionChange} disabled={disabled}>
<SelectTrigger className="h-8 w-full sm:w-56" aria-label={t('admin.audit.eventType')}>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value={AUDIT_FILTER_ALL}>{t('admin.audit.allEvents')}</SelectItem>
{AUDIT_EVENT_ACTIONS.map((item) => (
<SelectItem key={item} value={item}>
{t(`activity.action.${item}`, { defaultValue: item })}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="grid gap-1.5 text-sm">
<span className="text-xs font-medium text-muted-foreground">{t('admin.audit.timeRange')}</span>
<Select
value={timeRange}
onValueChange={(value) => onTimeRangeChange(value as AuditTimeRange)}
disabled={disabled}
>
<SelectTrigger className="h-8 w-full sm:w-44" aria-label={t('admin.audit.timeRange')}>
<SelectValue />
</SelectTrigger>
<SelectContent>
{(Object.keys(TIME_RANGE_KEYS) as AuditTimeRange[]).map((item) => (
<SelectItem key={item} value={item}>
{t(TIME_RANGE_KEYS[item])}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
)
}
interface AuditPaginationProps {
page: number
pageSize: number
total: number
disabled?: boolean
onPageChange: (page: number) => void
onPageSizeChange: (pageSize: number) => void
}
export function AuditPagination({
page,
pageSize,
total,
disabled = false,
onPageChange,
onPageSizeChange,
}: AuditPaginationProps) {
const { t } = useTranslation()
const totalPages = Math.max(1, Math.ceil(total / pageSize))
return (
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<span>{t('admin.users.pageSize')}</span>
<Select value={String(pageSize)} onValueChange={(value) => onPageSizeChange(Number(value))} disabled={disabled}>
<SelectTrigger className="h-8 w-32" aria-label={t('admin.users.pageSize')}>
<SelectValue />
</SelectTrigger>
<SelectContent>
{AUDIT_PAGE_SIZE_OPTIONS.map((option) => (
<SelectItem key={option} value={String(option)}>
{t('admin.users.pageSizeOption', { count: option })}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex items-center justify-end gap-2">
<Button variant="outline" size="sm" disabled={disabled || page <= 1} onClick={() => onPageChange(page - 1)}>
{t('admin.users.prevPage')}
</Button>
<span className="text-sm text-muted-foreground">{t('admin.users.pageInfo', { page, total: totalPages })}</span>
<Button
variant="outline"
size="sm"
disabled={disabled || page >= totalPages}
onClick={() => onPageChange(page + 1)}
>
{t('admin.users.nextPage')}
</Button>
</div>
</div>
)
}
+39
View File
@@ -0,0 +1,39 @@
import { Tabs as TabsPrimitive } from 'radix-ui'
import type * as React from 'react'
import { cn } from '@/lib/utils'
function Tabs({ className, ...props }: React.ComponentProps<typeof TabsPrimitive.Root>) {
return <TabsPrimitive.Root data-slot="tabs" className={cn('flex flex-col gap-2', className)} {...props} />
}
function TabsList({ className, ...props }: React.ComponentProps<typeof TabsPrimitive.List>) {
return (
<TabsPrimitive.List
data-slot="tabs-list"
className={cn(
'inline-flex h-9 w-fit items-center justify-center rounded-md bg-muted p-1 text-muted-foreground',
className,
)}
{...props}
/>
)
}
function TabsTrigger({ className, ...props }: React.ComponentProps<typeof TabsPrimitive.Trigger>) {
return (
<TabsPrimitive.Trigger
data-slot="tabs-trigger"
className={cn(
'inline-flex h-7 flex-1 items-center justify-center gap-1.5 rounded-sm border border-transparent px-3 py-1 text-sm font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-1 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-sm',
className,
)}
{...props}
/>
)
}
function TabsContent({ className, ...props }: React.ComponentProps<typeof TabsPrimitive.Content>) {
return <TabsPrimitive.Content data-slot="tabs-content" className={cn('flex-1 outline-none', className)} {...props} />
}
export { Tabs, TabsContent, TabsList, TabsTrigger }
+44
View File
@@ -466,6 +466,7 @@
"admin.users.revokeEntitlementConfirm": "Revoke this entitlement for {{name}}? Their available quota will be reduced immediately.",
"admin.users.entitlementRevoked": "Entitlement revoked",
"admin.users.entitlements": "Entitlements",
"admin.users.tabEntitlement": "Entitlement",
"admin.users.activeEntitlements": "Active entitlements",
"admin.users.backToUsers": "Back to users",
"admin.users.userDetails": "User details",
@@ -518,6 +519,14 @@
"admin.audit.upgradeButton": "Upgrade to Pro",
"admin.audit.empty": "No audit events yet.",
"admin.audit.loadError": "Failed to load audit logs.",
"admin.audit.eventType": "Event type",
"admin.audit.timeRange": "Time range",
"admin.audit.allEvents": "All events",
"admin.audit.allTime": "All time",
"admin.audit.last24Hours": "Last 24 hours",
"admin.audit.last7Days": "Last 7 days",
"admin.audit.last30Days": "Last 30 days",
"admin.audit.last90Days": "Last 90 days",
"admin.audit.loadMore": "Load more",
"admin.announcement.title": "Site Announcement",
"admin.announcement.description": "Manage the announcement area shown to users across the site.",
@@ -1076,11 +1085,15 @@
"activity.action.rename": "renamed",
"activity.action.move": "moved",
"activity.action.restore": "restored",
"activity.action.replace": "replaced",
"activity.action.save_from_share": "saved from share",
"activity.action.upload_confirm": "confirmed upload of",
"activity.action.upload_cancel": "cancelled upload of",
"activity.action.object_copy": "copied",
"activity.action.object_purge": "permanently deleted",
"activity.action.moved_from_org": "moved from space",
"activity.action.copied_from_org": "copied from space",
"activity.action.moved_to_org": "moved to space",
"activity.action.batch_trash": "moved to trash",
"activity.action.batch_purge": "permanently deleted",
"activity.action.trash_empty": "emptied trash",
@@ -1104,6 +1117,8 @@
"activity.action.quota_entitlement_grant": "granted quota entitlement for",
"activity.action.quota_entitlement_update": "updated quota entitlement for",
"activity.action.quota_entitlement_revoke": "revoked quota entitlement for",
"activity.action.quota_order_increase": "increased quota from order",
"activity.action.quota_order_decrease": "decreased quota from order",
"activity.action.invite_code_generate": "generated invite codes",
"activity.action.invite_code_delete": "deleted invite code",
"activity.action.site_invitation_create": "sent site invitation to",
@@ -1116,6 +1131,34 @@
"activity.action.license_disconnect": "disconnected license",
"activity.action.branding_update": "updated branding",
"activity.action.branding_reset": "reset branding field",
"activity.action.download_task_created": "created download task",
"activity.action.download_task_assigned": "assigned download task",
"activity.action.download_task_queued": "queued download task",
"activity.action.download_task_started": "started download task",
"activity.action.download_task_ingesting": "ingested download task",
"activity.action.download_task_completed": "completed download task",
"activity.action.download_task_failed": "failed download task",
"activity.action.download_task_canceled": "canceled download task",
"activity.action.download_task_suspended": "suspended download task",
"activity.action.download_task_paused": "paused download task",
"activity.action.download_task_interrupted": "interrupted download task",
"activity.action.download_task_pause_requested": "requested download pause",
"activity.action.download_task_resume_requested": "requested download resume",
"activity.action.download_task_cancel_requested": "requested download cancel",
"activity.action.download_task_retry_requested": "requested download retry",
"activity.action.download_task_restart_requested": "requested download restart",
"activity.action.download_task_deleted": "deleted download task",
"activity.action.download_task_error": "reported download task error",
"activity.action.download_task_billing_suspended": "suspended download billing",
"activity.action.download_resolve_started": "started resolving download",
"activity.action.download_resolve_completed": "resolved download source",
"activity.action.download_completed": "completed download",
"activity.action.download_ingest_started": "started download ingest",
"activity.action.download_ingest_completed": "completed download ingest",
"activity.action.download_seeding_started": "started download seeding",
"activity.action.download_seeding_stopped": "stopped download seeding",
"activity.action.download_stale_requeued": "requeued stale download",
"activity.action.download_stale_control_resolved": "resolved stale download control",
"activity.target.file": "file",
"activity.target.folder": "folder",
"activity.target.share": "share",
@@ -1128,6 +1171,7 @@
"activity.target.user": "user",
"activity.target.license": "license",
"activity.target.branding": "branding",
"activity.target.download_task": "download task",
"activity.meta.from": "from",
"activity.meta.to": "to",
"activity.loadMore": "Load more",
+44
View File
@@ -466,6 +466,7 @@
"admin.users.revokeEntitlementConfirm": "撤销 {{name}} 的这条权益?其可用额度将立即减少。",
"admin.users.entitlementRevoked": "权益已撤销",
"admin.users.entitlements": "额度权益",
"admin.users.tabEntitlement": "权益",
"admin.users.activeEntitlements": "有效权益",
"admin.users.backToUsers": "返回用户列表",
"admin.users.userDetails": "用户详情",
@@ -518,6 +519,14 @@
"admin.audit.upgradeButton": "升级到 Pro",
"admin.audit.empty": "暂无审计事件。",
"admin.audit.loadError": "加载审计日志失败。",
"admin.audit.eventType": "事件类型",
"admin.audit.timeRange": "时间范围",
"admin.audit.allEvents": "全部事件",
"admin.audit.allTime": "全部时间",
"admin.audit.last24Hours": "过去 24 小时",
"admin.audit.last7Days": "过去 7 天",
"admin.audit.last30Days": "过去 30 天",
"admin.audit.last90Days": "过去 90 天",
"admin.audit.loadMore": "加载更多",
"admin.announcement.title": "站点公告",
"admin.announcement.description": "管理面向站点用户展示的公告区域。",
@@ -1076,11 +1085,15 @@
"activity.action.rename": "重命名了",
"activity.action.move": "移动了",
"activity.action.restore": "恢复了",
"activity.action.replace": "替换了",
"activity.action.save_from_share": "保存了分享",
"activity.action.upload_confirm": "确认上传了",
"activity.action.upload_cancel": "取消上传了",
"activity.action.object_copy": "复制了",
"activity.action.object_purge": "永久删除了",
"activity.action.moved_from_org": "从空间移出了",
"activity.action.copied_from_org": "从空间复制了",
"activity.action.moved_to_org": "移动到其他空间了",
"activity.action.batch_trash": "批量移入回收站",
"activity.action.batch_purge": "批量永久删除了",
"activity.action.trash_empty": "清空了回收站",
@@ -1104,6 +1117,8 @@
"activity.action.quota_entitlement_grant": "发放了额度权益",
"activity.action.quota_entitlement_update": "更新了额度权益",
"activity.action.quota_entitlement_revoke": "撤销了额度权益",
"activity.action.quota_order_increase": "通过订单增加了配额",
"activity.action.quota_order_decrease": "通过订单减少了配额",
"activity.action.invite_code_generate": "生成了邀请码",
"activity.action.invite_code_delete": "删除了邀请码",
"activity.action.site_invitation_create": "发送了站点邀请",
@@ -1116,6 +1131,34 @@
"activity.action.license_disconnect": "解绑了许可证",
"activity.action.branding_update": "更新了品牌设置",
"activity.action.branding_reset": "重置了品牌字段",
"activity.action.download_task_created": "创建了下载任务",
"activity.action.download_task_assigned": "分配了下载任务",
"activity.action.download_task_queued": "将下载任务排队",
"activity.action.download_task_started": "开始了下载任务",
"activity.action.download_task_ingesting": "开始回传下载任务",
"activity.action.download_task_completed": "完成了下载任务",
"activity.action.download_task_failed": "下载任务失败",
"activity.action.download_task_canceled": "取消了下载任务",
"activity.action.download_task_suspended": "挂起了下载任务",
"activity.action.download_task_paused": "暂停了下载任务",
"activity.action.download_task_interrupted": "中断了下载任务",
"activity.action.download_task_pause_requested": "请求暂停下载任务",
"activity.action.download_task_resume_requested": "请求恢复下载任务",
"activity.action.download_task_cancel_requested": "请求取消下载任务",
"activity.action.download_task_retry_requested": "请求重试下载任务",
"activity.action.download_task_restart_requested": "请求重新开始下载任务",
"activity.action.download_task_deleted": "删除了下载任务",
"activity.action.download_task_error": "上报了下载任务错误",
"activity.action.download_task_billing_suspended": "挂起了下载任务计费",
"activity.action.download_resolve_started": "开始解析下载源",
"activity.action.download_resolve_completed": "完成下载源解析",
"activity.action.download_completed": "完成了下载",
"activity.action.download_ingest_started": "开始下载回传",
"activity.action.download_ingest_completed": "完成下载回传",
"activity.action.download_seeding_started": "开始下载做种",
"activity.action.download_seeding_stopped": "停止下载做种",
"activity.action.download_stale_requeued": "重新排队了失联下载任务",
"activity.action.download_stale_control_resolved": "处理了失联下载控制",
"activity.target.file": "文件",
"activity.target.folder": "文件夹",
"activity.target.share": "分享",
@@ -1128,6 +1171,7 @@
"activity.target.user": "用户",
"activity.target.license": "许可证",
"activity.target.branding": "品牌设置",
"activity.target.download_task": "下载任务",
"activity.meta.from": "从",
"activity.meta.to": "到",
"activity.loadMore": "加载更多",
+10 -1
View File
@@ -3838,7 +3838,14 @@ describe('api', () => {
it('includes optional filters in query', async () => {
vi.mocked(fetch).mockResolvedValueOnce(makeResponse({ items: [], total: 0, page: 1, pageSize: 20 }))
await listAdminAuditLogs(2, 10, { orgId: 'org-1', userId: 'user-1', action: 'upload', targetType: 'file' })
await listAdminAuditLogs(2, 10, {
orgId: 'org-1',
userId: 'user-1',
action: 'upload',
targetType: 'file',
createdFrom: '2026-01-01T00:00:00.000Z',
createdTo: '2026-02-01T00:00:00.000Z',
})
const [url] = vi.mocked(fetch).mock.calls[0] as [string]
expect(url).toContain('page=2')
@@ -3847,6 +3854,8 @@ describe('api', () => {
expect(url).toContain('userId=user-1')
expect(url).toContain('action=upload')
expect(url).toContain('targetType=file')
expect(url).toContain('createdFrom=2026-01-01T00%3A00%3A00.000Z')
expect(url).toContain('createdTo=2026-02-01T00%3A00%3A00.000Z')
})
it('returns parsed paginated response', async () => {
+4
View File
@@ -1455,6 +1455,8 @@ export interface AdminAuditFilter {
userId?: string
action?: string
targetType?: string
createdFrom?: string
createdTo?: string
}
export function listAdminAuditLogs(page = 1, pageSize = 20, filter: AdminAuditFilter = {}) {
@@ -1466,5 +1468,7 @@ export function listAdminAuditLogs(page = 1, pageSize = 20, filter: AdminAuditFi
if (filter.userId) query.userId = filter.userId
if (filter.action) query.action = filter.action
if (filter.targetType) query.targetType = filter.targetType
if (filter.createdFrom) query.createdFrom = filter.createdFrom
if (filter.createdTo) query.createdTo = filter.createdTo
return unwrap<PaginatedResponse<AdminAuditEvent>>(adminAuditApi.index.$get({ query }))
}
@@ -0,0 +1,194 @@
import type { AdminAuditEvent } from '@shared/types'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { cleanup, render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import type * as React from 'react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { TooltipProvider } from '@/components/ui/tooltip'
import { useEntitlement } from '@/hooks/useEntitlement'
import { listAdminAuditLogs } from '@/lib/api'
import { Route } from './audit'
vi.mock('@tanstack/react-router', () => ({
createFileRoute: () => (options: object) => options,
}))
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string, values?: Record<string, unknown>) => {
if (key === 'admin.audit.title') return 'Audit Logs'
if (key === 'admin.audit.description') return 'Track activity'
if (key === 'admin.audit.eventType') return 'Event type'
if (key === 'admin.audit.timeRange') return 'Time range'
if (key === 'admin.audit.allEvents') return 'All events'
if (key === 'admin.audit.allTime') return 'All time'
if (key === 'admin.audit.last24Hours') return 'Last 24 hours'
if (key === 'admin.audit.last7Days') return 'Last 7 days'
if (key === 'admin.audit.last30Days') return 'Last 30 days'
if (key === 'admin.audit.last90Days') return 'Last 90 days'
if (key === 'admin.audit.empty') return 'No audit events yet.'
if (key === 'admin.users.prevPage') return 'Previous'
if (key === 'admin.users.nextPage') return 'Next'
if (key === 'admin.users.pageInfo') return `Page ${values?.page} of ${values?.total}`
if (key === 'admin.users.pageSize') return 'Rows per page'
if (key === 'admin.users.pageSizeOption') return `${values?.count} / page`
if (key === 'activity.action.upload') return 'uploaded'
if (key === 'activity.action.delete') return 'deleted'
if (key === 'activity.action.replace') return 'replaced'
if (key === 'activity.target.file') return 'file'
return key
},
}),
}))
vi.mock('@/hooks/useEntitlement', () => ({
useEntitlement: vi.fn(),
}))
vi.mock('@/lib/api', () => ({
listAdminAuditLogs: vi.fn(),
}))
type AuditRoute = typeof Route & {
component: React.ComponentType
}
function renderAuditPage() {
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
},
})
const Component = (Route as AuditRoute).component
return render(
<QueryClientProvider client={queryClient}>
<TooltipProvider>
<Component />
</TooltipProvider>
</QueryClientProvider>,
)
}
function auditEvent(overrides: Partial<AdminAuditEvent> = {}): AdminAuditEvent {
return {
id: 'audit-1',
orgId: 'org-1',
orgName: 'Personal',
userId: 'user-1',
action: 'upload',
targetType: 'file',
targetId: 'file-1',
targetName: 'contract.pdf',
metadata: null,
createdAt: '2026-02-01T10:00:00.000Z',
user: {
id: 'user-1',
name: 'Ava Stone',
image: null,
},
...overrides,
}
}
function auditPage(page: number, items: AdminAuditEvent[], total = items.length, pageSize = 20) {
return {
items,
total,
page,
pageSize,
}
}
beforeEach(() => {
Element.prototype.scrollIntoView = vi.fn()
HTMLElement.prototype.hasPointerCapture = vi.fn(() => false)
HTMLElement.prototype.setPointerCapture = vi.fn()
HTMLElement.prototype.releasePointerCapture = vi.fn()
vi.mocked(useEntitlement).mockReturnValue({
bound: true,
active: true,
edition: 'pro',
licenseId: 'license-1',
cloudDashboardUrl: null,
hasFeature: (feature) => feature === 'audit_log',
isLoading: false,
isError: false,
})
})
afterEach(() => {
cleanup()
vi.clearAllMocks()
})
describe('AuditLogsPage filters and pagination', () => {
it('uses structured filters and standard pagination', async () => {
vi.mocked(listAdminAuditLogs).mockResolvedValue(auditPage(1, [auditEvent()], 21))
const user = userEvent.setup()
renderAuditPage()
expect(await screen.findByText(/contract\.pdf/)).toBeTruthy()
expect(listAdminAuditLogs).toHaveBeenCalledWith(1, 20, {})
expect(screen.queryByRole('button', { name: 'admin.audit.loadMore' })).toBeNull()
await user.click(screen.getByRole('combobox', { name: 'Event type' }))
await user.click(await screen.findByRole('option', { name: 'replaced' }))
await waitFor(() => expect(listAdminAuditLogs).toHaveBeenCalledWith(1, 20, { action: 'replace' }))
await user.click(screen.getByRole('combobox', { name: 'Time range' }))
await user.click(await screen.findByRole('option', { name: 'Last 30 days' }))
await waitFor(() =>
expect(listAdminAuditLogs).toHaveBeenCalledWith(
1,
20,
expect.objectContaining({
action: 'replace',
createdFrom: expect.any(String),
createdTo: expect.any(String),
}),
),
)
await user.click(screen.getByRole('button', { name: 'Next' }))
await waitFor(() =>
expect(listAdminAuditLogs).toHaveBeenCalledWith(
2,
20,
expect.objectContaining({
action: 'replace',
createdFrom: expect.any(String),
createdTo: expect.any(String),
}),
),
)
await user.click(screen.getByRole('combobox', { name: 'Rows per page' }))
await user.click(await screen.findByRole('option', { name: '50 / page' }))
await waitFor(() =>
expect(listAdminAuditLogs).toHaveBeenCalledWith(
1,
50,
expect.objectContaining({
action: 'replace',
createdFrom: expect.any(String),
createdTo: expect.any(String),
}),
),
)
})
it('keeps the empty state visible with pagination controls', async () => {
vi.mocked(listAdminAuditLogs).mockResolvedValue(auditPage(1, [], 0))
renderAuditPage()
expect(await screen.findByText('No audit events yet.')).toBeTruthy()
expect(screen.getByText('Page 1 of 1')).toBeTruthy()
})
})
+90 -46
View File
@@ -1,15 +1,24 @@
import type { AdminAuditEvent } from '@shared/types'
import { useInfiniteQuery } from '@tanstack/react-query'
import { useQuery } from '@tanstack/react-query'
import { createFileRoute } from '@tanstack/react-router'
import { useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { AdminPageHeader } from '@/components/admin/admin-page-header'
import {
AUDIT_DEFAULT_PAGE_SIZE,
AUDIT_FILTER_ALL,
AuditLogFilters,
AuditPagination,
type AuditTimeRange,
auditActionToFilter,
auditTimeRangeToFilter,
} from '@/components/admin/audit-log-controls'
import { ProBadge } from '@/components/ProBadge'
import { UpgradeHint } from '@/components/UpgradeHint'
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
import { Button } from '@/components/ui/button'
import { Card } from '@/components/ui/card'
import { useEntitlement } from '@/hooks/useEntitlement'
import { listAdminAuditLogs } from '@/lib/api'
import { type AdminAuditFilter, listAdminAuditLogs } from '@/lib/api'
export const Route = createFileRoute('/_authenticated/admin/audit')({
component: AuditLogsPage,
@@ -81,25 +90,46 @@ function AuditRow({ event }: { event: AdminAuditEvent }) {
)
}
const PAGE_SIZE = 20
function AuditLogsPage() {
const { t } = useTranslation()
const { hasFeature, isLoading: entitlementLoading } = useEntitlement()
const auditEnabled = hasFeature('audit_log')
const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(AUDIT_DEFAULT_PAGE_SIZE)
const [action, setAction] = useState(AUDIT_FILTER_ALL)
const [timeRange, setTimeRange] = useState<AuditTimeRange>('all')
const { data, fetchNextPage, hasNextPage, isFetchingNextPage, isPending, error } = useInfiniteQuery({
queryKey: ['admin', 'audit'],
queryFn: ({ pageParam = 1 }) => listAdminAuditLogs(pageParam as number, PAGE_SIZE),
initialPageParam: 1,
getNextPageParam: (lastPage) => {
const loaded = (lastPage.page - 1) * lastPage.pageSize + lastPage.items.length
return loaded < lastPage.total ? lastPage.page + 1 : undefined
},
const filter = useMemo<AdminAuditFilter>(() => {
const auditAction = auditActionToFilter(action)
return {
...(auditAction ? { action: auditAction } : {}),
...auditTimeRangeToFilter(timeRange),
}
}, [action, timeRange])
const { data, isFetching, isPending, error } = useQuery({
queryKey: ['admin', 'audit', page, pageSize, action, timeRange],
queryFn: () => listAdminAuditLogs(page, pageSize, filter),
enabled: auditEnabled,
})
const allItems = data?.pages.flatMap((p) => p.items) ?? []
const items = data?.items ?? []
const total = data?.total ?? 0
function handleActionChange(value: string) {
setAction(value)
setPage(1)
}
function handleTimeRangeChange(value: AuditTimeRange) {
setTimeRange(value)
setPage(1)
}
function handlePageSizeChange(value: number) {
setPageSize(value)
setPage(1)
}
return (
<div className="space-y-4">
@@ -116,41 +146,55 @@ function AuditLogsPage() {
description={t('admin.audit.upgradeDescription')}
actionLabel={t('admin.audit.upgradeButton')}
/>
) : isPending ? (
<div className="space-y-3">
{[1, 2, 3, 4, 5].map((i) => (
<div key={i} className="flex items-start gap-3 py-3">
<div className="h-8 w-8 flex-shrink-0 animate-pulse rounded-full bg-muted" />
<div className="flex-1 space-y-1.5">
<div className="h-4 w-2/3 animate-pulse rounded bg-muted" />
<div className="h-3 w-1/4 animate-pulse rounded bg-muted" />
</div>
</div>
))}
</div>
) : error ? (
<div className="rounded-md border border-destructive/30 bg-destructive/10 p-4 text-sm text-destructive">
{t('admin.audit.loadError')}
</div>
) : allItems.length === 0 ? (
<div className="rounded-md border border-dashed p-8 text-center text-muted-foreground">
{t('admin.audit.empty')}
</div>
) : (
<>
<Card className="gap-0 divide-y px-4 py-0 shadow-none">
{allItems.map((event) => (
<AuditRow key={event.id} event={event} />
))}
</Card>
{hasNextPage && (
<div className="pt-2 text-center">
<Button variant="outline" size="sm" onClick={() => fetchNextPage()} disabled={isFetchingNextPage}>
{isFetchingNextPage ? '...' : t('admin.audit.loadMore')}
</Button>
<div className="space-y-4">
<AuditLogFilters
action={action}
timeRange={timeRange}
disabled={isFetching}
onActionChange={handleActionChange}
onTimeRangeChange={handleTimeRangeChange}
/>
{isPending ? (
<div className="space-y-3" role="status">
{[1, 2, 3, 4, 5].map((i) => (
<div key={i} className="flex items-start gap-3 py-3">
<div className="h-8 w-8 flex-shrink-0 animate-pulse rounded-full bg-muted" />
<div className="flex-1 space-y-1.5">
<div className="h-4 w-2/3 animate-pulse rounded bg-muted" />
<div className="h-3 w-1/4 animate-pulse rounded bg-muted" />
</div>
</div>
))}
</div>
) : error ? (
<div className="rounded-md border border-destructive/30 bg-destructive/10 p-4 text-sm text-destructive">
{t('admin.audit.loadError')}
</div>
) : items.length === 0 ? (
<div className="rounded-md border border-dashed p-8 text-center text-muted-foreground">
{t('admin.audit.empty')}
</div>
) : (
<Card className="gap-0 divide-y px-4 py-0 shadow-none">
{items.map((event) => (
<AuditRow key={event.id} event={event} />
))}
</Card>
)}
</>
{!isPending && !error && (
<AuditPagination
page={page}
pageSize={pageSize}
total={total}
disabled={isFetching}
onPageChange={setPage}
onPageSizeChange={handlePageSizeChange}
/>
)}
</div>
)}
</div>
)
@@ -3,7 +3,7 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { cleanup, render, screen, waitFor, within } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import type * as React from 'react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { TooltipProvider } from '@/components/ui/tooltip'
import { useEntitlement } from '@/hooks/useEntitlement'
import { getUserQuotaById, listAdminAuditLogs, listUserEntitlements, revokeUserEntitlement } from '@/lib/api'
@@ -26,6 +26,20 @@ vi.mock('react-i18next', () => ({
if (key === 'activity.title') return 'Activity'
if (key === 'activity.empty') return 'No activity yet'
if (key === 'activity.loadMore') return 'Load more'
if (key === 'admin.users.tabEntitlement') return 'Entitlement'
if (key === 'admin.users.prevPage') return 'Previous'
if (key === 'admin.users.nextPage') return 'Next'
if (key === 'admin.users.pageInfo') return `Page ${values?.page} of ${values?.total}`
if (key === 'admin.users.pageSize') return 'Rows per page'
if (key === 'admin.users.pageSizeOption') return `${values?.count} / page`
if (key === 'admin.audit.eventType') return 'Event type'
if (key === 'admin.audit.timeRange') return 'Time range'
if (key === 'admin.audit.allEvents') return 'All events'
if (key === 'admin.audit.allTime') return 'All time'
if (key === 'admin.audit.last24Hours') return 'Last 24 hours'
if (key === 'admin.audit.last7Days') return 'Last 7 days'
if (key === 'admin.audit.last30Days') return 'Last 30 days'
if (key === 'admin.audit.last90Days') return 'Last 90 days'
if (key === 'admin.audit.upgradeTitle') return 'Unlock Audit Logs'
if (key === 'admin.audit.upgradeDescription') {
return 'Audit Logs are a Pro feature. Upgrade to gain full visibility into instance-wide activity.'
@@ -33,6 +47,7 @@ vi.mock('react-i18next', () => ({
if (key === 'admin.audit.upgradeButton') return 'Upgrade to Pro'
if (key === 'activity.action.upload') return 'uploaded'
if (key === 'activity.action.rename') return 'renamed'
if (key === 'activity.action.delete') return 'deleted'
if (key === 'activity.target.file') return 'file'
if (key === 'activity.meta.from') return 'from'
if (key === 'activity.meta.to') return 'to'
@@ -210,6 +225,13 @@ function mockBaseQueries(entitlements: OrgQuotaEntitlement[] = []) {
vi.mocked(listUserEntitlements).mockResolvedValue({ orgId: 'org-1', items: entitlements })
}
beforeEach(() => {
Element.prototype.scrollIntoView = vi.fn()
HTMLElement.prototype.hasPointerCapture = vi.fn(() => false)
HTMLElement.prototype.setPointerCapture = vi.fn()
HTMLElement.prototype.releasePointerCapture = vi.fn()
})
afterEach(() => {
cleanup()
vi.clearAllMocks()
@@ -235,11 +257,11 @@ describe('Admin user detail activity', () => {
renderUserDetailPage()
expect(await screen.findByText('Activity')).toBeTruthy()
expect(await screen.findByRole('tab', { name: 'Activity' })).toBeTruthy()
expect(await screen.findByText('No activity yet')).toBeTruthy()
})
it('loads page 2 with the same route user id filter and renders the second page', async () => {
it('renders Activity and Entitlement tabs in order and pages activity results', async () => {
allowAuditLogs()
mockBaseQueries()
vi.mocked(listAdminAuditLogs)
@@ -262,12 +284,52 @@ describe('Admin user detail activity', () => {
renderUserDetailPage()
expect(await screen.findByText(/contract\.pdf/)).toBeTruthy()
await userEvent.click(screen.getByRole('button', { name: 'Load more' }))
const tabs = screen.getAllByRole('tab')
expect(tabs.map((tab) => tab.textContent)).toEqual(['Activity', 'Entitlement'])
expect(screen.queryByRole('button', { name: 'Load more' })).toBeNull()
await userEvent.click(screen.getByRole('button', { name: 'Next' }))
expect(await screen.findByText(/renamed-contract\.pdf/)).toBeTruthy()
await waitFor(() => expect(listAdminAuditLogs).toHaveBeenCalledWith(2, 20, { userId: 'route-user-1' }))
})
it('filters activity by event type and time range while preserving the route user id', async () => {
allowAuditLogs()
mockBaseQueries()
vi.mocked(listAdminAuditLogs).mockResolvedValue(auditPage(1, [auditEvent()]))
const user = userEvent.setup()
renderUserDetailPage()
expect(await screen.findByText(/contract\.pdf/)).toBeTruthy()
await user.click(screen.getByRole('combobox', { name: 'Event type' }))
await user.click(await screen.findByRole('option', { name: 'renamed' }))
await waitFor(() =>
expect(listAdminAuditLogs).toHaveBeenCalledWith(1, 20, {
userId: 'route-user-1',
action: 'rename',
}),
)
await user.click(screen.getByRole('combobox', { name: 'Time range' }))
await user.click(await screen.findByRole('option', { name: 'Last 7 days' }))
await waitFor(() =>
expect(listAdminAuditLogs).toHaveBeenCalledWith(
1,
20,
expect.objectContaining({
userId: 'route-user-1',
action: 'rename',
createdFrom: expect.any(String),
createdTo: expect.any(String),
}),
),
)
})
it('renders status/result and all useful activity metadata fields', async () => {
allowAuditLogs()
mockBaseQueries()
@@ -339,6 +401,7 @@ describe('Admin user detail activity', () => {
expect(await screen.findByText(/before-revoke\.pdf/)).toBeTruthy()
const initialActivityCalls = vi.mocked(listAdminAuditLogs).mock.calls.length
await user.click(screen.getByRole('tab', { name: 'Entitlement' }))
await user.click(screen.getByRole('button', { name: 'admin.users.revokeEntitlement' }))
const dialog = await screen.findByRole('dialog')
await user.click(within(dialog).getByRole('button', { name: 'admin.users.revokeEntitlement' }))
@@ -348,6 +411,7 @@ describe('Admin user detail activity', () => {
const postRevokeActivityCalls = vi.mocked(listAdminAuditLogs).mock.calls.slice(initialActivityCalls)
expect(postRevokeActivityCalls).toContainEqual([1, 20, { userId: 'route-user-1' }])
})
await user.click(screen.getByRole('tab', { name: 'Activity' }))
expect(await screen.findByText(/after-revoke\.pdf/)).toBeTruthy()
})
+174 -123
View File
@@ -1,10 +1,19 @@
import type { AdminAuditEvent, OrgQuotaEntitlement } from '@shared/types'
import { useInfiniteQuery, useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { createFileRoute, Link } from '@tanstack/react-router'
import { Activity, ArrowLeft, BadgeCent, CalendarDays, Mail } from 'lucide-react'
import { useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import {
AUDIT_DEFAULT_PAGE_SIZE,
AUDIT_FILTER_ALL,
AuditLogFilters,
AuditPagination,
type AuditTimeRange,
auditActionToFilter,
auditTimeRangeToFilter,
} from '@/components/admin/audit-log-controls'
import { GrantEntitlementDialog } from '@/components/admin/grant-entitlement-dialog'
import { ProBadge } from '@/components/ProBadge'
import { UpgradeHint } from '@/components/UpgradeHint'
@@ -21,8 +30,15 @@ import {
DialogTitle,
} from '@/components/ui/dialog'
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { useEntitlement } from '@/hooks/useEntitlement'
import { getUserQuotaById, listAdminAuditLogs, listUserEntitlements, revokeUserEntitlement } from '@/lib/api'
import {
type AdminAuditFilter,
getUserQuotaById,
listAdminAuditLogs,
listUserEntitlements,
revokeUserEntitlement,
} from '@/lib/api'
import { adminGetUser } from '@/lib/auth-client'
import { formatDate, formatSize, formatStorageUsage, getInitials } from '@/lib/format'
@@ -30,8 +46,6 @@ export const Route = createFileRoute('/_authenticated/admin/users/$userId')({
component: AdminUserDetailPage,
})
const ACTIVITY_PAGE_SIZE = 20
export function AdminUserDetailPage() {
const { t } = useTranslation()
const queryClient = useQueryClient()
@@ -39,6 +53,10 @@ export function AdminUserDetailPage() {
const [grantOpen, setGrantOpen] = useState(false)
const [editTarget, setEditTarget] = useState<OrgQuotaEntitlement | null>(null)
const [revokeTarget, setRevokeTarget] = useState<OrgQuotaEntitlement | null>(null)
const [activityPage, setActivityPage] = useState(1)
const [activityPageSize, setActivityPageSize] = useState(AUDIT_DEFAULT_PAGE_SIZE)
const [activityAction, setActivityAction] = useState(AUDIT_FILTER_ALL)
const [activityTimeRange, setActivityTimeRange] = useState<AuditTimeRange>('all')
const { hasFeature, isLoading: entitlementLoading } = useEntitlement()
const auditEnabled = hasFeature('audit_log')
@@ -72,15 +90,19 @@ export function AdminUserDetailPage() {
queryFn: () => listUserEntitlements(userId),
})
const activityQuery = useInfiniteQuery({
queryKey: ['admin', 'users', userId, 'activity'],
queryFn: ({ pageParam = 1 }) => listAdminAuditLogs(pageParam as number, ACTIVITY_PAGE_SIZE, { userId }),
initialPageParam: 1,
const activityFilter = useMemo<AdminAuditFilter>(() => {
const action = auditActionToFilter(activityAction)
return {
userId,
...(action ? { action } : {}),
...auditTimeRangeToFilter(activityTimeRange),
}
}, [userId, activityAction, activityTimeRange])
const activityQuery = useQuery({
queryKey: ['admin', 'users', userId, 'activity', activityPage, activityPageSize, activityAction, activityTimeRange],
queryFn: () => listAdminAuditLogs(activityPage, activityPageSize, activityFilter),
enabled: auditEnabled && userQuery.isSuccess,
getNextPageParam: (lastPage) => {
const loaded = (lastPage.page - 1) * lastPage.pageSize + lastPage.items.length
return loaded < lastPage.total ? lastPage.page + 1 : undefined
},
})
const user = userQuery.data
@@ -96,7 +118,23 @@ export function AdminUserDetailPage() {
const quotaLabel = quota?.hasPersonalOrg ? formatStorageUsage(quota.used, quota.total) : '—'
const activeItems = useMemo(() => items.filter((item) => item.status === 'active'), [items])
const activityItems = activityQuery.data?.pages.flatMap((page) => page.items) ?? []
const activityItems = activityQuery.data?.items ?? []
const activityTotal = activityQuery.data?.total ?? 0
function handleActivityActionChange(value: string) {
setActivityAction(value)
setActivityPage(1)
}
function handleActivityTimeRangeChange(value: AuditTimeRange) {
setActivityTimeRange(value)
setActivityPage(1)
}
function handleActivityPageSizeChange(value: number) {
setActivityPageSize(value)
setActivityPage(1)
}
if (userQuery.isLoading) {
return (
@@ -170,103 +208,130 @@ export function AdminUserDetailPage() {
</CardContent>
</Card>
<Card className="rounded-md">
<CardHeader>
<CardTitle>{t('admin.users.entitlements')}</CardTitle>
<CardAction>
<Button variant="outline" size="sm" onClick={() => setGrantOpen(true)} disabled={!hasPersonalOrg}>
<BadgeCent />
{t('admin.users.addEntitlement')}
</Button>
</CardAction>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead>{t('admin.users.entitlementType')}</TableHead>
<TableHead>{t('admin.users.entitlementAmount')}</TableHead>
<TableHead>{t('admin.users.entitlementSource')}</TableHead>
<TableHead>{t('admin.users.entitlementExpires')}</TableHead>
<TableHead>{t('admin.users.entitlementStatus')}</TableHead>
<TableHead className="text-right">{t('admin.users.entitlementActions')}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{items.map((item) => (
<TableRow key={item.id}>
<TableCell>{formatEntitlementType(item.entitlementType, t)}</TableCell>
<TableCell className="font-medium tabular-nums">{formatSize(item.bytes)}</TableCell>
<TableCell className="max-w-[220px] truncate text-muted-foreground" title={item.sourceId}>
{formatSource(item.source, t)}
</TableCell>
<TableCell className="text-muted-foreground">
{item.expiresAt ? formatDate(item.expiresAt) : t('admin.users.noExpiry')}
</TableCell>
<TableCell>{formatStatus(item.status, t)}</TableCell>
<TableCell className="text-right">
{isEditable(item) && (
<div className="flex justify-end gap-1">
<Button variant="ghost" size="sm" onClick={() => setEditTarget(item)}>
{t('admin.users.editEntitlement')}
</Button>
<Button
variant="ghost"
size="sm"
className="text-destructive hover:text-destructive"
onClick={() => setRevokeTarget(item)}
>
{t('admin.users.revokeEntitlement')}
</Button>
</div>
)}
</TableCell>
</TableRow>
))}
{items.length === 0 && (
<TableRow>
<TableCell colSpan={6} className="py-8 text-center text-muted-foreground">
{entitlementsQuery.isLoading ? t('common.loading') : t('admin.users.noEntitlements')}
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</CardContent>
</Card>
<Tabs defaultValue="activity" className="gap-4">
<TabsList>
<TabsTrigger value="activity">{t('activity.title')}</TabsTrigger>
<TabsTrigger value="entitlement">{t('admin.users.tabEntitlement')}</TabsTrigger>
</TabsList>
<Card className="rounded-md">
<CardHeader>
<CardTitle className="inline-flex items-center gap-2">
<Activity className="h-5 w-5" />
{t('activity.title')}
</CardTitle>
<CardAction>
<ProBadge tooltip={t('admin.audit.proTooltip')} />
</CardAction>
</CardHeader>
<CardContent>
{entitlementLoading ? (
<div className="py-6 text-sm text-muted-foreground">{t('common.loading')}</div>
) : !auditEnabled ? (
<UpgradeHint
feature="audit_log"
title={t('admin.audit.upgradeTitle')}
description={t('admin.audit.upgradeDescription')}
actionLabel={t('admin.audit.upgradeButton')}
/>
) : (
<UserActivityFeed
events={activityItems}
isLoading={activityQuery.isPending}
isError={activityQuery.isError}
hasNextPage={activityQuery.hasNextPage}
isFetchingNextPage={activityQuery.isFetchingNextPage}
onLoadMore={() => activityQuery.fetchNextPage()}
/>
)}
</CardContent>
</Card>
<TabsContent value="activity">
<Card className="rounded-md">
<CardHeader>
<CardTitle className="inline-flex items-center gap-2">
<Activity className="h-5 w-5" />
{t('activity.title')}
</CardTitle>
<CardAction>
<ProBadge tooltip={t('admin.audit.proTooltip')} />
</CardAction>
</CardHeader>
<CardContent>
{entitlementLoading ? (
<div className="py-6 text-sm text-muted-foreground">{t('common.loading')}</div>
) : !auditEnabled ? (
<UpgradeHint
feature="audit_log"
title={t('admin.audit.upgradeTitle')}
description={t('admin.audit.upgradeDescription')}
actionLabel={t('admin.audit.upgradeButton')}
/>
) : (
<div className="space-y-4">
<AuditLogFilters
action={activityAction}
timeRange={activityTimeRange}
disabled={activityQuery.isFetching}
onActionChange={handleActivityActionChange}
onTimeRangeChange={handleActivityTimeRangeChange}
/>
<UserActivityFeed
events={activityItems}
isLoading={activityQuery.isPending}
isError={activityQuery.isError}
/>
{!activityQuery.isPending && !activityQuery.isError && (
<AuditPagination
page={activityPage}
pageSize={activityPageSize}
total={activityTotal}
disabled={activityQuery.isFetching}
onPageChange={setActivityPage}
onPageSizeChange={handleActivityPageSizeChange}
/>
)}
</div>
)}
</CardContent>
</Card>
</TabsContent>
<TabsContent value="entitlement">
<Card className="rounded-md">
<CardHeader>
<CardTitle>{t('admin.users.entitlements')}</CardTitle>
<CardAction>
<Button variant="outline" size="sm" onClick={() => setGrantOpen(true)} disabled={!hasPersonalOrg}>
<BadgeCent />
{t('admin.users.addEntitlement')}
</Button>
</CardAction>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead>{t('admin.users.entitlementType')}</TableHead>
<TableHead>{t('admin.users.entitlementAmount')}</TableHead>
<TableHead>{t('admin.users.entitlementSource')}</TableHead>
<TableHead>{t('admin.users.entitlementExpires')}</TableHead>
<TableHead>{t('admin.users.entitlementStatus')}</TableHead>
<TableHead className="text-right">{t('admin.users.entitlementActions')}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{items.map((item) => (
<TableRow key={item.id}>
<TableCell>{formatEntitlementType(item.entitlementType, t)}</TableCell>
<TableCell className="font-medium tabular-nums">{formatSize(item.bytes)}</TableCell>
<TableCell className="max-w-[220px] truncate text-muted-foreground" title={item.sourceId}>
{formatSource(item.source, t)}
</TableCell>
<TableCell className="text-muted-foreground">
{item.expiresAt ? formatDate(item.expiresAt) : t('admin.users.noExpiry')}
</TableCell>
<TableCell>{formatStatus(item.status, t)}</TableCell>
<TableCell className="text-right">
{isEditable(item) && (
<div className="flex justify-end gap-1">
<Button variant="ghost" size="sm" onClick={() => setEditTarget(item)}>
{t('admin.users.editEntitlement')}
</Button>
<Button
variant="ghost"
size="sm"
className="text-destructive hover:text-destructive"
onClick={() => setRevokeTarget(item)}
>
{t('admin.users.revokeEntitlement')}
</Button>
</div>
)}
</TableCell>
</TableRow>
))}
{items.length === 0 && (
<TableRow>
<TableCell colSpan={6} className="py-8 text-center text-muted-foreground">
{entitlementsQuery.isLoading ? t('common.loading') : t('admin.users.noEntitlements')}
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</CardContent>
</Card>
</TabsContent>
</Tabs>
<GrantEntitlementDialog
open={grantOpen}
@@ -309,16 +374,10 @@ function UserActivityFeed({
events,
isLoading,
isError,
hasNextPage,
isFetchingNextPage,
onLoadMore,
}: {
events: AdminAuditEvent[]
isLoading: boolean
isError: boolean
hasNextPage: boolean
isFetchingNextPage: boolean
onLoadMore: () => void
}) {
const { t } = useTranslation()
@@ -359,14 +418,6 @@ function UserActivityFeed({
))}
</div>
)}
{hasNextPage && (
<div className="pt-2 text-center">
<Button variant="outline" size="sm" onClick={onLoadMore} disabled={isFetchingNextPage}>
{isFetchingNextPage ? '...' : t('activity.loadMore')}
</Button>
</div>
)}
</div>
)
}