Skip to content

Commit 33cb29c

Browse files
feat(console): SOC2 audit log + account activity alerts (#1288)
* feat(console): SOC2 audit log + account activity alerts - Add Audit Log settings page (owner-only) with filter + cursor pagination - Log auth events (NextAuth login/logout, Firebase login/logout, OIDC login/logout) - Log membership events (invite, accept, role-change, remove) - New "account" event type in notification channels - Immediate email dispatcher for security-severity events (no cron) - Extend AuditLog with severity column + (workspaceId, timestamp) index * fix(console): redact audit log + UX polish Security: - Stop persisting raw config object versions (prevVersion/newVersion) to AuditLog.changes — these can contain API keys, passwords and OAuth tokens. Only objectType + objectName are stored now. - API allow-lists safe fields in the response, so any pre-existing rows with raw config payloads are also redacted on read. UX: - Audit log table is full-width - Time column shows relative time (with absolute UTC tooltip) - Drop the auth-method chip from the actor cell - Drop the expandable raw-changes JSON row - Render config-object events as 'Updated <type> <name>' with a link to the entity edit page (no link for delete) * fix(console): mask secrets via outputFilter, keep full diff in audit log Reuse the per-type outputFilter (the same one used by the config editor) to mask sensitive fields, so prevVersion / newVersion can stay in the audit log: - destinations: secrets at credentialsUi paths replaced with MASKED_SECRET - services: airbyte_secret-marked fields masked - streams: privateKey/publicKey plaintext + hash stripped - types without a registered outputFilter (link, profilebuilder, function code): fall back to a generic name-based scrubber that masks fields whose key matches password / secret / token / apiKey / etc. Rows written through this helper are tagged `_redacted: true`. The read API only exposes prevVersion / newVersion when that flag is present, so any rows already in the DB from before this fix continue to be redacted on read (objectType + objectName only). UI: expandable row shows the masked Before / After diff side-by-side. * fix(console): break import cycle in audit-log helper audit-log.ts imported config-objects.ts at module load, which transitively pulls pages/api/[workspaceId]/domain-check.ts -> lib/api.ts -> nextauth.config -> back to audit-log.ts. The cycle surfaced as 'Cannot access httpMethods before initialization' on the first /api request. Lazy-require config-objects on first call, and inline the MASKED_SECRET constant so we don't need to pull lib/schema/destinations at load time. * feat(console): audit log diff view + better link/time rendering - API computes a flat diff [{ field, description }] from the masked prevVersion/newVersion server-side and returns it as `diff`. The raw prev/next blobs are no longer sent to the client. Masked secrets are surfaced as 'changed (secret value)' so the sentinel doesn't leak. - API enriches connection (link) entries with a synthesized 'from.name → to.name' display name (links have no name field of their own). - UI Time column shows absolute UTC + relative ('ago') stacked. - Entity name is the visible label; the underlying ID lives in a tooltip. - Expandable row renders the diff as a small Field / Change table. * fix(console): show entity names for old audit rows + +/- expand icon - API derives objectName from prevVersion/newVersion server-side when the stored 'objectName' is empty. Pre-fix rows still showed entity IDs because they don't have the dedicated field — this fallback fixes that without exposing the raw versions to the client. - Expand toggle uses an explicit +/- icon (lucide Plus/Minus) so the expandable rows are obvious. Rows without a diff render a fixed-width spacer so columns stay aligned. * fix(console): default expand icon + diff for old rows too - Drop the custom +/- expand icon. Use Antd's default chevron — it's the same widget shown elsewhere in the app and avoids a fragile manual layout. - Compute the diff for non-redacted rows too. Re-run a name-based scrubber on the way out so credentials in legacy rows never reach the client. Without this, only post-fix rows showed an expand handle. * fix(console): drop id/workspaceId noise + flatten diff UI - API strips id, workspaceId, type, cloneId from prev/new before diffing. These are identity / metadata fields that the write path strips before saving (legacy audit-log payloads sometimes captured them inline, which surfaced as confusing 'id removed' rows). - Replace the nested Antd Table in the expandable row with a plain two-column grid — same field/change information, no header bar, no row borders or stripes. Field name in monospace, description plain text. * feat(console): extract AuditLogDiff component, polish layout - New components/AuditLogDiff/AuditLogDiff.tsx — self-contained, full-width, responsive (stacks field/change on narrow widths). Card with header ('Changes' + N fields summary), thin row separators, no per-cell borders. - Expanded row indents the card to align with the Time column (past Antd's expand-chevron gutter), with a subtle neutral-50 backdrop. * feat(console): flatten create/delete diffs leaf-by-leaf Recurse into plain objects even when one side is undefined, so creating an entity produces: data.mode added: "batch" data.batchSize added: 10000 data.dataLayout added: "segment-single-table" … instead of one '(root) → added: {…}' blob. Same for deletes — every leaf is listed as 'removed (was …)'. Arrays stay atomic, so 'foo.bar → added: []'. * feat(console): mask ssh_key, middle-truncate diff values + tooltip - Add ssh_key, ssl_key, signing_key, encryption_key to the redaction patterns on both the write side (lib/server/audit-log.ts) and the read-side scrubber (pages/api/[workspaceId]/audit-log.ts). credentials.tunnel_method.ssh_key on Airbyte connectors is now masked even on legacy rows that didn't go through outputFilter at write time. - Server: relax the per-value cap from 80 to 2000 chars so the client gets the full string to display. - AuditLogDiff: middle-truncate long descriptions to ~100 chars and show the full text in an Antd Tooltip on hover. Truncated values get a 'help' cursor as a hover affordance. * feat(console): icon-based diff + outputFilter on read - Revert the regex extension (ssh_key etc). Use the per-type outputFilter on the read side instead — same masking the editor UI does. Pulls config-objects via lazy require so the leaf API route doesn't reintroduce the api.ts cycle. credentials.tunnel_method.ssh_key on Airbyte sources is now masked via airbyte_secret on legacy rows. - API returns structured diff entries: { field, kind, prev?, next? } where kind is added | removed | changed | secret-changed | noop. - AuditLogDiff renders each kind with an icon (+, −, →, key, no-change). changed rows show prev → next, both middle-truncated with full-text tooltip. - For 'config-object-update' rows whose prev and next end up byte-identical (the user clicked Save without editing — produces a real audit row but empty diff), show a single 'no field-level changes' noop entry so the row is still expandable and the situation is explicit. * fix(console): denylist for changes, mask sentinel parity, load-more loading - Replace ALWAYS_SAFE_FIELDS allow-list with a small deny-list: prevVersion, newVersion (rendered server-side via 'diff' instead) and the internal _redacted marker. Everything else our audit-log helpers write is safe summary metadata; we shouldn't have to enumerate it here. - Add isMasked() that recognizes both the local display sentinel ('*********') and the canonical sentinel ('__MASKED_BY_JITSU__') emitted by lib/schema/secrets#maskSecrets via outputFilter. Without this the secret-changed kind never fires for legacy rows masked through the per-type filter. - UI: while a Load-more fetch is in flight, show a disabled loading button. The previous logic briefly fell through to 'End of log' because query.data is undefined during the in-flight request. * refactor(console): pick summary fields explicitly, drop deny-list The audit-log API never sent prev/new on the wire, but the previous code expressed that as 'pass r.changes through, strip prev/new on the way out', which inverted the intent. Switch to an explicit allow-list of summary fields (objectType, objectName, actorEmail, …) — the raw config blobs simply aren't enumerated, so they can't accidentally leak. The diff is the only place the per-field changes show up. * feat(console): admin-level audit log + reusable component - Move /api/[workspaceId]/audit-log -> /api/audit-log with workspaceId as an optional query param. With workspaceId: workspace-scoped, requires manageUsers in that workspace. Without: cross-workspace, requires admin (verifyAdmin). API also bulk-fetches workspace name+slug per row and returns it as 'workspace' so the admin view can render a workspace column. - Extract the table into components/AuditLog/AuditLog.tsx. When workspaceId is undefined the component runs in admin mode: adds a Workspace column with name + link to /<slug-or-id>, and uses each row's per-workspace slug to build entity edit links. - /admin/audit-log: new page that renders <AuditLog /> in admin mode. - /[workspaceId]/settings/audit-log: now a thin wrapper around <AuditLog workspaceId={...} workspaceSlug={...} />. - Add 'Admin Audit Log' entry next to 'Admin Workspaces' in the workspace selector menu (admin users only). * fix(console): throttle auth-login audit rows per (user, authType) /api/fb-auth/create-session is called every time Firebase mints or refreshes a session cookie — every short-lived ID-token rotation, every reload after a long idle — not just on actual sign-in. That flooded the audit log with dozens of 'Logged in' rows per user per day. Suppress duplicate auth-login rows for the same (userId, authType) inside a 30-minute window. Logouts still fire on every signout — they are explicit user actions, not implicit refreshes. * fix(console): replicas-safe auth-login dedup via Firebase auth_time The previous in-memory throttle didn't work across replicas. Switch to a DB-backed check keyed on the auth event's actual timestamp: - authAuditLog gains an opts.authTime parameter. When provided, it queries for any auth-login row for this user with timestamp >= authTime; if found, it skips the write. Firebase rotates ID tokens on a schedule but auth_time only changes on actual re-authentication, so refresh calls collapse onto the original row instead of creating duplicates. - create-session decodes the ID token (already does, for the user identity), pulls auth_time, and passes it through. - NextAuth and OIDC don't need this — their callbacks only fire on actual auth flows, not on token refreshes. - Drop the in-memory map and the per-(userId, authType) 30-min throttle. * fix(console): hook firebase audit-login at the actual sign-in moment Drop the dedup logic entirely. Replace it with explicit instrumentation on the two client entry points users actually hit: - New endpoint /api/fb-auth/audit-login that takes an idToken in the body, verifies it via Firebase Admin, and writes the auth-login row. - firebase-client signIn (email/password) and signInWith (popup OAuth) both call audit-login right after Firebase reports success. - create-session no longer logs — it's the cookie-mint endpoint and runs on every ID-token rotation, which is what was flooding the audit log. - authAuditLog reverts to the simple (user, op, authType, workspaceId?) signature; no opts, no auth_time, no DB lookup. The signOut path was already correct (revoke-session is only hit at sign-out), so logout needs no changes. Net: one audit row per actual user-driven sign-in, zero per token refresh / session re-establish. * fix(console): address audit-log + account-alerts review (#1288) - show auth events in workspace-scoped audit-log via member-id OR clause - gate member-removed audit on confirmed deletion + reject empty target - skip role-changed audit when role is unchanged - wire workspace-deleted (DELETE) and workspace-updated (PUT) audit rows - account-alerts: also fan out to workspace members with notifications.account on - detect secret rotations at write time (_rotatedSecrets) and surface them as secret-changed on read - prettier reformat across the 9 files CI flagged * fix(console): include members of deleted workspace in workspace-deleted alert The recipient query had `w.deleted = false`, which silently dropped all member recipients exactly for workspace-deleted events (the workspace is flagged deleted before the alert dispatches). * review fixes * review fixes --------- Co-authored-by: Ildar Nurislamov <absorbb@gmail.com>
1 parent f87821a commit 33cb29c

29 files changed

Lines changed: 1893 additions & 8 deletions

File tree

libs/juava/src/objects.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,24 @@ export function deepMerge(target: any, source: any) {
1111
}, target);
1212
}
1313

