mirror of
https://github.com/supabase/supabase.git
synced 2026-10-06 09:55:06 +03:00
## Context Follows up from [this PR](https://github.com/supabase/supabase/pull/50799) - updates the Audit Logs UI for organization audit logs. Just differs from Account audit logs slightly with added "Actor" + "Target" column Reuses the same UI components from account audit logs so quite a bit of code clean up 🙂 <img width="1451" height="955" alt="image" src="https://github.com/user-attachments/assets/a1002cee-917f-4d9a-b977-3fab8ecd2d13" /> <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Organization audit logs now use a sortable table with row selection and a resizable details panel. * Actor details show a member’s username when available, otherwise the actor’s email; a dash appears when neither is available. * Logs can be filtered by users and projects, with separate messages for no logs and no matching results. * A refresh control and loading indicators help show audit-log updates. * **Bug Fixes** * Organization project lists stop loading when pagination totals are unavailable. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
262 lines
8.0 KiB
TypeScript
262 lines
8.0 KiB
TypeScript
import type { CellContext, ColumnDef } from '@tanstack/react-table'
|
|
import dayjs from 'dayjs'
|
|
import { Box, Boxes } from 'lucide-react'
|
|
import type { RefObject } from 'react'
|
|
import { Checkbox, Tooltip, TooltipContent, TooltipTrigger } from 'ui'
|
|
import { ShimmeringLoader } from 'ui-patterns/ShimmeringLoader'
|
|
import { TanStackTableHeadSort } from 'ui-patterns/Table'
|
|
import { TimestampInfo } from 'ui-patterns/TimestampInfo'
|
|
|
|
import { getStatusLevel } from '@/components/interfaces/UnifiedLogs/UnifiedLogs.utils'
|
|
import { DataTableColumnStatusCode } from '@/components/ui/DataTable/DataTableColumn/DataTableColumnStatusCode'
|
|
import {
|
|
TIMESTAMP_MICROS_PER_MS,
|
|
type AuditLog,
|
|
} from '@/data/organizations/organization-audit-logs-query'
|
|
import { getShiftClickRowSelection } from '@/lib/shift-click-selection'
|
|
|
|
interface AuditLogColumnsOptions {
|
|
projects: { ref?: string; name: string }[]
|
|
organizations: { slug?: string; name: string }[]
|
|
members?: { gotrue_id?: string; username?: string }[]
|
|
lastSelectedRowId: RefObject<string | null>
|
|
isLoadingProjects: boolean
|
|
isLoadingOrganizations: boolean
|
|
}
|
|
|
|
export function getAuditLogColumns({
|
|
projects,
|
|
organizations,
|
|
members,
|
|
lastSelectedRowId,
|
|
isLoadingProjects,
|
|
isLoadingOrganizations,
|
|
}: AuditLogColumnsOptions): ColumnDef<AuditLog>[] {
|
|
return [
|
|
{
|
|
id: 'select',
|
|
header: ({ table }) => (
|
|
<Checkbox
|
|
checked={
|
|
table.getIsAllRowsSelected() ||
|
|
(table.getIsSomeRowsSelected() ? 'indeterminate' : false)
|
|
}
|
|
onCheckedChange={(value) => table.toggleAllRowsSelected(!!value)}
|
|
onClick={(e) => e.stopPropagation()}
|
|
/>
|
|
),
|
|
cell: ({ row, table }) => (
|
|
<Checkbox
|
|
checked={row.getIsSelected()}
|
|
onClick={(e) => {
|
|
e.stopPropagation()
|
|
|
|
const currentSelection = table.getState().rowSelection
|
|
const hasSelection = Object.values(currentSelection).some(Boolean)
|
|
const anchorRowId = hasSelection ? lastSelectedRowId.current : null
|
|
|
|
let next: typeof currentSelection
|
|
if (e.shiftKey) {
|
|
next = getShiftClickRowSelection({
|
|
orderedRowIds: table.getRowModel().rows.map((r) => r.id),
|
|
rowSelection: currentSelection,
|
|
anchorRowId,
|
|
targetRowId: row.id,
|
|
})
|
|
} else {
|
|
next = { ...currentSelection }
|
|
if (next[row.id]) delete next[row.id]
|
|
else next[row.id] = true
|
|
}
|
|
|
|
table.setRowSelection(next)
|
|
lastSelectedRowId.current = Object.values(next).some(Boolean) ? row.id : null
|
|
}}
|
|
/>
|
|
),
|
|
enableSorting: false,
|
|
size: 30,
|
|
minSize: 30,
|
|
maxSize: 30,
|
|
meta: {
|
|
cellClassName: 'w-[30px] min-w-[30px]',
|
|
headerClassName: 'w-[30px] min-w-[30px]',
|
|
},
|
|
},
|
|
{
|
|
id: 'date',
|
|
accessorFn: (log) => log.timestamp,
|
|
header: ({ column }) => <TanStackTableHeadSort column={column}>Date</TanStackTableHeadSort>,
|
|
cell: ({ row }) => (
|
|
<TimestampInfo
|
|
className="text-xs font-mono"
|
|
utcTimestamp={dayjs(row.original.timestamp / TIMESTAMP_MICROS_PER_MS).toISOString()}
|
|
/>
|
|
),
|
|
size: 155,
|
|
minSize: 155,
|
|
maxSize: 155,
|
|
meta: {
|
|
cellClassName: 'w-[155px] min-w-[155px] font-mono tracking-tight pl-3',
|
|
headerClassName: 'w-[155px] min-w-[155px] pl-3',
|
|
},
|
|
},
|
|
...(members
|
|
? [
|
|
{
|
|
id: 'actor',
|
|
header: 'Actor',
|
|
accessorFn: (log: AuditLog) => log.actor.user_id ?? log.actor.email,
|
|
cell: ({ row }: CellContext<AuditLog, unknown>) => {
|
|
const log = row.original
|
|
const member = members.find((m) => m.gotrue_id === log.actor.user_id)
|
|
const name = member?.username || log.actor.email
|
|
|
|
if (!name) return <span className="text-foreground-light text-xs">-</span>
|
|
|
|
return (
|
|
<p className="truncate text-foreground-light" title={name}>
|
|
{name}
|
|
</p>
|
|
)
|
|
},
|
|
size: 160,
|
|
minSize: 160,
|
|
maxSize: 160,
|
|
meta: {
|
|
cellClassName: 'w-[160px] min-w-[160px]',
|
|
headerClassName: 'w-[160px] min-w-[160px]',
|
|
},
|
|
} satisfies ColumnDef<AuditLog>,
|
|
]
|
|
: []),
|
|
{
|
|
id: 'target_type',
|
|
header: '',
|
|
accessorFn: (log) => log.action.status,
|
|
cell: ({ row }) => {
|
|
const log = row.original
|
|
|
|
if (log.project_ref || log.organization_slug) {
|
|
return (
|
|
<Tooltip>
|
|
<TooltipTrigger className="flex items-center">
|
|
{log.project_ref ? (
|
|
<Box size={12} className="text-foreground-light" />
|
|
) : (
|
|
<Boxes size={12} className="text-foreground-light" />
|
|
)}
|
|
</TooltipTrigger>
|
|
<TooltipContent side="bottom">
|
|
{log.project_ref ? 'Project' : 'Organization'}
|
|
</TooltipContent>
|
|
</Tooltip>
|
|
)
|
|
} else {
|
|
return null
|
|
}
|
|
},
|
|
size: 20,
|
|
minSize: 20,
|
|
maxSize: 20,
|
|
meta: {
|
|
cellClassName: 'w-[20px] min-w-[20px] px-0',
|
|
headerClassName: 'w-[20px] min-w-[20px] px-0',
|
|
},
|
|
},
|
|
{
|
|
id: 'target',
|
|
header: 'Target',
|
|
accessorFn: (log) => log.project_ref ?? log.organization_slug,
|
|
cell: ({ row }) => {
|
|
const log = row.original
|
|
|
|
if (log.project_ref && isLoadingProjects)
|
|
return <ShimmeringLoader className="w-20 h-4 py-0" />
|
|
if (log.organization_slug && isLoadingOrganizations) {
|
|
return <ShimmeringLoader className="w-20 h-4 py-0" />
|
|
}
|
|
|
|
let target: string | undefined
|
|
if (log.project_ref) {
|
|
target = projects.find((p) => p.ref === log.project_ref)?.name ?? log.project_ref
|
|
} else if (log.organization_slug) {
|
|
target =
|
|
organizations.find((org) => org.slug === log.organization_slug)?.name ??
|
|
log.organization_slug
|
|
}
|
|
|
|
if (!target) return <span className="text-foreground-light text-xs">-</span>
|
|
|
|
return (
|
|
<p className="truncate text-foreground-light" title={target}>
|
|
{target}
|
|
</p>
|
|
)
|
|
},
|
|
size: 130,
|
|
minSize: 130,
|
|
maxSize: 130,
|
|
meta: {
|
|
cellClassName: 'w-[130px] min-w-[130px]',
|
|
headerClassName: 'w-[130px] min-w-[130px]',
|
|
},
|
|
},
|
|
{
|
|
id: 'status',
|
|
header: '',
|
|
accessorFn: (log) => log.action.status,
|
|
cell: ({ row }) => (
|
|
<DataTableColumnStatusCode
|
|
value={row.original.action.status}
|
|
level={getStatusLevel(row.original.action.status)}
|
|
className="text-xs"
|
|
/>
|
|
),
|
|
size: 40,
|
|
minSize: 40,
|
|
maxSize: 40,
|
|
meta: {
|
|
cellClassName: 'w-[40px] min-w-[40px]',
|
|
headerClassName: 'w-[40px] min-w-[40px]',
|
|
},
|
|
},
|
|
{
|
|
id: 'method',
|
|
header: 'Method',
|
|
accessorFn: (log) => log.action.method,
|
|
cell: ({ row }) => (
|
|
<span className="text-foreground-light text-xs font-mono">
|
|
{row.original.action.method}
|
|
</span>
|
|
),
|
|
size: 60,
|
|
minSize: 60,
|
|
maxSize: 60,
|
|
meta: {
|
|
cellClassName: 'w-[60px] min-w-[60px]',
|
|
headerClassName: 'w-[60px] min-w-[60px]',
|
|
},
|
|
},
|
|
{
|
|
id: 'action',
|
|
accessorFn: (log) => log.action.name,
|
|
header: 'Event',
|
|
meta: {
|
|
cellClassName: 'min-w-[240px]',
|
|
headerClassName: 'min-w-[240px]',
|
|
},
|
|
cell: ({ row }) => {
|
|
return (
|
|
<p
|
|
className="truncate text-foreground-light font-mono tracking-tighter group-hover:text-foreground"
|
|
title={row.original.action.name}
|
|
>
|
|
{row.original.action.name}
|
|
</p>
|
|
)
|
|
},
|
|
},
|
|
]
|
|
}
|