diff --git a/src/app.tsx b/src/app.tsx index a6fb9151..fa8fc849 100644 --- a/src/app.tsx +++ b/src/app.tsx @@ -88,6 +88,8 @@ export function App() { dialog: certificateViewerDialog, } = useCertificateViewerDialog({ providers, + currentProviderId, + fortifyClient, }); const { open: handleProviderInfoDialogOpen, dialog: providerInfoDialog } = diff --git a/src/components/certificate-viewer-dialog/CertificateViewerDialog.test.tsx b/src/components/certificate-viewer-dialog/CertificateViewerDialog.test.tsx index 7bae24cc..5e310945 100644 --- a/src/components/certificate-viewer-dialog/CertificateViewerDialog.test.tsx +++ b/src/components/certificate-viewer-dialog/CertificateViewerDialog.test.tsx @@ -8,43 +8,76 @@ vi.mock("@peculiar/certificates-viewer-react", () => ({ })); describe("", () => { - const certificate = { - raw: new ArrayBuffer(0), - subjectName: "Certificate name", - type: "x509", - label: "Certificate name", - subject: { - commonName: "Certificate name", + const certificates = [ + { + raw: new ArrayBuffer(0), + subjectName: "Certificate name 1", + type: "x509", + label: "Certificate name 1", + subject: { + commonName: "Certificate name 1", + }, }, - } as unknown as CertificateProps; + { + raw: new ArrayBuffer(0), + subjectName: "Certificate name 2", + type: "x509", + label: "Certificate name 2", + subject: { + commonName: "Certificate name 2", + }, + }, + ] 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/)); + expect( + screen.getByText(`“${certificates[0].label}” details`) + ).toBeInTheDocument(); + expect( + screen.getByText(/x509 certificate viewer component/) + ).toBeInTheDocument(); - await userEvent.click(screen.getByRole("button", { name: /Cancel/ })); + await userEvent.click(screen.getByRole("button", { name: /Close/ })); expect(onCloseMock).toBeCalledTimes(1); }); + it("Should render & switches tabs", async () => { + render( + + ); + + expect( + screen.getByText(`${certificates[0].label}`).closest("button") + ).toHaveAttribute("aria-selected", "true"); + + await userEvent.click(screen.getByText(`${certificates[1].label}`)); + + expect( + screen.getByText(`${certificates[1].label}`).closest("button") + ).toHaveAttribute("aria-selected", "true"); + }); + it("Should render as CSR", () => { render( ); - expect(screen.getByText(/CSR certificate viewer component/)); + expect( + screen.getByText(/CSR certificate viewer component/) + ).toBeInTheDocument(); }); }); diff --git a/src/components/certificate-viewer-dialog/CertificateViewerDialog.tsx b/src/components/certificate-viewer-dialog/CertificateViewerDialog.tsx index 2746c0f8..0beed6a0 100644 --- a/src/components/certificate-viewer-dialog/CertificateViewerDialog.tsx +++ b/src/components/certificate-viewer-dialog/CertificateViewerDialog.tsx @@ -1,4 +1,4 @@ -import React from "react"; +import React, { useState } from "react"; import { Convert } from "pvtsutils"; import { Button, @@ -6,46 +6,86 @@ import { DialogActions, DialogContent, DialogTitle, + Tab, + Tabs, } from "@peculiar/react-components"; import { PeculiarCertificateViewer, PeculiarCsrViewer, } from "@peculiar/certificates-viewer-react"; import { useTranslation } from "react-i18next"; +import clsx from "clsx"; import { getCertificateName } from "../../utils/certificate"; -import { CertificateProps, CertificateType } from "../../types"; +import { CertificateProps } from "../../types"; import styles from "./styles/index.module.scss"; interface CertificateViewerDialogProps { - certificate: CertificateProps; + certificates: CertificateProps[]; onClose?: () => void; } export const CertificateViewerDialog: React.FunctionComponent< CertificateViewerDialogProps > = (props) => { - const { certificate, onClose } = props; + const { certificates, onClose } = props; const { t } = useTranslation(); + const [tabIndex, setTabIndex] = useState("0"); - const certBase64 = Convert.ToBase64(certificate?.raw); + const renderTabs = () => + certificates.length > 1 ? ( +
+ setTabIndex(value)} + > + {certificates.map((cert, index) => { + const name = getCertificateName(cert); + return ( + + {name} + + ); + })} + +
+ ) : null; + + const renderContent = () => + certificates[0]?.type === "x509" ? ( + certificates.map((cert, index) => ( + + )) + ) : ( + + ); return ( - - {t("certificate-viewer-dialog.title", { - name: getCertificateName(certificate), - })} + +
+ {t("certificate-viewer-dialog.title", { + name: getCertificateName(certificates[0]), + })} +
+ {renderTabs()}
- {(certificate?.type as CertificateType) === "csr" ? ( - - ) : ( - - )} + {renderContent()}
diff --git a/src/components/certificate-viewer-dialog/styles/index.module.scss b/src/components/certificate-viewer-dialog/styles/index.module.scss index 35a6c8e9..5af9d52f 100644 --- a/src/components/certificate-viewer-dialog/styles/index.module.scss +++ b/src/components/certificate-viewer-dialog/styles/index.module.scss @@ -4,3 +4,29 @@ padding: 0; } } + +.dialog_title_text { + padding: var(--pv-size-base-3) var(--pv-size-base-4); +} + +.dialog_title_tabs_wraper { + border-top: 1px solid var(--pv-color-gray-4); + max-width: 870px; + overflow: auto; + .dialog_title_tabs { + padding: 0 var(--pv-size-base-4); + } +} + +.dialog_title { + padding: 0 !important; + display: flex; + flex-direction: column; +} + +.dialog_tab_content { + display: none; +} +.dialog_tab_content_current { + display: block; +} diff --git a/src/dialogs/certificate-viewer-dialog/useCertificateViewerDialog.test.tsx b/src/dialogs/certificate-viewer-dialog/useCertificateViewerDialog.test.tsx index 3b7c0a25..570e7cb2 100644 --- a/src/dialogs/certificate-viewer-dialog/useCertificateViewerDialog.test.tsx +++ b/src/dialogs/certificate-viewer-dialog/useCertificateViewerDialog.test.tsx @@ -1,8 +1,23 @@ import { renderHook, act } from "@testing"; +import { vi } from "vitest"; import { useCertificateViewerDialog } from "./useCertificateViewerDialog"; import { CertificateProps } from "../../types"; -import type { IProviderInfo } from "@peculiar/fortify-client-core"; +import type { IProviderInfo, FortifyAPI } from "@peculiar/fortify-client-core"; + +vi.mock("@peculiar/x509", async (importOriginal) => { + const originalModule = + await importOriginal(); + + return { + ...originalModule, + X509Certificate: vi.fn().mockImplementation(() => ({ + rawData: new ArrayBuffer(0), + subject: "Test Subject", + subjectName: "Rest Subject Name", + })), + }; +}); describe("useCertificateViewerDialog", () => { const providers = [ @@ -15,8 +30,15 @@ describe("useCertificateViewerDialog", () => { const certificate = { id: "1", label: "Certificate name", + type: "x509", } as CertificateProps; + const defaultProps = { + providers, + fortifyClient: null, + currentProviderId: providers[0].id, + }; + const defaultOpenProps = { certificate, providerId: providers[0].id, @@ -24,9 +46,7 @@ describe("useCertificateViewerDialog", () => { it("Should initialize, open & close dialog", () => { const { result } = renderHook(() => - useCertificateViewerDialog({ - providers, - }) + useCertificateViewerDialog(defaultProps) ); expect(result.current.dialog).toBeInstanceOf(Function); @@ -39,34 +59,56 @@ describe("useCertificateViewerDialog", () => { const DialogComponent = result.current.dialog(); expect(DialogComponent).not.toBeNull(); - expect(DialogComponent?.props.certificate).toBe(certificate); - - act(() => { - DialogComponent?.props.onClose(); - }); + expect(DialogComponent?.props.certificates).toStrictEqual([certificate]); + DialogComponent?.props.onClose(); expect(result.current.dialog()).toBeNull(); }); - it("Should close dialog if current provider is not found", async () => { - const { result, rerender } = renderHook( - (localProviders) => - useCertificateViewerDialog({ - providers: localProviders, - }), - { initialProps: providers } + it("Should close the dialog when the current provider is not found", () => { + const { result } = renderHook(() => + useCertificateViewerDialog(defaultProps) ); act(() => { - result.current.open(defaultOpenProps); + result.current.open({ + ...defaultOpenProps, + providerId: "2", + }); }); - rerender([ - { - id: "2", - }, - ] as IProviderInfo[]); + const DialogComponent = result.current.dialog(); + expect(DialogComponent).toBeNull(); + }); - expect(result.current.dialog()).toBeNull(); + it("Should make a chain of certificates", async () => { + const mockFortifyClient: Partial = { + getProviderById: vi.fn().mockResolvedValue({ + certStorage: { + getChain: vi + .fn() + .mockResolvedValue([ + { value: new ArrayBuffer(0) }, + { value: new ArrayBuffer(0) }, + ]), + }, + }), + }; + + const { result } = renderHook(() => + useCertificateViewerDialog({ + ...defaultProps, + fortifyClient: mockFortifyClient as FortifyAPI, + }) + ); + + await act(async () => { + await result.current.open(defaultOpenProps); + }); + + const DialogComponent = result.current.dialog(); + expect(mockFortifyClient.getProviderById).toHaveBeenCalled(); + expect(DialogComponent).not.toBeNull(); + expect(DialogComponent?.props.certificates.length).toBe(2); }); }); diff --git a/src/dialogs/certificate-viewer-dialog/useCertificateViewerDialog.tsx b/src/dialogs/certificate-viewer-dialog/useCertificateViewerDialog.tsx index 71787009..17d65730 100644 --- a/src/dialogs/certificate-viewer-dialog/useCertificateViewerDialog.tsx +++ b/src/dialogs/certificate-viewer-dialog/useCertificateViewerDialog.tsx @@ -1,6 +1,9 @@ import React from "react"; -import { IProviderInfo } from "@peculiar/fortify-client-core"; +import { IProviderInfo, FortifyAPI } from "@peculiar/fortify-client-core"; import { useLockBodyScroll } from "react-use"; +import cloneDeep from "lodash/cloneDeep"; +import { X509Certificate } from "@peculiar/x509"; +import { getCertificateSubject } from "../../utils/certificate"; import { CertificateViewerDialog } from "../../components/certificate-viewer-dialog"; import { CertificateProps } from "../../types"; @@ -11,21 +14,65 @@ type UseCertificateViewerDialogOpenParams = { type UseCertificateViewerInitialParams = { providers: IProviderInfo[]; + currentProviderId?: string; + fortifyClient: FortifyAPI | null; }; export function useCertificateViewerDialog( props: UseCertificateViewerInitialParams ) { - const { providers } = props; + const { providers, currentProviderId, fortifyClient } = props; const [isOpen, setIsOpen] = React.useState(false); const openParamsRef = React.useRef(); + const certificatesRef = React.useRef(); - const handleOpen = (params: UseCertificateViewerDialogOpenParams) => { + const handleOpen = async (params: UseCertificateViewerDialogOpenParams) => { + let currentCertificates: CertificateProps[] = [params.certificate]; + + try { + if ( + fortifyClient && + currentProviderId && + params.certificate.type === "x509" + ) { + const localProvider = + await fortifyClient.getProviderById(currentProviderId); + + const certificateCopy = cloneDeep(params.certificate); + certificateCopy.raw = null as unknown as ArrayBuffer; + + const chain = await localProvider.certStorage.getChain(certificateCopy); + + if (chain.length > 1) { + currentCertificates = chain.map((chainItem) => { + const cert = new X509Certificate(chainItem.value); + + return { + raw: cert.rawData, + subjectName: cert.subject, + subject: getCertificateSubject(cert.subject), + type: "x509", + index: cert.serialNumber, + issuerName: cert.issuer, + notBefore: cert.notBefore, + notAfter: cert.notAfter, + serialNumber: cert.serialNumber, + publicKey: cert.publicKey as unknown as CryptoKey, + providerID: localProvider.id, + }; + }); + } + } + } catch (error) { + // + } + certificatesRef.current = currentCertificates; openParamsRef.current = params; setIsOpen(true); }; const handleClose = () => { + certificatesRef.current = undefined; openParamsRef.current = undefined; setIsOpen(false); }; @@ -43,9 +90,9 @@ export function useCertificateViewerDialog( return { open: handleOpen, dialog: () => - isOpen && openParamsRef.current ? ( + isOpen && openParamsRef.current && certificatesRef?.current ? ( ) : null, diff --git a/src/i18n/locales/en/main.json b/src/i18n/locales/en/main.json index a25d68d9..2917a373 100644 --- a/src/i18n/locales/en/main.json +++ b/src/i18n/locales/en/main.json @@ -216,7 +216,8 @@ "cancel": "Cancel", "clear": "Clear", "import-certificate": "Import certificate", - "try-again": "Try again" + "try-again": "Try again", + "close": "Close" }, "app-error": { "message": "Oops! Something went wrong.", diff --git a/src/utils/certificate.ts b/src/utils/certificate.ts index b88e0093..40ad5570 100644 --- a/src/utils/certificate.ts +++ b/src/utils/certificate.ts @@ -1,4 +1,8 @@ -import { Pkcs10CertificateRequest, X509Certificate } from "@peculiar/x509"; +import { + Pkcs10CertificateRequest, + X509Certificate, + Name, +} from "@peculiar/x509"; import { CertificateProps, @@ -38,6 +42,22 @@ export function certificateSubjectToString( return parts.join(", "); } +export function getCertificateSubject(subjectString: string) { + const jsonName = new Name(subjectString).toJSON(); + + return jsonName.reduce>((result, currentValue) => { + Object.keys(currentValue).forEach((keyName) => { + if (!result[keyName]) { + result[keyName] = currentValue[keyName]; + } else { + result[keyName].push(...currentValue[keyName]); + } + }); + + return result; + }, {}); +} + export function getCertificateName(certificate: CertificateProps) { if (!certificate.subject) { return certificate.subjectName;