diff --git a/apps/explorer/src/hooks/useGetEvents.ts b/apps/explorer/src/hooks/useGetEvents.ts new file mode 100644 index 0000000000..b5e87b16ca --- /dev/null +++ b/apps/explorer/src/hooks/useGetEvents.ts @@ -0,0 +1,89 @@ +// Copyright (c) 2026 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +import { useQuery } from '@tanstack/react-query'; +import { type IotaEvent, type EventId } from '@iota/iota-sdk/client'; +import { useIotaClient } from '@iota/dapp-kit'; + +type UseGetEventsProps = { + eventType: string; + objectId: string; // Optional objectId for more specific queries + limit?: number | null; + order?: 'ascending' | 'descending'; +}; + +const QUERY_MAX_RESULT_LIMIT = 50; + +/** + * A generic hook to query for Move events from the IOTA network. + */ +export function useGetEvents({ eventType, objectId, limit, order }: UseGetEventsProps) { + const client = useIotaClient(); + + // The query key will include all parameters to ensure uniqueness + const queryKey = ['events', eventType, objectId, limit, order]; + + return useQuery({ + queryKey, + queryFn: async () => { + if (!limit) { + // Do some validation at the runtime level for some extra type-safety + // https://tkdodo.eu/blog/react-query-and-type-script#type-safety-with-the-enabled-option + throw new Error( + `Limit needs to always be defined and non-zero! Received ${limit} instead.`, + ); + } + + if (!eventType) { + // Return empty array if eventType is not provided + return []; + } + + // The full event type name might need to be constructed based on the package, module, and event name. + // For now, we'll assume eventType is the full name. + // Example for audit trail: '0xabcde...::audit_trail::CapabilityIssued' + // const fullEventType = eventType; + + const results: IotaEvent[] = []; + let currCursor: EventId | null | undefined; + let hasNextPage = true; + + // Handle pagination similar to useGetValidatorsEvents + while (hasNextPage && results.length < limit) { + const response = await client.queryEvents({ + query: { + // Event Filter not supported + MoveEventField: { + path: '/parsedJson', + value: null, + }, + }, + // query: { + // MoveEventType: fullEventType, + // }, + // query: { + // And: [ + // { MoveEventType: fullEventType }, + // { + // MoveEventField: { + // path: 'parsedJson.target_key', + // value: objectId, + // }, + // }, + // ], + // }, + cursor: currCursor, + limit: Math.min(limit, QUERY_MAX_RESULT_LIMIT), + order, + }); + + results.push(...(response.data as IotaEvent[])); + hasNextPage = response.hasNextPage; + currCursor = response.nextCursor; + } + + return results.slice(0, limit); + }, + enabled: !!client && !!eventType, // The query will not run until the client and eventType are available + }); +} diff --git a/apps/explorer/src/pages/trust-framework/audit-trail-result/AuditTrailContent.tsx b/apps/explorer/src/pages/trust-framework/audit-trail-result/AuditTrailContent.tsx index d5964d469b..d178f31894 100644 --- a/apps/explorer/src/pages/trust-framework/audit-trail-result/AuditTrailContent.tsx +++ b/apps/explorer/src/pages/trust-framework/audit-trail-result/AuditTrailContent.tsx @@ -21,6 +21,7 @@ import { TransactionsView } from '../common/TransactionsView'; import { AuditTrailSummaryView } from './views/AuditTrailSummaryView'; import { MetadataView } from './views/MetadataView'; import { LockLifecycleView } from './views/lock-lifecycle/LockLifecycleView'; +import { CapabilitiesView } from './views/CapabilitiesView'; import { RolesView } from './views/roles/RolesView'; import { TagsView } from './views/TagsView'; import { RecordsView } from './views/RecordsView'; @@ -126,6 +127,7 @@ export function AuditTrailContent({ objectId }: AuditTrailContentProps) { secondPanel={} /> + } diff --git a/apps/explorer/src/pages/trust-framework/audit-trail-result/hooks/mockCapabilities.ts b/apps/explorer/src/pages/trust-framework/audit-trail-result/hooks/mockCapabilities.ts new file mode 100644 index 0000000000..7b6c1475ac --- /dev/null +++ b/apps/explorer/src/pages/trust-framework/audit-trail-result/hooks/mockCapabilities.ts @@ -0,0 +1,155 @@ +// Copyright (c) 2026 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +// apps/explorer/src/pages/trust-framework/audit-trail-result/mockCapabilities.ts + +export interface Capability { + holderAddress: string; + role: string; + status: 'active' | 'revoked'; + validFrom: Date | null; + validUntil: Date | null; +} + +export const mockCapabilities: Capability[] = [ + { + holderAddress: '0x1234567890123456789012345678901234567890', + role: 'Admin', + status: 'active', + validFrom: new Date('2023-01-01'), + validUntil: new Date('2025-01-01'), + }, + { + holderAddress: '0xabcdefabcdefabcdefabcdefabcdefabcdefabcd', + role: 'Auditor', + status: 'active', + validFrom: new Date(), + validUntil: new Date(new Date().setDate(new Date().getDate() + 30)), // 30 days from now + }, + { + holderAddress: '0xdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef', + role: 'Viewer', + status: 'revoked', + validFrom: null, + validUntil: null, + }, + { + holderAddress: '0x1111111111111111111111111111111111111111', + role: 'Contributor', + status: 'active', + validFrom: new Date('2022-01-01'), + validUntil: new Date('2023-01-01'), + }, + { + holderAddress: '0x2222222222222222222222222222222222222222', + role: 'Editor', + status: 'active', + validFrom: new Date(new Date().setDate(new Date().getDate() + 30)), // 30 days from now + validUntil: new Date(new Date().setDate(new Date().getDate() + 60)), // 60 days from now + }, + { + holderAddress: '0x3333333333333333333333333333333333333333', + role: 'Admin', + status: 'active', + validFrom: new Date('2023-01-01'), + validUntil: new Date('2025-01-01'), + }, + { + holderAddress: '0x4444444444444444444444444444444444444444', + role: 'Auditor', + status: 'revoked', + validFrom: new Date(), + validUntil: new Date(new Date().setDate(new Date().getDate() + 30)), // 30 days from now + }, + { + holderAddress: '0x5555555555555555555555555555555555555555', + role: 'Viewer', + status: 'active', + validFrom: null, + validUntil: null, + }, + { + holderAddress: '0x6666666666666666666666666666666666666666', + role: 'Contributor', + status: 'active', + validFrom: new Date('2022-01-01'), + validUntil: new Date('2023-01-01'), + }, + { + holderAddress: '0x7777777777777777777777777777777777777777', + role: 'Editor', + status: 'revoked', + validFrom: new Date(new Date().setDate(new Date().getDate() + 30)), // 30 days from now + validUntil: new Date(new Date().setDate(new Date().getDate() + 60)), // 60 days from now + }, + { + holderAddress: '0x8888888888888888888888888888888888888888', + role: 'Admin', + status: 'active', + validFrom: new Date('2023-01-01'), + validUntil: new Date('2025-01-01'), + }, + { + holderAddress: '0x9999999999999999999999999999999999999999', + role: 'Auditor', + status: 'active', + validFrom: new Date(), + validUntil: new Date(new Date().setDate(new Date().getDate() + 30)), // 30 days from now + }, + { + holderAddress: '0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', + role: 'Viewer', + status: 'revoked', + validFrom: null, + validUntil: null, + }, + { + holderAddress: '0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb', + role: 'Contributor', + status: 'active', + validFrom: new Date('2022-01-01'), + validUntil: new Date('2023-01-01'), + }, + { + holderAddress: '0xcccccccccccccccccccccccccccccccccccccccc', + role: 'Editor', + status: 'active', + validFrom: new Date(new Date().setDate(new Date().getDate() + 30)), // 30 days from now + validUntil: new Date(new Date().setDate(new Date().getDate() + 60)), // 60 days from now + }, + { + holderAddress: '0xdddddddddddddddddddddddddddddddddddddddd', + role: 'Admin', + status: 'revoked', + validFrom: new Date('2023-01-01'), + validUntil: new Date('2025-01-01'), + }, + { + holderAddress: '0xeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee', + role: 'Auditor', + status: 'active', + validFrom: new Date(), + validUntil: new Date(new Date().setDate(new Date().getDate() + 30)), // 30 days from now + }, + { + holderAddress: '0xffffffffffffffffffffffffffffffffffffffff', + role: 'Viewer', + status: 'active', + validFrom: null, + validUntil: null, + }, + { + holderAddress: '0x0000000000000000000000000000000000000001', + role: 'Contributor', + status: 'revoked', + validFrom: new Date('2022-01-01'), + validUntil: new Date('2023-01-01'), + }, + { + holderAddress: '0x0000000000000000000000000000000000000002', + role: 'Editor', + status: 'active', + validFrom: new Date(new Date().setDate(new Date().getDate() + 30)), // 30 days from now + validUntil: new Date(new Date().setDate(new Date().getDate() + 60)), // 60 days from now + }, +]; diff --git a/apps/explorer/src/pages/trust-framework/audit-trail-result/hooks/useCapabilities.ts b/apps/explorer/src/pages/trust-framework/audit-trail-result/hooks/useCapabilities.ts new file mode 100644 index 0000000000..8239665a0e --- /dev/null +++ b/apps/explorer/src/pages/trust-framework/audit-trail-result/hooks/useCapabilities.ts @@ -0,0 +1,45 @@ +// Copyright (c) 2026 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +import { keepPreviousData, useInfiniteQuery } from '@tanstack/react-query'; + +import { mockCapabilities } from './mockCapabilities'; + +export const DEFAULT_CAPABILITIES_LIMIT = 10; + +export enum CapabilityFilterValue { + Issued = 'Issued', + Revoked = 'Revoked', +} + +export function useCapabilities(filter: CapabilityFilterValue, limit = DEFAULT_CAPABILITIES_LIMIT) { + return useInfiniteQuery({ + queryKey: ['get-capabilities', filter, limit], + queryFn: async ({ pageParam = 0 }) => { + const allCapabilities = mockCapabilities.filter((c) => { + if (filter === CapabilityFilterValue.Issued) { + return c.status !== 'revoked'; + } + return c.status === 'revoked'; + }); + + const start = pageParam * limit; + const end = start + limit; + const page = allCapabilities.slice(start, end); + + // Simulate network delay + await new Promise((resolve) => setTimeout(resolve, 500)); + + return { + data: page, + nextCursor: allCapabilities.length > end ? pageParam + 1 : null, + hasNextPage: allCapabilities.length > end, + }; + }, + initialPageParam: 0, + getNextPageParam: ({ hasNextPage, nextCursor }) => (hasNextPage ? nextCursor : null), + staleTime: 10 * 1000, + retry: false, + placeholderData: keepPreviousData, + }); +} diff --git a/apps/explorer/src/pages/trust-framework/audit-trail-result/views/CapabilitiesView.tsx b/apps/explorer/src/pages/trust-framework/audit-trail-result/views/CapabilitiesView.tsx new file mode 100644 index 0000000000..ea3f7ec511 --- /dev/null +++ b/apps/explorer/src/pages/trust-framework/audit-trail-result/views/CapabilitiesView.tsx @@ -0,0 +1,304 @@ +// Copyright (c) 2026 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +import { + TableCellBase, + TableCellText, + ButtonSegment, + ButtonSegmentType, + Panel, + SegmentedButton, + SegmentedButtonType, + Title, + Input, + InputType, +} from '@iota/apps-ui-kit'; +import { + useCapabilities, + CapabilityFilterValue, + DEFAULT_CAPABILITIES_LIMIT, +} from '../hooks/useCapabilities'; +import { type Capability } from '../hooks/mockCapabilities'; +import { TableCard, TransactionLink, Pagination, PlaceholderTable } from '~/components'; +import { formatDigest } from '@iota/iota-sdk/utils'; +import { type Dispatch, type SetStateAction, useReducer, useState } from 'react'; +import { type ColumnDef } from '@tanstack/react-table'; + +interface CapabilitiesViewProps { + objectId: string; +} + +enum PageAction { + Next, + Prev, + First, +} + +type PageStateByFilterMap = { + [CapabilityFilterValue.Issued]: number; + [CapabilityFilterValue.Revoked]: number; +}; + +type CapabilitiesActionType = { + type: PageAction; + filterValue: CapabilityFilterValue; +}; + +const FILTER_OPTIONS: { label: string; value: CapabilityFilterValue }[] = [ + { label: 'Issued', value: CapabilityFilterValue.Issued }, + { label: 'Revoked', value: CapabilityFilterValue.Revoked }, +]; + +function reducer( + state: PageStateByFilterMap, + action: CapabilitiesActionType, +): PageStateByFilterMap { + switch (action.type) { + case PageAction.Next: + return { + ...state, + [action.filterValue]: state[action.filterValue] + 1, + }; + case PageAction.Prev: + return { + ...state, + [action.filterValue]: state[action.filterValue] - 1, + }; + case PageAction.First: + return { + ...state, + [action.filterValue]: 0, + }; + default: + return { ...state }; + } +} + +interface FiltersControlProps { + filterValue: string; + setFilterValue: Dispatch>; +} + +export function FiltersControl({ filterValue, setFilterValue }: FiltersControlProps): JSX.Element { + return ( + + {FILTER_OPTIONS.map(({ label, value }) => ( + setFilterValue(value)} + label={label} + selected={filterValue === value} + type={ButtonSegmentType.Rounded} + /> + ))} + + ); +} + +export function CapabilitiesView({ objectId }: CapabilitiesViewProps) { + const [filterValue, setFilterValue] = useState(CapabilityFilterValue.Issued); + const [searchTerm, setSearchTerm] = useState(''); + const [currentPageState, dispatch] = useReducer(reducer, { + [CapabilityFilterValue.Issued]: 0, + [CapabilityFilterValue.Revoked]: 0, + }); + + const { data, isPending, isFetching, isFetchingNextPage, fetchNextPage, hasNextPage } = + useCapabilities(filterValue); + + const currentPage = currentPageState[filterValue]; + + const capabilities = data?.pages[currentPage]?.data ?? []; + const filteredCapabilities = capabilities.filter((capability) => + capability.holderAddress.toLowerCase().includes(searchTerm.toLowerCase()), + ); + + return ( + +
+
+ + <div className="inline-flex content-between items-center gap-x-2"> + <Input + type={InputType.Text} + placeholder="Holder Address..." + value={searchTerm} + onChange={(e) => setSearchTerm(e.target.value)} + /> + + <div className="flex-shrink-0"> + {' '} + {/* This wrapper prevents wrapping */} + <FiltersControl + filterValue={filterValue} + setFilterValue={setFilterValue} + /> + </div> + </div> + </div> + <div className="flex flex-col gap-sm p-md--rs"> + {isPending || + isFetching || + isFetchingNextPage || + !data?.pages[currentPage]?.data ? ( + <PlaceholderTable + rowCount={DEFAULT_CAPABILITIES_LIMIT} + rowHeight="16px" + colHeadings={[ + 'Holder Address', + 'Role', + 'Status', + 'Valid From', + 'Valid Until', + ]} + /> + ) : ( + <div> + <CapabilitiesTable capabilities={filteredCapabilities} /> + </div> + )} + + {(hasNextPage || (data && data?.pages.length > 1)) && ( + <Pagination + hasFirst={currentPageState[filterValue] !== 0} + onNext={() => { + if (isPending || isFetching) { + return; + } + + // Make sure we are at the end before fetching another page + if ( + data && + currentPageState[filterValue] === data?.pages.length - 1 && + !isPending && + !isFetching + ) { + fetchNextPage(); + } + dispatch({ + type: PageAction.Next, + + filterValue, + }); + }} + hasNext={ + (Boolean(hasNextPage) && Boolean(data?.pages[currentPage])) || + currentPage < (data?.pages.length ?? 0) - 1 + } + hasPrev={currentPageState[filterValue] !== 0} + onPrev={() => + dispatch({ + type: PageAction.Prev, + + filterValue, + }) + } + onFirst={() => + dispatch({ + type: PageAction.First, + filterValue, + }) + } + /> + )} + </div> + </div> + </Panel> + ); +} + +function CapabilitiesTable({ capabilities }: { capabilities: Capability[] }) { + return <TableCard data={capabilities} columns={generateCapabilitiesTableColumns()} />; +} + +export function generateCapabilitiesTableColumns(): ColumnDef<Capability>[] { + const getTimeWindowStatus = ( + validFrom: Date | null, + validUntil: Date | null, + ): { label: string; variant: string } => { + const now = new Date(); + if (validFrom && now < validFrom) { + return { label: 'To Be Active', variant: 'warning' }; + } + if (validUntil && now > validUntil) { + return { label: 'Expired', variant: 'error' }; + } + return { label: 'Active', variant: 'success' }; + }; + return [ + { + accessorKey: 'holderAddress', + header: 'Holder Address', + cell: ({ getValue }) => { + const digest = getValue<string>(); + return ( + <TableCellBase> + <TransactionLink + digest={digest} + label={<TableCellText>{formatDigest(digest)}</TableCellText>} + copyText={digest} + /> + </TableCellBase> + ); + }, + }, + { + accessorKey: 'role', + header: 'Role', + cell: ({ getValue }) => { + const role = getValue<string>(); + return ( + <TableCellBase> + <TableCellText>{role}</TableCellText> + </TableCellBase> + ); + }, + }, + { + accessorKey: 'status', + header: 'Status', + cell: ({ row, getValue }) => { + const status = getValue<string>(); + const validFromOptional = row.getValue<Date | null>('validFrom'); + const validUntilOptional = row.getValue<Date | null>('validUntil'); + const statusLabel = + status === 'revoked' + ? 'Revoked' + : getTimeWindowStatus(validFromOptional, validUntilOptional).label; + return ( + <TableCellBase> + <TableCellText>{statusLabel}</TableCellText> + </TableCellBase> + ); + }, + }, + { + accessorKey: 'validFrom', + header: 'Valid From', + cell: ({ getValue }) => { + const fromDateOptional = getValue<Date | null>(); + const fromValue = + fromDateOptional == null ? 'N/A' : fromDateOptional.toLocaleString('en'); + return ( + <TableCellBase> + <TableCellText>{fromValue}</TableCellText> + </TableCellBase> + ); + }, + }, + { + accessorKey: 'validUntil', + header: 'Valid Until', + cell: ({ getValue }) => { + const fromDateOptional = getValue<Date | null>(); + const fromValue = + fromDateOptional == null ? 'N/A' : fromDateOptional.toLocaleString('en'); + return ( + <TableCellBase> + <TableCellText>{fromValue}</TableCellText> + </TableCellBase> + ); + }, + }, + ]; +}