Merge pull request #15302 from supabase/feat/audit-logs

Feat/audit logs
This commit is contained in:
Joshen Lim authored and GitHub committed 2023-07-04 15:13:51 +08:00
commit 412d0adc47
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>
+62 -47
View File
@@ -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>
</>
+4
View File
@@ -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])
}
+2 -10
View File
@@ -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
+18
View File
@@ -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
+2 -3
View File
@@ -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
+2 -3
View File
@@ -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
+2 -3
View File
@@ -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
+2 -7
View File
@@ -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
+7
View File
@@ -150,6 +150,13 @@ a {
}
}
.input-xs {
input,
textarea {
@apply text-xs;
}
}
input[type='radio'] {
@apply p-0;
}