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