mirror of
https://github.com/saltbo/zpan.git
synced 2026-08-29 00:01:42 +08:00
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:
committed by
GitHub
parent
bc15bc3617
commit
7ccaba2f8b
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
@@ -39,6 +39,8 @@ export interface ListAdminAuditOpts {
|
||||
userId?: string
|
||||
action?: string
|
||||
targetType?: string
|
||||
createdFrom?: Date
|
||||
createdTo?: Date
|
||||
}
|
||||
|
||||
export interface ListActivityByTargetOpts {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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",
|
||||
|
||||
@@ -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
@@ -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 () => {
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user