add UI for certificate authority list (#26955)

For #26605

This is the UI for the certificate authority list that shows the added
CAs. This includes:

**new CA section and list on integration page**


![image](https://github.com/user-attachments/assets/4159bd6d-632e-4adc-ae45-d83f824380ae)

**empty CA list state**


![image](https://github.com/user-attachments/assets/8f27cd8b-53b2-4cf3-ac64-8fa6ec0a2ae2)

**gitops mode on add CA card and CA list**


![image](https://github.com/user-attachments/assets/981a353c-f515-44ed-90d5-f55e412053ba)


![image](https://github.com/user-attachments/assets/286dc503-f2cd-4329-aa46-6301df75b826)


- [ ] Added/updated automated tests
- [x] Manual QA for all new/changed functionality
This commit is contained in:
Gabriel Hernandez
2025-03-11 14:31:02 +00:00
committed by GitHub
parent 7be7d17489
commit 9484ac30bc
27 changed files with 606 additions and 20 deletions
@@ -30,16 +30,16 @@ const GitOpsModeTooltipWrapper = ({
const tipContent = (
// at this point repoURL will always be defined
<>
<div className={`${baseClass}__tooltip-content`}>
{repoURL && (
<>
<span>
Manage in{" "}
<CustomLink newTab text="YAML" variant="tooltip-link" url={repoURL} />
<br />
</>
</span>
)}
(GitOps mode enabled)
</>
<span>(GitOps mode enabled)</span>
</div>
);
return (
@@ -1,4 +1,10 @@
.gitops-mode-tooltip-wrapper {
&__tooltip-content {
display: flex;
flex-direction: column;
align-items: center;
}
.component__tooltip-wrapper {
&__tip-text {
cursor: default;
@@ -17,6 +17,7 @@ export type ISupportedGraphicNames = Extract<
| "file-pkg"
| "file-p7m"
| "file-pem"
| "file-certificate"
>;
/**
File diff suppressed because one or more lines are too long
+2
View File
@@ -13,6 +13,7 @@ import FilePkg from "./FilePkg";
import FileP7m from "./FileP7m";
import FilePem from "./FilePem";
import FileVpp from "./FileVpp";
import FileCertificate from "./FileCertificate";
import AppStore from "./AppStore";
import EmptyHosts from "./EmptyHosts";
import EmptyTeams from "./EmptyTeams";
@@ -49,6 +50,7 @@ export const GRAPHIC_MAP = {
"file-p7m": FileP7m,
"file-pem": FilePem,
"file-vpp": FileVpp,
"file-certificate": FileCertificate,
"app-store": AppStore, // Used in non-editable file uploader for vpp apps edit modal
// Other graphics
"collecting-results": CollectingResults,
+21 -2
View File
@@ -17,13 +17,30 @@ export interface IZendeskIntegration {
enable_software_vulnerabilities?: boolean;
}
export interface IScepIntegration {
export interface ICertificatesIntegrationNDES {
url: string;
admin_url: string;
username: string;
password: string;
}
export interface ICertificatesIntegrationDigicert {
id: number;
name: string;
api_token: string;
profile_id: string;
certificate_common_name: string;
certificate_user_principal_names: string[];
certificate_seat_id: string;
}
export interface ICertificatesIntegrationCustomSCEP {
id: number;
name: string;
server_url: string;
challenge: string;
}
export interface IIntegration {
url: string;
username?: string;
@@ -91,7 +108,9 @@ export interface IZendeskJiraIntegrations {
// Partial<IZendeskJiraIntegrations>`, but that leads to a mess of types to resolve.
export interface IGlobalIntegrations extends IZendeskJiraIntegrations {
google_calendar?: IGlobalCalendarIntegration[] | null;
ndes_scep_proxy?: IScepIntegration | null;
ndes_scep_proxy?: ICertificatesIntegrationNDES | null;
digicert?: ICertificatesIntegrationDigicert[];
custom_scep_proxy?: ICertificatesIntegrationCustomSCEP[];
}
export interface ITeamIntegrations extends IZendeskJiraIntegrations {
@@ -1,23 +1,24 @@
import React from "react";
import classnames from "classnames";
const baseClass = "upload-list";
interface IUploadListProps {
interface IUploadListProps<T = any> {
/** The attribute name that is used for the react key for each list item */
keyAttribute: string;
listItems: any[]; // TODO: typings
HeadingComponent?: (props: any) => JSX.Element; // TODO: Typings
ListItemComponent: (props: { listItem: any }) => JSX.Element; // TODO: types
sortCompareFn?: (a: any, b: any) => number;
keyAttribute: keyof T;
listItems: T[];
HeadingComponent?: (props: any) => JSX.Element;
ListItemComponent: (props: { listItem: T }) => JSX.Element;
className?: string;
}
const UploadList = ({
const UploadList = <T,>({
keyAttribute,
listItems,
HeadingComponent,
ListItemComponent,
sortCompareFn,
}: IUploadListProps) => {
className,
}: IUploadListProps<T>) => {
const items = listItems.map((listItem) => {
return (
<li
@@ -28,16 +29,17 @@ const UploadList = ({
</li>
);
});
const classNames = classnames(baseClass, className);
return (
<div className={baseClass}>
<div className={classNames}>
{HeadingComponent && (
<div className={`${baseClass}__header`}>
<HeadingComponent />
</div>
)}
<ul className={`${baseClass}__list`}>
{sortCompareFn ? items.sort(sortCompareFn) : items}
</ul>
<ul className={`${baseClass}__list`}>{items}</ul>
</div>
);
};
@@ -5,6 +5,7 @@ import Integrations from "./cards/Integrations";
import MdmSettings from "./cards/MdmSettings";
import Calendars from "./cards/Calendars";
import ChangeManagement from "./cards/ChangeManagement";
import CertificateAuthorities from "./cards/CertificateAuthorities";
const integrationSettingsNavItems: ISideNavItem<any>[] = [
// TODO: types
@@ -32,6 +33,13 @@ const integrationSettingsNavItems: ISideNavItem<any>[] = [
path: PATHS.ADMIN_INTEGRATIONS_CHANGE_MANAGEMENT,
Card: ChangeManagement,
},
// TODO: digicert update: add this back when the feature is ready
// {
// title: "Certificates",
// urlSection: "certificate-authorities",
// path: PATHS.ADMIN_INTEGRATIONS_CERTIFICATE_AUTHORITIES,
// Card: CertificateAuthorities,
// },
];
export default integrationSettingsNavItems;
@@ -0,0 +1,148 @@
import React, { useContext, useMemo, useState } from "react";
import { Link } from "react-router";
import paths from "router/paths";
import { AppContext } from "context/app";
import createMockConfig from "__mocks__/configMock";
import SectionHeader from "components/SectionHeader";
import CustomLink from "components/CustomLink";
import CertificateAuthorityList from "./components/CertificateAuthorityList";
import {
generateListData,
getCertificateAuthority,
ICertAuthority,
} from "./helpers";
import AddCertAuthorityCard from "./components/AddCertAuthorityCard";
const baseClass = "certificate-authorities";
const CertificateAuthorities = () => {
let { config } = useContext(AppContext);
config = createMockConfig({
integrations: {
zendesk: [],
jira: [],
digicert: [
{
name: "DigiCert CA",
id: 1,
api_token: "123456",
profile_id: "7ed77396-9186-4bfa-9fa7-63dddc46b8a3",
certificate_common_name:
"$FLEET_VAR_HOST_HARDWARE_SERIAL@example.com",
certificate_user_principal_names: ["$FLEET_VAR_HOST_HARDWARE_SERIAL"],
certificate_seat_id: "$FLEET_VAR_HOST_HARDWARE_SERIAL@example.com",
},
],
ndes_scep_proxy: {
url: "https://ndes.scep.com",
admin_url: "https://ndes.scep.com/admin",
username: "ndes",
password: "password",
},
custom_scep_proxy: [
{
id: 1,
name: "Custom SCEP Proxy",
server_url: "https://custom.scep.com",
challenge: "challenge",
},
{
id: 2,
name: "Custom SCEP Proxy 2",
server_url: "https://custom.scep2.com",
challenge: "challenge-2",
},
],
},
});
const [showAddCertAuthorityModal, setShowAddCertAuthorityModal] = useState(
false
);
const [showEditCertAuthorityModal, setShowEditCertAuthorityModal] = useState(
false
);
const [
showDeleteCertAuthoirtyModal,
setShowDeleteCertAuthorityModal,
] = useState(false);
const certificateAuthorities = useMemo(() => {
if (!config) return [];
return generateListData(
config?.integrations.ndes_scep_proxy,
config?.integrations.digicert,
config?.integrations.custom_scep_proxy
);
}, [config]);
const onAddCertAuthority = () => {
setShowAddCertAuthorityModal(true);
};
const onEditCertAuthority = (cert: ICertAuthority) => {
// TODO: use useCallback
const ca = getCertificateAuthority(
cert.id,
config?.integrations.ndes_scep_proxy,
config?.integrations.digicert,
config?.integrations.custom_scep_proxy
);
console.log(ca);
setShowEditCertAuthorityModal(true);
};
const onDeleteCertAuthority = (cert: ICertAuthority) => {
// TODO: use useCallback
getCertificateAuthority(
cert.id,
config?.integrations.ndes_scep_proxy,
config?.integrations.digicert,
config?.integrations.custom_scep_proxy
);
setShowDeleteCertAuthorityModal(true);
};
const renderContent = () => {
if (certificateAuthorities.length === 0) {
return <AddCertAuthorityCard onAddCertAuthority={onAddCertAuthority} />;
}
return (
<CertificateAuthorityList
certAuthorities={certificateAuthorities}
onAddCertAuthority={onAddCertAuthority}
onClickEdit={onEditCertAuthority}
onClickDelete={onDeleteCertAuthority}
/>
);
};
return (
<div className={baseClass}>
<SectionHeader title="Certificates" />
<p className={`${baseClass}__page-description`}>
To help your end users connect to Wi-Fi or VPNs, you can add your
certificate authority. Then, head over to{" "}
<Link to={paths.CONTROLS_CUSTOM_SETTINGS}>
Controls {">"} OS Settings {">"} Custom
</Link>{" "}
settings to configure how certificates are delivered to your hosts.{" "}
<CustomLink
text="Learn more"
url="https://fleetdm.com/learn-more-about/certificate-authorities"
newTab
/>
</p>
{renderContent()}
{showAddCertAuthorityModal && <div>Modal showing</div>}
{showEditCertAuthorityModal && <div>Modal showing</div>}
{showDeleteCertAuthoirtyModal && <div>Modal showing</div>}
</div>
);
};
export default CertificateAuthorities;
@@ -0,0 +1,6 @@
.certificate-authorities {
&__page-description {
margin: 0 0 $pad-xlarge;
}
}
@@ -0,0 +1,39 @@
import React from "react";
import Button from "components/buttons/Button";
import Card from "components/Card";
import GitOpsModeTooltipWrapper from "components/GitOpsModeTooltipWrapper";
const baseClass = "add-cert-authority-card";
interface IAddCertAuthoityCardProps {
onAddCertAuthority: () => void;
}
const AddCertAuthorityCard = ({
onAddCertAuthority,
}: IAddCertAuthoityCardProps) => {
return (
<Card paddingSize="xxlarge" className={baseClass}>
<div className={`${baseClass}__content`}>
<p className={`${baseClass}__title`}>
Add your certificate authority (CA)
</p>
<p>Help your end users connect to Wi-Fi or VPNs.</p>
</div>
<GitOpsModeTooltipWrapper
renderChildren={(disableChildren) => (
<Button
disabled={disableChildren}
className={`${baseClass}__button`}
onClick={onAddCertAuthority}
>
Add CA
</Button>
)}
/>
</Card>
);
};
export default AddCertAuthorityCard;
@@ -0,0 +1,20 @@
.add-cert-authority-card {
display: flex;
flex-direction: column;
align-items: center;
gap: $pad-medium;
&__content {
text-align: center;
p {
margin: 0;
}
}
&__title {
font-size: $small;
font-weight: $bold;
margin-bottom: $pad-small;
}
}
@@ -0,0 +1 @@
export { default } from "./AddCertAuthorityCard";
@@ -0,0 +1,40 @@
import Button from "components/buttons/Button";
import GitOpsModeTooltipWrapper from "components/GitOpsModeTooltipWrapper";
import Icon from "components/Icon";
import React from "react";
const baseClass = "cert-authority-list-header";
interface ICertAuthorityListHeaderProps {
onClickAddCertAuthority: () => void;
}
const CertAuthorityListHeader = ({
onClickAddCertAuthority,
}: ICertAuthorityListHeaderProps) => {
return (
<div className={baseClass}>
<span className={`${baseClass}__name`}>Certificate authority (CA)</span>
<span className={`${baseClass}__actions`}>
<GitOpsModeTooltipWrapper
position="left"
renderChildren={(disableChildren) => (
<Button
disabled={disableChildren}
variant="text-icon"
className={`${baseClass}__add-button`}
onClick={onClickAddCertAuthority}
>
<span className={`${baseClass}__btn-label`}>
<Icon name="plus" />
<span>Add CA</span>
</span>
</Button>
)}
/>
</span>
</div>
);
};
export default CertAuthorityListHeader;
@@ -0,0 +1,15 @@
.cert-authority-list-header {
display: flex;
justify-content: space-between;
align-items: center;
&__name {
font-size: $x-small;
font-weight: $bold;
}
&__btn-label {
display: flex;
gap: $pad-small;
}
}
@@ -0,0 +1 @@
export { default } from "./CertAuthorityListHeader";
@@ -0,0 +1,72 @@
import React from "react";
import ListItem from "components/ListItem";
import GitOpsModeTooltipWrapper from "components/GitOpsModeTooltipWrapper";
import Button from "components/buttons/Button";
import Icon from "components/Icon";
import { ICertAuthority } from "../../helpers";
const baseClass = "cert-authority-list-item";
interface IActionsProps {
onEdit: () => void;
onDelete: () => void;
}
const Actions = ({ onEdit, onDelete }: IActionsProps) => {
return (
<>
<GitOpsModeTooltipWrapper
position="left"
renderChildren={(disableChildren) => (
<Button
disabled={disableChildren}
onClick={onEdit}
className={`${baseClass}__action-button`}
variant="text-icon"
>
<Icon name="pencil" color="ui-fleet-black-75" />
</Button>
)}
/>
<GitOpsModeTooltipWrapper
position="left"
renderChildren={(disableChildren) => (
<Button
disabled={disableChildren}
onClick={onDelete}
className={`${baseClass}__action-button`}
variant="text-icon"
>
<Icon name="trash" color="ui-fleet-black-75" />
</Button>
)}
/>
</>
);
};
interface ICertAuthorityListItemProps {
cert: ICertAuthority;
onClickEdit: () => void;
onClickDelete: () => void;
}
const CertAuthorityListItem = ({
cert,
onClickEdit,
onClickDelete,
}: ICertAuthorityListItemProps) => {
return (
<ListItem
className={`${baseClass}__list-item`}
graphic="file-certificate"
title={cert.name}
details={cert.name}
actions={<Actions onEdit={onClickEdit} onDelete={onClickDelete} />}
/>
);
};
export default CertAuthorityListItem;
@@ -0,0 +1,6 @@
.cert-authority-list-item {
&__action-button {
width: 40px;
height: 40px;
}
}
@@ -0,0 +1 @@
export { default } from "./CertAuthorityListItem";
@@ -0,0 +1,43 @@
import React from "react";
import UploadList from "pages/ManageControlsPage/components/UploadList";
import CertAuthorityListHeader from "../CertAuthorityListHeader";
import CertAuthorityListItem from "../CertAuthorityListItem";
import { ICertAuthority } from "../../helpers";
const baseClass = "certificate-authority-list";
interface ICertificateAuthorityListProps {
certAuthorities: ICertAuthority[];
onAddCertAuthority: () => void;
onClickEdit: (cert: ICertAuthority) => void;
onClickDelete: (cert: ICertAuthority) => void;
}
const CertificateAuthorityList = ({
certAuthorities,
onAddCertAuthority,
onClickEdit,
onClickDelete,
}: ICertificateAuthorityListProps) => {
return (
<UploadList<ICertAuthority>
className={baseClass}
keyAttribute="name"
listItems={certAuthorities}
HeadingComponent={() => (
<CertAuthorityListHeader onClickAddCertAuthority={onAddCertAuthority} />
)}
ListItemComponent={({ listItem }) => (
<CertAuthorityListItem
cert={listItem}
onClickEdit={() => onClickEdit(listItem)}
onClickDelete={() => onClickDelete(listItem)}
/>
)}
/>
);
};
export default CertificateAuthorityList;
@@ -0,0 +1 @@
export { default } from "./CertificateAuthorityList";
@@ -0,0 +1,81 @@
import {
ICertificatesIntegrationCustomSCEP,
ICertificatesIntegrationDigicert,
ICertificatesIntegrationNDES,
} from "interfaces/integration";
export interface ICertAuthority {
id: string;
name: string;
description: string;
}
export const generateListData = (
ndesProxy?: ICertificatesIntegrationNDES | null,
digicertCerts?: ICertificatesIntegrationDigicert[],
customProxies?: ICertificatesIntegrationCustomSCEP[]
) => {
const listData: ICertAuthority[] = [];
// these values for the certificateAuthority is meant to be a hard coded .
if (ndesProxy) {
listData.push({
id: "ndes", // only ever one NDES so no need to make the id dynamic
name: "NDES",
description: "Microsoft Network Device Enrollment Service (NDES)",
});
}
if (digicertCerts?.length) {
digicertCerts.forEach((cert) => {
listData.push({
id: `digicert-${cert.id}`,
name: cert.name,
description: "DigiCert",
});
});
}
if (customProxies?.length) {
customProxies.forEach((cert) => {
listData.push({
id: `custom-scep-proxy-${cert.id}`,
name: cert.name,
description: "Custom Simple Certificate Enrollment Protocol (SCEP)",
});
});
}
return listData.sort((a, b) =>
a.name.toLowerCase().localeCompare(b.name.toLocaleLowerCase())
);
};
/**
* Gets the original certificate aithority integration object from the id
* of the data representing a certificate authority list item.
*/
export const getCertificateAuthority = (
id: string,
ncspProxy?: ICertificatesIntegrationNDES | null,
digicertCerts?: ICertificatesIntegrationDigicert[],
customProxies?: ICertificatesIntegrationCustomSCEP[]
) => {
if (id === "ndes") {
return ncspProxy as ICertificatesIntegrationNDES;
}
if (id.includes("digicert")) {
return digicertCerts?.find(
(cert) => id.split("digicert-")[1] === cert.id.toString()
);
}
if (id.includes("custom-scep-proxy")) {
return customProxies?.find(
(cert) => id.split("custom-scep-proxy-")[1] === cert.id.toString()
);
}
return null;
};
@@ -0,0 +1 @@
export { default } from "./CertificateAuthorities";
@@ -133,6 +133,7 @@ const MdmSettings = ({ router }: IMdmSettingsProps) => {
isVppOn={!noVppTokenUploaded}
isPremiumTier={!!isPremiumTier}
/>
{/* TODO: digicert update: remove scep section when digicert feature is ready */}
<ScepSection
router={router}
isScepOn={!noScepCredentials}
+2
View File
@@ -66,6 +66,7 @@ import WindowsMdmPage from "pages/admin/IntegrationsPage/cards/MdmSettings/Windo
import AppleMdmPage from "pages/admin/IntegrationsPage/cards/MdmSettings/AppleMdmPage";
import AndroidMdmPage from "pages/admin/IntegrationsPage/cards/MdmSettings/AndroidMdmPage";
import ScepPage from "pages/admin/IntegrationsPage/cards/MdmSettings/ScepPage";
import CertificatesPage from "pages/admin/IntegrationsPage/cards/CertificateAuthorities";
import Scripts from "pages/ManageControlsPage/Scripts/Scripts";
import WindowsAutomaticEnrollmentPage from "pages/admin/IntegrationsPage/cards/MdmSettings/WindowsAutomaticEnrollmentPage";
import AppleBusinessManagerPage from "pages/admin/IntegrationsPage/cards/MdmSettings/AppleBusinessManagerPage";
@@ -200,6 +201,7 @@ const routes = (
<Route path="integrations/mdm/windows" component={WindowsMdmPage} />
<Route path="integrations/mdm/apple" component={AppleMdmPage} />
<Route path="integrations/mdm/android" component={AndroidMdmPage} />
{/* TODO: digicert update: remove scep route when digicert feature is ready */}
<Route path="integrations/mdm/scep" component={ScepPage} />
{/* This redirect is used to handle old apple automatic enrollments page */}
<Redirect
+1
View File
@@ -49,6 +49,7 @@ export default {
ADMIN_INTEGRATIONS_SCEP: `${URL_PREFIX}/settings/integrations/mdm/scep`,
ADMIN_INTEGRATIONS_CALENDARS: `${URL_PREFIX}/settings/integrations/calendars`,
ADMIN_INTEGRATIONS_CHANGE_MANAGEMENT: `${URL_PREFIX}/settings/integrations/change-management`,
ADMIN_INTEGRATIONS_CERTIFICATE_AUTHORITIES: `${URL_PREFIX}/settings/integrations/certificate-authorities`,
ADMIN_INTEGRATIONS_VPP: `${URL_PREFIX}/settings/integrations/mdm/vpp`,
ADMIN_INTEGRATIONS_VPP_SETUP: `${URL_PREFIX}/settings/integrations/vpp/setup`,