diff --git a/.changeset/twenty-radios-tease.md b/.changeset/twenty-radios-tease.md
new file mode 100644
index 00000000..fb7bf961
--- /dev/null
+++ b/.changeset/twenty-radios-tease.md
@@ -0,0 +1,5 @@
+---
+'@smartcontractkit/operator-ui': minor
+---
+
+Added support for the display and deletion of OCR version 2 (OCR2) keys
diff --git a/.gitignore b/.gitignore
index 80c59072..f0b97a9b 100644
--- a/.gitignore
+++ b/.gitignore
@@ -8,3 +8,6 @@ tsconfig.tsbuildinfo
.npmrc
assets
yarn-error.log
+
+# OS specific
+.DS_Store
diff --git a/src/hooks/useQueryErrorHandler.test.tsx b/src/hooks/useQueryErrorHandler.test.tsx
new file mode 100644
index 00000000..8469110b
--- /dev/null
+++ b/src/hooks/useQueryErrorHandler.test.tsx
@@ -0,0 +1,94 @@
+import React from 'react'
+
+import { ApolloError } from '@apollo/client'
+import { GraphQLError } from 'graphql'
+import { Route } from 'react-router-dom'
+import { renderWithRouter, screen } from 'support/test-utils'
+import { getAuthentication } from 'utils/storage'
+
+import Notifications from 'pages/Notifications'
+import { useQueryErrorHandler } from 'hooks/useQueryErrorHandler'
+
+const { getByText } = screen
+
+const StubComponent = ({ mockError }: { mockError?: unknown }) => {
+ const { handleQueryError } = useQueryErrorHandler()
+
+ React.useEffect(() => {
+ handleQueryError(mockError)
+ }, [mockError, handleQueryError])
+
+ return null
+}
+
+function renderComponent(mockError?: unknown) {
+ renderWithRouter(
+ <>
+
+
+
+
+
+
+ Redirect Success
+
+ >,
+ )
+}
+
+describe('useQueryErrorHandler', () => {
+ it('renders an empty component if error undefined', () => {
+ renderComponent()
+
+ expect(document.documentElement).toHaveTextContent('')
+ })
+
+ it('renders the apollo error message', () => {
+ const graphQLErrors = [new GraphQLError('GraphQL error')]
+ const errorMessage = 'Something went wrong'
+ const apolloError = new ApolloError({
+ graphQLErrors,
+ errorMessage,
+ })
+
+ renderComponent(apolloError)
+
+ expect(getByText('Something went wrong')).toBeInTheDocument()
+ })
+
+ it('redirects an authenticated error', () => {
+ const graphQLErrors = [
+ new GraphQLError(
+ 'Unauthorized',
+ undefined,
+ undefined,
+ undefined,
+ undefined,
+ undefined,
+ { code: 'UNAUTHORIZED' },
+ ),
+ ]
+ const errorMessage = 'Something went wrong'
+ const apolloError = new ApolloError({
+ graphQLErrors,
+ errorMessage,
+ })
+
+ renderComponent(apolloError)
+
+ expect(getByText('Redirect Success')).toBeInTheDocument()
+ expect(getAuthentication()).toEqual({ allowed: false })
+ })
+
+ it('renders the message in an alert when it is a simple error', () => {
+ renderComponent(new Error('Something went wrong'))
+
+ expect(getByText('Something went wrong')).toBeInTheDocument()
+ })
+
+ it('renders a generic message in an alert as a default', () => {
+ renderComponent('generic message') // A string type is not handled and falls to the default
+
+ expect(getByText('An error occurred')).toBeInTheDocument()
+ })
+})
diff --git a/src/hooks/useQueryErrorHandler.tsx b/src/hooks/useQueryErrorHandler.tsx
new file mode 100644
index 00000000..f49b81c3
--- /dev/null
+++ b/src/hooks/useQueryErrorHandler.tsx
@@ -0,0 +1,44 @@
+import React from 'react'
+import { useHistory } from 'react-router-dom'
+import { useDispatch } from 'react-redux'
+
+import { notifyErrorMsg } from 'actionCreators'
+import { ApolloError } from '@apollo/client'
+import { receiveSignoutSuccess } from 'actionCreators'
+/**
+ * Handles an unknown error which is caught from a
+ * query operation. If the error returned is an authentication error, it
+ * signs the user out and redirects them to the sign-in page, otherwise it
+ * displays an alert with the error message.
+ */
+export const useQueryErrorHandler = () => {
+ const [error, handleQueryError] = React.useState()
+ const history = useHistory()
+ const dispatch = useDispatch()
+
+ React.useEffect(() => {
+ if (!error) {
+ return
+ }
+
+ if (error instanceof ApolloError) {
+ // Check for an authentication error and logout
+ for (const gqlError of error.graphQLErrors) {
+ if (gqlError.extensions?.code == 'UNAUTHORIZED') {
+ dispatch(
+ notifyErrorMsg(
+ 'Unauthorized, please log in with proper credentials',
+ ),
+ )
+ dispatch(receiveSignoutSuccess())
+ history.push('/signin')
+
+ return
+ }
+ }
+ }
+ dispatch(notifyErrorMsg((error as Error).message || 'An error occurred'))
+ }, [dispatch, error, history])
+
+ return { handleQueryError }
+}
diff --git a/src/screens/KeyManagement/KeyManagementView.tsx b/src/screens/KeyManagement/KeyManagementView.tsx
index 3fe8b7c8..fea29997 100644
--- a/src/screens/KeyManagement/KeyManagementView.tsx
+++ b/src/screens/KeyManagement/KeyManagementView.tsx
@@ -6,6 +6,7 @@ import Content from 'components/Content'
import { EVMAccounts } from './EVMAccounts'
import { CSAKeys } from './CSAKeys'
import { OCRKeys } from './OCRKeys'
+import { OCR2Keys } from './OCR2Keys'
import { P2PKeys } from './P2PKeys'
interface Props {
@@ -22,6 +23,10 @@ export const KeyManagementView: React.FC = ({
+
+
+
+
diff --git a/src/screens/KeyManagement/OCR2KeyBundleRow.test.tsx b/src/screens/KeyManagement/OCR2KeyBundleRow.test.tsx
new file mode 100644
index 00000000..9a56ba94
--- /dev/null
+++ b/src/screens/KeyManagement/OCR2KeyBundleRow.test.tsx
@@ -0,0 +1,57 @@
+import * as React from 'react'
+
+import { render, screen } from 'support/test-utils'
+
+import { buildOCR2KeyBundle } from 'support/factories/gql/fetchOCR2KeyBundles'
+import { OCR2KeyBundleRow } from './OCR2KeyBundleRow'
+import userEvent from '@testing-library/user-event'
+
+const { getByRole, queryByText } = screen
+
+describe('OCR2KeyBundleRow', () => {
+ let handleDelete: jest.Mock
+
+ beforeEach(() => {
+ handleDelete = jest.fn()
+ })
+
+ function renderComponent(bundle: Ocr2KeyBundlesPayload_ResultsFields) {
+ render(
+ ,
+ )
+ }
+
+ it('renders a row', () => {
+ const bundle = buildOCR2KeyBundle()
+
+ renderComponent(bundle)
+
+ expect(queryByText(`Key ID: ${bundle.id}`)).toBeInTheDocument()
+ expect(
+ queryByText(`Chain Type: ${bundle.chainType}`),
+ ).toBeInTheDocument()
+ expect(
+ queryByText(`Config Public Key: ${bundle.configPublicKey}`),
+ ).toBeInTheDocument()
+ expect(
+ queryByText(`On-Chain Public Key: ${bundle.onChainPublicKey}`),
+ ).toBeInTheDocument()
+ expect(
+ queryByText(`Off-Chain Public Key: ${bundle.offChainPublicKey}`),
+ ).toBeInTheDocument()
+ })
+
+ it('calls delete', () => {
+ const bundle = buildOCR2KeyBundle()
+
+ renderComponent(bundle)
+
+ userEvent.click(getByRole('button', { name: /delete/i }))
+
+ expect(handleDelete).toHaveBeenCalled()
+ })
+})
diff --git a/src/screens/KeyManagement/OCR2KeyBundleRow.tsx b/src/screens/KeyManagement/OCR2KeyBundleRow.tsx
new file mode 100644
index 00000000..6fe59a0b
--- /dev/null
+++ b/src/screens/KeyManagement/OCR2KeyBundleRow.tsx
@@ -0,0 +1,44 @@
+import React from 'react'
+
+import Button from 'src/components/Button'
+import TableCell from '@material-ui/core/TableCell'
+import TableRow from '@material-ui/core/TableRow'
+
+import { KeyBundle } from './KeyBundle'
+import { CopyIconButton } from 'src/components/Copy/CopyIconButton'
+
+interface Props {
+ bundle: Ocr2KeyBundlesPayload_ResultsFields
+ onDelete: () => void
+}
+
+/**
+ * This row follows the form and structure of OCRKeyBundleRow but
+ * uses the new data for keys from OCR2
+ */
+export const OCR2KeyBundleRow: React.FC = ({ bundle, onDelete }) => {
+ return (
+
+
+
+ Key ID: {bundle.id}
+
+ }
+ secondary={[
+ <>Chain Type: {bundle.chainType}>,
+ <>Config Public Key: {bundle.configPublicKey}>,
+ <>On-Chain Public Key: {bundle.onChainPublicKey}>,
+ <>Off-Chain Public Key: {bundle.offChainPublicKey}>,
+ ]}
+ />
+
+
+
+
+
+ )
+}
diff --git a/src/screens/KeyManagement/OCR2Keys.test.tsx b/src/screens/KeyManagement/OCR2Keys.test.tsx
new file mode 100644
index 00000000..eb4f4ccf
--- /dev/null
+++ b/src/screens/KeyManagement/OCR2Keys.test.tsx
@@ -0,0 +1,122 @@
+import * as React from 'react'
+
+import { GraphQLError } from 'graphql'
+import {
+ renderWithRouter,
+ screen,
+ waitForElementToBeRemoved,
+} from 'support/test-utils'
+import { MockedProvider, MockedResponse } from '@apollo/client/testing'
+import userEvent from '@testing-library/user-event'
+
+import {
+ OCR2Keys,
+ DELETE_OCR2_KEY_BUNDLE_MUTATION,
+} from './OCR2Keys'
+import {
+ buildOCR2KeyBundle,
+ buildOCR2KeyBundles,
+} from 'support/factories/gql/fetchOCR2KeyBundles'
+import Notifications from 'pages/Notifications'
+import { OCR2_KEY_BUNDLES_QUERY } from 'src/hooks/queries/useOCR2KeysQuery'
+import { waitForLoading } from 'support/test-helpers/wait'
+
+const { findByText, getByRole, queryByText } = screen
+
+function renderComponent(mocks: MockedResponse[]) {
+ renderWithRouter(
+ <>
+
+
+
+
+ >,
+ )
+}
+
+function fetchOCR2KeyBundlesQuery(
+ bundles: ReadonlyArray,
+) {
+ return {
+ request: {
+ query: OCR2_KEY_BUNDLES_QUERY,
+ },
+ result: {
+ data: {
+ ocr2KeyBundles: {
+ results: bundles,
+ },
+ },
+ },
+ }
+}
+
+describe('OCR2Keys', () => {
+ it('renders the page', async () => {
+ const payload = buildOCR2KeyBundles()
+ const mocks: MockedResponse[] = [fetchOCR2KeyBundlesQuery(payload)]
+
+ renderComponent(mocks)
+
+ await waitForLoading()
+
+ expect(await findByText(`Key ID: ${payload[0].id}`)).toBeInTheDocument()
+ })
+
+ it('renders GQL query errors', async () => {
+ const mocks: MockedResponse[] = [
+ {
+ request: {
+ query: OCR2_KEY_BUNDLES_QUERY,
+ },
+ result: {
+ errors: [new GraphQLError('Error!')],
+ },
+ },
+ ]
+
+ renderComponent(mocks)
+
+ expect(await findByText('Error!')).toBeInTheDocument()
+ })
+
+ it('deletes an OCR2 Key Bundle', async () => {
+ const payload = buildOCR2KeyBundle()
+
+ const mocks: MockedResponse[] = [
+ fetchOCR2KeyBundlesQuery([payload]),
+ {
+ request: {
+ query: DELETE_OCR2_KEY_BUNDLE_MUTATION,
+ variables: { id: payload.id },
+ },
+ result: {
+ data: {
+ deleteOCR2KeyBundle: {
+ __typename: 'DeleteOCR2KeyBundleSuccess',
+ bundle: payload,
+ },
+ },
+ },
+ },
+ fetchOCR2KeyBundlesQuery([]),
+ ]
+
+ renderComponent(mocks)
+
+ expect(await findByText(`Key ID: ${payload.id}`)).toBeInTheDocument()
+
+ userEvent.click(getByRole('button', { name: /delete/i }))
+ userEvent.click(getByRole('button', { name: /confirm/i }))
+
+ await waitForElementToBeRemoved(getByRole('dialog'))
+
+ expect(
+ await findByText(
+ 'Successfully deleted Off-ChainReporting Key Bundle Key',
+ ),
+ ).toBeInTheDocument()
+
+ expect(queryByText(`Key ID: ${payload.id}`)).toBeNull()
+ })
+})
diff --git a/src/screens/KeyManagement/OCR2Keys.tsx b/src/screens/KeyManagement/OCR2Keys.tsx
new file mode 100644
index 00000000..828edbda
--- /dev/null
+++ b/src/screens/KeyManagement/OCR2Keys.tsx
@@ -0,0 +1,62 @@
+import React from 'react'
+
+import { gql, useMutation } from '@apollo/client'
+import { useDispatch } from 'react-redux'
+
+import { deleteSuccessNotification } from './notifications'
+import { OCR2KeysCard } from './OCR2KeysCard'
+import { useOCR2KeysQuery } from 'src/hooks/queries/useOCR2KeysQuery'
+import { useQueryErrorHandler } from 'hooks/useQueryErrorHandler'
+
+export const DELETE_OCR2_KEY_BUNDLE_MUTATION = gql`
+ mutation DeleteOCR2KeyBundle($id: ID!) {
+ deleteOCR2KeyBundle(id: $id) {
+ ... on DeleteOCR2KeyBundleSuccess {
+ bundle {
+ id
+ }
+ }
+ }
+ }
+`
+
+/**
+ * This follows the form and structure of OCRKeys but
+ */
+export const OCR2Keys = () => {
+ const dispatch = useDispatch()
+ const { handleQueryError } = useQueryErrorHandler()
+ const { data, loading, refetch } = useOCR2KeysQuery({
+ fetchPolicy: 'network-only',
+ onError: handleQueryError,
+ })
+
+ const [deleteOCR2KeyBundle] = useMutation<
+ DeleteOcr2KeyBundle,
+ DeleteOcr2KeyBundleVariables
+ >(DELETE_OCR2_KEY_BUNDLE_MUTATION)
+
+ const handleDelete = async (id: string) => {
+ try {
+ const result = await deleteOCR2KeyBundle({ variables: { id } })
+
+ const payload = result.data?.deleteOCR2KeyBundle
+ switch (payload?.__typename) {
+ case 'DeleteOCR2KeyBundleSuccess':
+ dispatch(
+ deleteSuccessNotification({
+ keyType: 'Off-ChainReporting Key Bundle',
+ }),
+ )
+
+ refetch()
+
+ break
+ }
+ } catch (e) {
+ handleQueryError(e)
+ }
+ }
+
+ return
+}
diff --git a/src/screens/KeyManagement/OCR2KeysCard.test.tsx b/src/screens/KeyManagement/OCR2KeysCard.test.tsx
new file mode 100644
index 00000000..c22653fc
--- /dev/null
+++ b/src/screens/KeyManagement/OCR2KeysCard.test.tsx
@@ -0,0 +1,90 @@
+import * as React from 'react'
+
+import { render, screen, waitForElementToBeRemoved } from 'support/test-utils'
+
+import {
+ buildOCR2KeyBundle,
+ buildOCR2KeyBundles,
+} from 'support/factories/gql/fetchOCR2KeyBundles'
+import { OCR2KeysCard, Props as OCR2KeysCardProps } from './OCR2KeysCard'
+import userEvent from '@testing-library/user-event'
+
+const { getAllByRole, getByRole, queryByRole, queryByText } = screen
+
+function renderComponent(cardProps: OCR2KeysCardProps) {
+ render()
+}
+
+describe('OCR2KeysCard', () => {
+ let promise: Promise
+ let handleDelete: jest.Mock
+
+ beforeEach(() => {
+ promise = Promise.resolve()
+ handleDelete = jest.fn(() => promise)
+ })
+
+ it('renders the key bundles', () => {
+ const bundles = buildOCR2KeyBundles()
+
+ renderComponent({
+ loading: false,
+ data: {
+ ocr2KeyBundles: {
+ results: bundles,
+ },
+ },
+ onDelete: handleDelete,
+ })
+
+ expect(getAllByRole('row')).toHaveLength(3)
+
+ expect(queryByText(`Key ID: ${bundles[0].id}`)).toBeInTheDocument()
+ expect(queryByText(`Key ID: ${bundles[1].id}`)).toBeInTheDocument()
+ })
+
+ it('renders no content', () => {
+ renderComponent({
+ loading: false,
+ data: {
+ ocr2KeyBundles: {
+ results: [],
+ },
+ },
+ onDelete: handleDelete,
+ })
+
+ expect(queryByText('No entries to show')).toBeInTheDocument()
+ })
+
+ it('renders a loading spinner', () => {
+ renderComponent({
+ loading: true,
+ onDelete: handleDelete,
+ })
+
+ expect(queryByRole('progressbar')).toBeInTheDocument()
+ })
+
+ it('calls onDelete', async () => {
+ const bundle = buildOCR2KeyBundle()
+ renderComponent({
+ loading: false,
+ data: {
+ ocr2KeyBundles: {
+ results: [bundle],
+ },
+ },
+ onDelete: handleDelete,
+ })
+
+ userEvent.click(getByRole('button', { name: /delete/i }))
+ expect(queryByText(bundle.id)).toBeInTheDocument()
+
+ userEvent.click(getByRole('button', { name: /confirm/i }))
+
+ await waitForElementToBeRemoved(getByRole('dialog'))
+
+ expect(handleDelete).toHaveBeenCalled()
+ })
+})
diff --git a/src/screens/KeyManagement/OCR2KeysCard.tsx b/src/screens/KeyManagement/OCR2KeysCard.tsx
new file mode 100644
index 00000000..a0cf2798
--- /dev/null
+++ b/src/screens/KeyManagement/OCR2KeysCard.tsx
@@ -0,0 +1,87 @@
+import React from 'react'
+
+import Card from '@material-ui/core/Card'
+import CardHeader from '@material-ui/core/CardHeader'
+import Chip from '@material-ui/core/Chip'
+import Table from '@material-ui/core/Table'
+import TableBody from '@material-ui/core/TableBody'
+import TableCell from '@material-ui/core/TableCell'
+import TableHead from '@material-ui/core/TableHead'
+import TableRow from '@material-ui/core/TableRow'
+
+import { ConfirmationDialog } from 'src/components/Dialogs/ConfirmationDialog'
+import { LoadingRow } from 'src/components/TableRow/LoadingRow'
+import { NoContentRow } from 'src/components/TableRow/NoContentRow'
+import { OCR2KeyBundleRow } from './OCR2KeyBundleRow'
+
+export interface Props {
+ loading: boolean
+ data?: FetchOcr2KeyBundles
+ errorMsg?: string
+ onDelete: (id: string) => Promise
+}
+
+/**
+ * This card follows the form and structure of OCRKeysCard but
+ * does NOT yet offer a 'create' button as there are architecture
+ * decisions TBD because OCR2 keys require association with a
+ * chain family (e.g. EVM, Starknet, Solana) - but that list of
+ * chains is becoming a more and more fluid/dynamic collection
+ * and we need to consider how to offer this information from
+ * the core to a client (in this case the operator UI)
+ */
+export const OCR2KeysCard: React.FC = ({ data, loading, onDelete }) => {
+ const [confirmDeleteID, setConfirmDeleteID] = React.useState(
+ null,
+ )
+
+ return (
+ <>
+
+
+
+
+
+
+ Key Bundle
+
+
+
+
+
+
+
+ {data?.ocr2KeyBundles.results?.map((bundle, idx) => (
+ setConfirmDeleteID(bundle.id)}
+ />
+ ))}
+
+
+
+
+ }
+ confirmButtonText="Confirm"
+ onConfirm={async () => {
+ if (confirmDeleteID) {
+ await onDelete(confirmDeleteID)
+ setConfirmDeleteID(null)
+ }
+ }}
+ cancelButtonText="Cancel"
+ onCancel={() => setConfirmDeleteID(null)}
+ />
+ >
+ )
+}
diff --git a/support/factories/gql/fetchOCR2KeyBundles.ts b/support/factories/gql/fetchOCR2KeyBundles.ts
new file mode 100644
index 00000000..ea2c34c7
--- /dev/null
+++ b/support/factories/gql/fetchOCR2KeyBundles.ts
@@ -0,0 +1,33 @@
+// buildOCR2KeyBundle builds a ocr2 key bundle for the FetchOCR2KeyBundles query.
+export function buildOCR2KeyBundle(
+ overrides?: Partial,
+): Ocr2KeyBundlesPayload_ResultsFields {
+ return {
+ __typename: 'OCR2KeyBundle',
+ id: '68ae4225aa9fd932e62a2411e4c757a9fa72de45426ac455801a6f08cb4392b3',
+ chainType: 'EVM',
+ configPublicKey:
+ 'ocr2cfg_evm_b04f2db79d3f7f6a7bf942c55119085f012f1aef4bb1df19765ddff79d01fa78',
+ onChainPublicKey: 'ocr2on_evm_d16fc50d52b0cd2268a6d826fc5740a4a22de39b',
+ offChainPublicKey:
+ 'ocr2off_evm_95a5c6777faeae4c3cd7b05961d31515274c368359e64cab9e3b5db76f69dfaa',
+ ...overrides,
+ }
+}
+
+// buildOCR2KeyBundles builds a list of ocr2 key bundles.
+export function buildOCR2KeyBundles(): ReadonlyArray {
+ return [
+ buildOCR2KeyBundle(),
+ buildOCR2KeyBundle({
+ id: '3f44a22aa9ea34fb5ab8a3c04caeaacce3b9edd9d24fb410ce61f8dc77085539',
+ chainType: 'SOLANA',
+ configPublicKey:
+ 'ocr2cfg_solana_14b26702b79e19d0b708a9257e9d6803c6360f2d1eb4bee5d059a7a4f3aea26c',
+ onChainPublicKey:
+ 'ocr2on_solana_18df35e43fd58cf2ed5cd3526f3a35dadcc31efc',
+ offChainPublicKey:
+ 'ocr2off_solana_7fcf4bb539eb617ff0f55a436e9c45e5011de4021a1fa75ef27691794e38336e',
+ }),
+ ]
+}