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', + }), + ] +}