From f69b7ae29c2f577d9f2723487c73755cf28c1f7f Mon Sep 17 00:00:00 2001 From: Seulkee Kang Date: Tue, 1 Aug 2023 00:11:38 +0100 Subject: [PATCH] build a signing key table with modals --- static/js/brand-store/atoms/index.ts | 20 ++- static/js/brand-store/components/App/App.tsx | 1 + .../components/SigningKeys/DisableModal.tsx | 58 +++++++ .../components/SigningKeys/ModelsModal.tsx | 63 +++++++ .../SigningKeys/SigningKeys.test.tsx | 47 +++++ .../components/SigningKeys/SigningKeys.tsx | 96 ++++++++++- .../SigningKeys/SigningKeysTable.tsx | 163 ++++++++++++++++++ static/js/brand-store/selectors/index.ts | 44 ++++- static/js/brand-store/types/shared.ts | 1 + .../utils/getFilteredSigningKeys.test.ts | 52 ++++++ .../utils/getFilteredSigningKeys.ts | 22 +++ static/js/brand-store/utils/index.ts | 8 + 12 files changed, 567 insertions(+), 8 deletions(-) create mode 100644 static/js/brand-store/components/SigningKeys/DisableModal.tsx create mode 100644 static/js/brand-store/components/SigningKeys/ModelsModal.tsx create mode 100644 static/js/brand-store/components/SigningKeys/SigningKeys.test.tsx create mode 100644 static/js/brand-store/components/SigningKeys/SigningKeysTable.tsx create mode 100644 static/js/brand-store/utils/getFilteredSigningKeys.test.ts create mode 100644 static/js/brand-store/utils/getFilteredSigningKeys.ts diff --git a/static/js/brand-store/atoms/index.ts b/static/js/brand-store/atoms/index.ts index 0dd8d35b22..221b84cc4a 100644 --- a/static/js/brand-store/atoms/index.ts +++ b/static/js/brand-store/atoms/index.ts @@ -1,4 +1,4 @@ -import { atom } from "recoil"; +import { atom, RecoilState } from "recoil"; import type { Store, Model, Policy, SigningKey } from "../types/shared"; const brandStoresState = atom({ @@ -34,9 +34,21 @@ const policiesListFilterState = atom({ default: "" as string, }); -const signingKeysListState = atom({ +const signingKeysListState = atom({ key: "signingKeysList", - default: [] as Array, + default: [], +}); + +const signingKeysListFilterState = atom({ + key: "signingKeysListFilter", + default: "" as string, +}); + +const newSigningKeyState = atom({ + key: "newSigningKey", + default: { + name: "", + }, }); export { @@ -47,4 +59,6 @@ export { policiesListState, policiesListFilterState, signingKeysListState, + signingKeysListFilterState, + newSigningKeyState, }; diff --git a/static/js/brand-store/components/App/App.tsx b/static/js/brand-store/components/App/App.tsx index 9009336e83..526f8a31b5 100644 --- a/static/js/brand-store/components/App/App.tsx +++ b/static/js/brand-store/components/App/App.tsx @@ -89,6 +89,7 @@ function App() { element={} /> } /> + } /> diff --git a/static/js/brand-store/components/SigningKeys/DisableModal.tsx b/static/js/brand-store/components/SigningKeys/DisableModal.tsx new file mode 100644 index 0000000000..45a05c6db9 --- /dev/null +++ b/static/js/brand-store/components/SigningKeys/DisableModal.tsx @@ -0,0 +1,58 @@ +import React from "react"; +import { Modal, Button } from "@canonical/react-components"; +import { useRecoilValue } from "recoil"; + +import { filteredSigningKeysListState } from "../../selectors"; + +import type { SigningKey } from "../../types/shared"; + +type Props = { + disableModalOpen: boolean; + setDisableModalOpen: Function; + handleDisable: Function; + isDeleting: boolean; + signingKey: SigningKey; +}; + +function DisableModal({ + disableModalOpen, + setDisableModalOpen, + handleDisable, + isDeleting, + signingKey, +}: Props) { + + const closeHandler = () => setDisableModalOpen(false); + const signingKeysList = useRecoilValue>(filteredSigningKeysListState); + + if (!disableModalOpen) { + return null; + } + + return ( + + + + + + } + > +

{`Warning: This will permanently disable the signing key ${signingKey.name}.`}

+
+ ); +} + +export default DisableModal; diff --git a/static/js/brand-store/components/SigningKeys/ModelsModal.tsx b/static/js/brand-store/components/SigningKeys/ModelsModal.tsx new file mode 100644 index 0000000000..f1d87572d2 --- /dev/null +++ b/static/js/brand-store/components/SigningKeys/ModelsModal.tsx @@ -0,0 +1,63 @@ +import React from "react"; +import { Modal, Icon } from "@canonical/react-components"; +import { useRecoilValue } from "recoil"; + +import { filteredModelsListState, filteredPoliciesListState } from "../../selectors"; + +import type { SigningKey, Model, Policy } from "../../types/shared"; + +type Props = { + modelsModalOpen: boolean; + setModelsModalOpen: Function; + signingKey: SigningKey; +}; + +function ModelsModal({ + modelsModalOpen, + setModelsModalOpen, + signingKey, +}: Props) { + + const closeHandler = () => setModelsModalOpen(false); + const filteredModels = useRecoilValue>(filteredModelsListState); + const filteredPolicies = useRecoilValue>(filteredPoliciesListState); + + if (!modelsModalOpen) { + return null; + } + + const relatedModels = filteredModels.filter((model) => + signingKey.models.includes(model.name) + ); + + const relatedPolicies = filteredPolicies.filter((policy) => + signingKey.models.includes(policy["model-name"]) + ); + + return ( + + + {` Deactivate ${signingKey.name}`} + } + close={closeHandler} + > +

{signingKey.name} is used in :

+
    + {relatedModels.map((model) => { + const relatedPolicy = relatedPolicies.find((policy) => policy["model-name"] === model.name); + return ( + +
  • {model.name}
  • +
  • {relatedPolicy ? relatedPolicy["created-by"] : "No policy found"}
  • +
    + ); + })} +
+

You need to update each policy with a new key first to be able to delete this one.

+
+ ); +} + +export default ModelsModal; diff --git a/static/js/brand-store/components/SigningKeys/SigningKeys.test.tsx b/static/js/brand-store/components/SigningKeys/SigningKeys.test.tsx new file mode 100644 index 0000000000..7f622b3e13 --- /dev/null +++ b/static/js/brand-store/components/SigningKeys/SigningKeys.test.tsx @@ -0,0 +1,47 @@ +import React from "react"; +import { BrowserRouter } from "react-router-dom"; +import { RecoilRoot } from "recoil"; +import { QueryClient, QueryClientProvider } from "react-query"; +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; + +import "@testing-library/jest-dom"; + +import SigningKeys from "./SigningKeys"; + +let mockFilterQuery = "signing-key-1"; + +jest.mock("react-router-dom", () => { + return { + ...jest.requireActual("react-router-dom"), + useSearchParams: () => [new URLSearchParams({ filter: mockFilterQuery })], + }; +}); + +const queryClient = new QueryClient({ + defaultOptions: { + queries: { + refetchOnWindowFocus: false, + refetchOnReconnect: false, + }, + }, +}); + +function renderComponent() { + return render( + + + + + + + + ); +} + +describe("Models", () => { + it("displays a table of models", () => { + renderComponent(); + expect(screen.getByTestId("signing-keys-table")).toBeInTheDocument(); + }); +}); diff --git a/static/js/brand-store/components/SigningKeys/SigningKeys.tsx b/static/js/brand-store/components/SigningKeys/SigningKeys.tsx index 83d35c8671..31c630c8a6 100644 --- a/static/js/brand-store/components/SigningKeys/SigningKeys.tsx +++ b/static/js/brand-store/components/SigningKeys/SigningKeys.tsx @@ -1,8 +1,60 @@ -import React from "react"; +import React, { useState } from "react"; +import { useQuery } from "react-query"; +import { useSetRecoilState, useRecoilState } from "recoil"; +import { + Link, + useParams, + useLocation, + useNavigate, + useSearchParams, +} from "react-router-dom"; +import { Row, Col, Notification } from "@canonical/react-components"; + +import { + signingKeysListState, + signingKeysListFilterState, + policiesListState, +} from "../../atoms"; import SectionNav from "../SectionNav"; +import SigningKeysTable from "./SigningKeysTable"; + +import { isClosedPanel } from "../../utils"; + +import type { SigningKey, Policy, Query } from "../../types/shared"; function SigningKeys() { + const getSigningKeys = async () => { + const response = await fetch(`/admin/store/${id}/signing-keys`); + + if (!response.ok) { + throw new Error("There was a problem fetching signing keys"); + } + + const signingKeysData = await response.json(); + + if (!signingKeysData.success) { + throw new Error(signingKeysData.message); + } + + setSigningKeysList(signingKeysData.data); + } + + const { id } = useParams(); + const location = useLocation(); + const navigate = useNavigate(); + const { isLoading, isError, error }: Query = useQuery( + "signingKeys", + getSigningKeys + ); + const setSigningKeysList = useSetRecoilState>(signingKeysListState); + const setFilter = useSetRecoilState(signingKeysListFilterState); + const [searchParams] = useSearchParams(); + const [showNotification, setShowNotification] = useState(false); + const [showErrorNotification, setShowErrorNotification] = useState( + false + ); + return (
@@ -10,11 +62,47 @@ function SigningKeys() {
-
-

This is where the signing keys table will be

+ {showNotification && ( +
+ { + setShowNotification(false); + }} + > + New signing key created + +
+ )} + {showErrorNotification && ( +
+ { + setShowErrorNotification(false); + }} + > + Unable to create signing key + +
+ )} + + + + Create new signing key + + + + + + +
+ {isLoading &&

Fetching signing keys...

} + {isError && error &&

Error: {error.message}

} + +
-
); } diff --git a/static/js/brand-store/components/SigningKeys/SigningKeysTable.tsx b/static/js/brand-store/components/SigningKeys/SigningKeysTable.tsx new file mode 100644 index 0000000000..49e1f83c00 --- /dev/null +++ b/static/js/brand-store/components/SigningKeys/SigningKeysTable.tsx @@ -0,0 +1,163 @@ +import React, { useState }from "react"; +import { useParams } from "react-router-dom"; +import { useRecoilValue, useSetRecoilState } from "recoil"; +import { format } from "date-fns"; +import { MainTable, Button } from "@canonical/react-components"; +import ModelsModal from "./ModelsModal"; +import DisableModal from "./DisableModal"; + +import { filteredSigningKeysListState } from "../../selectors"; +import { signingKeysListState } from "../../atoms"; + +import type { SigningKey } from "../../types/shared"; + +function SigningKeysTable() { + const { id, signing_key_id } = useParams(); + const signingKeysList = useRecoilValue>(filteredSigningKeysListState); + const setSigningKeysListState = useSetRecoilState(signingKeysListState); + const [isDeleting, setIsDeleting] = useState(false); + const [modelsModalOpen, setModelsModalOpen] = useState(false); + const [disableModalOpen, setDisableModalOpen] = useState(false); + const [selectedSigningKey, setSelectedSigningKey] = useState(undefined); + + const handleDisableClick = (signingKey: SigningKey) => { + setSelectedSigningKey(signingKey); + const modelsUsingKey = signingKeysList.some( + (key) => key.fingerprint === signingKey.fingerprint && key.models.length > 0 + ); + + if (modelsUsingKey) { + setModelsModalOpen(true); + } else { + setDisableModalOpen(true); + + if (typeof signing_key_id === 'string') { + handleDisable(signingKey, signing_key_id); + } else { + console.error("Invalid signing key ID."); + } + } + }; + + const handleDisable = async (signingKey: SigningKey, signing_key_id: string) => { + if (!signingKey || !signingKey["sha3-384"]) { + console.error("Invalid signing key:", signingKey); + return; + } + + try { + const response = await fetch(`/admin/store/${id}/signing-keys/${signingKey["sha3-384"]}`, { + method: "DELETE", + }); + + setIsDeleting(true); + + const deactivatedIndex = signingKeysList.findIndex((key) => key.fingerprint === signingKey["fingerprint"]); + + if (deactivatedIndex !== -1) { + const updatedSigningKeysList = [...signingKeysList]; + updatedSigningKeysList.splice(deactivatedIndex, 1); + setSigningKeysListState(updatedSigningKeysList); + } else { + throw new Error("Failed to delete a signing key."); + } + } catch (error) { + console.error("Error deleting the signing key:", error); + } finally { + setModelsModalOpen(false); + setDisableModalOpen(false); + } + }; + + return ( + <> + {modelsModalOpen && selectedSigningKey && ( + + )} + {selectedSigningKey && ( + + )} + + { + return { + columns: [ + { + content: signingKey["name"], + }, + { + content: signingKey.models, + className: "u-align--right", + }, + { + content: + signingKey["created-at"] !== null + ? format(new Date(signingKey["created-at"]), "dd/MM/yyyy") + : "-", + className: "u-align--right", + }, + { + content: signingKey["fingerprint"], + }, + { + content: ( + + ), + className: "u-align--right", + }, + ], + sortData: { + name: signingKey.name, + "created-at": signingKey["created-at"], + }, + }; + })} + /> + + ); +} + +export default SigningKeysTable; diff --git a/static/js/brand-store/selectors/index.ts b/static/js/brand-store/selectors/index.ts index da736a7d27..c5ed800416 100644 --- a/static/js/brand-store/selectors/index.ts +++ b/static/js/brand-store/selectors/index.ts @@ -6,10 +6,14 @@ import { policiesListState, policiesListFilterState, signingKeysListState, +<<<<<<< Updated upstream brandStoresState, +======= + signingKeysListFilterState, +>>>>>>> Stashed changes } from "../atoms"; -import { getFilteredModels, getFilteredPolicies } from "../utils"; +import { getFilteredModels, getFilteredPolicies, getFilteredSigningKeys, } from "../utils"; import type { StoresSlice, @@ -19,6 +23,7 @@ import type { MembersSlice, Model, Policy, + SigningKey } from "../types/shared"; // Redux selectors @@ -81,6 +86,7 @@ const filteredPoliciesListState = selector>({ }, }); +<<<<<<< Updated upstream const brandStoreState = selectorFamily({ key: "brandStore", get: (storeId) => ({ get }) => { @@ -89,6 +95,38 @@ const brandStoreState = selectorFamily({ }, }); +======= +const filteredSigningKeysListState = selector>({ + key: "filteredSigningKeysList", + get: ({ get }) => { + const filter = get(signingKeysListFilterState); + const policies = get(policiesListState); + const signingKeys = get(signingKeysListState); + const signingKeysWithPolicies = signingKeys.map((signingKey) => { + const matchingPolicies = policies.filter((policy) => { + return policy["signing-key-sha3-384"] === signingKey["sha3-384"]; + }); + + const signingKeyModels: string[] = []; + + matchingPolicies.forEach((policy) => { + if (!signingKeyModels.includes(policy["model-name"])) { + signingKeyModels.push(policy["model-name"]); + } + }); + + return { + ...signingKey, + models: signingKeyModels, + }; + }); + + return getFilteredSigningKeys(signingKeysWithPolicies, filter); + }, +}); + + +>>>>>>> Stashed changes export { brandStoresListSelector, currentStoreSelector, @@ -98,5 +136,9 @@ export { filteredModelsListState, currentModelState, filteredPoliciesListState, +<<<<<<< Updated upstream brandStoreState, +======= + filteredSigningKeysListState, +>>>>>>> Stashed changes }; diff --git a/static/js/brand-store/types/shared.ts b/static/js/brand-store/types/shared.ts index bb5afcc15a..faad825c25 100644 --- a/static/js/brand-store/types/shared.ts +++ b/static/js/brand-store/types/shared.ts @@ -137,4 +137,5 @@ export type SigningKey = { "modified-by": string; fingerprint: string; "sha3-384": string; + models: string[]; }; diff --git a/static/js/brand-store/utils/getFilteredSigningKeys.test.ts b/static/js/brand-store/utils/getFilteredSigningKeys.test.ts new file mode 100644 index 0000000000..d8fa74c99c --- /dev/null +++ b/static/js/brand-store/utils/getFilteredSigningKeys.test.ts @@ -0,0 +1,52 @@ +import getFilteredSigningKeys from "./getFilteredSigningKeys"; + +const mockSigningKeys = [ + { + name: "signing-key-1", + "created-at": "2022-07-18T13:03:11.095Z", + "created-by": "John Doe", + "modified-at": "2022-07-18T14:03:11.095Z", + "modified-by": "John Doe", + fingerprint: "fingerprint1", + "sha3-384": "96c3bf18447370259bab86ccc650ed3fe9ffb3ec2f8d55b4beafc09efc38405ccecb9e3c0de86ae6f89466493fbf8bbe", + models: [], + }, + { + name: "signing-key-2", + "created-at": "2022-07-19T13:03:11.095Z", + "created-by": "John Doe", + "modified-at": "2022-07-19T15:03:11.095Z", + "modified-by": "John Doe", + fingerprint: "fingerprint1", + "sha3-384": "98d5bf18447370259bab86ccc650ed3fe9ffb3ec2f8d55b4beafc09efc38405ccecb9e3c0de86ae6f89466493fbf8bbe", + models: [], + }, + { + name: "signing-key-3", + "created-at": "2022-07-20T13:03:11.095Z", + "created-by": "John Doe", + "modified-at": "2022-07-20T15:03:11.095Z", + "modified-by": "John Doe", + fingerprint: "fingerprint1", + "sha3-384": "99a7bf18447370259bab86ccc650ed3fe9ffb3ec2f8d55b4beafc09efc38405ccecb9e3c0de86ae6f89466493fbf8bbe", + models: [], + }, +]; + +describe("getFilteredSigningKeys", () => { + it("returns unfiltered signing keys if no filter query", () => { + expect(getFilteredSigningKeys(mockSigningKeys).length).toEqual(mockSigningKeys.length); + expect(getFilteredSigningKeys(mockSigningKeys)[0].name).toEqual(mockSigningKeys[0].name); + expect(getFilteredSigningKeys(mockSigningKeys)[1].name).toEqual(mockSigningKeys[1].name); + expect(getFilteredSigningKeys(mockSigningKeys)[2].name).toEqual(mockSigningKeys[2].name); + }); + + it("returns no signing keys if filter query doesn't match", () => { + expect(getFilteredSigningKeys(mockSigningKeys, "foobar").length).toEqual(0); + }); + + it("returns filtered signing keys if query matches", () => { + expect(getFilteredSigningKeys(mockSigningKeys, "signing-key-1").length).toBe(1); + expect(getFilteredSigningKeys(mockSigningKeys, "signing-key-2")[0].name).toEqual("signing-key-2"); + }); +}); diff --git a/static/js/brand-store/utils/getFilteredSigningKeys.ts b/static/js/brand-store/utils/getFilteredSigningKeys.ts new file mode 100644 index 0000000000..1cf50b5080 --- /dev/null +++ b/static/js/brand-store/utils/getFilteredSigningKeys.ts @@ -0,0 +1,22 @@ +import type { SigningKey } from "../types/shared"; + +function getFilteredSigningKeys(signingKeys: Array, filterQuery?: string | null) { + if (!filterQuery) { + return signingKeys; + } + + return signingKeys.filter((signingKey: SigningKey) => { + if ( + (signingKey.name && signingKey.name.includes(filterQuery)) || + (signingKey["created-at"] && signingKey["created-at"].includes(filterQuery)) || + (signingKey["modified-at"] && signingKey["modified-at"].includes(filterQuery)) || + signingKey.fingerprint.toString().includes(filterQuery) + ) { + return true; + } + + return false; + }); +} + +export default getFilteredSigningKeys; diff --git a/static/js/brand-store/utils/index.ts b/static/js/brand-store/utils/index.ts index 68281575af..7268bf9055 100644 --- a/static/js/brand-store/utils/index.ts +++ b/static/js/brand-store/utils/index.ts @@ -3,7 +3,11 @@ import getFilteredModels from "./getFilteredModels"; import getFilteredPolicies from "./getFilteredPolicies"; import isClosedPanel from "./isClosedPanel"; import maskString from "./maskString"; +<<<<<<< Updated upstream import setPageTitle from "./setPageTitle"; +======= +import getFilteredSigningKeys from "./getFilteredSigningKeys"; +>>>>>>> Stashed changes export { checkModelNameExists, @@ -11,5 +15,9 @@ export { getFilteredPolicies, isClosedPanel, maskString, +<<<<<<< Updated upstream setPageTitle, +======= + getFilteredSigningKeys, +>>>>>>> Stashed changes };