diff --git a/ui/package.json b/ui/package.json
index 8cd72885e..b44387955 100644
--- a/ui/package.json
+++ b/ui/package.json
@@ -23,6 +23,7 @@
"@raystack/proton": "^0.1.0-434b8aec0c95625a6633f4e890be311d3e0fefef",
"@stitches/react": "^1.2.8",
"@tanstack/react-query": "^5.83.0",
+ "@tanstack/react-query-devtools": "^5.90.2",
"@tanstack/react-table": "^8.9.3",
"@tanstack/table-core": "^8.21.3",
"axios": "^1.8.4",
diff --git a/ui/pnpm-lock.yaml b/ui/pnpm-lock.yaml
index 0f8e1ce66..e90ee504d 100644
--- a/ui/pnpm-lock.yaml
+++ b/ui/pnpm-lock.yaml
@@ -44,6 +44,9 @@ importers:
'@tanstack/react-query':
specifier: ^5.83.0
version: 5.84.1(react@18.3.1)
+ '@tanstack/react-query-devtools':
+ specifier: ^5.90.2
+ version: 5.90.2(@tanstack/react-query@5.84.1(react@18.3.1))(react@18.3.1)
'@tanstack/react-table':
specifier: ^8.9.3
version: 8.21.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
@@ -1495,6 +1498,15 @@ packages:
'@tanstack/query-core@5.83.1':
resolution: {integrity: sha512-OG69LQgT7jSp+5pPuCfzltq/+7l2xoweggjme9vlbCPa/d7D7zaqv5vN/S82SzSYZ4EDLTxNO1PWrv49RAS64Q==}
+ '@tanstack/query-devtools@5.90.1':
+ resolution: {integrity: sha512-GtINOPjPUH0OegJExZ70UahT9ykmAhmtNVcmtdnOZbxLwT7R5OmRztR5Ahe3/Cu7LArEmR6/588tAycuaWb1xQ==}
+
+ '@tanstack/react-query-devtools@5.90.2':
+ resolution: {integrity: sha512-vAXJzZuBXtCQtrY3F/yUNJCV4obT/A/n81kb3+YqLbro5Z2+phdAbceO+deU3ywPw8B42oyJlp4FhO0SoivDFQ==}
+ peerDependencies:
+ '@tanstack/react-query': ^5.90.2
+ react: ^18 || ^19
+
'@tanstack/react-query@5.84.1':
resolution: {integrity: sha512-zo7EUygcWJMQfFNWDSG7CBhy8irje/XY0RDVKKV4IQJAysb+ZJkkJPcnQi+KboyGUgT+SQebRFoTqLuTtfoDLw==}
peerDependencies:
@@ -4228,6 +4240,14 @@ snapshots:
'@tanstack/query-core@5.83.1': {}
+ '@tanstack/query-devtools@5.90.1': {}
+
+ '@tanstack/react-query-devtools@5.90.2(@tanstack/react-query@5.84.1(react@18.3.1))(react@18.3.1)':
+ dependencies:
+ '@tanstack/query-devtools': 5.90.1
+ '@tanstack/react-query': 5.84.1(react@18.3.1)
+ react: 18.3.1
+
'@tanstack/react-query@5.84.1(react@18.3.1)':
dependencies:
'@tanstack/query-core': 5.83.1
diff --git a/ui/src/assets/icons/cpu-chip.svg b/ui/src/assets/icons/cpu-chip.svg
index dd282c4e8..7d12689b0 100644
--- a/ui/src/assets/icons/cpu-chip.svg
+++ b/ui/src/assets/icons/cpu-chip.svg
@@ -1,3 +1,3 @@
-
+
\ No newline at end of file
diff --git a/ui/src/assets/images/service-user.jpg b/ui/src/assets/images/service-user.jpg
new file mode 100644
index 000000000..5fa61ed51
Binary files /dev/null and b/ui/src/assets/images/service-user.jpg differ
diff --git a/ui/src/components/Sidebar/index.tsx b/ui/src/components/Sidebar/index.tsx
index 5a2e7844c..9bde62c8b 100644
--- a/ui/src/components/Sidebar/index.tsx
+++ b/ui/src/components/Sidebar/index.tsx
@@ -22,6 +22,7 @@ import PlansIcon from "~/assets/icons/plans.svg?react";
import WebhooksIcon from "~/assets/icons/webhooks.svg?react";
import PreferencesIcon from "~/assets/icons/preferences.svg?react";
import AdminsIcon from "~/assets/icons/admins.svg?react";
+import CpuChipIcon from "~/assets/icons/cpu-chip.svg?react";
import { AppContext } from "~/contexts/App";
import { MoonIcon, SunIcon } from "@radix-ui/react-icons";
import { Link, useLocation } from "react-router-dom";
@@ -46,6 +47,11 @@ const navigationItems: NavigationItemsTypes[] = [
to: `/users`,
icon: ,
},
+ {
+ name: "Audit Logs",
+ to: `/audit-logs`,
+ icon: ,
+ },
{
name: "Invoices",
to: `/invoices`,
@@ -132,21 +138,19 @@ export default function IAMSidebar() {
- {navigationItems.map((nav) => {
+ {navigationItems.map(nav => {
return nav?.subItems?.length ? (
- {nav.subItems?.map((subItem) => (
+ className={styles["sidebar-group"]}>
+ {nav.subItems?.map(subItem => (
}
- >
+ as={}>
{subItem.name}
))}
@@ -157,8 +161,7 @@ export default function IAMSidebar() {
key={nav.name}
active={isActive(nav.to)}
data-test-id={`admin-ui-sidebar-navigation-cell-${nav.name}`}
- as={}
- >
+ as={}>
{nav.name}
);
@@ -203,22 +206,19 @@ function UserDropdown() {
leadingIcon={
}
- data-test-id="frontier-sdk-sidebar-logout"
- >
+ data-test-id="frontier-sdk-sidebar-logout">
{user?.email}
+ data-test-id="admin-ui-toggle-theme">
{themeData.icon} {themeData.label}
logoutMutation.mutate({})}
- data-test-id="admin-ui-logout-btn"
- >
+ data-test-id="admin-ui-logout-btn">
Logout
diff --git a/ui/src/contexts/ConnectProvider.tsx b/ui/src/contexts/ConnectProvider.tsx
index dd9eeb714..11393824e 100644
--- a/ui/src/contexts/ConnectProvider.tsx
+++ b/ui/src/contexts/ConnectProvider.tsx
@@ -1,4 +1,5 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
import type { ReactNode } from "react";
import { TransportProvider } from "@connectrpc/connect-query";
import { jsonTransport as transport } from "~/connect/transport";
@@ -20,9 +21,8 @@ interface ConnectProviderProps {
export function ConnectProvider({ children }: ConnectProviderProps) {
return (
-
- {children}
-
+ {children}
+
);
}
diff --git a/ui/src/pages/audit-logs/list/columns.tsx b/ui/src/pages/audit-logs/list/columns.tsx
new file mode 100644
index 000000000..98ba55f26
--- /dev/null
+++ b/ui/src/pages/audit-logs/list/columns.tsx
@@ -0,0 +1,131 @@
+import {
+ Avatar,
+ Badge,
+ DataTableColumnDef,
+ Flex,
+ getAvatarColor,
+ Text,
+} from "@raystack/apsara";
+import dayjs from "dayjs";
+import styles from "./list.module.css";
+import {
+ AuditRecord,
+ AuditRecordActor,
+ AuditRecordResource,
+} from "@raystack/proton/frontier";
+import {
+ isNullTimestamp,
+ TimeStamp,
+ timestampToDate,
+} from "~/utils/connect-timestamp";
+import {
+ getActionBadgeColor,
+ getAuditLogActorName,
+ isAuditLogActorServiceUser,
+} from "../util";
+import serviceUserIcon from "~/assets/images/service-user.jpg";
+import { OrganizationCell } from "./organization-cell";
+import { ComponentPropsWithoutRef } from "react";
+
+interface getColumnsOptions {
+ groupCountMap: Record>;
+}
+
+export const getColumns = ({
+ groupCountMap,
+}: getColumnsOptions): DataTableColumnDef[] => {
+ return [
+ {
+ accessorKey: "actor",
+ header: "Actor",
+ classNames: {
+ cell: styles["name-column"],
+ header: styles["name-column"],
+ },
+ cell: ({ getValue }) => {
+ const value = getValue() as AuditRecordActor;
+ const name = getAuditLogActorName(value);
+ const isServiceUser = isAuditLogActorServiceUser(value);
+
+ return (
+
+
+ {name}
+
+ );
+ },
+ },
+ {
+ accessorKey: "orgId",
+ header: "Organization",
+ classNames: {
+ cell: styles["org-column"],
+ header: styles["org-column"],
+ },
+ cell: ({ getValue }) => {
+ return ;
+ },
+ enableColumnFilter: true,
+ },
+ {
+ accessorKey: "event",
+ header: "Action",
+ cell: ({ getValue }) => {
+ const value = getValue() as string;
+ const color = getActionBadgeColor(value) as ComponentPropsWithoutRef<
+ typeof Badge
+ >["variant"];
+ return {value};
+ },
+ enableColumnFilter: true,
+ enableSorting: true,
+ },
+ {
+ accessorKey: "resource",
+ header: "Resource",
+ cell: ({ getValue }) => {
+ const value = getValue() as AuditRecordResource;
+ return (
+
+
+ {value.name}
+
+
+ {value.type.toLowerCase()}
+
+
+ );
+ },
+ },
+ {
+ accessorKey: "occurredAt",
+ header: "Timestamp",
+ filterType: "date",
+ cell: ({ getValue }) => {
+ const value = getValue() as TimeStamp;
+ if (isNullTimestamp(value)) {
+ return -;
+ }
+ const date = dayjs(timestampToDate(value));
+ return (
+
+
+ {date.format("DD MMM YYYY")}
+
+
+ {date.format("hh:mm A")}
+
+
+ );
+ },
+ enableHiding: true,
+ enableSorting: true,
+ },
+ ];
+};
diff --git a/ui/src/pages/audit-logs/list/index.ts b/ui/src/pages/audit-logs/list/index.ts
new file mode 100644
index 000000000..629e7b2eb
--- /dev/null
+++ b/ui/src/pages/audit-logs/list/index.ts
@@ -0,0 +1 @@
+export { AuditLogsList } from "./list";
diff --git a/ui/src/pages/audit-logs/list/list.module.css b/ui/src/pages/audit-logs/list/list.module.css
new file mode 100644
index 000000000..fc848a517
--- /dev/null
+++ b/ui/src/pages/audit-logs/list/list.module.css
@@ -0,0 +1,63 @@
+.navbar {
+ padding: var(--rs-space-4) var(--rs-space-7);
+ border-bottom: 0.5px solid var(--rs-color-border-base-primary);
+ background: var(--rs-color-background-base-primary);
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+}
+
+.table {
+ height: auto;
+}
+
+.table-empty {
+ height: 100%;
+}
+
+.empty-state {
+ height: 100%;
+}
+
+.empty-state-subheading {
+ max-width: 360px;
+ text-wrap: auto;
+}
+
+.name-column {
+ padding-left: var(--rs-space-7);
+ max-width: 200px;
+}
+.org-column {
+ max-width: 200px;
+}
+
+.country-column {
+ max-width: 150px;
+}
+
+.table-wrapper {
+ flex: 1;
+ height: 100%;
+}
+
+.table-header {
+ z-index: 2;
+}
+
+.side-panel {
+ position: sticky;
+ top: 0;
+}
+
+.capitalize {
+ text-transform: capitalize;
+}
+
+.table-content-container {
+ width: 100%;
+ height: 100%;
+ max-height: calc(100vh - 90px);
+ overflow: scroll;
+ position: relative;
+}
diff --git a/ui/src/pages/audit-logs/list/list.tsx b/ui/src/pages/audit-logs/list/list.tsx
new file mode 100644
index 000000000..0f9a5fb5c
--- /dev/null
+++ b/ui/src/pages/audit-logs/list/list.tsx
@@ -0,0 +1,170 @@
+import { DataTable, EmptyState, Flex } from "@raystack/apsara";
+import type { DataTableQuery, DataTableSort } from "@raystack/apsara";
+import { useCallback, useMemo, useState } from "react";
+import Navbar from "./navbar";
+import styles from "./list.module.css";
+import { getColumns } from "./columns";
+import PageTitle from "~/components/page-title";
+import CpuChipIcon from "~/assets/icons/cpu-chip.svg?react";
+import { useInfiniteQuery } from "@connectrpc/connect-query";
+import { AdminServiceQueries, AuditRecord } from "@raystack/proton/frontier";
+import {
+ getConnectNextPageParam,
+ getGroupCountMapFromFirstPage,
+ DEFAULT_PAGE_SIZE,
+} from "~/utils/connect-pagination";
+import { ExclamationTriangleIcon } from "@radix-ui/react-icons";
+import { transformDataTableQueryToRQLRequest } from "~/utils/transform-query";
+import SidePanelDetails from "./sidepanel-details";
+
+const NoAuditLogs = () => {
+ return (
+ }
+ />
+ );
+};
+
+const DEFAULT_SORT: DataTableSort = { name: "occurredAt", order: "desc" };
+const INITIAL_QUERY: DataTableQuery = {
+ offset: 0,
+ limit: DEFAULT_PAGE_SIZE,
+};
+
+export const AuditLogsList = () => {
+ const [tableQuery, setTableQuery] = useState(INITIAL_QUERY);
+ const [sidePanelOpen, setSidePanelOpen] = useState(false);
+ const [selectedAuditLog, setSelectedAuditLog] = useState(
+ null,
+ );
+
+ const query = transformDataTableQueryToRQLRequest(tableQuery, {
+ fieldNameMapping: {
+ occurredAt: "occurred_at",
+ orgId: "org_id",
+ actor: "actor.name",
+ },
+ });
+
+ const {
+ data: infiniteData,
+ isLoading,
+ isFetchingNextPage,
+ fetchNextPage,
+ error,
+ isError,
+ hasNextPage,
+ } = useInfiniteQuery(
+ AdminServiceQueries.listAuditRecords,
+ { query: query },
+ {
+ pageParamKey: "query",
+ getNextPageParam: lastPage =>
+ getConnectNextPageParam(lastPage, { query: query }, "auditRecords"),
+ staleTime: 0,
+ refetchOnWindowFocus: false,
+ retry: 1,
+ retryDelay: 1000,
+ },
+ );
+
+ const data =
+ infiniteData?.pages?.flatMap(page => page?.auditRecords || []) || [];
+
+ const onTableQueryChange = (newQuery: DataTableQuery) => {
+ setTableQuery({
+ ...newQuery,
+ offset: 0,
+ limit: newQuery.limit || DEFAULT_PAGE_SIZE,
+ });
+ };
+
+ const handleLoadMore = async () => {
+ try {
+ if (!hasNextPage) return;
+ await fetchNextPage();
+ } catch (error) {
+ console.error("Error loading more audit logs:", error);
+ }
+ };
+
+ const columns = useMemo(
+ () =>
+ getColumns({
+ groupCountMap: infiniteData
+ ? getGroupCountMapFromFirstPage(infiniteData)
+ : {},
+ }),
+ [infiniteData],
+ );
+
+ const loading = isLoading || isFetchingNextPage;
+
+ const onRowClick = useCallback((row: AuditRecord) => {
+ setSidePanelOpen(_value => !_value);
+ setSelectedAuditLog(row);
+ }, []);
+
+ if (isError) {
+ console.error("ConnectRPC Error:", error);
+ return (
+ <>
+
+ }
+ heading="Error Loading Audit Logs"
+ subHeading={
+ error?.message ||
+ "Something went wrong while loading audit logs. Please try again."
+ }
+ />
+ >
+ );
+ }
+
+ const tableClassName =
+ data.length || loading ? styles["table"] : styles["table-empty"];
+
+ return (
+ <>
+
+
+
+
+
+
+ }
+ />
+ {sidePanelOpen && (
+ setSidePanelOpen(false)}
+ />
+ )}
+
+
+
+ >
+ );
+};
diff --git a/ui/src/pages/audit-logs/list/navbar.tsx b/ui/src/pages/audit-logs/list/navbar.tsx
new file mode 100644
index 000000000..34f806ebb
--- /dev/null
+++ b/ui/src/pages/audit-logs/list/navbar.tsx
@@ -0,0 +1,83 @@
+import { DataTable, Flex, Text, IconButton, Spinner } from "@raystack/apsara";
+import CpuChipIcon from "~/assets/icons/cpu-chip.svg?react";
+import styles from "./list.module.css";
+import { DownloadIcon, MagnifyingGlassIcon } from "@radix-ui/react-icons";
+import React, { useState } from "react";
+import { clients } from "~/connect/clients";
+import { exportCsvFromStream } from "~/utils/helper";
+
+const adminClient = clients.admin({ useBinary: true });
+
+interface NavbarProps {
+ searchQuery?: string;
+}
+
+const Navbar = ({ searchQuery }: NavbarProps) => {
+ const [showSearch, setShowSearch] = useState(searchQuery ? true : false);
+ const [isDownloading, setIsDownloading] = useState(false);
+
+ function toggleSearch() {
+ setShowSearch(prev => !prev);
+ }
+
+ function onSearchBlur(e: React.FocusEvent) {
+ const value = e.target.value;
+ if (!value) {
+ setShowSearch(false);
+ }
+ }
+
+ async function onDownloadClick() {
+ try {
+ setIsDownloading(true);
+ await exportCsvFromStream(
+ adminClient.exportAuditRecords,
+ {},
+ "audit-logs.csv",
+ );
+ } catch (error) {
+ console.error(error);
+ } finally {
+ setIsDownloading(false);
+ }
+ }
+
+ return (
+
+ );
+};
+
+export default Navbar;
diff --git a/ui/src/pages/audit-logs/list/organization-cell.tsx b/ui/src/pages/audit-logs/list/organization-cell.tsx
new file mode 100644
index 000000000..5933b26bd
--- /dev/null
+++ b/ui/src/pages/audit-logs/list/organization-cell.tsx
@@ -0,0 +1,28 @@
+import { Skeleton, Text } from "@raystack/apsara";
+import { AdminServiceQueries } from "@raystack/proton/frontier";
+import { useQuery } from "@connectrpc/connect-query";
+import { memo } from "react";
+import styles from "./list.module.css";
+
+type OrganizationCellProps = { id: string };
+
+export const OrganizationCell = memo(({ id }: OrganizationCellProps) => {
+ const { data, isLoading } = useQuery(
+ AdminServiceQueries.listAllOrganizations,
+ {},
+ {
+ staleTime: 0,
+ refetchOnWindowFocus: false,
+ },
+ );
+ const orgData = data?.organizations?.find(org => org.id === id);
+
+ if (isLoading) return ;
+ return (
+
+ {orgData?.title || orgData?.name}
+
+ );
+});
+
+OrganizationCell.displayName = "OrganizationCell";
diff --git a/ui/src/pages/audit-logs/list/sidepanel-details.tsx b/ui/src/pages/audit-logs/list/sidepanel-details.tsx
new file mode 100644
index 000000000..ace70cf3d
--- /dev/null
+++ b/ui/src/pages/audit-logs/list/sidepanel-details.tsx
@@ -0,0 +1,42 @@
+import { IconButton, SidePanel } from "@raystack/apsara";
+import { Cross2Icon } from "@radix-ui/react-icons";
+import { List } from "@raystack/apsara";
+import styles from "./list.module.css";
+import { AuditRecord } from "@raystack/proton/frontier";
+
+type SidePanelDetailsProps = Partial & {
+ onClose: () => void;
+};
+
+export default function SidePanelDetails({
+ actor,
+ onClose,
+}: SidePanelDetailsProps) {
+ return (
+
+
+
+ ,
+ ]}
+ />
+
+
+ Overview
+
+ Actor
+ {actor?.name}
+
+
+
+
+ );
+}
diff --git a/ui/src/pages/audit-logs/util.ts b/ui/src/pages/audit-logs/util.ts
new file mode 100644
index 000000000..44edad795
--- /dev/null
+++ b/ui/src/pages/audit-logs/util.ts
@@ -0,0 +1,32 @@
+import { AuditRecordActor } from "@raystack/proton/frontier";
+
+export const isAuditLogActorServiceUser = (actor?: AuditRecordActor) =>
+ actor?.type === ACTOR_TYPES.SERVICE_USER;
+
+export const getAuditLogActorName = (actor?: AuditRecordActor) => {
+ if (isAuditLogActorServiceUser(actor)) return "System";
+
+ const name = actor?.name || "-";
+
+ if (actor?.metadata?.["is_super_user"] === true) return name + " (Admin)";
+
+ return name;
+};
+
+const actionBadgeColorPatterns = {
+ warning: /invite|unverify|unverified/i,
+ success: /success|create|verify|verified/i,
+ danger: /error|delete|revoke|remove|disable/i,
+};
+
+export const getActionBadgeColor = (action: string) => {
+ for (const [color, pattern] of Object.entries(actionBadgeColorPatterns)) {
+ if (pattern.test(action)) return color;
+ }
+ return "accent";
+};
+
+export const ACTOR_TYPES = {
+ USER: "app/user",
+ SERVICE_USER: "app/serviceuser",
+} as const;
diff --git a/ui/src/routes.tsx b/ui/src/routes.tsx
index 5c62a35f6..e4b1d4fef 100644
--- a/ui/src/routes.tsx
+++ b/ui/src/routes.tsx
@@ -44,9 +44,9 @@ import { OrganizationApisPage } from "./pages/organizations/details/apis";
import { UsersList } from "./pages/users/list";
import { UserDetails } from "./pages/users/details";
import { UserDetailsSecurityPage } from "./pages/users/details/security";
-import { UserDetailsAuditLogPage } from "./pages/users/details/audit-log";
import { InvoicesList } from "./pages/invoices/list";
+import { AuditLogsList } from "./pages/audit-logs/list";
export default memo(function AppRoutes() {
const { isAdmin, isLoading, user } = useContext(AppContext);
@@ -72,8 +72,7 @@ export default memo(function AppRoutes() {
} />
}
- >
+ element={}>
} />
} />
} />
@@ -86,11 +85,12 @@ export default memo(function AppRoutes() {
} />
}>
- } />
- } />
+ } />
} />
+ } />
+
}>
} />