Files
supabase/apps/studio/components/interfaces/AuditLogs/AuditLogs.columns.tsx
Joshen Lim e3fec137fe Joshenlim/fe 4466 audit logs update organization logs as well (#50935)
## 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 -->
2026-09-29 17:41:54 +08:00

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>
)
},
},
]
}