diff --git a/package.json b/package.json index 46d4d21f..b5acc191 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "@peculiar/fortify-tools", "homepage": "https://tools.fortifyapp.com", - "version": "2.0.3", + "version": "2.0.5", "author": "PeculiarVentures Team", "license": "MIT", "private": true, diff --git a/src/app.tsx b/src/app.tsx index 3e25151a..a6fb9151 100644 --- a/src/app.tsx +++ b/src/app.tsx @@ -12,6 +12,7 @@ import { useSortList } from "./hooks/sort-list"; import { useSearchList } from "./hooks/search-list"; import { useCertificateImportDialog } from "./dialogs/certificate-import-dialog"; import { useCertificateCreateDialog } from "./dialogs/certificate-create-dialog"; +import { useProviderInfoDialog } from "./dialogs/provider-info-dialog"; import styles from "./app.module.scss"; @@ -21,13 +22,19 @@ export function App() { fetching, challenge, providers, - currentProviderId, + currentProvider, certificates, + isCurrentProviderLogedin, handleCertificatesDataReload, handleProviderChange, handleRetryConection, + handleProviderLoginLogout, + handleProviderResetAndRefreshList, } = useApp(); + const currentProviderId = currentProvider?.id; + const isCurrentProviderReadOnly = Boolean(currentProvider?.readOnly); + const { searchedText, list: searchedCertificate, @@ -38,6 +45,7 @@ export function App() { open: handleCertificateDeleteDialogOpen, dialog: certificateDeleteDialog, } = useCertificateDeleteDialog({ + providers, fortifyClient, onSuccess: (providerId) => { handleCertificatesDataReload(providerId); @@ -78,7 +86,12 @@ export function App() { const { open: handleCertificateViewerDialogOpen, dialog: certificateViewerDialog, - } = useCertificateViewerDialog(); + } = useCertificateViewerDialog({ + providers, + }); + + const { open: handleProviderInfoDialogOpen, dialog: providerInfoDialog } = + useProviderInfoDialog({ providers }); return ( <> @@ -92,13 +105,20 @@ export function App() { - currentProviderId && handleCertificatesDataReload(currentProviderId) + onReload={handleProviderResetAndRefreshList} + onInfo={() => + currentProvider && handleProviderInfoDialogOpen(currentProvider) } + isLoggedIn={isCurrentProviderLogedin} + onLoginLogout={handleProviderLoginLogout} > - {certificateViewerDialog()} - {certificateDeleteDialog()} - {certificateImportDialog()} - {certificateCreateDialog()} + {providers.length ? ( + <> + {certificateViewerDialog()} + {certificateDeleteDialog()} + {certificateImportDialog()} + {certificateCreateDialog()} + {providerInfoDialog()} + + ) : null} +
); diff --git a/src/components/certificate-delete-dialog/CertificateDeleteDialog.stories.tsx b/src/components/certificate-delete-dialog/CertificateDeleteDialog.stories.tsx index 5e449b6f..f56c2653 100644 --- a/src/components/certificate-delete-dialog/CertificateDeleteDialog.stories.tsx +++ b/src/components/certificate-delete-dialog/CertificateDeleteDialog.stories.tsx @@ -4,6 +4,7 @@ import { CertificateDeleteDialog } from "./CertificateDeleteDialog"; const meta: Meta = { title: "Components/CertificateDeleteDialog", component: CertificateDeleteDialog, + tags: ["!autodocs"], }; export default meta; @@ -15,11 +16,3 @@ export const Default: Story = { certificateId: "12345", }, }; - -export const Loading: Story = { - args: { - certificateName: "Certificate Name", - certificateId: "12345", - loading: true, - }, -}; diff --git a/src/components/certificate-delete-dialog/CertificateDeleteDialog.test.tsx b/src/components/certificate-delete-dialog/CertificateDeleteDialog.test.tsx new file mode 100644 index 00000000..477fe00c --- /dev/null +++ b/src/components/certificate-delete-dialog/CertificateDeleteDialog.test.tsx @@ -0,0 +1,39 @@ +import { ComponentProps } from "react"; +import { render, userEvent, screen } from "@testing"; +import { CertificateDeleteDialog } from "./CertificateDeleteDialog"; + +describe("", () => { + const defaultProps: ComponentProps = { + certificateName: "Certificate Name", + certificateId: "1", + onDialogClose: vi.fn(), + onDeleteClick: vi.fn((data) => data), + }; + + it("Should render and handle buttons click", async () => { + render(); + + expect( + screen.getByText( + `Are you sure you want to delete “${defaultProps.certificateName}”?` + ) + ).toBeInTheDocument(); + + await userEvent.click(screen.getByRole("button", { name: /Cancel/ })); + + expect(defaultProps.onDialogClose).toBeCalledTimes(1); + + await userEvent.click(screen.getByRole("button", { name: /Delete/ })); + + expect(defaultProps.onDeleteClick).toBeCalledTimes(1); + expect(defaultProps.onDeleteClick).toBeCalledWith( + defaultProps.certificateId + ); + }); + + it("Should render loading", () => { + render(); + + expect(screen.getByText(/Deleting certificate/)).toBeInTheDocument(); + }); +}); diff --git a/src/components/certificate-type-label/CertificateTypeLabel.tsx b/src/components/certificate-type-label/CertificateTypeLabel.tsx index e14cce81..3c906f3b 100644 --- a/src/components/certificate-type-label/CertificateTypeLabel.tsx +++ b/src/components/certificate-type-label/CertificateTypeLabel.tsx @@ -3,18 +3,20 @@ import { ICertificate } from "@peculiar/fortify-client-core"; import { useTranslation } from "react-i18next"; import clsx from "clsx"; import { Typography } from "@peculiar/react-components"; -import CertificateIcon from "../../icons/certificate.svg?react"; +import CertificateIcon from "../../icons/certificate-30.svg?react"; +import CertificateWithKeyIcon from "../../icons/certificate-with-key-30.svg?react"; import styles from "./styles/index.module.scss"; interface CertificateTypeLabelProps { type: ICertificate["type"]; + withPrivatKey: boolean; className?: ComponentProps<"div">["className"]; } export const CertificateTypeLabel: React.FunctionComponent< CertificateTypeLabelProps > = (props) => { - const { type, className } = props; + const { type, className, withPrivatKey } = props; const { t } = useTranslation(); return ( @@ -22,11 +24,27 @@ export const CertificateTypeLabel: React.FunctionComponent< {type === "x509" ? ( <> - + {withPrivatKey ? : } + + + + {t("certificates.list.cell.certificate")} + + {withPrivatKey ? ( + + {" "} + {t("certificates.list.cell.with-privat-key")} + + ) : undefined} - - {t("certificates.list.cell.certificate")} - ) : ( diff --git a/src/components/certificate-type-label/styles/index.module.scss b/src/components/certificate-type-label/styles/index.module.scss index ec02e929..fa04f6ae 100644 --- a/src/components/certificate-type-label/styles/index.module.scss +++ b/src/components/certificate-type-label/styles/index.module.scss @@ -2,15 +2,19 @@ display: flex; gap: var(--pv-size-base-2); align-items: center; - .icon_wrapper { - display: flex; - flex-direction: column; - justify-content: center; +} +.icon_wrapper { + display: flex; + flex-direction: column; + justify-content: center; + color: var(--pv-color-gray-9); + width: var(--pv-size-base-6); + svg { width: var(--pv-size-base-6); - svg { - width: var(--pv-size-base-6); - height: var(--pv-size-base-6); - } + height: var(--pv-size-base-6); } } +.label_part { + display: inline; +} diff --git a/src/components/certificate-viewer-dialog/CertificateViewerDialog.test.tsx b/src/components/certificate-viewer-dialog/CertificateViewerDialog.test.tsx new file mode 100644 index 00000000..865526ac --- /dev/null +++ b/src/components/certificate-viewer-dialog/CertificateViewerDialog.test.tsx @@ -0,0 +1,50 @@ +import { render, userEvent, screen } from "@testing"; +import { CertificateViewerDialog } from "./CertificateViewerDialog"; +import { CertificateProps } from "../../types"; + +vi.mock("@peculiar/certificates-viewer-react", () => ({ + PeculiarCertificateViewer: () => "x509 certificate viewer component", + PeculiarCsrViewer: () => "CSR certificate viewer component", +})); + +describe("", () => { + const certificate = { + raw: new ArrayBuffer(0), + subjectName: "Certificate name", + type: "x509", + label: "Certificate name", + subject: { + commonName: "Certificate name", + }, + } as unknown as CertificateProps; + + it("Should render as x509 and handle close", async () => { + const onCloseMock = vi.fn(); + + render( + + ); + + expect(screen.getByText(`“${certificate.label}” details`)); + expect(screen.getByText(/x509 certificate viewer component/)); + + await userEvent.click(screen.getByRole("button", { name: /Cancel/ })); + + expect(onCloseMock).toBeCalledTimes(1); + }); + + it("Should render as CSR", async () => { + render( + + ); + expect(screen.getByText(/CSR certificate viewer component/)); + }); +}); diff --git a/src/components/certificates-list/CertificatesList.tsx b/src/components/certificates-list/CertificatesList.tsx index c4c30853..a77a85d3 100644 --- a/src/components/certificates-list/CertificatesList.tsx +++ b/src/components/certificates-list/CertificatesList.tsx @@ -53,10 +53,15 @@ interface CertificatesListProps { providerId: string; label: string; }) => void; - onViewDetails: (certificate: CertificateProps) => void; + onViewDetails: (params: { + certificate: CertificateProps; + providerId: string; + }) => void; className?: ComponentProps<"table">["className"]; highlightedText?: string; loading?: boolean; + isLoggedIn: boolean; + isReadOnly: boolean; } export const CertificatesList: React.FunctionComponent< @@ -69,6 +74,8 @@ export const CertificatesList: React.FunctionComponent< currentSortDir, highlightedText, loading, + isLoggedIn, + isReadOnly = false, onSort, onViewDetails, onDelete, @@ -134,7 +141,11 @@ export const CertificatesList: React.FunctionComponent< ); return ( -
+
@@ -168,6 +179,7 @@ export const CertificatesList: React.FunctionComponent< notAfter, raw, index, + privateKeyId, } = certificate; const certificateName = getCertificateName(certificate); @@ -176,12 +188,14 @@ export const CertificatesList: React.FunctionComponent< onViewDetails(certificate)} + onClick={() => + onViewDetails({ certificate, providerId: providerID }) + } onFocus={() => setCurrentRow(id)} onBlur={() => setCurrentRow(undefined)} onKeyDown={(event) => ["Space", "Enter"].includes(event.code) && - onViewDetails(certificate) + onViewDetails({ certificate, providerId: providerID }) } onMouseOver={() => currentRow && setCurrentRow(undefined)} className={clsx({ @@ -189,7 +203,10 @@ export const CertificatesList: React.FunctionComponent< })} > - + onViewDetails(certificate)} + onClick={() => + onViewDetails({ certificate, providerId: providerID }) + } > {t("certificates.list.action.view-details")} certificateRawToPem(raw, type) @@ -236,21 +256,24 @@ export const CertificatesList: React.FunctionComponent< > - - onDelete({ - certificateIndex: index, - providerId: providerID, - label: certificateName, - }) - } - size="small" - className={styles.action_icon_button} - > - - + {!isReadOnly ? ( + + onDelete({ + certificateIndex: index, + providerId: providerID, + label: certificateName, + }) + } + size="small" + className={styles.action_icon_button} + disabled={!isLoggedIn} + > + + + ) : null} @@ -267,12 +290,8 @@ function CertificatesListLoading() { return [...Array(12).keys()].map((index) => ( {[...Array(4).keys()].map((index) => ( - - + + ))} diff --git a/src/components/certificates-list/styles/index.module.scss b/src/components/certificates-list/styles/index.module.scss index 04e90a67..fe4e16b8 100644 --- a/src/components/certificates-list/styles/index.module.scss +++ b/src/components/certificates-list/styles/index.module.scss @@ -37,7 +37,7 @@ margin-right: var(--pv-size-base-2); } - .action_icon_button { + .action_icon_button:not(:disabled) { color: var(--pv-color-gray-10); } } @@ -46,6 +46,20 @@ width: fit-content; overflow: visible; padding-right: var(--pv-size-base-5); + &:before { + content: ""; + width: 50px; + position: absolute; + top: 0; + bottom: 0; + left: -50px; + background: linear-gradient( + 90deg, + rgba(255, 255, 255, 0) 0%, + rgba(255, 255, 255, 1) 80%, + rgba(255, 255, 255, 1) 100% + ); + } } } } @@ -55,6 +69,13 @@ min-height: calc(100vh - var(--pv-top-header-height)); background-color: var(--pv-color-white); } +.table_wrapper_loading { + height: calc(100vh - var(--pv-top-header-height)); + overflow: hidden; + thead th { + top: 0 !important; + } +} .table_wrapper { position: relative; diff --git a/src/components/certificates-topbar/CertificatesTopbar.test.tsx b/src/components/certificates-topbar/CertificatesTopbar.test.tsx new file mode 100644 index 00000000..eb3f163d --- /dev/null +++ b/src/components/certificates-topbar/CertificatesTopbar.test.tsx @@ -0,0 +1,191 @@ +import { render, userEvent, vi, screen } from "@testing"; +import { CertificatesTopbar } from "./CertificatesTopbar"; + +describe("", () => { + const defaultProps = { + isLoggedIn: true, + isDisabled: false, + isReadOnly: false, + onReload: vi.fn(), + onLoginLogout: vi.fn((data) => data), + onCreate: vi.fn(), + onImport: vi.fn(), + onSearch: vi.fn(), + onInfo: vi.fn(), + }; + + it("Should render as disabled", async () => { + render( + + ); + + expect(screen.getByRole("searchbox")).toBeDisabled(); + expect( + screen.getByRole("button", { + name: /Reset session and refresh certificate list/, + }) + ).toBeDisabled(); + expect( + screen.getByRole("button", { name: /Provider information/ }) + ).toBeDisabled(); + expect(screen.getByRole("button", { name: /Sign in/ })).toBeDisabled(); + expect(screen.getByRole("button", { name: /New/ })).toBeDisabled(); + }); + + it("Should render as not logged in", async () => { + render(); + + expect(screen.getByRole("button", { name: /Sign in/ })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: /New/ })).toBeDisabled(); + }); + + it("Should render as logged in", async () => { + render(); + + expect( + screen.getByRole("button", { name: /Sign out/ }) + ).toBeInTheDocument(); + expect(screen.getByRole("button", { name: /New/ })).toBeEnabled(); + }); + + it("Should render as read only", async () => { + render(); + + expect( + screen.queryByRole("button", { name: /New/ }) + ).not.toBeInTheDocument(); + }); + + it("Should handle onReload", async () => { + render(); + + await userEvent.click( + screen.getByRole("button", { + name: /Reset session and refresh certificate list/, + }) + ); + + expect(defaultProps.onReload).toBeCalledTimes(1); + }); + + it("Should handle onInfo", async () => { + render(); + + await userEvent.click( + screen.getByRole("button", { name: /Provider information/ }) + ); + + expect(defaultProps.onInfo).toBeCalledTimes(1); + }); + + it("Should handle onLoginLogout", async () => { + render(); + + await userEvent.click(screen.getByRole("button", { name: /Sign out/ })); + + expect(defaultProps.onLoginLogout).toBeCalledTimes(1); + expect(defaultProps.onLoginLogout).toHaveReturnedWith(true); + }); + + it("Should handle create CSR", async () => { + const onCreateMock = vi.fn((data) => data); + render(); + + const newButton = screen.getByRole("button", { name: "New" }); + + await userEvent.click(newButton); + + expect(screen.getByRole("presentation")).toBeInTheDocument(); + + await userEvent.click( + screen.getByRole("menuitem", { + name: /Create certificate signing request \(CSR\)/, + }) + ); + + expect(screen.queryByRole("presentation")).not.toBeInTheDocument(); + + expect(onCreateMock).toBeCalledTimes(1); + expect(onCreateMock).toHaveReturnedWith("csr"); + }); + + it("Should handle create x509", async () => { + const onCreateMock = vi.fn((data) => data); + render(); + + const newButton = screen.getByRole("button", { name: "New" }); + + await userEvent.click(newButton); + + expect(screen.getByRole("presentation")).toBeInTheDocument(); + + await userEvent.click( + screen.getByRole("menuitem", { + name: /Create self-signed certificate/, + }) + ); + + expect(screen.queryByRole("presentation")).not.toBeInTheDocument(); + + expect(onCreateMock).toBeCalledTimes(1); + expect(onCreateMock).toHaveReturnedWith("x509"); + }); + + it("Should handle import", async () => { + render(); + + const newButton = screen.getByRole("button", { name: "New" }); + + await userEvent.click(newButton); + + expect(screen.getByRole("presentation")).toBeInTheDocument(); + + await userEvent.click( + screen.getByRole("menuitem", { + name: /Import certificate/, + }) + ); + + expect(screen.queryByRole("presentation")).not.toBeInTheDocument(); + + expect(defaultProps.onImport).toBeCalledTimes(1); + }); + + it("Should handle search", async () => { + const onSearchMock = vi.fn((data) => data); + + render( + + ); + + await userEvent.type(screen.getByRole("searchbox"), "a"); + + expect(onSearchMock).toBeCalledTimes(1); + expect(onSearchMock).toHaveReturnedWith("testa"); + }); + + it("Should handle search clear", async () => { + const onSearchMock = vi.fn((data) => data); + + render( + + ); + + await userEvent.click(screen.getByTestId("clear-search-button")); + + expect(onSearchMock).toBeCalledTimes(1); + expect(onSearchMock).toHaveReturnedWith(""); + }); +}); diff --git a/src/components/certificates-topbar/CertificatesTopbar.tsx b/src/components/certificates-topbar/CertificatesTopbar.tsx index f73b076a..24e26c0e 100644 --- a/src/components/certificates-topbar/CertificatesTopbar.tsx +++ b/src/components/certificates-topbar/CertificatesTopbar.tsx @@ -6,7 +6,11 @@ import { IconButton, Menu, TextField, + Tooltip, } from "@peculiar/react-components"; +import InfoIcon from "../../icons/info-20.svg?react"; +import LoginIcon from "../../icons/login-20.svg?react"; +import LogoutIcon from "../../icons/logout-20.svg?react"; import ReloadIcon from "../../icons/reload-20.svg?react"; import ImportIcon from "../../icons/import-30.svg?react"; import SearchIcon from "../../icons/search.svg?react"; @@ -20,10 +24,15 @@ import styles from "./styles/index.module.scss"; interface CertificatesTopbarProps { className?: ComponentProps<"div">["className"]; searchValue?: string; + isDisabled: boolean; + isLoggedIn: boolean; + isReadOnly: boolean; onSearch: (value: string) => void; onImport: () => void; onCreate: (type: "csr" | "x509") => void; onReload: () => void; + onInfo: () => void; + onLoginLogout: (isLoggedin: boolean) => void; } export const CertificatesTopbar: React.FunctionComponent< CertificatesTopbarProps @@ -31,14 +40,90 @@ export const CertificatesTopbar: React.FunctionComponent< const { className, searchValue = "", + isDisabled = true, + isLoggedIn, + isReadOnly = false, onSearch, onImport, onCreate, onReload, + onInfo, + onLoginLogout, } = props; const { t } = useTranslation(); + const renderCreateButton = () => { + if (isReadOnly) { + return null; + } + + if (isDisabled || !isLoggedIn) { + return ( + + + + ); + } + + return ( + + ), + onClick: () => onCreate("csr"), + }, + { + label: t("topbar.create-certificate-ssc"), + startIcon: ( + + ), + onClick: () => onCreate("x509"), + }, + { + label: t("topbar.import-certificate"), + startIcon: , + onClick: onImport, + }, + ]} + > + + + ); + }; + return (
@@ -50,6 +135,7 @@ export const CertificatesTopbar: React.FunctionComponent< onChange={(event) => { onSearch(event.target.value); }} + disabled={isDisabled} /> { onSearch(""); }} + data-testid="clear-search-button" > @@ -77,47 +164,45 @@ export const CertificatesTopbar: React.FunctionComponent< arrow: true, size: "large", }} + className={styles.icon_button} + disabled={isDisabled} > - + -
-
- - ), - onClick: () => onCreate("csr"), - }, - { - label: t("topbar.create-certificate-ssc"), - startIcon: ( - - ), - onClick: () => onCreate("x509"), - }, - { - label: t("topbar.import-certificate"), - startIcon: , - onClick: onImport, - }, - ]} + className={styles.icon_button} + disabled={isDisabled} > - - + + + onLoginLogout(isLoggedIn)} + title={t(`topbar.provider-${isLoggedIn ? "logout" : "login"}`)} + tooltipProps={{ + color: "white", + offset: 2, + placement: "bottom-end", + arrow: true, + size: "large", + }} + className={isLoggedIn ? styles.icon_button_wrong : styles.icon_button} + disabled={isDisabled} + > + {isLoggedIn ? : } +
+ {renderCreateButton()}
); }; diff --git a/src/components/certificates-topbar/styles/index.module.scss b/src/components/certificates-topbar/styles/index.module.scss index 26fa34cb..0f953345 100644 --- a/src/components/certificates-topbar/styles/index.module.scss +++ b/src/components/certificates-topbar/styles/index.module.scss @@ -55,4 +55,7 @@ .icon_button { color: var(--pv-color-gray-10); } + .icon_button_wrong { + color: var(--pv-color-wrong); + } } diff --git a/src/components/provider-info-dialog/ProviderInfoDialog.stories.tsx b/src/components/provider-info-dialog/ProviderInfoDialog.stories.tsx new file mode 100644 index 00000000..0a0c4764 --- /dev/null +++ b/src/components/provider-info-dialog/ProviderInfoDialog.stories.tsx @@ -0,0 +1,39 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { ProviderInfoDialog } from "./ProviderInfoDialog"; +import type { IProviderInfo } from "@peculiar/fortify-client-core"; + +const meta: Meta = { + title: "Components/ProviderInfoDialog", + component: ProviderInfoDialog, +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + data: { + name: "MacOS Crypto", + isHardware: true, + isRemovable: true, + token: { + label: "MacOS Crypto", + serialNumber: "1", + freePrivateMemory: 18446, + // freePrivateMemory: 18446744073709552000, + hardwareVersion: { + major: 0, + minor: 1, + version: 0, + }, + firmwareVersion: { + major: 0, + minor: 1, + version: 0, + }, + model: "MacOS Crypto", + }, + algorithms: ["SHA-1", "SHA-256", "SHA-384"], + } as IProviderInfo, + }, +}; diff --git a/src/components/provider-info-dialog/ProviderInfoDialog.test.tsx b/src/components/provider-info-dialog/ProviderInfoDialog.test.tsx new file mode 100644 index 00000000..cbb99d5b --- /dev/null +++ b/src/components/provider-info-dialog/ProviderInfoDialog.test.tsx @@ -0,0 +1,122 @@ +import { ComponentProps } from "react"; +import { render, userEvent, vi, screen } from "@testing"; +import type { IProviderInfo } from "@peculiar/fortify-client-core"; +import { ProviderInfoDialog } from "./ProviderInfoDialog"; + +describe("", () => { + const defaultProps: ComponentProps = { + data: { + name: "name-text", + isHardware: false, + isRemovable: false, + token: { + label: "label-text", + serialNumber: "1", + freePrivateMemory: 18446, + hardwareVersion: { + major: 1, + minor: 1, + version: 0, + }, + firmwareVersion: { + major: 1, + minor: 2, + version: 0, + }, + model: "model-text", + }, + algorithms: ["SHA-1", "SHA-256"], + } as IProviderInfo, + onDialogClose: vi.fn(), + }; + + it("Should render and handle click close", async () => { + render(); + + expect( + screen.getByText(`${defaultProps.data.name} information`) + ).toBeInTheDocument(); + + expect(screen.getByText(/Token name/).nextElementSibling).toHaveTextContent( + defaultProps.data.token!.label + ); + + expect( + screen.getByText(/Token category/).nextElementSibling + ).toHaveTextContent(/Software/); + + expect( + screen.getByText(/Extractable/).nextElementSibling + ).toHaveTextContent(/No/); + + expect( + screen.getByText(/Serial number/).nextElementSibling + ).toHaveTextContent(defaultProps.data.token!.serialNumber); + + expect(screen.getByText(/Free space/).nextElementSibling).toHaveTextContent( + defaultProps.data.token!.freePrivateMemory.toString() + ); + + expect( + screen.getByText(/Hardware version/).nextElementSibling + ).toHaveTextContent("1.1"); + + expect( + screen.getByText(/Firmware version/).nextElementSibling + ).toHaveTextContent("1.2"); + + expect(screen.getByText(/Model/).nextElementSibling).toHaveTextContent( + defaultProps.data.token!.model + ); + + expect(screen.getByText(/Algorithms/).nextElementSibling).toHaveTextContent( + "SHA-1, SHA-256" + ); + + await userEvent.click(screen.getByRole("button", { name: /Cancel/ })); + + expect(defaultProps.onDialogClose).toBeCalledTimes(1); + }); + + it("Should render as hardware", () => { + const props: ComponentProps = { + ...defaultProps, + data: { ...defaultProps.data, isHardware: true } as IProviderInfo, + }; + render(); + + expect( + screen.getByText(/Token category/).nextElementSibling + ).toHaveTextContent(/Hardware/); + }); + + it("Should render as extractable", () => { + const props: ComponentProps = { + ...defaultProps, + data: { ...defaultProps.data, isRemovable: true } as IProviderInfo, + }; + render(); + + expect( + screen.getByText(/Extractable/).nextElementSibling + ).toHaveTextContent(/Yes/); + }); + + it("Should render as free space is unavailable", () => { + const props: ComponentProps = { + ...defaultProps, + data: { + ...defaultProps.data, + token: { + ...defaultProps.data.token, + freePrivateMemory: 18446744073709552000, + }, + } as IProviderInfo, + }; + render(); + + expect(screen.getByText(/Free space/).nextElementSibling).toHaveTextContent( + /Information is unavailable/ + ); + }); +}); diff --git a/src/components/provider-info-dialog/ProviderInfoDialog.tsx b/src/components/provider-info-dialog/ProviderInfoDialog.tsx new file mode 100644 index 00000000..3c2ab879 --- /dev/null +++ b/src/components/provider-info-dialog/ProviderInfoDialog.tsx @@ -0,0 +1,107 @@ +import React from "react"; +import { + Button, + Dialog, + DialogActions, + DialogContent, + DialogTitle, + Typography, +} from "@peculiar/react-components"; +import { useTranslation } from "react-i18next"; +import type { IProviderInfo } from "@peculiar/fortify-client-core"; + +import styles from "./styles/index.module.scss"; + +interface ProviderInfoDialogProps { + data: IProviderInfo; + onDialogClose: () => void; +} + +export const ProviderInfoDialog: React.FunctionComponent< + ProviderInfoDialogProps +> = (props) => { + const { onDialogClose, data } = props; + const { t } = useTranslation(); + + const items = [ + { + label: t("providers.dialog.info.list.token-name"), + value: data.token?.label, + }, + { + label: t("providers.dialog.info.list.token-category.label"), + value: t( + `providers.dialog.info.list.token-category.value.${data.isHardware ? "hardware" : "software"}` + ), + }, + { + label: t("providers.dialog.info.list.extractable.label"), + value: t( + `providers.dialog.info.list.extractable.value.${data.isRemovable ? "yes" : "no"}` + ), + }, + { + label: t("providers.dialog.info.list.serial-number"), + value: data.token?.serialNumber, + }, + { + label: t("providers.dialog.info.list.free-space"), + value: + // Some providers return this value as "unknown number" + // We don't want to show the value in this case (requested by @microshine) + data.token?.freePrivateMemory == 18446744073709552000 + ? undefined + : data.token?.freePrivateMemory, + }, + { + label: t("providers.dialog.info.list.hardware-version"), + value: data.token?.hardwareVersion + ? `${data.token?.hardwareVersion?.major}.${data.token?.hardwareVersion?.minor}` + : undefined, + }, + { + label: t("providers.dialog.info.list.firmware-version"), + value: data.token?.firmwareVersion + ? `${data.token?.firmwareVersion?.major}.${data.token?.firmwareVersion?.minor}` + : undefined, + }, + { + label: t("providers.dialog.info.list.model"), + value: data.token?.model, + }, + { + label: t("providers.dialog.info.list.algorithms"), + value: data.algorithms?.join(", "), + }, + ]; + + const renderInfoItems = () => + items.map(({ label, value }) => ( + + + {label}: + + + {value || t("providers.dialog.info.empty-value")} + + + )); + + return ( + + <> + + {t("providers.dialog.info.title", { name: data.name })} + + +
{renderInfoItems()}
+
+ + + + +
+ ); +}; diff --git a/src/components/provider-info-dialog/index.ts b/src/components/provider-info-dialog/index.ts new file mode 100644 index 00000000..019f22d6 --- /dev/null +++ b/src/components/provider-info-dialog/index.ts @@ -0,0 +1 @@ +export * from "./ProviderInfoDialog"; diff --git a/src/components/provider-info-dialog/styles/index.module.scss b/src/components/provider-info-dialog/styles/index.module.scss new file mode 100644 index 00000000..b0a402c4 --- /dev/null +++ b/src/components/provider-info-dialog/styles/index.module.scss @@ -0,0 +1,22 @@ +.dialog { + max-width: 870px !important; + + .dialog_title { + padding-left: var(--pv-size-base-6); + padding-right: var(--pv-size-base-6); + } + + .dialog_content { + max-height: 525px; + .dialog_content_list { + padding: var(--pv-size-base) var(--pv-size-base-2) 0 var(--pv-size-base-2); + display: grid; + grid-template-columns: 35% auto; + gap: var(--pv-size-base-4) var(--pv-size-base-8); + } + } + + .dialog_footer { + padding: var(--pv-size-base-6); + } +} diff --git a/src/dialogs/certificate-delete-dialog/useCertificateDeleteDialog.test.tsx b/src/dialogs/certificate-delete-dialog/useCertificateDeleteDialog.test.tsx new file mode 100644 index 00000000..9d7c9b28 --- /dev/null +++ b/src/dialogs/certificate-delete-dialog/useCertificateDeleteDialog.test.tsx @@ -0,0 +1,67 @@ +import { renderHook, act } from "@testing"; +import { vi } from "vitest"; +import { useCertificateDeleteDialog } from "./useCertificateDeleteDialog"; + +import type { IProviderInfo } from "@peculiar/fortify-client-core"; +import type { FortifyAPI } from "@peculiar/fortify-client-core"; + +vi.mock("@peculiar/react-components", async () => { + const actual = await vi.importActual("@peculiar/react-components"); + return { + ...actual, + useToast: () => ({ + addToast: vi.fn(), + }), + }; +}); + +describe("useCertificateDeleteDialog", () => { + const providers = [ + { + id: "1", + name: "Provider 1", + }, + ] as IProviderInfo[]; + + it("Should initialize and call onSuccess", async () => { + const certificateIndex = "1"; + const providerId = providers[0].id; + + const mockFortifyClient: Partial = { + removeCertificateById: vi.fn().mockResolvedValue({}), + }; + const onSuccessMock = vi.fn(); + + const { result } = renderHook(() => + useCertificateDeleteDialog({ + providers: providers, + onSuccess: onSuccessMock, + fortifyClient: mockFortifyClient as FortifyAPI, + }) + ); + + expect(result.current.dialog).toBeInstanceOf(Function); + expect(result.current.open).toBeInstanceOf(Function); + + act(() => { + result.current.open({ + certificateIndex, + providerId, + label: "Message", + }); + }); + + await act(async () => { + const DialogComponent = result.current.dialog(); + + DialogComponent && + (await DialogComponent.props.onDeleteClick(certificateIndex)); + }); + + expect(onSuccessMock).toHaveBeenCalledWith(providerId); + expect(mockFortifyClient.removeCertificateById).toHaveBeenCalledWith( + providerId, + certificateIndex + ); + }); +}); diff --git a/src/dialogs/certificate-delete-dialog/useCertificateDeleteDialog.tsx b/src/dialogs/certificate-delete-dialog/useCertificateDeleteDialog.tsx index dbda982e..36f32fe9 100644 --- a/src/dialogs/certificate-delete-dialog/useCertificateDeleteDialog.tsx +++ b/src/dialogs/certificate-delete-dialog/useCertificateDeleteDialog.tsx @@ -1,4 +1,5 @@ import React from "react"; +import { IProviderInfo } from "@peculiar/fortify-client-core"; import { useToast } from "@peculiar/react-components"; import { useTranslation } from "react-i18next"; import { useLockBodyScroll } from "react-use"; @@ -12,6 +13,7 @@ type UseCertificateDeleteDialogOpenParams = { }; type UseCertificateDeleteDialogInitialParams = { + providers: IProviderInfo[]; fortifyClient: FortifyAPI | null; onSuccess: (providerId: string) => void; }; @@ -19,7 +21,7 @@ type UseCertificateDeleteDialogInitialParams = { export function useCertificateDeleteDialog( props: UseCertificateDeleteDialogInitialParams ) { - const { fortifyClient, onSuccess } = props; + const { providers, fortifyClient, onSuccess } = props; const { addToast } = useToast(); const { t } = useTranslation(); @@ -42,11 +44,13 @@ export function useCertificateDeleteDialog( return; } setIsLoading(true); + try { await fortifyClient.removeCertificateById( openParamsRef.current.providerId, openParamsRef.current.certificateIndex ); + onSuccess(openParamsRef.current.providerId); addToast({ message: t("certificates.dialog.delete.success-message"), @@ -69,6 +73,14 @@ export function useCertificateDeleteDialog( useLockBodyScroll(isOpen); + const currentProvider = providers.find( + ({ id }) => openParamsRef.current?.providerId === id + ); + + if (isOpen && !currentProvider) { + handleClose(); + } + return { open: handleOpen, dialog: () => diff --git a/src/dialogs/certificate-viewer-dialog/useCertificateViewerDialog.test.tsx b/src/dialogs/certificate-viewer-dialog/useCertificateViewerDialog.test.tsx new file mode 100644 index 00000000..1f6e0cfb --- /dev/null +++ b/src/dialogs/certificate-viewer-dialog/useCertificateViewerDialog.test.tsx @@ -0,0 +1,42 @@ +import { renderHook, act } from "@testing"; +import { useCertificateViewerDialog } from "./useCertificateViewerDialog"; +import { CertificateProps } from "../../types"; + +import type { IProviderInfo } from "@peculiar/fortify-client-core"; + +describe("useCertificateViewerDialog", () => { + const providers = [ + { + id: "1", + name: "Provider 1", + }, + ] as IProviderInfo[]; + + it("Should initialize", () => { + const { result } = renderHook(() => + useCertificateViewerDialog({ + providers, + }) + ); + + expect(result.current.dialog).toBeInstanceOf(Function); + expect(result.current.open).toBeInstanceOf(Function); + + const certificate = { + id: "1", + label: "Certificate name", + } as CertificateProps; + + act(() => { + result.current.open({ + certificate, + providerId: providers[0].id, + }); + }); + + const DialogComponent = result.current.dialog(); + + expect(DialogComponent).not.toBeNull(); + expect(DialogComponent?.props.certificate).toBe(certificate); + }); +}); diff --git a/src/dialogs/certificate-viewer-dialog/useCertificateViewerDialog.tsx b/src/dialogs/certificate-viewer-dialog/useCertificateViewerDialog.tsx index 98cfc50b..71787009 100644 --- a/src/dialogs/certificate-viewer-dialog/useCertificateViewerDialog.tsx +++ b/src/dialogs/certificate-viewer-dialog/useCertificateViewerDialog.tsx @@ -1,30 +1,51 @@ import React from "react"; +import { IProviderInfo } from "@peculiar/fortify-client-core"; import { useLockBodyScroll } from "react-use"; import { CertificateViewerDialog } from "../../components/certificate-viewer-dialog"; import { CertificateProps } from "../../types"; -export function useCertificateViewerDialog() { +type UseCertificateViewerDialogOpenParams = { + providerId: string; + certificate: CertificateProps; +}; + +type UseCertificateViewerInitialParams = { + providers: IProviderInfo[]; +}; + +export function useCertificateViewerDialog( + props: UseCertificateViewerInitialParams +) { + const { providers } = props; const [isOpen, setIsOpen] = React.useState(false); - const certificateRef = React.useRef(); + const openParamsRef = React.useRef(); - const handleOpen = (certificaate: CertificateProps) => { - certificateRef.current = certificaate; + const handleOpen = (params: UseCertificateViewerDialogOpenParams) => { + openParamsRef.current = params; setIsOpen(true); }; const handleClose = () => { - certificateRef.current = undefined; + openParamsRef.current = undefined; setIsOpen(false); }; useLockBodyScroll(isOpen); + const currentProvider = providers.find( + ({ id }) => openParamsRef.current?.providerId === id + ); + + if (isOpen && !currentProvider) { + handleClose(); + } + return { open: handleOpen, dialog: () => - isOpen && certificateRef.current ? ( + isOpen && openParamsRef.current ? ( ) : null, diff --git a/src/dialogs/provider-info-dialog/index.ts b/src/dialogs/provider-info-dialog/index.ts new file mode 100644 index 00000000..f695a3d8 --- /dev/null +++ b/src/dialogs/provider-info-dialog/index.ts @@ -0,0 +1 @@ +export * from "./useProviderInfoDialog"; diff --git a/src/dialogs/provider-info-dialog/useProviderInfoDialog.test.tsx b/src/dialogs/provider-info-dialog/useProviderInfoDialog.test.tsx new file mode 100644 index 00000000..91c39d33 --- /dev/null +++ b/src/dialogs/provider-info-dialog/useProviderInfoDialog.test.tsx @@ -0,0 +1,33 @@ +import { renderHook, act } from "@testing"; +import { useProviderInfoDialog } from "./useProviderInfoDialog"; + +import type { IProviderInfo } from "@peculiar/fortify-client-core"; + +describe("useProviderInfoDialog", () => { + const providers = [ + { + id: "1", + name: "Provider 1", + }, + ] as IProviderInfo[]; + + it("Should initialize", async () => { + const { result } = renderHook(() => + useProviderInfoDialog({ + providers, + }) + ); + + expect(result.current.dialog).toBeInstanceOf(Function); + expect(result.current.open).toBeInstanceOf(Function); + + act(() => { + result.current.open(providers[0]); + }); + + const DialogComponent = result.current.dialog(); + + expect(DialogComponent).not.toBeNull(); + expect(DialogComponent?.props.data).toBe(providers[0]); + }); +}); diff --git a/src/dialogs/provider-info-dialog/useProviderInfoDialog.tsx b/src/dialogs/provider-info-dialog/useProviderInfoDialog.tsx new file mode 100644 index 00000000..c4a58f6f --- /dev/null +++ b/src/dialogs/provider-info-dialog/useProviderInfoDialog.tsx @@ -0,0 +1,47 @@ +import React from "react"; +import { useLockBodyScroll } from "react-use"; +import type { IProviderInfo } from "@peculiar/fortify-client-core"; +import { ProviderInfoDialog } from "../../components/provider-info-dialog"; + +type UseCertificateViewerInitialParams = { + providers: IProviderInfo[]; +}; + +export function useProviderInfoDialog( + props: UseCertificateViewerInitialParams +) { + const { providers } = props; + const [isOpen, setIsOpen] = React.useState(false); + const providerRef = React.useRef(); + + const handleOpen = (provider: IProviderInfo) => { + providerRef.current = provider; + setIsOpen(true); + }; + + const handleClose = () => { + providerRef.current = undefined; + setIsOpen(false); + }; + + useLockBodyScroll(isOpen); + + const currentProvider = providers.find( + ({ id }) => providerRef.current?.id === id + ); + + if (isOpen && !currentProvider) { + handleClose(); + } + + return { + open: handleOpen, + dialog: () => + isOpen && providerRef.current ? ( + + ) : null, + }; +} diff --git a/src/hooks/app/useApp.tsx b/src/hooks/app/useApp.tsx index 4abbdad8..f9337600 100644 --- a/src/hooks/app/useApp.tsx +++ b/src/hooks/app/useApp.tsx @@ -4,6 +4,8 @@ import { IProviderInfo, ICertificate, } from "@peculiar/fortify-client-core"; +import { useTranslation } from "react-i18next"; +import { useToast } from "@peculiar/react-components"; import { AppFetchingStatus, AppFetchingType } from "./types"; @@ -14,9 +16,11 @@ export function useApp() { const fortifyClient = React.useRef(null); const [providers, setProviders] = React.useState([]); - const [currentProviderId, setCurrentProviderId] = React.useState< - string | undefined + const [currentProvider, setCurrentProvider] = React.useState< + IProviderInfo | undefined >(undefined); + const [isCurrentProviderLogedin, setIsCurrentProviderLogedin] = + React.useState(false); const [certificates, setCertificates] = React.useState([]); const [challenge, setChallenge] = React.useState(null); const [fetching, setFetching] = React.useState({ @@ -27,6 +31,9 @@ export function useApp() { * */ + const { addToast } = useToast(); + const { t } = useTranslation(); + const setFetchingValue = ( name: keyof AppFetchingType, value: AppFetchingStatus @@ -75,35 +82,41 @@ export function useApp() { return; } - let providersLocal: IProviderInfo[] = []; - setFetchingValue("providers", "pending"); - setProviders([]); - setCertificates([]); try { - providersLocal = await fortifyClient.current.getProviders(); - + const providersLocal = await fortifyClient.current.getProviders(); setProviders(providersLocal); setFetchingValue("providers", "resolved"); - } catch (error) { - setFetchingValue("providers", "rejected"); - return; - } + if (!providersLocal.length) { + setCurrentProvider(undefined); + setIsCurrentProviderLogedin(false); + setCertificates([]); + return; + } - setFetchingValue("certificates", "pending"); + const defaultProvider = providersLocal[0]; - try { - setCertificates( - await fortifyClient.current.getCertificatesByProviderId( - providersLocal[0].id - ) + if (!currentProvider) { + setCurrentProvider(defaultProvider); + handleProviderChange(defaultProvider.id); + return; + } + const curProvider = providersLocal.find( + ({ id }) => currentProvider.id === id ); - setCurrentProviderId(providersLocal[0].id); - setFetchingValue("certificates", "resolved"); + if (!curProvider) { + setCurrentProvider(defaultProvider); + handleProviderChange(defaultProvider.id); + } } catch (error) { - setFetchingValue("certificates", "rejected"); + setProviders([]); + setFetchingValue("providers", "rejected"); + + setCurrentProvider(undefined); + setIsCurrentProviderLogedin(false); + setCertificates([]); } }; @@ -153,20 +166,34 @@ export function useApp() { }; const handleProviderChange = async (id: string) => { - if (currentProviderId === id || fetching.certificates === "pending") { + if (!fortifyClient.current) { + return; + } + if (currentProvider?.id === id || fetching.certificates === "pending") { return; } setFetchingValue("certificates", "pending"); + try { + const localProvider = await fortifyClient.current.getProviderById(id); + const isLoggedIn = await localProvider.isLoggedIn(); + setIsCurrentProviderLogedin(isLoggedIn); + } catch (error) { + setIsCurrentProviderLogedin(false); + } + try { setCertificates( - await fortifyClient.current!.getCertificatesByProviderId(id) + await fortifyClient.current.getCertificatesByProviderId(id) ); - setCurrentProviderId(id); + if (providers?.length) { + setCurrentProvider(providers.find((provider) => provider.id === id)); + } setFetchingValue("certificates", "resolved"); } catch (error) { setFetchingValue("certificates", "rejected"); + setCertificates([]); } }; @@ -195,10 +222,10 @@ export function useApp() { setCertificates( await fortifyClient.current.getCertificatesByProviderId(providerId) ); - setCurrentProviderId(providerId); setFetchingValue("certificates", "resolved"); } catch (error) { setFetchingValue("certificates", "rejected"); + setCertificates([]); } }; @@ -206,15 +233,71 @@ export function useApp() { window.location.reload(); }; + const handleProviderResetAndRefreshList = async () => { + if (!fortifyClient.current || !currentProvider) { + return; + } + + try { + const localProvider = await fortifyClient.current.getProviderById( + currentProvider.id + ); + await localProvider.reset(); + } catch (error) { + // + } + handleCertificatesDataReload(currentProvider.id); + }; + + const handleProviderLoginLogout = async (isLogedin: boolean) => { + if (!fortifyClient.current || !currentProvider) { + return; + } + + try { + const localProvider = await fortifyClient.current.getProviderById( + currentProvider.id + ); + if (isLogedin) { + await localProvider.logout(); + const isLoggedIn = await localProvider.isLoggedIn(); + if (!isLoggedIn) { + setIsCurrentProviderLogedin(false); + handleCertificatesDataReload(currentProvider.id); + } else { + addToast({ + message: t("topbar.provider-doesnt-support-signing-in"), + variant: "attention", + disableIcon: true, + isClosable: true, + id: "provider-doesnt-support-signing-in", + }); + } + } else { + await localProvider.login(); + const isLoggedIn = await localProvider.isLoggedIn(); + if (isLoggedIn) { + setIsCurrentProviderLogedin(true); + handleCertificatesDataReload(currentProvider.id); + } + } + } catch (error) { + setIsCurrentProviderLogedin(false); + } + }; + return { fortifyClient: fortifyClient.current, fetching, challenge, providers, - currentProviderId, + currentProvider, certificates, + isCurrentProviderLogedin, handleCertificatesDataReload, handleProviderChange, handleRetryConection, + handleProviderLoginLogout, + handleProviderResetAndRefreshList, }; } diff --git a/src/i18n/locales/en/main.json b/src/i18n/locales/en/main.json index 6461674c..d54c9ed8 100644 --- a/src/i18n/locales/en/main.json +++ b/src/i18n/locales/en/main.json @@ -6,6 +6,35 @@ "read-only": "Read only" }, "empty-text": "No providers connected" + }, + "dialog": { + "info": { + "title": "{{name}} information", + "list": { + "token-name": "Token name:", + "token-category": { + "label": "Token category", + "value": { + "hardware": "Hardware", + "software": "Software" + } + }, + "serial-number": "Serial number", + "free-space": "Free space (minimum estimated)", + "hardware-version": "Hardware version", + "firmware-version": "Firmware version", + "model": "Model", + "algorithms": "Algorithms", + "extractable": { + "label": "Extractable", + "value": { + "yes": "Yes", + "no": "No" + } + } + }, + "empty-value": "Information is unavailable" + } } }, "certificates": { @@ -17,12 +46,14 @@ "expires": "Expires" }, "cell": { - "certificate": "Certificate" + "certificate": "Certificate", + "with-privat-key": "(with private key)" }, "action": { "view-details": "View details", "delete": "Delete certificate", - "download": "Download certificate" + "download": "Download certificate", + "copy": "Copy certificate" }, "empty-text": "There are no certificates yet.", "empty-search-text": "There are no results for <0>{{text}}" @@ -169,9 +200,14 @@ "search-placeholder": "Search", "import-certificate": "Import certificate", "create-certificate": "New", + "create-certificate-disabled-tooltip": "Please sign in to the provider to add new certificates.", "create-certificate-scr": "Create certificate signing request (CSR)", "create-certificate-ssc": "Create self-signed certificate", - "reload-certificates": "Refresh list" + "reload-certificates": "Reset session and refresh certificate list", + "provider-information": "Provider information", + "provider-login": "Sign in", + "provider-logout": "Sign out", + "provider-doesnt-support-signing-in": "This provider doesn’t support signing in." }, "certificate-viewer-dialog": { "title": "“{{name}}” details" diff --git a/src/icons/certificate-30.svg b/src/icons/certificate-30.svg index a7255d79..4358a03c 100644 --- a/src/icons/certificate-30.svg +++ b/src/icons/certificate-30.svg @@ -1,6 +1,6 @@ - - - - - + + + + + diff --git a/src/icons/certificate-with-key-30.svg b/src/icons/certificate-with-key-30.svg new file mode 100644 index 00000000..d5c6c0c1 --- /dev/null +++ b/src/icons/certificate-with-key-30.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/src/icons/info-20.svg b/src/icons/info-20.svg new file mode 100644 index 00000000..0b9a3e08 --- /dev/null +++ b/src/icons/info-20.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/src/icons/login-20.svg b/src/icons/login-20.svg new file mode 100644 index 00000000..c3d2cbc0 --- /dev/null +++ b/src/icons/login-20.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/src/icons/logout-20.svg b/src/icons/logout-20.svg new file mode 100644 index 00000000..a85b377e --- /dev/null +++ b/src/icons/logout-20.svg @@ -0,0 +1,5 @@ + + + + +