mirror of
https://github.com/supabase/supabase.git
synced 2026-10-10 20:05:06 +03:00
Merge pull request #15302 from supabase/feat/audit-logs
Feat/audit logs
This commit is contained in:
17 files changed
+778
-105
No files matched your search
@@ -84,6 +84,7 @@ function Group({
|
||||
checked={option.checked}
|
||||
name={option.name}
|
||||
description={option.description}
|
||||
defaultChecked={option.defaultChecked}
|
||||
/>
|
||||
)
|
||||
})
|
||||
|
||||
@@ -61,10 +61,6 @@ const SidePanel = ({
|
||||
}: SidePanelProps) => {
|
||||
const __styles = styleHandler('sidepanel')
|
||||
|
||||
// function stopPropagation(e: React.MouseEvent) {
|
||||
// e.stopPropagation()
|
||||
// }
|
||||
|
||||
const footerContent = customFooter ? (
|
||||
customFooter
|
||||
) : (
|
||||
@@ -74,35 +70,37 @@ const SidePanel = ({
|
||||
{cancelText}
|
||||
</Button>
|
||||
</div>
|
||||
<Tooltip.Root delayDuration={0}>
|
||||
<Tooltip.Trigger asChild>
|
||||
<div>
|
||||
<Button
|
||||
htmlType="submit"
|
||||
disabled={disabled || loading}
|
||||
loading={loading}
|
||||
onClick={() => (onConfirm ? onConfirm() : null)}
|
||||
>
|
||||
{confirmText}
|
||||
</Button>
|
||||
</div>
|
||||
</Tooltip.Trigger>
|
||||
{tooltip !== undefined && (
|
||||
<Tooltip.Portal>
|
||||
<Tooltip.Content side="bottom">
|
||||
<Tooltip.Arrow className="radix-tooltip-arrow" />
|
||||
<div
|
||||
className={[
|
||||
'rounded bg-scale-100 py-1 px-2 leading-none shadow',
|
||||
'border border-scale-200',
|
||||
].join(' ')}
|
||||
{onConfirm !== undefined && (
|
||||
<Tooltip.Root delayDuration={0}>
|
||||
<Tooltip.Trigger asChild>
|
||||
<div>
|
||||
<Button
|
||||
htmlType="submit"
|
||||
disabled={disabled || loading}
|
||||
loading={loading}
|
||||
onClick={() => (onConfirm ? onConfirm() : null)}
|
||||
>
|
||||
<span className="text-xs text-scale-1200">{tooltip}</span>
|
||||
</div>
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Portal>
|
||||
)}
|
||||
</Tooltip.Root>
|
||||
{confirmText}
|
||||
</Button>
|
||||
</div>
|
||||
</Tooltip.Trigger>
|
||||
{tooltip !== undefined && (
|
||||
<Tooltip.Portal>
|
||||
<Tooltip.Content side="bottom">
|
||||
<Tooltip.Arrow className="radix-tooltip-arrow" />
|
||||
<div
|
||||
className={[
|
||||
'rounded bg-scale-100 py-1 px-2 leading-none shadow',
|
||||
'border border-scale-200',
|
||||
].join(' ')}
|
||||
>
|
||||
<span className="text-xs text-scale-1200">{tooltip}</span>
|
||||
</div>
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Portal>
|
||||
)}
|
||||
</Tooltip.Root>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
||||
|
||||
@@ -0,0 +1,346 @@
|
||||
import * as Tooltip from '@radix-ui/react-tooltip'
|
||||
import dayjs from 'dayjs'
|
||||
import Image from 'next/image'
|
||||
import { useState } from 'react'
|
||||
|
||||
import { useParams } from 'common'
|
||||
import Table from 'components/to-be-cleaned/Table'
|
||||
import AlertError from 'components/ui/AlertError'
|
||||
import { DatePicker } from 'components/ui/DatePicker'
|
||||
import ShimmeringLoader from 'components/ui/ShimmeringLoader'
|
||||
import {
|
||||
OrganizationAuditLog,
|
||||
useOrganizationAuditLogsQuery,
|
||||
} from 'data/organizations/organization-audit-logs-query'
|
||||
import { useOrganizationDetailQuery } from 'data/organizations/organization-detail-query'
|
||||
import { useOrganizationRolesQuery } from 'data/organizations/organization-roles-query'
|
||||
import { useOrganizationsQuery } from 'data/organizations/organizations-query'
|
||||
import { useProjectsQuery } from 'data/projects/projects-query'
|
||||
import { Alert, Button, IconArrowDown, IconArrowUp, IconRefreshCw, IconUser } from 'ui'
|
||||
import FilterPopover from './FilterPopover'
|
||||
import LogDetailsPanel from './LogDetailsPanel'
|
||||
import { ScaffoldContainerLegacy } from 'components/layouts/Scaffold'
|
||||
|
||||
// [Joshen considerations]
|
||||
// - Maybe fix the height of the table to the remaining height of the viewport, so that the search input is always visible
|
||||
// - We'll need pagination as well if the audit logs get too large, but that needs to be implemented on the API side first if possible
|
||||
// - I've hidden time input in the date picker for now cause the time support in the component is a bit iffy, need to investigate
|
||||
// - Maybe a rule to follow from here is just everytime we call dayjs, use UTC(), one TZ to rule them all
|
||||
|
||||
const AuditLogs = () => {
|
||||
const { slug } = useParams()
|
||||
const currentTime = dayjs().utc().set('millisecond', 0)
|
||||
const [dateSortDesc, setDateSortDesc] = useState(true)
|
||||
const [dateRange, setDateRange] = useState({
|
||||
from: currentTime.subtract(1, 'day').toISOString(),
|
||||
to: currentTime.toISOString(),
|
||||
})
|
||||
const [selectedLog, setSelectedLog] = useState<OrganizationAuditLog>()
|
||||
const [filters, setFilters] = useState<{ users: string[]; projects: string[] }>({
|
||||
users: [], // gotrue_id[]
|
||||
projects: [], // project_ref[]
|
||||
})
|
||||
|
||||
const { data: projects } = useProjectsQuery()
|
||||
const { data: organizations } = useOrganizationsQuery()
|
||||
const { data: detailData } = useOrganizationDetailQuery({ slug })
|
||||
const { data: rolesData } = useOrganizationRolesQuery({ slug })
|
||||
const { data, error, isLoading, isSuccess, isError, isRefetching, refetch } =
|
||||
useOrganizationAuditLogsQuery({
|
||||
slug,
|
||||
iso_timestamp_start: dateRange.from,
|
||||
iso_timestamp_end: dateRange.to,
|
||||
})
|
||||
|
||||
const members = detailData?.members ?? []
|
||||
const roles = rolesData?.roles ?? []
|
||||
|
||||
const retentionPeriod = data?.retention_period ?? 0
|
||||
const logs = data?.result ?? []
|
||||
const sortedLogs = logs
|
||||
?.sort((a, b) =>
|
||||
dateSortDesc
|
||||
? Number(new Date(b.occurred_at)) - Number(new Date(a.occurred_at))
|
||||
: Number(new Date(a.occurred_at)) - Number(new Date(b.occurred_at))
|
||||
)
|
||||
?.filter((log) => {
|
||||
if (filters.users.length > 0) {
|
||||
return filters.users.includes(log.actor.id)
|
||||
} else {
|
||||
return log
|
||||
}
|
||||
})
|
||||
?.filter((log) => {
|
||||
if (filters.projects.length > 0) {
|
||||
return filters.projects.includes(log.target.metadata.project_ref || '')
|
||||
} else {
|
||||
return log
|
||||
}
|
||||
})
|
||||
|
||||
return (
|
||||
<>
|
||||
<ScaffoldContainerLegacy>
|
||||
<div className="space-y-4 flex flex-col">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-2">
|
||||
<p className="text-xs prose">Filter by</p>
|
||||
<FilterPopover
|
||||
name="Users"
|
||||
options={members ?? []}
|
||||
labelKey="username"
|
||||
valueKey="gotrue_id"
|
||||
activeOptions={filters.users}
|
||||
onSaveFilters={(values) => setFilters({ ...filters, users: values })}
|
||||
/>
|
||||
<FilterPopover
|
||||
name="Projects"
|
||||
options={projects ?? []}
|
||||
labelKey="name"
|
||||
valueKey="ref"
|
||||
activeOptions={filters.projects}
|
||||
onSaveFilters={(values) => setFilters({ ...filters, projects: values })}
|
||||
/>
|
||||
<DatePicker
|
||||
hideTime
|
||||
hideClear
|
||||
triggerButtonType="dashed"
|
||||
triggerButtonTitle=""
|
||||
from={dateRange.from}
|
||||
to={dateRange.to}
|
||||
minDate={dayjs().subtract(retentionPeriod, 'days').toDate()}
|
||||
maxDate={dayjs().toDate()}
|
||||
onChange={(value) => {
|
||||
if (value.from !== null && value.to !== null) {
|
||||
const current = dayjs().utc()
|
||||
const from = dayjs(value.from)
|
||||
.utc()
|
||||
.hour(current.hour())
|
||||
.minute(current.minute())
|
||||
.second(current.second())
|
||||
.toISOString()
|
||||
const to = dayjs(value.to)
|
||||
.utc()
|
||||
.hour(current.hour())
|
||||
.minute(current.minute())
|
||||
.second(current.second())
|
||||
.toISOString()
|
||||
setDateRange({ from, to })
|
||||
}
|
||||
}}
|
||||
renderFooter={() => {
|
||||
return (
|
||||
<Alert title="" variant="info" className="mx-3 pl-2 pr-2 pt-1 pb-2">
|
||||
Your organization has a log retention period of{' '}
|
||||
<span className="text-brand-900">
|
||||
{retentionPeriod} day
|
||||
{retentionPeriod > 1 ? 's' : ''}
|
||||
</span>
|
||||
. You may only view logs from{' '}
|
||||
{dayjs().subtract(retentionPeriod, 'days').format('DD MMM YYYY')} as the
|
||||
earliest date.
|
||||
</Alert>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
{isSuccess && (
|
||||
<>
|
||||
<div className="h-[20px] border-r border-scale-700 !ml-4 !mr-2" />
|
||||
<p className="prose text-xs">Viewing {sortedLogs.length} logs in total</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
type="default"
|
||||
disabled={isLoading || isRefetching}
|
||||
icon={<IconRefreshCw className={isRefetching ? 'animate-spin' : ''} />}
|
||||
onClick={() => refetch()}
|
||||
>
|
||||
{isRefetching ? 'Refreshing' : 'Refresh'}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{isLoading && (
|
||||
<div className="space-y-2">
|
||||
<ShimmeringLoader />
|
||||
<ShimmeringLoader className="w-3/4" />
|
||||
<ShimmeringLoader className="w-1/2" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isError && <AlertError error={error} subject="Failed to retrieve audit logs" />}
|
||||
|
||||
{isSuccess && (
|
||||
<>
|
||||
{logs.length === 0 ? (
|
||||
<div className="bg-scale-100 dark:bg-scale-300 border rounded p-4 flex items-center justify-between">
|
||||
<p className="prose text-sm">
|
||||
Your organization does not have any audit logs available yet
|
||||
</p>
|
||||
</div>
|
||||
) : logs.length > 0 && sortedLogs.length === 0 ? (
|
||||
<div className="bg-scale-100 dark:bg-scale-300 border rounded p-4 flex items-center justify-between">
|
||||
<p className="prose text-sm">No audit logs found based on the filters applied</p>
|
||||
</div>
|
||||
) : (
|
||||
<Table
|
||||
head={[
|
||||
<Table.th key="user" className="py-2">
|
||||
User
|
||||
</Table.th>,
|
||||
<Table.th key="action" className="py-2">
|
||||
Action
|
||||
</Table.th>,
|
||||
<Table.th key="target" className="py-2">
|
||||
Target
|
||||
</Table.th>,
|
||||
<Table.th key="date" className="py-2">
|
||||
<div className="flex items-center space-x-2">
|
||||
<p>Date</p>
|
||||
|
||||
<Tooltip.Root delayDuration={0}>
|
||||
<Tooltip.Trigger>
|
||||
<Button
|
||||
type="text"
|
||||
className="px-1"
|
||||
icon={
|
||||
dateSortDesc ? (
|
||||
<IconArrowDown strokeWidth={1.5} size={14} />
|
||||
) : (
|
||||
<IconArrowUp strokeWidth={1.5} size={14} />
|
||||
)
|
||||
}
|
||||
onClick={() => setDateSortDesc(!dateSortDesc)}
|
||||
/>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Portal>
|
||||
<Tooltip.Portal>
|
||||
<Tooltip.Content side="right">
|
||||
<Tooltip.Arrow className="radix-tooltip-arrow" />
|
||||
<div
|
||||
className={[
|
||||
'rounded bg-scale-100 py-1 px-2 leading-none shadow',
|
||||
'border border-scale-200',
|
||||
].join(' ')}
|
||||
>
|
||||
<span className="text-xs text-scale-1200">
|
||||
{dateSortDesc ? 'Sort latest first' : 'Sort earliest first'}
|
||||
</span>
|
||||
</div>
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Portal>
|
||||
</Tooltip.Portal>
|
||||
</Tooltip.Root>
|
||||
</div>
|
||||
</Table.th>,
|
||||
<Table.th key="actions" className="py-2"></Table.th>,
|
||||
]}
|
||||
body={
|
||||
sortedLogs?.map((log) => {
|
||||
const user = members.find((member) => member.gotrue_id === log.actor.id)
|
||||
const role = roles.find((role) => user?.role_ids?.[0] === role.id)
|
||||
const project = projects?.find(
|
||||
(project) => project.ref === log.target.metadata.project_ref
|
||||
)
|
||||
const organization = organizations?.find(
|
||||
(org) => org.slug === log.target.metadata.org_slug
|
||||
)
|
||||
|
||||
const hasStatusCode = log.action.metadata[0]?.status !== undefined
|
||||
const userIcon =
|
||||
user === undefined ? (
|
||||
<div className="flex h-[30px] w-[30px] flex items-center justify-center border-2 rounded-full border-scale-700">
|
||||
<p>?</p>
|
||||
</div>
|
||||
) : user?.invited_id || user?.username === user?.primary_email ? (
|
||||
<div className="flex h-[30px] w-[30px] flex items-center justify-center border-2 rounded-full border-scale-700">
|
||||
<IconUser size={18} strokeWidth={2} />
|
||||
</div>
|
||||
) : (
|
||||
<Image
|
||||
alt={user?.username}
|
||||
src={`https://github.com/${user?.username ?? ''}.png?size=80`}
|
||||
width="30"
|
||||
height="30"
|
||||
className="border rounded-full"
|
||||
/>
|
||||
)
|
||||
|
||||
return (
|
||||
<Table.tr
|
||||
key={log.occurred_at}
|
||||
onClick={() => setSelectedLog(log)}
|
||||
className="cursor-pointer hover:!bg-scale-100 transition duration-100"
|
||||
>
|
||||
<Table.td>
|
||||
<div className="flex items-center space-x-4">
|
||||
<div>{userIcon}</div>
|
||||
<div>
|
||||
<p className="text-scale-1100">{user?.username ?? 'Unknown'}</p>
|
||||
{role && (
|
||||
<p className="mt-0.5 text-xs text-scale-1000">{role?.name}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Table.td>
|
||||
<Table.td>
|
||||
<div className="flex items-center space-x-2">
|
||||
{hasStatusCode && (
|
||||
<p className="bg-scale-400 rounded px-1 flex items-center justify-center text-xs font-mono border">
|
||||
{log.action.metadata[0].status}
|
||||
</p>
|
||||
)}
|
||||
<p className="max-w-[170px] truncate" title={log.action.name}>
|
||||
{log.action.name}
|
||||
</p>
|
||||
</div>
|
||||
</Table.td>
|
||||
<Table.td>
|
||||
<p
|
||||
className="text-scale-1100 max-w-[230px] truncate"
|
||||
title={project?.name ?? organization?.name ?? 'Unknown'}
|
||||
>
|
||||
{project?.name
|
||||
? 'Project: '
|
||||
: organization?.name
|
||||
? 'Organization: '
|
||||
: null}
|
||||
{project?.name ?? organization?.name ?? 'Unknown'}
|
||||
</p>
|
||||
<p
|
||||
className="text-scale-1000 text-xs mt-0.5 truncate"
|
||||
title={
|
||||
log.target.metadata.project_ref ?? log.target.metadata.org_slug
|
||||
}
|
||||
>
|
||||
{log.target.metadata.project_ref
|
||||
? 'Ref: '
|
||||
: log.target.metadata.org_slug
|
||||
? 'Slug: '
|
||||
: null}
|
||||
{log.target.metadata.project_ref ?? log.target.metadata.org_slug}
|
||||
</p>
|
||||
</Table.td>
|
||||
<Table.td>
|
||||
{dayjs(log.occurred_at).format('DD MMM YYYY, HH:mm:ss')}
|
||||
</Table.td>
|
||||
<Table.td align="right">
|
||||
<Button type="default">View details</Button>
|
||||
</Table.td>
|
||||
</Table.tr>
|
||||
)
|
||||
}) ?? []
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</ScaffoldContainerLegacy>
|
||||
|
||||
<LogDetailsPanel selectedLog={selectedLog} onClose={() => setSelectedLog(undefined)} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default AuditLogs
|
||||
@@ -0,0 +1,100 @@
|
||||
import { useState } from 'react'
|
||||
import { Button, Checkbox, Popover } from 'ui'
|
||||
|
||||
interface FilterPopoverProps {
|
||||
options: any[]
|
||||
activeOptions: any[]
|
||||
valueKey: string
|
||||
labelKey: string
|
||||
name: string
|
||||
onSaveFilters: (options: string[]) => void
|
||||
}
|
||||
|
||||
// [Joshen] Form + Checkbox.Group doesn't seem to work properly RE form state
|
||||
// hence why the manual implementation here as a workaround
|
||||
|
||||
const FilterPopover = ({
|
||||
options = [],
|
||||
activeOptions = [],
|
||||
valueKey,
|
||||
labelKey,
|
||||
name,
|
||||
onSaveFilters,
|
||||
}: FilterPopoverProps) => {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [selectedOptions, setSelectedOptions] = useState<string[]>([])
|
||||
|
||||
const formattedOptions = activeOptions.map((option) => {
|
||||
const base = options.find((x) => x[valueKey] === option)
|
||||
return base[labelKey]
|
||||
})
|
||||
|
||||
return (
|
||||
<Popover
|
||||
side="bottom"
|
||||
align="start"
|
||||
open={open}
|
||||
onOpenChange={() => setOpen(!open)}
|
||||
header={<div className="prose text-xs">Select {name.toLowerCase()}</div>}
|
||||
overlay={
|
||||
<>
|
||||
<div className="space-y-4 px-3 py-3 min-w-[170px]">
|
||||
<Checkbox.Group
|
||||
id="projects"
|
||||
onChange={(event) => {
|
||||
const value = event.target.value
|
||||
if (selectedOptions.includes(value)) {
|
||||
setSelectedOptions(selectedOptions.filter((x) => x !== value))
|
||||
} else {
|
||||
setSelectedOptions(selectedOptions.concat(value))
|
||||
}
|
||||
}}
|
||||
options={options.map((option) => {
|
||||
return {
|
||||
value: option[valueKey],
|
||||
label: option[labelKey],
|
||||
defaultChecked: activeOptions.includes(option[valueKey]),
|
||||
}
|
||||
})}
|
||||
></Checkbox.Group>
|
||||
</div>
|
||||
<div className="flex items-center justify-end gap-2 border-t border-scale-400 bg-scale-200 py-2 px-3 dark:border-scale-500 dark:bg-scale-400 ">
|
||||
<Button
|
||||
size="tiny"
|
||||
type="default"
|
||||
onClick={() => {
|
||||
onSaveFilters([])
|
||||
setOpen(false)
|
||||
}}
|
||||
>
|
||||
Clear
|
||||
</Button>
|
||||
<Button
|
||||
type="primary"
|
||||
onClick={() => {
|
||||
onSaveFilters(selectedOptions)
|
||||
setOpen(false)
|
||||
}}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<Button type={activeOptions.length > 0 ? 'default' : 'dashed'} onClick={() => setOpen(false)}>
|
||||
<span>{name}</span>
|
||||
{activeOptions.length > 0 && <span className="mr-1">:</span>}
|
||||
{activeOptions.length >= 3 ? (
|
||||
<span>
|
||||
{formattedOptions[0]} and {activeOptions.length - 1} others
|
||||
</span>
|
||||
) : activeOptions.length > 0 ? (
|
||||
<span>{formattedOptions.join(', ')}</span>
|
||||
) : null}
|
||||
</Button>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
|
||||
export default FilterPopover
|
||||
@@ -0,0 +1,97 @@
|
||||
import { FormSection, FormSectionContent, FormSectionLabel } from 'components/ui/Forms'
|
||||
import { OrganizationAuditLog } from 'data/organizations/organization-audit-logs-query'
|
||||
import { useOrganizationsQuery } from 'data/organizations/organizations-query'
|
||||
import { useProjectsQuery } from 'data/projects/projects-query'
|
||||
import dayjs from 'dayjs'
|
||||
import { Input, SidePanel } from 'ui'
|
||||
|
||||
export interface LogDetailsPanelProps {
|
||||
selectedLog?: OrganizationAuditLog
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
const LogDetailsPanel = ({ selectedLog, onClose }: LogDetailsPanelProps) => {
|
||||
const { data: projects } = useProjectsQuery()
|
||||
const { data: organizations } = useOrganizationsQuery()
|
||||
|
||||
const project = projects?.find(
|
||||
(project) => project.ref === selectedLog?.target.metadata.project_ref
|
||||
)
|
||||
const organization = organizations?.find(
|
||||
(org) => org.slug === selectedLog?.target.metadata.org_slug
|
||||
)
|
||||
|
||||
return (
|
||||
<SidePanel
|
||||
size="large"
|
||||
header={`"${selectedLog?.action.name}" on ${dayjs(selectedLog?.occurred_at).format(
|
||||
'DD MMM YYYY, HH:mm:ss'
|
||||
)}`}
|
||||
visible={selectedLog !== undefined}
|
||||
onCancel={onClose}
|
||||
cancelText="Close"
|
||||
>
|
||||
<FormSection header={<FormSectionLabel>General</FormSectionLabel>}>
|
||||
<FormSectionContent loading={false}>
|
||||
<Input
|
||||
readOnly
|
||||
size="small"
|
||||
label="Occurred at"
|
||||
value={selectedLog?.occurred_at}
|
||||
descriptionText={dayjs(selectedLog?.occurred_at).format('DD MMM YYYY, HH:mm:ss (ZZ)')}
|
||||
/>
|
||||
</FormSectionContent>
|
||||
</FormSection>
|
||||
|
||||
<SidePanel.Separator />
|
||||
|
||||
<FormSection header={<FormSectionLabel>Actor</FormSectionLabel>}>
|
||||
<FormSectionContent loading={false}>
|
||||
<Input readOnly size="small" label="Actor ID" value={selectedLog?.actor.id} />
|
||||
<Input.TextArea
|
||||
readOnly
|
||||
size="small"
|
||||
label="Metadata"
|
||||
rows={5}
|
||||
className="input-mono input-xs"
|
||||
value={JSON.stringify(selectedLog?.actor.metadata, null, 2)}
|
||||
/>
|
||||
</FormSectionContent>
|
||||
</FormSection>
|
||||
|
||||
<SidePanel.Separator />
|
||||
|
||||
<FormSection header={<FormSectionLabel>Action</FormSectionLabel>}>
|
||||
<FormSectionContent loading={false}>
|
||||
<Input readOnly size="small" label="Name" value={selectedLog?.action.name} />
|
||||
<Input.TextArea
|
||||
readOnly
|
||||
size="small"
|
||||
label="Metadata"
|
||||
rows={5}
|
||||
className="input-mono input-xs"
|
||||
value={JSON.stringify(selectedLog?.action.metadata, null, 2)}
|
||||
/>
|
||||
</FormSectionContent>
|
||||
</FormSection>
|
||||
|
||||
<SidePanel.Separator />
|
||||
|
||||
<FormSection header={<FormSectionLabel>Target</FormSectionLabel>}>
|
||||
<FormSectionContent loading={false}>
|
||||
<Input readOnly size="small" label="Name" value={selectedLog?.target.description} />
|
||||
<Input.TextArea
|
||||
readOnly
|
||||
size="small"
|
||||
label="Metadata"
|
||||
rows={5}
|
||||
className="input-mono input-xs"
|
||||
value={JSON.stringify(selectedLog?.target.metadata, null, 2)}
|
||||
/>
|
||||
</FormSectionContent>
|
||||
</FormSection>
|
||||
</SidePanel>
|
||||
)
|
||||
}
|
||||
|
||||
export default LogDetailsPanel
|
||||
@@ -3,6 +3,7 @@ import GeneralSettings from './GeneralSettings/GeneralSettings'
|
||||
import IntegrationSettings from './IntegrationSettings/IntegrationSettings'
|
||||
import InvoicesSettings from './InvoicesSettings/InvoicesSettings'
|
||||
import NewOrgForm from './NewOrg/NewOrgForm'
|
||||
import AuditLogs from './AuditLogs/AuditLogs'
|
||||
import OAuthApps from './OAuthApps/OAuthApps'
|
||||
import TeamSettings from './TeamSettings/TeamSettings'
|
||||
|
||||
@@ -13,5 +14,6 @@ export {
|
||||
InvoicesSettings,
|
||||
NewOrgForm,
|
||||
OAuthApps,
|
||||
AuditLogs,
|
||||
TeamSettings,
|
||||
}
|
||||
@@ -12,6 +12,7 @@ const OrganizationLayout = ({ children }: PropsWithChildren<{}>) => {
|
||||
const { slug } = useParams()
|
||||
const id = router.asPath.split('/').at(-1)
|
||||
const showOAuthApps = useFlag('oauthApps')
|
||||
const showAuditLogs = useFlag('auditLogs')
|
||||
const showIntegrationsV2 = useFlag('integrationsV2')
|
||||
|
||||
return (
|
||||
@@ -42,6 +43,7 @@ const OrganizationLayout = ({ children }: PropsWithChildren<{}>) => {
|
||||
<Tabs.Panel id="billing" label="Billing" className="!my-0" />
|
||||
<Tabs.Panel id="invoices" label="Invoices" className="!my-0" />
|
||||
{showOAuthApps && <Tabs.Panel id="apps" label="OAuth Apps" className="!my-0" />}
|
||||
{showAuditLogs && <Tabs.Panel id="audit" label="Audit Logs" className="!my-0" />}
|
||||
</Tabs>
|
||||
</nav>
|
||||
</ScaffoldContainer>
|
||||
|
||||
@@ -17,11 +17,15 @@ import { DatePickerToFrom } from 'components/interfaces/Settings/Logs'
|
||||
|
||||
export interface DatePickerProps {
|
||||
onChange?: (args: DatePickerToFrom) => void
|
||||
to?: string
|
||||
from?: string
|
||||
to?: string // ISO string
|
||||
from?: string // ISO string
|
||||
triggerButtonType?: ButtonProps['type']
|
||||
triggerButtonClassName?: string
|
||||
triggerButtonTitle?: string
|
||||
minDate?: Date
|
||||
maxDate?: Date
|
||||
hideTime?: boolean
|
||||
hideClear?: boolean
|
||||
renderFooter?: (args: DatePickerToFrom) => React.ReactNode | void
|
||||
}
|
||||
|
||||
@@ -38,6 +42,10 @@ function _DatePicker({
|
||||
triggerButtonType = 'default',
|
||||
triggerButtonClassName = '',
|
||||
triggerButtonTitle,
|
||||
minDate,
|
||||
maxDate,
|
||||
hideTime = false,
|
||||
hideClear = false,
|
||||
renderFooter = () => null,
|
||||
}: DatePickerProps) {
|
||||
const [open, setOpen] = useState<boolean>(false)
|
||||
@@ -64,6 +72,7 @@ function _DatePicker({
|
||||
setAppliedEndDate(end)
|
||||
setEndDate(end)
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [to, from])
|
||||
|
||||
function handleDatePickerChange(dates: [from: Date | null, to: Date | null]) {
|
||||
@@ -81,15 +90,15 @@ function _DatePicker({
|
||||
const payload = {
|
||||
from: dayjs
|
||||
.utc(startDate)
|
||||
.second(startTime.ss)
|
||||
.minute(startTime.mm)
|
||||
.hour(startTime.HH)
|
||||
.second(Number(startTime.ss))
|
||||
.minute(Number(startTime.mm))
|
||||
.hour(Number(startTime.HH))
|
||||
.toISOString(),
|
||||
to: dayjs
|
||||
.utc(endDate || startDate)
|
||||
.second(endTime.ss)
|
||||
.minute(endTime.mm)
|
||||
.hour(endTime.HH)
|
||||
.second(Number(endTime.ss))
|
||||
.minute(Number(endTime.mm))
|
||||
.hour(Number(endTime.HH))
|
||||
.toISOString(),
|
||||
}
|
||||
if (onChange) onChange(payload)
|
||||
@@ -114,52 +123,56 @@ function _DatePicker({
|
||||
align="center"
|
||||
side="bottom"
|
||||
header={
|
||||
<>
|
||||
<div className="flex items-stretch justify-between py-2">
|
||||
<div className="flex grow flex-col gap-1">
|
||||
<TimeSplitInput
|
||||
type="start"
|
||||
startTime={startTime}
|
||||
endTime={endTime}
|
||||
time={startTime}
|
||||
setTime={setStartTime}
|
||||
setStartTime={setStartTime}
|
||||
setEndTime={setEndTime}
|
||||
startDate={startDate}
|
||||
endDate={endDate}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className={`
|
||||
hideTime ? null : (
|
||||
<>
|
||||
<div className="flex items-stretch justify-between py-2">
|
||||
<div className="flex grow flex-col gap-1">
|
||||
<TimeSplitInput
|
||||
type="start"
|
||||
startTime={startTime}
|
||||
endTime={endTime}
|
||||
time={startTime}
|
||||
setTime={setStartTime}
|
||||
setStartTime={setStartTime}
|
||||
setEndTime={setEndTime}
|
||||
startDate={startDate}
|
||||
endDate={endDate}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className={`
|
||||
flex
|
||||
w-12
|
||||
items-center
|
||||
justify-center
|
||||
text-scale-900
|
||||
`}
|
||||
>
|
||||
<IconArrowRight strokeWidth={1.5} size={14} />
|
||||
>
|
||||
<IconArrowRight strokeWidth={1.5} size={14} />
|
||||
</div>
|
||||
<div className="flex grow flex-col gap-1">
|
||||
<TimeSplitInput
|
||||
type="end"
|
||||
startTime={startTime}
|
||||
endTime={endTime}
|
||||
time={endTime}
|
||||
setTime={setEndTime}
|
||||
setStartTime={setStartTime}
|
||||
setEndTime={setEndTime}
|
||||
startDate={startDate}
|
||||
endDate={endDate}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex grow flex-col gap-1">
|
||||
<TimeSplitInput
|
||||
type="end"
|
||||
startTime={startTime}
|
||||
endTime={endTime}
|
||||
time={endTime}
|
||||
setTime={setEndTime}
|
||||
setStartTime={setStartTime}
|
||||
setEndTime={setEndTime}
|
||||
startDate={startDate}
|
||||
endDate={endDate}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
</>
|
||||
)
|
||||
}
|
||||
overlay={
|
||||
<>
|
||||
<div className="px-3 py-4">
|
||||
<DatePicker
|
||||
inline
|
||||
selectsRange
|
||||
selected={startDate}
|
||||
onChange={(dates) => {
|
||||
handleDatePickerChange(dates)
|
||||
@@ -167,8 +180,8 @@ function _DatePicker({
|
||||
dateFormat="MMMM d, yyyy h:mm aa"
|
||||
startDate={startDate}
|
||||
endDate={endDate}
|
||||
selectsRange
|
||||
inline
|
||||
minDate={minDate}
|
||||
maxDate={maxDate}
|
||||
dayClassName={() => 'cursor-pointer'}
|
||||
renderCustomHeader={({
|
||||
date,
|
||||
@@ -213,9 +226,11 @@ function _DatePicker({
|
||||
})}
|
||||
<Popover.Separator />
|
||||
<div className="flex items-center justify-end gap-2 py-2 px-3 pb-4">
|
||||
<Button type="default" onClick={() => handleClear()}>
|
||||
Clear
|
||||
</Button>
|
||||
{!hideClear && (
|
||||
<Button type="default" onClick={() => handleClear()}>
|
||||
Clear
|
||||
</Button>
|
||||
)}
|
||||
<Button onClick={() => handleSubmit()}>Apply</Button>
|
||||
</div>
|
||||
</>
|
||||
|
||||
@@ -7,6 +7,10 @@ export const organizationKeys = {
|
||||
['organizations', slug, 'free-project-limit-check'] as const,
|
||||
customerProfile: (slug: string | undefined) =>
|
||||
['organizations', slug, 'customer-profile'] as const,
|
||||
auditLogs: (
|
||||
slug: string | undefined,
|
||||
{ date_start, date_end }: { date_start: string | undefined; date_end: string | undefined }
|
||||
) => ['organizations', slug, 'audit-logs', { date_start, date_end }] as const,
|
||||
migrateBilling: (slug: string | undefined) => ['organizations', slug, 'migrate-billing'] as const,
|
||||
migrateBillingPreview: (slug: string | undefined) =>
|
||||
['organizations', slug, 'migrate-billing', 'preview'] as const,
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
import { useQuery, useQueryClient, UseQueryOptions } from '@tanstack/react-query'
|
||||
import { get } from 'lib/common/fetch'
|
||||
import { API_URL } from 'lib/constants'
|
||||
import { useCallback } from 'react'
|
||||
import { organizationKeys } from './keys'
|
||||
import { ResponseError } from 'types'
|
||||
import dayjs from 'dayjs'
|
||||
|
||||
export type OrganizationAuditLog = {
|
||||
action: {
|
||||
metadata: {
|
||||
method?: string
|
||||
status?: number
|
||||
}[]
|
||||
name: string
|
||||
}
|
||||
actor: {
|
||||
id: string
|
||||
type: 'user' | string
|
||||
metadata: {
|
||||
email?: string
|
||||
}[]
|
||||
}
|
||||
target: {
|
||||
description: string
|
||||
metadata: {
|
||||
org_slug?: string
|
||||
project_ref?: string
|
||||
}
|
||||
}
|
||||
occurred_at: string
|
||||
}
|
||||
|
||||
export type OrganizationAuditLogsResponse = {
|
||||
result: OrganizationAuditLog[]
|
||||
retention_period: number
|
||||
}
|
||||
|
||||
export type OrganizationAuditLogsVariables = {
|
||||
slug?: string
|
||||
iso_timestamp_start?: string
|
||||
iso_timestamp_end?: string
|
||||
project_refs?: string // Comma-separated
|
||||
}
|
||||
|
||||
export async function getOrganizationAuditLogs(
|
||||
{ slug, iso_timestamp_start, iso_timestamp_end, project_refs }: OrganizationAuditLogsVariables,
|
||||
signal?: AbortSignal
|
||||
) {
|
||||
if (!slug) throw new Error('slug is required')
|
||||
|
||||
const response = await get(
|
||||
`${API_URL}/organizations/${slug}/audit?iso_timestamp_start=${iso_timestamp_start}&iso_timestamp_end=${iso_timestamp_end}`,
|
||||
{ signal }
|
||||
)
|
||||
if (response.error) throw response.error
|
||||
return response as OrganizationAuditLogsResponse
|
||||
}
|
||||
|
||||
export type OrganizationAuditLogsData = Awaited<ReturnType<typeof getOrganizationAuditLogs>>
|
||||
export type OrganizationAuditLogsError = ResponseError
|
||||
|
||||
export const useOrganizationAuditLogsQuery = <TData = OrganizationAuditLogsData>(
|
||||
vars: OrganizationAuditLogsVariables,
|
||||
{
|
||||
enabled = true,
|
||||
...options
|
||||
}: UseQueryOptions<OrganizationAuditLogsData, OrganizationAuditLogsError, TData> = {}
|
||||
) => {
|
||||
const { slug, iso_timestamp_start, iso_timestamp_end } = vars
|
||||
const date_start = dayjs(iso_timestamp_start).utc().format('YYYY-MM-DD')
|
||||
const date_end = dayjs(iso_timestamp_end).utc().format('YYYY-MM-DD')
|
||||
|
||||
return useQuery<OrganizationAuditLogsData, OrganizationAuditLogsError, TData>(
|
||||
organizationKeys.auditLogs(slug, { date_start, date_end }),
|
||||
({ signal }) => getOrganizationAuditLogs(vars, signal),
|
||||
{
|
||||
enabled: enabled && typeof slug !== 'undefined',
|
||||
...options,
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
export const useOrganizationAuditLogsPrefetch = (vars: OrganizationAuditLogsVariables) => {
|
||||
const { slug, iso_timestamp_start, iso_timestamp_end } = vars
|
||||
const client = useQueryClient()
|
||||
|
||||
return useCallback(() => {
|
||||
if (slug) {
|
||||
const date_start = dayjs(iso_timestamp_start).utc().format('YYYY-MM-DD')
|
||||
const date_end = dayjs(iso_timestamp_end).utc().format('YYYY-MM-DD')
|
||||
client.prefetchQuery(
|
||||
organizationKeys.auditLogs(slug, { date_start, date_end }),
|
||||
({ signal }) => getOrganizationAuditLogs(vars, signal)
|
||||
)
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [client, vars])
|
||||
}
|
||||
@@ -7,20 +7,12 @@ import { NextPageWithLayout } from 'types'
|
||||
|
||||
const OrgOAuthApps: NextPageWithLayout = () => {
|
||||
const selectedOrganization = useSelectedOrganization()
|
||||
// const { isLoading: isLoadingPermissions } = usePermissionsQuery()
|
||||
const { data: permissions } = usePermissionsQuery()
|
||||
const { isLoading: isLoadingPermissions } = usePermissionsQuery()
|
||||
|
||||
return (
|
||||
<>
|
||||
{selectedOrganization === undefined && (permissions ?? []).length === 0 ? (
|
||||
<Loading />
|
||||
) : (
|
||||
<OAuthApps />
|
||||
)}
|
||||
</>
|
||||
<>{selectedOrganization === undefined && isLoadingPermissions ? <Loading /> : <OAuthApps />}</>
|
||||
)
|
||||
}
|
||||
|
||||
OrgOAuthApps.getLayout = (page) => <OrganizationLayout>{page}</OrganizationLayout>
|
||||
|
||||
export default OrgOAuthApps
|
||||
@@ -0,0 +1,18 @@
|
||||
import { AuditLogs } from 'components/interfaces/Organization'
|
||||
import { OrganizationLayout } from 'components/layouts'
|
||||
import Loading from 'components/ui/Loading'
|
||||
import { usePermissionsQuery } from 'data/permissions/permissions-query'
|
||||
import { useSelectedOrganization } from 'hooks'
|
||||
import { NextPageWithLayout } from 'types'
|
||||
|
||||
const OrgAuditLogs: NextPageWithLayout = () => {
|
||||
const { isLoading: isLoadingPermissions } = usePermissionsQuery()
|
||||
const selectedOrganization = useSelectedOrganization()
|
||||
|
||||
return (
|
||||
<>{selectedOrganization === undefined && isLoadingPermissions ? <Loading /> : <AuditLogs />}</>
|
||||
)
|
||||
}
|
||||
|
||||
OrgAuditLogs.getLayout = (page) => <OrganizationLayout>{page}</OrganizationLayout>
|
||||
export default OrgAuditLogs
|
||||
@@ -6,12 +6,12 @@ import { useSelectedOrganization } from 'hooks'
|
||||
import { NextPageWithLayout } from 'types'
|
||||
|
||||
const OrgBillingSettings: NextPageWithLayout = () => {
|
||||
const { data: permissions } = usePermissionsQuery()
|
||||
const { isLoading: isLoadingPermissions } = usePermissionsQuery()
|
||||
const selectedOrganization = useSelectedOrganization()
|
||||
|
||||
return (
|
||||
<>
|
||||
{selectedOrganization === undefined && (permissions ?? []).length === 0 ? (
|
||||
{selectedOrganization === undefined && isLoadingPermissions ? (
|
||||
<Loading />
|
||||
) : (
|
||||
<BillingSettings />
|
||||
@@ -21,5 +21,4 @@ const OrgBillingSettings: NextPageWithLayout = () => {
|
||||
}
|
||||
|
||||
OrgBillingSettings.getLayout = (page) => <OrganizationLayout>{page}</OrganizationLayout>
|
||||
|
||||
export default OrgBillingSettings
|
||||
@@ -6,12 +6,12 @@ import { useSelectedOrganization } from 'hooks'
|
||||
import { NextPageWithLayout } from 'types'
|
||||
|
||||
const OrgGeneralSettings: NextPageWithLayout = () => {
|
||||
const { data: permissions } = usePermissionsQuery()
|
||||
const { isLoading: isLoadingPermissions } = usePermissionsQuery()
|
||||
const selectedOrganization = useSelectedOrganization()
|
||||
|
||||
return (
|
||||
<>
|
||||
{selectedOrganization === undefined && (permissions ?? []).length === 0 ? (
|
||||
{selectedOrganization === undefined && isLoadingPermissions ? (
|
||||
<Loading />
|
||||
) : (
|
||||
<GeneralSettings />
|
||||
@@ -21,5 +21,4 @@ const OrgGeneralSettings: NextPageWithLayout = () => {
|
||||
}
|
||||
|
||||
OrgGeneralSettings.getLayout = (page) => <OrganizationLayout>{page}</OrganizationLayout>
|
||||
|
||||
export default OrgGeneralSettings
|
||||
@@ -6,12 +6,12 @@ import { useSelectedOrganization } from 'hooks'
|
||||
import { NextPageWithLayout } from 'types'
|
||||
|
||||
const OrgInvoices: NextPageWithLayout = () => {
|
||||
const { data: permissions } = usePermissionsQuery()
|
||||
const { isLoading: isLoadingPermissions } = usePermissionsQuery()
|
||||
const selectedOrganization = useSelectedOrganization()
|
||||
|
||||
return (
|
||||
<>
|
||||
{selectedOrganization === undefined && (permissions ?? []).length === 0 ? (
|
||||
{selectedOrganization === undefined && isLoadingPermissions ? (
|
||||
<Loading />
|
||||
) : (
|
||||
<InvoicesSettings />
|
||||
@@ -21,5 +21,4 @@ const OrgInvoices: NextPageWithLayout = () => {
|
||||
}
|
||||
|
||||
OrgInvoices.getLayout = (page) => <OrganizationLayout>{page}</OrganizationLayout>
|
||||
|
||||
export default OrgInvoices
|
||||
@@ -6,20 +6,15 @@ import { useSelectedOrganization } from 'hooks'
|
||||
import { NextPageWithLayout } from 'types'
|
||||
|
||||
const OrgTeamSettings: NextPageWithLayout = () => {
|
||||
const { data: permissions } = usePermissionsQuery()
|
||||
const { isLoading: isLoadingPermissions } = usePermissionsQuery()
|
||||
const selectedOrganization = useSelectedOrganization()
|
||||
|
||||
return (
|
||||
<>
|
||||
{selectedOrganization === undefined && (permissions ?? []).length === 0 ? (
|
||||
<Loading />
|
||||
) : (
|
||||
<TeamSettings />
|
||||
)}
|
||||
{selectedOrganization === undefined && isLoadingPermissions ? <Loading /> : <TeamSettings />}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
OrgTeamSettings.getLayout = (page) => <OrganizationLayout>{page}</OrganizationLayout>
|
||||
|
||||
export default OrgTeamSettings
|
||||
@@ -150,6 +150,13 @@ a {
|
||||
}
|
||||
}
|
||||
|
||||
.input-xs {
|
||||
input,
|
||||
textarea {
|
||||
@apply text-xs;
|
||||
}
|
||||
}
|
||||
|
||||
input[type='radio'] {
|
||||
@apply p-0;
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user