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() { } /> }> - } /> - } /> + } /> } /> + } /> + }> } />