From f49a45f26d96e50d472726d1ad5a4599c7220761 Mon Sep 17 00:00:00 2001 From: Gabriel Hernandez Date: Wed, 26 Feb 2025 15:32:53 +0000 Subject: [PATCH] Feat UI finish cert api integration (#26598) For #25464 UI work to integrate the host certificates UI with the finished API endpoints - [x] Manual QA must be performed in the three main OSs, macOS, Windows and Linux. --- frontend/__mocks__/certificatesMock.ts | 2 +- frontend/interfaces/certificates.ts | 2 +- .../DeviceUserPage/DeviceUserPage.tests.tsx | 63 +++- .../details/DeviceUserPage/DeviceUserPage.tsx | 59 ++-- .../HostDetailsPage/HostDetailsPage.tsx | 36 ++- .../CertificatesTable/CertificatesTable.tsx | 3 + .../CertificateDetailsModal.tsx | 286 +++++++++++------- frontend/services/entities/device_user.ts | 16 +- frontend/services/entities/hosts.ts | 22 +- frontend/test/handlers/device-handler.ts | 15 + 10 files changed, 340 insertions(+), 164 deletions(-) diff --git a/frontend/__mocks__/certificatesMock.ts b/frontend/__mocks__/certificatesMock.ts index 49e0678030..9e6fa0f722 100644 --- a/frontend/__mocks__/certificatesMock.ts +++ b/frontend/__mocks__/certificatesMock.ts @@ -10,7 +10,7 @@ const DEFAULT_HOST_CERTIFICATE_MOCK: IHostCertificate = { key_algorithm: "rsaEncryption", key_strength: 2048, key_usage: "CRL Sign, Key Cert Sign", - serial: 1, + serial: "123", signing_algorithm: "sha256WithRSAEncryption", subject: { country: "US", diff --git a/frontend/interfaces/certificates.ts b/frontend/interfaces/certificates.ts index c2ec9e7b55..55e76fbe21 100644 --- a/frontend/interfaces/certificates.ts +++ b/frontend/interfaces/certificates.ts @@ -7,7 +7,7 @@ export interface IHostCertificate { key_algorithm: string; key_strength: number; key_usage: string; - serial: number; + serial: string; signing_algorithm: string; subject: { country: string; diff --git a/frontend/pages/hosts/details/DeviceUserPage/DeviceUserPage.tests.tsx b/frontend/pages/hosts/details/DeviceUserPage/DeviceUserPage.tests.tsx index 29d42e4c15..fd8be10b38 100644 --- a/frontend/pages/hosts/details/DeviceUserPage/DeviceUserPage.tests.tsx +++ b/frontend/pages/hosts/details/DeviceUserPage/DeviceUserPage.tests.tsx @@ -5,7 +5,11 @@ import { IDeviceUserResponse, IHostDevice } from "interfaces/host"; import createMockHost from "__mocks__/hostMock"; import mockServer from "test/mock-server"; import { createCustomRenderer } from "test/test-utils"; -import { customDeviceHandler } from "test/handlers/device-handler"; +import { + customDeviceHandler, + defaultDeviceCertificatesHandler, + defaultDeviceHandler, +} from "test/handlers/device-handler"; import DeviceUserPage from "./DeviceUserPage"; const mockRouter = { @@ -34,12 +38,14 @@ const mockLocation = { describe("Device User Page", () => { it("hides the software tab if the device has no software", async () => { + mockServer.use(defaultDeviceHandler); + mockServer.use(defaultDeviceCertificatesHandler); + const render = createCustomRenderer({ withBackendMock: true, }); - // TODO: fix return type from render - const { user } = render( + render( { await screen.findByText("About"); expect(screen.queryByText(/Software/)).not.toBeInTheDocument(); + }); - // TODO: Fix this to the new copy - // expect(screen.getByText("No software detected")).toBeInTheDocument(); + it("hides the certificates card if the device has no certificates", async () => { + mockServer.use(defaultDeviceHandler); + mockServer.use(defaultDeviceCertificatesHandler); + + const render = createCustomRenderer({ + withBackendMock: true, + }); + + render( + + ); + + // waiting for the device data to render + await screen.findByText("About"); + + expect(screen.queryByText(/Certificates/)).not.toBeInTheDocument(); + }); + + it("hides the certificates card if the device is not an apple device (mac, iphone, ipad)", async () => { + const host = createMockHost() as IHostDevice; + host.mdm.enrollment_status = "On (manual)"; + host.platform = "windows"; + host.dep_assigned_to_fleet = false; + + mockServer.use(customDeviceHandler({ host })); + mockServer.use(defaultDeviceCertificatesHandler); + + const render = createCustomRenderer({ + withBackendMock: true, + }); + + render( + + ); + + // waiting for the device data to render + await screen.findByText("About"); + + expect(screen.queryByText(/Certificates/)).not.toBeInTheDocument(); }); describe("MDM enrollment", () => { const setupTest = async (overrides: Partial) => { mockServer.use(customDeviceHandler(overrides)); + mockServer.use(defaultDeviceCertificatesHandler); const render = createCustomRenderer({ withBackendMock: true, diff --git a/frontend/pages/hosts/details/DeviceUserPage/DeviceUserPage.tsx b/frontend/pages/hosts/details/DeviceUserPage/DeviceUserPage.tsx index eeb74c0728..d90ceebd29 100644 --- a/frontend/pages/hosts/details/DeviceUserPage/DeviceUserPage.tsx +++ b/frontend/pages/hosts/details/DeviceUserPage/DeviceUserPage.tsx @@ -18,6 +18,7 @@ import { IHostPolicy } from "interfaces/policy"; import { IDeviceGlobalConfig } from "interfaces/config"; import { IHostSoftware } from "interfaces/software"; import { IHostCertificate } from "interfaces/certificates"; +import { isAppleDevice } from "interfaces/platform"; import DeviceUserError from "components/DeviceUserError"; // @ts-ignore @@ -74,6 +75,9 @@ const FREE_TAB_PATHS = [ PATHS.DEVICE_USER_DETAILS_SOFTWARE, ] as const; +const DEFAULT_CERTIFICATES_PAGE_SIZE = 500; +const DEFAULT_CERTIFICATES_PAGE = 0; + interface IDeviceUserPageProps { location: { pathname: string; @@ -153,19 +157,6 @@ const DeviceUserPage = ({ } ); - const { - data: deviceCertificates, - isLoading: isLoadingDeviceCertificates, - isError: isErrorDeviceCertificates, - } = useQuery( - ["hostCertificates", deviceAuthToken], - () => deviceUserAPI.getDeviceCertificates(deviceAuthToken), - { - ...DEFAULT_USE_QUERY_OPTIONS, - enabled: !!deviceUserAPI, - } - ); - const refetchExtensions = () => { deviceMapping !== null && refetchDeviceMapping(); }; @@ -262,6 +253,25 @@ const DeviceUserPage = ({ self_service: hasSelfService = false, } = dupResponse || {}; const isPremiumTier = license?.tier === "premium"; + const isAppleHost = host && isAppleDevice(host.platform); + + const { + data: deviceCertificates, + isLoading: isLoadingDeviceCertificates, + isError: isErrorDeviceCertificates, + } = useQuery( + ["hostCertificates", deviceAuthToken], + () => + deviceUserAPI.getDeviceCertificates( + deviceAuthToken, + DEFAULT_CERTIFICATES_PAGE, + DEFAULT_CERTIFICATES_PAGE_SIZE + ), + { + ...DEFAULT_USE_QUERY_OPTIONS, + enabled: !!deviceUserAPI && isAppleHost, + } + ); const summaryData = normalizeEmptyValues(pick(host, HOST_SUMMARY_DATA)); @@ -377,13 +387,9 @@ const DeviceUserPage = ({ const isSoftwareEnabled = !!globalConfig?.features ?.enable_software_inventory; - const isDarwinHost = host?.platform === "darwin"; - const isIosOrIpadosHost = - host?.platform === "ios" || host?.platform === "ipados"; - return (
- {!host || isLoadingHost ? ( + {!host || isLoadingHost || isLoadingDeviceCertificates ? ( ) : (
@@ -447,15 +453,14 @@ const DeviceUserPage = ({ deviceMapping={deviceMapping} munki={deviceMacAdminsData?.munki} /> - {(isIosOrIpadosHost || isDarwinHost) && - deviceCertificates?.certificates.length && ( - - )} + {isAppleHost && deviceCertificates?.certificates.length && ( + + )} {isPremiumTier && isSoftwareEnabled && hasSelfService && ( diff --git a/frontend/pages/hosts/details/HostDetailsPage/HostDetailsPage.tsx b/frontend/pages/hosts/details/HostDetailsPage/HostDetailsPage.tsx index 7afb70d77c..2580cb0cd9 100644 --- a/frontend/pages/hosts/details/HostDetailsPage/HostDetailsPage.tsx +++ b/frontend/pages/hosts/details/HostDetailsPage/HostDetailsPage.tsx @@ -15,7 +15,7 @@ import activitiesAPI, { IHostPastActivitiesResponse, IHostUpcomingActivitiesResponse, } from "services/entities/activities"; -import hostAPI from "services/entities/hosts"; +import hostAPI, { IGetHostCertificatesResponse } from "services/entities/hosts"; import teamAPI, { ILoadTeamsResponse } from "services/entities/teams"; import { @@ -132,6 +132,8 @@ interface IHostDetailsSubNavItem { } const DEFAULT_ACTIVITY_PAGE_SIZE = 8; +const DEFAULT_CERTIFICATES_PAGE_SIZE = 500; +const DEFAULT_CERTIFICATES_PAGE = 0; const HostDetailsPage = ({ router, @@ -461,12 +463,30 @@ const HostDetailsPage = ({ data: hostCertificates, isLoading: isLoadingHostCertificates, isError: isErrorHostCertificates, - } = useQuery( - ["hostCertificates", host_id], - () => hostAPI.getHostCertificates(hostIdFromURL), + } = useQuery< + IGetHostCertificatesResponse, + Error, + IGetHostCertificatesResponse + >( + [ + "host-certificates", + host_id, + DEFAULT_CERTIFICATES_PAGE, + DEFAULT_CERTIFICATES_PAGE_SIZE, + ], + () => + hostAPI.getHostCertificates( + hostIdFromURL, + DEFAULT_CERTIFICATES_PAGE, + DEFAULT_CERTIFICATES_PAGE_SIZE + ), { ...DEFAULT_USE_QUERY_OPTIONS, - enabled: !!hostIdFromURL, + enabled: + !!hostIdFromURL && + (host?.platform === "darwin" || + host?.platform === "ios" || + host?.platform === "ipados"), } ); @@ -756,7 +776,8 @@ const HostDetailsPage = ({ !host || isLoadingHost || pastActivitiesIsLoading || - upcomingActivitiesIsLoading + upcomingActivitiesIsLoading || + isLoadingHostCertificates ) { return ; } @@ -828,8 +849,7 @@ const HostDetailsPage = ({ }; const isDarwinHost = host.platform === "darwin"; - const isIosOrIpadosHost = - host.platform === "ios" || host.platform === "ipados"; + const isIosOrIpadosHost = isIPadOrIPhone(host.platform); const detailsPanelClass = classNames(`${baseClass}__details-panel`, { [`${baseClass}__details-panel--ios-grid`]: isIosOrIpadosHost, diff --git a/frontend/pages/hosts/details/cards/Certificates/CertificatesTable/CertificatesTable.tsx b/frontend/pages/hosts/details/cards/Certificates/CertificatesTable/CertificatesTable.tsx index e6bb565b2a..6733521067 100644 --- a/frontend/pages/hosts/details/cards/Certificates/CertificatesTable/CertificatesTable.tsx +++ b/frontend/pages/hosts/details/cards/Certificates/CertificatesTable/CertificatesTable.tsx @@ -5,6 +5,7 @@ import { IHostCertificate } from "interfaces/certificates"; import TableContainer from "components/TableContainer"; import CustomLink from "components/CustomLink"; +import TableCount from "components/TableContainer/TableCount"; import generateTableConfig from "./CertificatesTableConfig"; @@ -50,6 +51,8 @@ const CertificatesTable = ({ isLoading={false} onClickRow={onClickTableRow} renderTableHelpText={() => helpText} + renderCount={() => } + disablePagination /> ); }; diff --git a/frontend/pages/hosts/details/modals/CertificateDetailsModal/CertificateDetailsModal.tsx b/frontend/pages/hosts/details/modals/CertificateDetailsModal/CertificateDetailsModal.tsx index d976ee3f73..1bcdc3968f 100644 --- a/frontend/pages/hosts/details/modals/CertificateDetailsModal/CertificateDetailsModal.tsx +++ b/frontend/pages/hosts/details/modals/CertificateDetailsModal/CertificateDetailsModal.tsx @@ -18,121 +18,203 @@ const CertificateDetailsModal = ({ certificate, onExit, }: ICertificateDetailsModalProps) => { + // Destructure the certificate object so we can check for presence of values + const { + subject: { + country: subjectCountry, + organization: subjectOrganization, + organizational_unit: subjectOrganizationalUnit, + common_name: subjectCommonName, + }, + issuer: { + country: issuerCountry, + organization: issuerOrganization, + organizational_unit: issuerOrganizationalUnit, + common_name: issuerCommonName, + }, + not_valid_before, + not_valid_after, + key_algorithm, + key_strength, + key_usage, + serial, + certificate_authority, + signing_algorithm, + } = certificate; + + const showSubjectSection = Boolean( + subjectCountry || + subjectOrganization || + subjectOrganizationalUnit || + subjectCommonName + ); + const showIssuerNameSection = Boolean( + issuerCommonName || + issuerCountry || + issuerOrganization || + issuerOrganizationalUnit + ); + const showValidityPeriodSection = Boolean( + not_valid_before || not_valid_after + ); + const showKeyInfoSection = Boolean( + key_algorithm || key_strength || key_usage || serial + ); + const showSignatureSection = Boolean(signing_algorithm); + return ( <>
-
-

Subject Name

-
- - - - -
-
-
-

Issuer name

-
- - - - -
-
-
-

Validity period

-
- - -
-
-
-

Key info

-
- - - - -
-
- + {showSubjectSection && ( +
+

Subject Name

+
+ {subjectCountry && ( + + )} + {subjectOrganization && ( + + )} + {subjectOrganizationalUnit && ( + + )} + {subjectCommonName && ( + + )} +
+
+ )} + {showIssuerNameSection && ( +
+

Issuer name

+
+ {issuerCountry && ( + + )} + {issuerOrganization && ( + + )} + {issuerOrganizationalUnit && ( + + )} + {issuerCommonName && ( + + )} +
+
+ )} + {showValidityPeriodSection && ( +
+

Validity period

+
+ {not_valid_before && ( + + )} + {not_valid_after && ( + + )} +
+
+ )} + {showKeyInfoSection && ( +
+

Key info

+
+ {key_algorithm && ( + + )} + {key_strength && ( + + )} + {key_usage && ( + + )} + {serial && ( + + )} +
+
+ )} + {/* will always show this section */}

Basic constraints

-
-
-
-

Signature

-
-
+ {showSignatureSection && ( +
+

Signature

+
+ +
+
+ )}
diff --git a/frontend/services/entities/device_user.ts b/frontend/services/entities/device_user.ts index 57fcb66306..72b7a93ac0 100644 --- a/frontend/services/entities/device_user.ts +++ b/frontend/services/entities/device_user.ts @@ -87,14 +87,18 @@ export default { }, getDeviceCertificates: ( - deviceToken: string + deviceToken: string, + page = 0, + perPage = 10 ): Promise => { const { DEVICE_CERTIFICATES } = endpoints; - const path = DEVICE_CERTIFICATES(deviceToken); + const path = `${DEVICE_CERTIFICATES( + deviceToken + )}?${buildQueryStringFromParams({ + page, + per_page: perPage, + })}`; - // return sendRequest("GET", path); - return new Promise((resolve) => { - resolve(createMockGetHostCertificatesResponse()); - }); + return sendRequest("GET", path); }, }; diff --git a/frontend/services/entities/hosts.ts b/frontend/services/entities/hosts.ts index 56b484ee8f..6d21f7500e 100644 --- a/frontend/services/entities/hosts.ts +++ b/frontend/services/entities/hosts.ts @@ -602,22 +602,16 @@ export default { }, getHostCertificates: ( - hostId: number + hostId: number, + page = 0, + perPage = 10 ): Promise => { const { HOST_CERTIFICATES } = endpoints; + const path = `${HOST_CERTIFICATES(hostId)}?${buildQueryStringFromParams({ + page, + per_page: perPage, + })}`; - // return sendRequest("GET", HOST_CERTIFICATES(hostId)); - return new Promise((resolve) => { - resolve( - createMockGetHostCertificatesResponse({ - certificates: [ - createMockHostCertificate({ - common_name: "Test 2", - not_valid_after: "2025-05-01T00:00:00.000Z", - }), - ], - }) - ); - }); + return sendRequest("GET", path); }, }; diff --git a/frontend/test/handlers/device-handler.ts b/frontend/test/handlers/device-handler.ts index ea8fb0532c..d089a4a50b 100644 --- a/frontend/test/handlers/device-handler.ts +++ b/frontend/test/handlers/device-handler.ts @@ -6,9 +6,11 @@ import createMockDeviceUser, { import createMockHost from "__mocks__/hostMock"; import createMockLicense from "__mocks__/licenseMock"; import createMockMacAdmins from "__mocks__/macAdminsMock"; +import { createMockHostCertificate } from "__mocks__/certificatesMock"; import { baseUrl } from "test/test-utils"; import { IDeviceUserResponse } from "interfaces/host"; import { IGetDeviceSoftwareResponse } from "services/entities/device_user"; +import { IGetHostCertificatesResponse } from "services/entities/hosts"; export const defaultDeviceHandler = http.get(baseUrl("/device/:token"), () => { return HttpResponse.json({ @@ -63,3 +65,16 @@ export const customDeviceSoftwareHandler = ( http.get(baseUrl("/device/:token/software"), () => { return HttpResponse.json(createMockDeviceSoftwareResponse(overrides)); }); + +export const defaultDeviceCertificatesHandler = http.get( + baseUrl("/device/:token/certificates"), + () => { + return HttpResponse.json({ + certificates: [createMockHostCertificate()], + meta: { + has_next_results: false, + has_previous_results: false, + }, + }); + } +);