14+
export function deepCopy<T>(o: T): T {
15+
if (typeof o !== "object") return o;
16+
if (!o) return o;
17+
if (Array.isArray(o)) {
18+
const newO: any[] = [];
19+
for (let i = 0; i < o.length; i++) {
20+
const v = o[i];
21+
newO[i] = !v || typeof v !== "object" ? v : deepCopy(v);
22+
}
23+
return newO as T;
24+
}
25+
const newO: Record<string, any> = {};
26+
for (const [k, v] of Object.entries(o)) {
27+
newO[k] = !v || typeof v !== "object" ? v : deepCopy(v);
28+
}
29+
return newO as T;
30+
}
31+
1432
export function isEqual(x: any, y: any) {
1533
const ok = Object.keys,
1634
tx = typeof x,
Lines changed: 359 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,359 @@
1+
import React, { useMemo, useState } from "react";
2+
import { Alert, Button, DatePicker, Select, Table, Tag, Tooltip } from "antd";
3+
import dayjs, { Dayjs } from "dayjs";
4+
import utc from "dayjs/plugin/utc";
5+
import relativeTime from "dayjs/plugin/relativeTime";
6+
import { rpc } from "juava";
7+
import { useQuery } from "@tanstack/react-query";
8+
import Link from "next/link";
9+
import { AuditLogDiff } from "../AuditLogDiff/AuditLogDiff";
10+
11+
dayjs.extend(utc);
12+
dayjs.extend(relativeTime);
13+
14+
const { RangePicker } = DatePicker;
15+
16+
type DiffEntry = {
17+
field: string;
18+
kind: "added" | "removed" | "changed" | "secret-changed" | "noop";
19+
prev?: string;
20+
next?: string;
21+
};
22+
23+
export type AuditLogItem = {
24+
id: string;
25+
timestamp: string;
26+
type: string;
27+
severity?: string | null;
28+
workspaceId?: string | null;
29+
workspace?: { id: string; name?: string | null; slug?: string | null } | null;
30+
userId?: string | null;
31+
objectId?: string | null;
32+
authType?: string | null;
33+
changes?: any;
34+
diff?: DiffEntry[];
35+
actor?: { id: string; email?: string | null; name?: string | null } | null;
36+
};
37+
38+
export type AuditLogPage = { items: AuditLogItem[]; nextCursor?: string };
39+
40+
const eventTypeOptions = [
41+
{ value: "auth-login", label: "Login" },
42+
{ value: "auth-logout", label: "Logout" },
43+
{ value: "member-invited", label: "Member invited" },
44+
{ value: "member-joined", label: "Member joined" },
45+
{ value: "member-removed", label: "Member removed" },
46+
{ value: "member-role-changed", label: "Member role changed" },
47+
{ value: "workspace-deleted", label: "Workspace deleted" },
48+
{ value: "workspace-updated", label: "Workspace updated" },
49+
{ value: "config-object-create", label: "Config object created" },
50+
{ value: "config-object-update", label: "Config object updated" },
51+
{ value: "config-object-delete", label: "Config object deleted" },
52+
];
53+
54+
const severityOptions = [
55+
{ value: "info", label: "Info" },
56+
{ value: "warning", label: "Warning" },
57+
{ value: "security", label: "Security" },
58+
];
59+
60+
function severityTag(s?: string | null) {
61+
if (!s) return null;
62+
const color = s === "security" ? "red" : s === "warning" ? "orange" : "default";
63+
return <Tag color={color}>{s}</Tag>;
64+
}
65+
66+
function entityHref(objectType: string | undefined, objectId?: string | null): string | null {
67+
if (!objectType || !objectId) return null;
68+
switch (objectType) {
69+
case "stream":
70+
return `/streams?id=${encodeURIComponent(objectId)}`;
71+
case "destination":
72+
return `/destinations?id=${encodeURIComponent(objectId)}`;
73+
case "service":
74+
return `/services?id=${encodeURIComponent(objectId)}`;
75+
case "function":
76+
return `/functions?id=${encodeURIComponent(objectId)}`;
77+
case "link":
78+
return `/connections/edit?id=${encodeURIComponent(objectId)}`;
79+
case "profilebuilder":
80+
case "profile-builder":
81+
return `/profile-builder`;
82+
default:
83+
return null;
84+
}
85+
}
86+
87+
const verbForOp: Record<string, string> = {
88+
"config-object-create": "Created",
89+
"config-object-update": "Updated",
90+
"config-object-delete": "Deleted",
91+
};
92+
93+
const objectTypeLabel: Record<string, string> = {
94+
stream: "site",
95+
destination: "destination",
96+
service: "service",
97+
function: "function",
98+
link: "connection",
99+
profilebuilder: "profile builder",
100+
"profile-builder": "profile builder",
101+
};
102+
103+
const EventCell: React.FC<{ item: AuditLogItem; workspaceSlug: string | undefined }> = ({ item, workspaceSlug }) => {
104+
const c = item.changes || {};
105+
if (item.type.startsWith("config-object-")) {
106+
const verb = verbForOp[item.type] || item.type;
107+
const objType = c.objectType as string | undefined;
108+
const typeLabel = objType ? objectTypeLabel[objType] || objType : "object";
109+
const name = (c.objectName as string | undefined) || item.objectId || "";
110+
const isDelete = item.type === "config-object-delete";
111+
const href = !isDelete && workspaceSlug ? entityHref(objType, item.objectId) : null;
112+
const nameNode = (
113+
<Tooltip title={item.objectId || undefined}>
114+
{href ? (
115+
<Link href={`/${workspaceSlug}${href}`} className="text-primary hover:underline">
116+
{name}
117+
</Link>
118+
) : (
119+
<span className="font-medium">{name}</span>
120+
)}
121+
</Tooltip>
122+
);
123+
return (
124+
<span>
125+
{verb} {typeLabel} {nameNode}
126+
</span>
127+
);
128+
}
129+
switch (item.type) {
130+
case "auth-login":
131+
return <span>Logged in via {item.authType || "unknown"}</span>;
132+
case "auth-logout":
133+
return <span>Logged out via {item.authType || "unknown"}</span>;
134+
case "member-invited":
135+
return (
136+
<span>
137+
Invited <span className="font-medium">{c.targetEmail || "user"}</span>
138+
{c.newRole ? <> as {c.newRole}</> : null}
139+
</span>
140+
);
141+
case "member-joined":
142+
return <span>Joined as {c.newRole || "member"}</span>;
143+
case "member-removed":
144+
return (
145+
<span>
146+
Removed <span className="font-medium">{c.targetEmail || c.targetUserId || "user"}</span>
147+
</span>
148+
);
149+
case "member-role-changed":
150+
return (
151+
<span>
152+
Changed <span className="font-medium">{c.targetEmail || c.targetUserId || "user"}</span> role:{" "}
153+
{c.prevRole || "?"}{c.newRole || "?"}
154+
</span>
155+
);
156+
case "workspace-deleted":
157+
return <span>Deleted workspace</span>;
158+
case "workspace-updated":
159+
return <span>Updated workspace</span>;
160+
default:
161+
return <span>{item.type}</span>;
162+
}
163+
};
164+
165+
export type AuditLogProps = {
166+
/**
167+
* When set, scopes the table to a single workspace and uses that workspace
168+
* slug for entity edit links. When omitted, the table runs in admin mode:
169+
* shows a Workspace column with name + link, and queries across all
170+
* workspaces (server enforces admin-only access).
171+
*/
172+
workspaceId?: string;
173+
/**
174+
* Slug used to build entity edit links inside config-object events. If the
175+
* caller already has the workspace slug it can pass it; otherwise per-row
176+
* `workspace.slug` (admin view) is used.
177+
*/
178+
workspaceSlug?: string;
179+
/** Heading text. Defaults to "Audit Log". */
180+
title?: string;
181+
description?: React.ReactNode;
182+
};
183+
184+
export const AuditLog: React.FC<AuditLogProps> = ({ workspaceId, workspaceSlug, title = "Audit Log", description }) => {
185+
const adminView = !workspaceId;
186+
const [types, setTypes] = useState<string[]>([]);
187+
const [severities, setSeverities] = useState<string[]>([]);
188+
const [range, setRange] = useState<[Dayjs | null, Dayjs | null] | null>(null);
189+
const [cursor, setCursor] = useState<string | undefined>(undefined);
190+
const [pages, setPages] = useState<AuditLogItem[][]>([]);
191+
192+
const filterKey = useMemo(
193+
() => JSON.stringify({ types, severities, from: range?.[0]?.toISOString(), to: range?.[1]?.toISOString() }),
194+
[types, severities, range]
195+
);
196+
197+
const query = useQuery<AuditLogPage, Error>(
198+
["audit-log", workspaceId || "$all", filterKey, cursor],
199+
async () => {
200+
const params: Record<string, string> = {};
201+
if (workspaceId) params.workspaceId = workspaceId;
202+
if (types.length) params.type = types.join(",");
203+
if (severities.length) params.severity = severities.join(",");
204+
if (range?.[0]) params.from = range[0].toISOString();
205+
if (range?.[1]) params.to = range[1].toISOString();
206+
if (cursor) params.cursor = cursor;
207+
params.limit = "50";
208+
return (await rpc(`/api/audit-log`, { query: params })) as AuditLogPage;
209+
},
210+
{
211+
retry: false,
212+
cacheTime: 0,
213+
staleTime: 0,
214+
refetchOnWindowFocus: false,
215+
onSuccess: data => {
216+
setPages(prev => (cursor ? [...prev, data.items] : [data.items]));
217+
},
218+
}
219+
);
220+
221+
const items = useMemo(() => pages.flat(), [pages]);
222+
223+
const columns = useMemo(() => {
224+
const base: any[] = [
225+
{
226+
title: "Time",
227+
dataIndex: "timestamp",
228+
key: "timestamp",
229+
render: (ts: string) => (
230+
<div className="flex flex-col">
231+
<span className="font-mono text-xs">{dayjs(ts).utc().format("YYYY-MM-DD HH:mm:ss [UTC]")}</span>
232+
<span className="text-text-light text-xs">{dayjs(ts).fromNow()}</span>
233+
</div>
234+
),
235+
},
236+
{
237+
title: "Severity",
238+
dataIndex: "severity",
239+
key: "severity",
240+
render: severityTag,
241+
},
242+
];
243+
if (adminView) {
244+
base.push({
245+
title: "Workspace",
246+
key: "workspace",
247+
render: (_: any, item: AuditLogItem) => {
248+
const w = item.workspace;
249+
if (!w) return <span className="text-text-light"></span>;
250+
const target = w.slug || w.id;
251+
return (
252+
<Link href={`/${target}`} className="text-primary hover:underline">
253+
{w.name || w.slug || w.id}
254+
</Link>
255+
);
256+
},
257+
});
258+
}
259+
base.push(
260+
{
261+
title: "Actor",
262+
key: "actor",
263+
render: (_: any, item: AuditLogItem) => item.actor?.email || item.actor?.name || "—",
264+
},
265+
{
266+
title: "Event",
267+
key: "event",
268+
render: (_: any, item: AuditLogItem) => (
269+
<EventCell item={item} workspaceSlug={workspaceSlug || item.workspace?.slug || item.workspace?.id} />
270+
),
271+
}
272+
);
273+
return base;
274+
}, [adminView, workspaceSlug]);
275+
276+
const reset = () => {
277+
setCursor(undefined);
278+
setPages([]);
279+
};
280+
281+
return (
282+
<div className="w-full flex flex-col gap-4">
283+
<h1 className="text-2xl font-bold">{title}</h1>
284+
{description ? (
285+
<p className="text-text-light">{description}</p>
286+
) : (
287+
<p className="text-text-light">
288+
{adminView
289+
? "Cross-workspace record of authentication, membership, and configuration changes."
290+
: "A workspace-scoped record of authentication, membership, and configuration changes."}
291+
</p>
292+
)}
293+
<div className="flex flex-row gap-3 flex-wrap">
294+
<Select
295+
mode="multiple"
296+
allowClear
297+
placeholder="Event type"
298+
style={{ minWidth: 240 }}
299+
value={types}
300+
options={eventTypeOptions}
301+
onChange={v => {
302+
setTypes(v);
303+
reset();
304+
}}
305+
/>
306+
<Select
307+
mode="multiple"
308+
allowClear
309+
placeholder="Severity"
310+
style={{ minWidth: 160 }}
311+
value={severities}
312+
options={severityOptions}
313+
onChange={v => {
314+
setSeverities(v);
315+
reset();
316+
}}
317+
/>
318+
<RangePicker
319+
showTime
320+
value={range as any}
321+
onChange={v => {
322+
setRange((v as any) || null);
323+
reset();
324+
}}
325+
/>
326+
</div>
327+
{query.isError ? <Alert type="error" message={`Failed to load audit log: ${query.error?.message}`} /> : null}
328+
<Table
329+
rowKey="id"
330+
className="w-full"
331+
columns={columns}
332+
dataSource={items}
333+
loading={query.isLoading}
334+
pagination={false}
335+
expandable={{
336+
rowExpandable: (item: AuditLogItem) => Array.isArray(item.diff) && item.diff.length > 0,
337+
expandedRowRender: (item: AuditLogItem) => (
338+
<div className="pl-12 pr-4 py-2 bg-neutral-50">
339+
<AuditLogDiff diff={item.diff || []} />
340+
</div>
341+
),
342+
}}
343+
/>
344+
<div className="flex justify-center">
345+
{query.isFetching ? (
346+
<Button loading disabled>
347+
Loading
348+
</Button>
349+
) : query.data?.nextCursor ? (
350+
<Button onClick={() => setCursor(query.data?.nextCursor)}>Load more</Button>
351+
) : items.length > 0 ? (
352+
<span className="text-text-light text-sm">End of log</span>
353+
) : null}
354+
</div>
355+
</div>
356+
);
357+
};
358+
359+
export default AuditLog;

0 commit comments

Comments
 (0)