+
+
+
Filter by
+
setFilters({ ...filters, users: values })}
+ />
+ setFilters({ ...filters, projects: values })}
+ />
+ {
+ 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 (
+
+ Your organization has a log retention period of{' '}
+
+ {retentionPeriod} day
+ {retentionPeriod > 1 ? 's' : ''}
+
+ . You may only view logs from{' '}
+ {dayjs().subtract(retentionPeriod, 'days').format('DD MMM YYYY')} as the
+ earliest date.
+
+ )
+ }}
+ />
+ {isSuccess && (
+ <>
+
+ Viewing {sortedLogs.length} logs in total
+ >
+ )}
+
+
}
+ onClick={() => refetch()}
+ >
+ {isRefetching ? 'Refreshing' : 'Refresh'}
+
+
+
+ {isLoading && (
+
+
+
+
+
+ )}
+
+ {isError &&
}
+
+ {isSuccess && (
+ <>
+ {logs.length === 0 ? (
+
+
+ Your organization does not have any audit logs available yet
+
+
+ ) : logs.length > 0 && sortedLogs.length === 0 ? (
+
+
No audit logs found based on the filters applied
+
+ ) : (
+
+ User
+ ,
+
+ Action
+ ,
+
+ Target
+ ,
+
+
+
Date
+
+
+
+
+ ) : (
+
+ )
+ }
+ onClick={() => setDateSortDesc(!dateSortDesc)}
+ />
+
+
+
+
+
+
+
+ {dateSortDesc ? 'Sort latest first' : 'Sort earliest first'}
+
+
+
+
+
+
+
+ ,
+ ,
+ ]}
+ 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 ? (
+
+ ) : user?.invited_id || user?.username === user?.primary_email ? (
+
+
+
+ ) : (
+
+ )
+
+ return (
+ setSelectedLog(log)}
+ className="cursor-pointer hover:!bg-scale-100 transition duration-100"
+ >
+
+
+
{userIcon}
+
+
{user?.username ?? 'Unknown'}
+ {role && (
+
{role?.name}
+ )}
+
+
+
+
+
+ {hasStatusCode && (
+
+ {log.action.metadata[0].status}
+
+ )}
+
+ {log.action.name}
+
+
+
+
+
+ {project?.name
+ ? 'Project: '
+ : organization?.name
+ ? 'Organization: '
+ : null}
+ {project?.name ?? organization?.name ?? 'Unknown'}
+
+
+ {log.target.metadata.project_ref
+ ? 'Ref: '
+ : log.target.metadata.org_slug
+ ? 'Slug: '
+ : null}
+ {log.target.metadata.project_ref ?? log.target.metadata.org_slug}
+
+
+
+ {dayjs(log.occurred_at).format('DD MMM YYYY, HH:mm:ss')}
+
+
+
+
+
+ )
+ }) ?? []
+ }
+ />
+ )}
+ >
+ )}
+
+
+
+ setSelectedLog(undefined)} />
+ >
+ )
+}
+
+export default AuditLogs
diff --git a/studio/components/interfaces/Organization/AuditLogs/FilterPopover.tsx b/studio/components/interfaces/Organization/AuditLogs/FilterPopover.tsx
new file mode 100644
index 00000000000..c38c96736e3
--- /dev/null
+++ b/studio/components/interfaces/Organization/AuditLogs/FilterPopover.tsx
@@ -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([])
+
+ const formattedOptions = activeOptions.map((option) => {
+ const base = options.find((x) => x[valueKey] === option)
+ return base[labelKey]
+ })
+
+ return (
+ setOpen(!open)}
+ header={Select {name.toLowerCase()}
}
+ overlay={
+ <>
+
+ {
+ 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]),
+ }
+ })}
+ >
+
+
+
+
+
+ >
+ }
+ >
+
+
+ )
+}
+
+export default FilterPopover
diff --git a/studio/components/interfaces/Organization/AuditLogs/LogDetailsPanel.tsx b/studio/components/interfaces/Organization/AuditLogs/LogDetailsPanel.tsx
new file mode 100644
index 00000000000..0f374319b15
--- /dev/null
+++ b/studio/components/interfaces/Organization/AuditLogs/LogDetailsPanel.tsx
@@ -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 (
+
+ General}>
+
+
+
+
+
+
+
+ Actor}>
+
+
+
+
+
+
+
+
+ Action}>
+
+
+
+
+
+
+
+
+ Target}>
+
+
+
+
+
+
+ )
+}
+
+export default LogDetailsPanel
diff --git a/studio/components/interfaces/Organization/index.ts b/studio/components/interfaces/Organization/index.ts
index 1c6271c6d6f..800b5c31cfa 100644
--- a/studio/components/interfaces/Organization/index.ts
+++ b/studio/components/interfaces/Organization/index.ts
@@ -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,
}
diff --git a/studio/components/layouts/OrganizationLayout.tsx b/studio/components/layouts/OrganizationLayout.tsx
index c79d90c60c2..48a780dd744 100644
--- a/studio/components/layouts/OrganizationLayout.tsx
+++ b/studio/components/layouts/OrganizationLayout.tsx
@@ -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<{}>) => {
{showOAuthApps && }
+ {showAuditLogs && }
diff --git a/studio/components/ui/DatePicker/DatePicker.tsx b/studio/components/ui/DatePicker/DatePicker.tsx
index aca6f5d504d..ba89eeecfe1 100644
--- a/studio/components/ui/DatePicker/DatePicker.tsx
+++ b/studio/components/ui/DatePicker/DatePicker.tsx
@@ -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(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={
- <>
-
-
-
-
-
- >
+ >
+ )
}
overlay={
<>
{
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({
})}
-
+ {!hideClear && (
+
+ )}
>
diff --git a/studio/data/organizations/keys.ts b/studio/data/organizations/keys.ts
index a7b2d230164..a1c97b3ac4f 100644
--- a/studio/data/organizations/keys.ts
+++ b/studio/data/organizations/keys.ts
@@ -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,
diff --git a/studio/data/organizations/organization-audit-logs-query.ts b/studio/data/organizations/organization-audit-logs-query.ts
new file mode 100644
index 00000000000..8b5663dd6c2
--- /dev/null
+++ b/studio/data/organizations/organization-audit-logs-query.ts
@@ -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>
+export type OrganizationAuditLogsError = ResponseError
+
+export const useOrganizationAuditLogsQuery = (
+ vars: OrganizationAuditLogsVariables,
+ {
+ enabled = true,
+ ...options
+ }: UseQueryOptions = {}
+) => {
+ 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(
+ 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])
+}
diff --git a/studio/pages/org/[slug]/apps.tsx b/studio/pages/org/[slug]/apps.tsx
index c53ee052c42..961fe12b405 100644
--- a/studio/pages/org/[slug]/apps.tsx
+++ b/studio/pages/org/[slug]/apps.tsx
@@ -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 ? (
-
- ) : (
-
- )}
- >
+ <>{selectedOrganization === undefined && isLoadingPermissions ? : }>
)
}
OrgOAuthApps.getLayout = (page) => {page}
-
export default OrgOAuthApps
diff --git a/studio/pages/org/[slug]/audit.tsx b/studio/pages/org/[slug]/audit.tsx
new file mode 100644
index 00000000000..895526ed470
--- /dev/null
+++ b/studio/pages/org/[slug]/audit.tsx
@@ -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 ? : }>
+ )
+}
+
+OrgAuditLogs.getLayout = (page) => {page}
+export default OrgAuditLogs
diff --git a/studio/pages/org/[slug]/billing.tsx b/studio/pages/org/[slug]/billing.tsx
index 17b7d313641..c6cae0c44ad 100644
--- a/studio/pages/org/[slug]/billing.tsx
+++ b/studio/pages/org/[slug]/billing.tsx
@@ -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 ? (
) : (
@@ -21,5 +21,4 @@ const OrgBillingSettings: NextPageWithLayout = () => {
}
OrgBillingSettings.getLayout = (page) => {page}
-
export default OrgBillingSettings
diff --git a/studio/pages/org/[slug]/general.tsx b/studio/pages/org/[slug]/general.tsx
index a2144d6383f..245a3e23f05 100644
--- a/studio/pages/org/[slug]/general.tsx
+++ b/studio/pages/org/[slug]/general.tsx
@@ -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 ? (
) : (
@@ -21,5 +21,4 @@ const OrgGeneralSettings: NextPageWithLayout = () => {
}
OrgGeneralSettings.getLayout = (page) => {page}
-
export default OrgGeneralSettings
diff --git a/studio/pages/org/[slug]/invoices.tsx b/studio/pages/org/[slug]/invoices.tsx
index bb21316adbd..418510243c3 100644
--- a/studio/pages/org/[slug]/invoices.tsx
+++ b/studio/pages/org/[slug]/invoices.tsx
@@ -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 ? (
) : (
@@ -21,5 +21,4 @@ const OrgInvoices: NextPageWithLayout = () => {
}
OrgInvoices.getLayout = (page) => {page}
-
export default OrgInvoices
diff --git a/studio/pages/org/[slug]/team.tsx b/studio/pages/org/[slug]/team.tsx
index 579249d3001..d1a75aec2f4 100644
--- a/studio/pages/org/[slug]/team.tsx
+++ b/studio/pages/org/[slug]/team.tsx
@@ -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 ? (
-
- ) : (
-
- )}
+ {selectedOrganization === undefined && isLoadingPermissions ? : }
>
)
}
OrgTeamSettings.getLayout = (page) => {page}
-
export default OrgTeamSettings
diff --git a/studio/styles/main.scss b/studio/styles/main.scss
index 70970273ab4..344bc70f909 100644
--- a/studio/styles/main.scss
+++ b/studio/styles/main.scss
@@ -150,6 +150,13 @@ a {
}
}
+.input-xs {
+ input,
+ textarea {
+ @apply text-xs;
+ }
+}
+
input[type='radio'] {
@apply p-0;
}