AuditLogs.tsx 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427
  1. import { PermissionAction } from '@supabase/shared-types/out/constants'
  2. import { keepPreviousData } from '@tanstack/react-query'
  3. import { useDebounce } from '@uidotdev/usehooks'
  4. import { useParams } from 'common'
  5. import dayjs from 'dayjs'
  6. import { ArrowDown, ArrowUp, RefreshCw, User } from 'lucide-react'
  7. import Image from 'next/legacy/image'
  8. import { useEffect, useMemo, useState } from 'react'
  9. import { Alert, AlertDescription, AlertTitle, Button, WarningIcon } from 'ui'
  10. import { ShimmeringLoader } from 'ui-patterns/ShimmeringLoader'
  11. import { filterByProjects, filterByUsers, sortAuditLogs } from './AuditLogs.utils'
  12. import { LogDetailsPanel } from '@/components/interfaces/AuditLogs/LogDetailsPanel'
  13. import { LogsDatePicker } from '@/components/interfaces/Settings/Logs/Logs.DatePickers'
  14. import { ScaffoldContainer, ScaffoldSection } from '@/components/layouts/Scaffold'
  15. import Table from '@/components/to-be-cleaned/Table'
  16. import AlertError from '@/components/ui/AlertError'
  17. import { ButtonTooltip } from '@/components/ui/ButtonTooltip'
  18. import { FilterPopover } from '@/components/ui/FilterPopover'
  19. import NoPermission from '@/components/ui/NoPermission'
  20. import { UpgradeToPro } from '@/components/ui/UpgradeToPro'
  21. import { useOrganizationRolesV2Query } from '@/data/organization-members/organization-roles-query'
  22. import {
  23. AuditLog,
  24. TIMESTAMP_MICROS_PER_MS,
  25. useOrganizationAuditLogsQuery,
  26. } from '@/data/organizations/organization-audit-logs-query'
  27. import { useOrganizationMembersQuery } from '@/data/organizations/organization-members-query'
  28. import { useOrganizationsQuery } from '@/data/organizations/organizations-query'
  29. import { useOrgProjectsInfiniteQuery } from '@/data/projects/org-projects-infinite-query'
  30. import { useCheckEntitlements } from '@/hooks/misc/useCheckEntitlements'
  31. import { useAsyncCheckPermissions } from '@/hooks/misc/useCheckPermissions'
  32. const logsUpgradeError = 'upgrade to Team or Enterprise Plan to access audit logs.'
  33. // [Joshen considerations]
  34. // - Maybe fix the height of the table to the remaining height of the viewport, so that the search input is always visible
  35. // - 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
  36. // - 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
  37. // - Maybe a rule to follow from here is just everytime we call dayjs, use UTC(), one TZ to rule them all
  38. export const AuditLogs = () => {
  39. const { slug } = useParams()
  40. const currentTime = dayjs().utc().set('millisecond', 0)
  41. const [dateSortDesc, setDateSortDesc] = useState(true)
  42. const [dateRange, setDateRange] = useState({
  43. from: currentTime.subtract(1, 'day').toISOString(),
  44. to: currentTime.toISOString(),
  45. })
  46. const [selectedLog, setSelectedLog] = useState<AuditLog>()
  47. const [filters, setFilters] = useState<{ users: string[]; projects: string[] }>({
  48. users: [], // gotrue_id[]
  49. projects: [], // project_ref[]
  50. })
  51. const [search, setSearch] = useState('')
  52. const debouncedSearch = useDebounce(search, 500)
  53. const { can: canReadAuditLogs, isLoading: isLoadingPermissions } = useAsyncCheckPermissions(
  54. PermissionAction.READ,
  55. 'notifications'
  56. )
  57. const { hasAccess: hasAccessToAuditLogs, isLoading: isLoadingEntitlements } =
  58. useCheckEntitlements('security.audit_logs_days')
  59. const {
  60. data,
  61. error,
  62. isPending: isLoading,
  63. isSuccess,
  64. isError,
  65. isRefetching,
  66. fetchStatus,
  67. refetch,
  68. } = useOrganizationAuditLogsQuery(
  69. {
  70. slug,
  71. iso_timestamp_start: dateRange.from,
  72. iso_timestamp_end: dateRange.to,
  73. },
  74. {
  75. enabled: canReadAuditLogs,
  76. retry: false,
  77. refetchOnWindowFocus: (query) => {
  78. return !query.state.error?.message.endsWith(logsUpgradeError)
  79. },
  80. }
  81. )
  82. const isLogsNotAvailableBasedOnPlan = isError && !hasAccessToAuditLogs
  83. const isRangeExceededError = isError && error.message.includes('range exceeded')
  84. const showFilters = !isLoading && !isLogsNotAvailableBasedOnPlan
  85. const {
  86. data: projectsData,
  87. isLoading: isLoadingProjects,
  88. isFetching,
  89. isFetchingNextPage,
  90. hasNextPage,
  91. fetchNextPage,
  92. } = useOrgProjectsInfiniteQuery(
  93. { slug, search: search.length === 0 ? search : debouncedSearch },
  94. { placeholderData: keepPreviousData, enabled: showFilters }
  95. )
  96. const { data: organizations } = useOrganizationsQuery({
  97. enabled: showFilters,
  98. })
  99. const { data: members } = useOrganizationMembersQuery({ slug }, { enabled: showFilters })
  100. const { data: rolesData } = useOrganizationRolesV2Query({ slug }, { enabled: showFilters })
  101. const activeMembers = (members ?? []).filter((x) => !x.invited_at)
  102. const roles = [...(rolesData?.org_scoped_roles ?? []), ...(rolesData?.project_scoped_roles ?? [])]
  103. const projects =
  104. useMemo(() => projectsData?.pages.flatMap((page) => page.projects), [projectsData?.pages]) || []
  105. const logs = data?.result ?? []
  106. const sortedLogs = filterByProjects(
  107. filterByUsers(sortAuditLogs(logs, dateSortDesc), filters.users),
  108. filters.projects
  109. )
  110. const shouldShowLoadingState =
  111. (isLoading && fetchStatus !== 'idle') || isLoadingPermissions || isLoadingEntitlements
  112. // This feature depends on the subscription tier of the user.
  113. // The API limits the logs to maximum of 62 days and 5 minutes so when the page is
  114. // viewed for more than 5 minutes, the call parameters needs to be updated. This also works with
  115. // higher tiers.The user will see a loading shimmer.
  116. useEffect(() => {
  117. const duration = dayjs(dateRange.from).diff(dayjs(dateRange.to))
  118. const interval = setInterval(() => {
  119. const currentTime = dayjs().utc().set('millisecond', 0)
  120. setDateRange({
  121. from: currentTime.add(duration).toISOString(),
  122. to: currentTime.toISOString(),
  123. })
  124. }, 5 * 60000)
  125. return () => clearInterval(interval)
  126. }, [dateRange.from, dateRange.to])
  127. if (isLogsNotAvailableBasedOnPlan) {
  128. return (
  129. <ScaffoldContainer className="px-6 xl:px-10">
  130. <ScaffoldSection isFullWidth>
  131. <UpgradeToPro
  132. plan="Team"
  133. source="organizationAuditLogs"
  134. primaryText="Organization Audit Logs are not available on Free or Pro plans"
  135. secondaryText="Upgrade to Team or Enterprise to view up to 62 days of Audit Logs for your organization."
  136. featureProposition="enable audit logs"
  137. />
  138. </ScaffoldSection>
  139. </ScaffoldContainer>
  140. )
  141. }
  142. return (
  143. <>
  144. <ScaffoldContainer className="px-6 xl:px-10">
  145. <ScaffoldSection isFullWidth>
  146. <div className="space-y-4 flex flex-col">
  147. {showFilters && (
  148. <div className="flex items-center justify-between">
  149. <div className="flex items-center space-x-2">
  150. <p className="text-xs prose">Filter by</p>
  151. <FilterPopover
  152. name="Users"
  153. options={activeMembers}
  154. labelKey="username"
  155. valueKey="gotrue_id"
  156. activeOptions={filters.users}
  157. onSaveFilters={(values) => setFilters({ ...filters, users: values })}
  158. />
  159. <FilterPopover
  160. name="Projects"
  161. options={projects}
  162. labelKey="name"
  163. valueKey="ref"
  164. activeOptions={filters.projects}
  165. onSaveFilters={(values) => setFilters({ ...filters, projects: values })}
  166. search={search}
  167. setSearch={setSearch}
  168. hasNextPage={hasNextPage}
  169. isLoading={isLoadingProjects}
  170. isFetching={isFetching}
  171. isFetchingNextPage={isFetchingNextPage}
  172. fetchNextPage={fetchNextPage}
  173. />
  174. <LogsDatePicker
  175. hideWarnings
  176. value={dateRange}
  177. onSubmit={(value) => setDateRange(value)}
  178. helpers={[
  179. {
  180. text: 'Last 1 hour',
  181. calcFrom: () => dayjs().subtract(1, 'hour').toISOString(),
  182. calcTo: () => dayjs().toISOString(),
  183. },
  184. {
  185. text: 'Last 3 hours',
  186. calcFrom: () => dayjs().subtract(3, 'hour').toISOString(),
  187. calcTo: () => dayjs().toISOString(),
  188. },
  189. {
  190. text: 'Last 6 hours',
  191. calcFrom: () => dayjs().subtract(6, 'hour').toISOString(),
  192. calcTo: () => dayjs().toISOString(),
  193. },
  194. {
  195. text: 'Last 12 hours',
  196. calcFrom: () => dayjs().subtract(12, 'hour').toISOString(),
  197. calcTo: () => dayjs().toISOString(),
  198. },
  199. {
  200. text: 'Last 24 hours',
  201. calcFrom: () => dayjs().subtract(1, 'day').toISOString(),
  202. calcTo: () => dayjs().toISOString(),
  203. },
  204. ]}
  205. />
  206. {isSuccess && (
  207. <>
  208. <div className="h-[20px] border-r border-strong ml-4! mr-2!" />
  209. <p className="prose text-xs">Viewing {sortedLogs.length} logs in total</p>
  210. </>
  211. )}
  212. </div>
  213. <Button
  214. type="default"
  215. disabled={isLoading || isRefetching}
  216. icon={<RefreshCw className={isRefetching ? 'animate-spin' : ''} />}
  217. onClick={() => refetch()}
  218. >
  219. {isRefetching ? 'Refreshing' : 'Refresh'}
  220. </Button>
  221. </div>
  222. )}
  223. {shouldShowLoadingState ? (
  224. <div className="space-y-2">
  225. <ShimmeringLoader />
  226. <ShimmeringLoader className="w-3/4" />
  227. <ShimmeringLoader className="w-1/2" />
  228. </div>
  229. ) : !canReadAuditLogs ? (
  230. <NoPermission resourceText="view organization audit logs" />
  231. ) : null}
  232. {isError &&
  233. (isRangeExceededError ? (
  234. <Alert variant="destructive" title="Date range too large">
  235. <WarningIcon />
  236. <AlertTitle>Date range too large</AlertTitle>
  237. <AlertDescription>
  238. The selected date range exceeds the maximum allowed period. Please select a
  239. smaller time range.
  240. </AlertDescription>
  241. </Alert>
  242. ) : (
  243. <AlertError error={error} subject="Failed to retrieve audit logs" />
  244. ))}
  245. {isSuccess && (
  246. <>
  247. {logs.length === 0 ? (
  248. <div className="bg-surface-100 border rounded-sm p-4 flex items-center justify-between">
  249. <p className="prose text-sm">
  250. Your organization does not have any audit logs available yet
  251. </p>
  252. </div>
  253. ) : logs.length > 0 && sortedLogs.length === 0 ? (
  254. <div className="bg-surface-100 border rounded-sm p-4 flex items-center justify-between">
  255. <p className="prose text-sm">
  256. No audit logs found based on the filters applied
  257. </p>
  258. </div>
  259. ) : (
  260. <Table
  261. head={[
  262. <Table.th key="user" className="py-2">
  263. User
  264. </Table.th>,
  265. <Table.th key="action" className="py-2">
  266. Action
  267. </Table.th>,
  268. <Table.th key="target" className="py-2">
  269. Target
  270. </Table.th>,
  271. <Table.th key="date" className="py-2">
  272. <div className="flex items-center space-x-2">
  273. <p>Date</p>
  274. <ButtonTooltip
  275. type="text"
  276. className="px-1"
  277. icon={
  278. dateSortDesc ? (
  279. <ArrowDown strokeWidth={1.5} size={14} />
  280. ) : (
  281. <ArrowUp strokeWidth={1.5} size={14} />
  282. )
  283. }
  284. onClick={() => setDateSortDesc(!dateSortDesc)}
  285. tooltip={{
  286. content: {
  287. side: 'bottom',
  288. text: dateSortDesc ? 'Sort latest first' : 'Sort earliest first',
  289. },
  290. }}
  291. />
  292. </div>
  293. </Table.th>,
  294. <Table.th key="actions" className="py-2"></Table.th>,
  295. ]}
  296. body={
  297. sortedLogs?.map((log) => {
  298. const user = (members ?? []).find(
  299. (member) => member.gotrue_id === log.actor.user_id
  300. )
  301. const role = roles.find((role) => user?.role_ids?.[0] === role.id)
  302. const project = projects?.find((p) => p.ref === log.project_ref)
  303. const organization = organizations?.find(
  304. (org) => org.slug === log.organization_slug
  305. )
  306. const userIcon =
  307. user === undefined ? (
  308. <div className="flex h-[30px] w-[30px] items-center justify-center border-2 rounded-full border-strong">
  309. <p>?</p>
  310. </div>
  311. ) : user?.invited_id || user?.username === user?.primary_email ? (
  312. <div className="flex h-[30px] w-[30px] items-center justify-center border-2 rounded-full border-strong">
  313. <User size={18} strokeWidth={2} />
  314. </div>
  315. ) : (
  316. <Image
  317. alt={user?.username}
  318. src={`https://github.com/${user?.username ?? ''}.png?size=80`}
  319. width="30"
  320. height="30"
  321. className="border rounded-full"
  322. />
  323. )
  324. return (
  325. <Table.tr
  326. key={log.request_id}
  327. onClick={() => setSelectedLog(log)}
  328. className="cursor-pointer hover:bg-alternative! transition duration-100"
  329. >
  330. <Table.td>
  331. <div className="flex items-center space-x-4">
  332. <div>{userIcon}</div>
  333. <div>
  334. <p className="text-foreground-light">
  335. {user?.username ?? log.actor.email ?? '-'}
  336. </p>
  337. {role && (
  338. <p className="mt-0.5 text-xs text-foreground-light">
  339. {role?.name}
  340. </p>
  341. )}
  342. </div>
  343. </div>
  344. </Table.td>
  345. <Table.td className="max-w-[250px]">
  346. <div className="flex items-center space-x-2">
  347. <p className="bg-surface-200 rounded-sm px-1 flex items-center justify-center text-xs font-mono border">
  348. {log.action.status}
  349. </p>
  350. <p className="text-foreground-light text-xs font-mono">
  351. {log.action.method}
  352. </p>
  353. <p className="truncate" title={log.action.name}>
  354. {log.action.name}
  355. </p>
  356. </div>
  357. </Table.td>
  358. <Table.td>
  359. {project || organization ? (
  360. <>
  361. <p
  362. className="text-foreground-light max-w-[230px] truncate"
  363. title={project?.name ?? organization?.name}
  364. >
  365. {project ? 'Project: ' : 'Organization: '}
  366. {project?.name ?? organization?.name}
  367. </p>
  368. <p className="text-foreground-light text-xs mt-0.5 truncate">
  369. {log.project_ref
  370. ? `Ref: ${log.project_ref}`
  371. : `Slug: ${log.organization_slug}`}
  372. </p>
  373. </>
  374. ) : (
  375. <p className="text-foreground-light text-sm">
  376. {log.project_ref ?? log.organization_slug ?? '-'}
  377. </p>
  378. )}
  379. </Table.td>
  380. <Table.td>
  381. {dayjs(log.timestamp / TIMESTAMP_MICROS_PER_MS).format(
  382. 'DD MMM YYYY, HH:mm:ss'
  383. )}
  384. </Table.td>
  385. <Table.td align="right">
  386. <Button type="default">View details</Button>
  387. </Table.td>
  388. </Table.tr>
  389. )
  390. }) ?? []
  391. }
  392. />
  393. )}
  394. </>
  395. )}
  396. </div>
  397. </ScaffoldSection>
  398. </ScaffoldContainer>
  399. <LogDetailsPanel selectedLog={selectedLog} onClose={() => setSelectedLog(undefined)} />
  400. </>
  401. )
  402. }