mirror of
https://github.com/fleetdm/fleet
synced 2026-05-24 09:28:54 +00:00
add UI for adding, editing, deleting new NDES and custom scep cert authorities (#27270)
For #26607, #26608 This adds the ndes and custom scep forms to add those types of certificate authorities. this includes: **form for adding and editing ndes**  **form for adding and editing custom scep**  This also contains the removal of the current ndes UI which was on the mdm settings page > NOTE: there will be another PR to handle the various error messages and other polish to the UI. - [ ] Changes file added for user-visible changes in `changes/`, `orbit/changes/` or `ee/fleetd-chrome/changes`. See [Changes files](https://github.com/fleetdm/fleet/blob/main/docs/Contributing/Committing-Changes.md#changes-files) for more information. - [ ] Added/updated automated tests - [ ] Manual QA for all new/changed functionality
This commit is contained in:
parent
1de78d40ab
commit
69165966c8
25 changed files with 844 additions and 709 deletions
|
|
@ -35,7 +35,6 @@ export interface ICertificatesIntegrationDigicert {
|
|||
}
|
||||
|
||||
export interface ICertificatesIntegrationCustomSCEP {
|
||||
id: number;
|
||||
name: string;
|
||||
url: string;
|
||||
challenge: string;
|
||||
|
|
@ -65,7 +64,9 @@ export const isDigicertCertIntegration = (
|
|||
export const isCustomSCEPCertIntegration = (
|
||||
integration: ICertificateIntegration
|
||||
): integration is ICertificatesIntegrationCustomSCEP => {
|
||||
return "id" in integration && "challenge" in integration;
|
||||
return (
|
||||
"name" in integration && "url" in integration && "challenge" in integration
|
||||
);
|
||||
};
|
||||
|
||||
export type ICertificateAuthorityType = "ndes" | "digicert" | "custom";
|
||||
|
|
|
|||
|
|
@ -36,7 +36,7 @@ const integrationSettingsNavItems: ISideNavItem<any>[] = [
|
|||
// TODO: digicert update: add this back when the feature is ready
|
||||
{
|
||||
title: "Certificates",
|
||||
urlSection: "certificate-authorities",
|
||||
urlSection: "certificates",
|
||||
path: PATHS.ADMIN_INTEGRATIONS_CERTIFICATE_AUTHORITIES,
|
||||
Card: CertificateAuthorities,
|
||||
},
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import React, { useContext, useState } from "react";
|
||||
import React, { useContext, useMemo, useState } from "react";
|
||||
|
||||
import { NotificationContext } from "context/notification";
|
||||
import certificatesAPI from "services/entities/certificates";
|
||||
|
|
@ -9,14 +9,20 @@ import { AppContext } from "context/app";
|
|||
import Dropdown from "components/forms/fields/Dropdown";
|
||||
import Modal from "components/Modal";
|
||||
|
||||
import { generateErrorMessage } from "./helpers";
|
||||
import { generateDropdownOptions, getErrorMessage } from "./helpers";
|
||||
|
||||
import DigicertForm from "../DigicertForm";
|
||||
import { IDigicertFormData } from "../DigicertForm/DigicertForm";
|
||||
import { useCertAuthorityDataGenerator } from "../DeleteCertificateAuthorityModal/helpers";
|
||||
import NDESForm from "../NDESForm";
|
||||
import { INDESFormData } from "../NDESForm/NDESForm";
|
||||
import CustomSCEPForm from "../CustomSCEPForm";
|
||||
import { ICustomSCEPFormData } from "../CustomSCEPForm/CustomSCEPForm";
|
||||
|
||||
export type ICertFormData = IDigicertFormData;
|
||||
// | IAnotherCertFormData
|
||||
// | IYetAnotherCertFormData;
|
||||
export type ICertFormData =
|
||||
| IDigicertFormData
|
||||
| INDESFormData
|
||||
| ICustomSCEPFormData;
|
||||
|
||||
const baseClass = "add-cert-authority-modal";
|
||||
|
||||
|
|
@ -25,14 +31,14 @@ interface IAddCertAuthorityModalProps {
|
|||
}
|
||||
|
||||
const AddCertAuthorityModal = ({ onExit }: IAddCertAuthorityModalProps) => {
|
||||
const { setConfig } = useContext(AppContext);
|
||||
const { config, setConfig } = useContext(AppContext);
|
||||
const { renderFlash } = useContext(NotificationContext);
|
||||
const [
|
||||
certAuthorityType,
|
||||
setCertAuthorityType,
|
||||
] = useState<ICertificateAuthorityType>("digicert");
|
||||
const [isAdding, setIsAdding] = useState(false);
|
||||
const [formData, setFormData] = useState<IDigicertFormData>({
|
||||
const [digicertFormData, setDigicertFormData] = useState<IDigicertFormData>({
|
||||
name: "",
|
||||
url: "https://one.digicert.com",
|
||||
apiToken: "",
|
||||
|
|
@ -41,6 +47,21 @@ const AddCertAuthorityModal = ({ onExit }: IAddCertAuthorityModalProps) => {
|
|||
userPrincipalName: "",
|
||||
certificateSeatId: "",
|
||||
});
|
||||
const [ndesFormData, setNDESFormData] = useState<INDESFormData>({
|
||||
scepURL: "",
|
||||
adminURL: "",
|
||||
username: "",
|
||||
password: "",
|
||||
});
|
||||
const [
|
||||
customSCEPFormData,
|
||||
setCustomSCEPFormData,
|
||||
] = useState<ICustomSCEPFormData>({
|
||||
name: "",
|
||||
scepURL: "",
|
||||
challenge: "",
|
||||
});
|
||||
|
||||
const { generateAddPatchData } = useCertAuthorityDataGenerator(
|
||||
certAuthorityType
|
||||
);
|
||||
|
|
@ -50,10 +71,47 @@ const AddCertAuthorityModal = ({ onExit }: IAddCertAuthorityModalProps) => {
|
|||
};
|
||||
|
||||
const onChangeForm = (update: { name: string; value: string }) => {
|
||||
setFormData({ ...formData, [update.name]: update.value });
|
||||
let setFormData;
|
||||
let formData: ICertFormData;
|
||||
switch (certAuthorityType) {
|
||||
case "digicert":
|
||||
setFormData = setDigicertFormData;
|
||||
formData = digicertFormData;
|
||||
break;
|
||||
case "ndes":
|
||||
setFormData = setNDESFormData;
|
||||
formData = ndesFormData;
|
||||
break;
|
||||
case "custom":
|
||||
setFormData = setCustomSCEPFormData;
|
||||
formData = customSCEPFormData;
|
||||
break;
|
||||
default:
|
||||
return;
|
||||
}
|
||||
|
||||
(setFormData as React.Dispatch<React.SetStateAction<ICertFormData>>)({
|
||||
...formData,
|
||||
[update.name]: update.value,
|
||||
});
|
||||
};
|
||||
|
||||
const onAddCertAuthority = async () => {
|
||||
let formData: ICertFormData;
|
||||
switch (certAuthorityType) {
|
||||
case "digicert":
|
||||
formData = digicertFormData;
|
||||
break;
|
||||
case "ndes":
|
||||
formData = ndesFormData;
|
||||
break;
|
||||
case "custom":
|
||||
formData = customSCEPFormData;
|
||||
break;
|
||||
default:
|
||||
return;
|
||||
}
|
||||
|
||||
const addPatchData = generateAddPatchData(formData);
|
||||
setIsAdding(true);
|
||||
try {
|
||||
|
|
@ -64,34 +122,74 @@ const AddCertAuthorityModal = ({ onExit }: IAddCertAuthorityModalProps) => {
|
|||
onExit();
|
||||
setConfig(newConfig);
|
||||
} catch (e) {
|
||||
renderFlash("error", generateErrorMessage(e));
|
||||
renderFlash("error", getErrorMessage(e));
|
||||
}
|
||||
setIsAdding(false);
|
||||
};
|
||||
|
||||
const dropdownOptions = useMemo(() => {
|
||||
return generateDropdownOptions(!!config?.integrations.ndes_scep_proxy);
|
||||
}, [config?.integrations.ndes_scep_proxy]);
|
||||
|
||||
const renderForm = () => {
|
||||
const submitBtnText = "Add CA";
|
||||
|
||||
switch (certAuthorityType) {
|
||||
case "digicert":
|
||||
return (
|
||||
<DigicertForm
|
||||
formData={digicertFormData}
|
||||
submitBtnText={submitBtnText}
|
||||
isSubmitting={isAdding}
|
||||
onChange={onChangeForm}
|
||||
onSubmit={onAddCertAuthority}
|
||||
onCancel={onExit}
|
||||
/>
|
||||
);
|
||||
case "ndes":
|
||||
return (
|
||||
<NDESForm
|
||||
formData={ndesFormData}
|
||||
submitBtnText={submitBtnText}
|
||||
isSubmitting={isAdding}
|
||||
onChange={onChangeForm}
|
||||
onSubmit={onAddCertAuthority}
|
||||
onCancel={onExit}
|
||||
/>
|
||||
);
|
||||
case "custom":
|
||||
return (
|
||||
<CustomSCEPForm
|
||||
formData={customSCEPFormData}
|
||||
submitBtnText={submitBtnText}
|
||||
isSubmitting={isAdding}
|
||||
onChange={onChangeForm}
|
||||
onSubmit={onAddCertAuthority}
|
||||
onCancel={onExit}
|
||||
/>
|
||||
);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
className={baseClass}
|
||||
title="Add certificate authority (CA)"
|
||||
width="large"
|
||||
onExit={onExit}
|
||||
isContentDisabled={isAdding}
|
||||
>
|
||||
<>
|
||||
<Dropdown
|
||||
options={[{ label: "Digicert", value: "digicert" }]}
|
||||
options={dropdownOptions}
|
||||
value={certAuthorityType}
|
||||
className={`${baseClass}__cert-authority-dropdown`}
|
||||
onChange={onChangeDropdown}
|
||||
searchable={false}
|
||||
/>
|
||||
<DigicertForm
|
||||
formData={formData}
|
||||
submitBtnText="Add CA"
|
||||
isSubmitting={isAdding}
|
||||
onChange={onChangeForm}
|
||||
onSubmit={onAddCertAuthority}
|
||||
onCancel={onExit}
|
||||
/>
|
||||
{renderForm()}
|
||||
</>
|
||||
</Modal>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,7 +1,33 @@
|
|||
import { IDropdownOption } from "interfaces/dropdownOption";
|
||||
|
||||
const DEFAULT_CERT_AUTHORITY_OPTIONS: IDropdownOption[] = [
|
||||
{ label: "Digicert", value: "digicert" },
|
||||
{
|
||||
label: "Microsoft NDES (Network Device Enrollment Service)",
|
||||
value: "ndes",
|
||||
},
|
||||
{
|
||||
label: "Custom (SCEP: Simple Certificate Enrollment Protocol)",
|
||||
value: "custom",
|
||||
},
|
||||
];
|
||||
|
||||
export const generateDropdownOptions = (hasNDESCert: boolean) => {
|
||||
if (!hasNDESCert) {
|
||||
return DEFAULT_CERT_AUTHORITY_OPTIONS;
|
||||
}
|
||||
|
||||
const ndesOption = DEFAULT_CERT_AUTHORITY_OPTIONS[1];
|
||||
ndesOption.disabled = true;
|
||||
ndesOption.tooltipContent = "Only one NDES can be added.";
|
||||
|
||||
return DEFAULT_CERT_AUTHORITY_OPTIONS;
|
||||
};
|
||||
|
||||
const DEFAULT_ERROR_MESSAGE =
|
||||
"Couldn't add certificate authority. Please try again.";
|
||||
|
||||
// eslint-disable-next-line import/prefer-default-export
|
||||
export const generateErrorMessage = (e: unknown) => {
|
||||
export const getErrorMessage = (e: unknown) => {
|
||||
return DEFAULT_ERROR_MESSAGE;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,11 +1,9 @@
|
|||
.certificate-authority-list {
|
||||
.list-item__actions {
|
||||
li .list-item__actions {
|
||||
display: none;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
.list-item__actions {
|
||||
display: flex;
|
||||
}
|
||||
li:hover .list-item__actions {
|
||||
display: flex;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,113 @@
|
|||
import React, { useState } from "react";
|
||||
|
||||
// @ts-ignore
|
||||
import InputField from "components/forms/fields/InputField";
|
||||
import Button from "components/buttons/Button";
|
||||
import TooltipWrapper from "components/TooltipWrapper";
|
||||
|
||||
import { ICustomSCEPFormValidation, validateFormData } from "./helpers";
|
||||
|
||||
const baseClass = "ndes-form";
|
||||
|
||||
export interface ICustomSCEPFormData {
|
||||
name: string;
|
||||
scepURL: string;
|
||||
challenge: string;
|
||||
}
|
||||
|
||||
interface ICustomSCEPFormProps {
|
||||
formData: ICustomSCEPFormData;
|
||||
submitBtnText: string;
|
||||
isSubmitting: boolean;
|
||||
onChange: (update: { name: string; value: string }) => void;
|
||||
onSubmit: () => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
const CustomSCEPForm = ({
|
||||
formData,
|
||||
submitBtnText,
|
||||
isSubmitting,
|
||||
onChange,
|
||||
onSubmit,
|
||||
onCancel,
|
||||
}: ICustomSCEPFormProps) => {
|
||||
const [
|
||||
formValidation,
|
||||
setFormValidation,
|
||||
] = useState<ICustomSCEPFormValidation>({
|
||||
isValid: false,
|
||||
});
|
||||
|
||||
const { name, scepURL, challenge } = formData;
|
||||
|
||||
const onSubmitForm = (evt: React.FormEvent<HTMLFormElement>) => {
|
||||
evt.preventDefault();
|
||||
onSubmit();
|
||||
};
|
||||
|
||||
const onInputChange = (update: { name: string; value: string }) => {
|
||||
setFormValidation(
|
||||
validateFormData({ ...formData, [update.name]: update.value })
|
||||
);
|
||||
onChange(update);
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={onSubmitForm}>
|
||||
<div className={`${baseClass}__fields`}>
|
||||
<InputField
|
||||
label="Name"
|
||||
name="name"
|
||||
value={name}
|
||||
error={formValidation.name?.message}
|
||||
onChange={onInputChange}
|
||||
parseTarget
|
||||
placeholder="SCEP_WIFI"
|
||||
helpText="Letters, numbers, and underscores only. Fleet will create configuration profile variables with the name as suffix (e.g. $FLEET_VAR_CUSTOM_SCEP_CHALLENGE_SCEP_WIFI)."
|
||||
/>
|
||||
<InputField
|
||||
label="SCEP URL"
|
||||
name="scepURL"
|
||||
value={scepURL}
|
||||
error={formValidation.scepURL?.message}
|
||||
onChange={onInputChange}
|
||||
parseTarget
|
||||
placeholder="https://example.com/scep"
|
||||
/>
|
||||
<InputField
|
||||
type="password"
|
||||
label="Challenge"
|
||||
name="challenge"
|
||||
value={challenge}
|
||||
onChange={onInputChange}
|
||||
parseTarget
|
||||
placeholder="••••••••••••"
|
||||
helpText="Password to authenticate with a SCEP server."
|
||||
/>
|
||||
</div>
|
||||
<div className={`${baseClass}__cta`}>
|
||||
<TooltipWrapper
|
||||
tipContent="Complete all required fields to save."
|
||||
underline={false}
|
||||
position="top"
|
||||
disableTooltip={formValidation.isValid}
|
||||
showArrow
|
||||
>
|
||||
<Button
|
||||
type="submit"
|
||||
isLoading={isSubmitting}
|
||||
disabled={!formValidation.isValid || isSubmitting}
|
||||
>
|
||||
{submitBtnText}
|
||||
</Button>
|
||||
</TooltipWrapper>
|
||||
<Button variant="inverse" onClick={onCancel}>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
export default CustomSCEPForm;
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
.custom-scep-form {
|
||||
&__fields {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $pad-large;
|
||||
}
|
||||
|
||||
&__cta {
|
||||
display: flex;
|
||||
flex-direction: row-reverse;
|
||||
gap: $pad-medium;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,148 @@
|
|||
import valid_url from "components/forms/validators/valid_url";
|
||||
|
||||
import { ICustomSCEPFormData } from "./CustomSCEPForm";
|
||||
|
||||
// TODO: create a validator abstraction for this and the other form validation files
|
||||
|
||||
export interface ICustomSCEPFormValidation {
|
||||
isValid: boolean;
|
||||
name?: { isValid: boolean; message?: string };
|
||||
scepURL?: { isValid: boolean; message?: string };
|
||||
challenge?: { isValid: boolean };
|
||||
}
|
||||
|
||||
type IMessageFunc = (formData: ICustomSCEPFormData) => string;
|
||||
type IValidationMessage = string | IMessageFunc;
|
||||
type IFormValidationKey = keyof Omit<ICustomSCEPFormValidation, "isValid">;
|
||||
|
||||
interface IValidation {
|
||||
name: string;
|
||||
isValid: (formData: ICustomSCEPFormData) => boolean;
|
||||
message?: IValidationMessage;
|
||||
}
|
||||
|
||||
const FORM_VALIDATIONS: Record<
|
||||
IFormValidationKey,
|
||||
{ validations: IValidation[] }
|
||||
> = {
|
||||
name: {
|
||||
validations: [
|
||||
{
|
||||
name: "required",
|
||||
isValid: (formData: ICustomSCEPFormData) => {
|
||||
return formData.name.length > 0;
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "invalidCharacters",
|
||||
isValid: (formData: ICustomSCEPFormData) => {
|
||||
return /^[a-zA-Z0-9_]+$/.test(formData.name);
|
||||
},
|
||||
message:
|
||||
"Inalid characters. Only letters, numbers and underscores allowed.",
|
||||
},
|
||||
],
|
||||
},
|
||||
scepURL: {
|
||||
validations: [
|
||||
{
|
||||
name: "required",
|
||||
isValid: (formData: ICustomSCEPFormData) => {
|
||||
return formData.scepURL.length > 0;
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "validUrl",
|
||||
isValid: (formData: ICustomSCEPFormData) => {
|
||||
return valid_url({ url: formData.scepURL });
|
||||
},
|
||||
message: (formData: ICustomSCEPFormData) =>
|
||||
`${formData.scepURL} is not a valid URL`,
|
||||
},
|
||||
],
|
||||
},
|
||||
challenge: {
|
||||
validations: [
|
||||
{
|
||||
name: "required",
|
||||
isValid: (formData: ICustomSCEPFormData) => {
|
||||
return formData.challenge.length > 0;
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const getErrorMessage = (
|
||||
formData: ICustomSCEPFormData,
|
||||
message?: IValidationMessage
|
||||
) => {
|
||||
if (message === undefined || typeof message === "string") {
|
||||
return message;
|
||||
}
|
||||
return message(formData);
|
||||
};
|
||||
|
||||
// eslint-disable-next-line import/prefer-default-export
|
||||
export const validateFormData = (formData: ICustomSCEPFormData) => {
|
||||
const formValidation: ICustomSCEPFormValidation = {
|
||||
isValid: true,
|
||||
};
|
||||
|
||||
Object.keys(FORM_VALIDATIONS).forEach((key) => {
|
||||
const objKey = key as keyof typeof FORM_VALIDATIONS;
|
||||
const failedValidation = FORM_VALIDATIONS[objKey].validations.find(
|
||||
(validation) => !validation.isValid(formData)
|
||||
);
|
||||
|
||||
if (!failedValidation) {
|
||||
formValidation[objKey] = {
|
||||
isValid: true,
|
||||
};
|
||||
} else {
|
||||
formValidation.isValid = false;
|
||||
formValidation[objKey] = {
|
||||
isValid: false,
|
||||
message: getErrorMessage(formData, failedValidation.message),
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
return formValidation;
|
||||
};
|
||||
|
||||
const BAD_SCEP_URL_ERROR = "Invalid SCEP URL. Please correct and try again.";
|
||||
const BAD_CREDENTIALS_ERROR =
|
||||
"Couldn't add. Admin URL or credentials are invalid.";
|
||||
const CACHE_ERROR =
|
||||
"The NDES password cache is full. Please increase the number of cached passwords in NDES and try again. By default, NDES caches 5 passwords and they expire 60 minutes after they are created.";
|
||||
const INSUFFICIENT_PERMISSIONS_ERROR =
|
||||
"Couldn't add. This account doesn't have sufficient permissions. Please use the account with enroll permission.";
|
||||
const SCEP_URL_TIMEOUT_ERROR =
|
||||
"Couldn't add. Request to NDES (SCEP URL) timed out. Please try again.";
|
||||
const DEFAULT_ERROR =
|
||||
"Something went wrong updating your SCEP server. Please try again.";
|
||||
|
||||
// export const getErrorMessage = (
|
||||
// err: unknown,
|
||||
// formData: ICustomSCEPFormData
|
||||
// ) => {
|
||||
// const reason = getErrorReason(err);
|
||||
|
||||
// if (reason.includes("invalid admin URL or credentials")) {
|
||||
// return BAD_CREDENTIALS_ERROR;
|
||||
// } else if (reason.includes("the password cache is full")) {
|
||||
// return CACHE_ERROR;
|
||||
// } else if (reason.includes("does not have sufficient permissions")) {
|
||||
// INSUFFICIENT_PERMISSIONS_ERROR;
|
||||
// } else if (
|
||||
// reason.includes(formData.scepURL) &&
|
||||
// reason.includes("context deadline exceeded")
|
||||
// ) {
|
||||
// return SCEP_URL_TIMEOUT_ERROR;
|
||||
// } else if (reason.includes("invalid SCEP URL")) {
|
||||
// return BAD_SCEP_URL_ERROR;
|
||||
// }
|
||||
|
||||
// return DEFAULT_ERROR;
|
||||
// };
|
||||
|
|
@ -0,0 +1 @@
|
|||
export { default } from "./CustomSCEPForm";
|
||||
|
|
@ -9,6 +9,9 @@ import {
|
|||
} from "interfaces/integration";
|
||||
import { useCallback, useContext } from "react";
|
||||
import { IDigicertFormData } from "../DigicertForm/DigicertForm";
|
||||
import { ICertFormData } from "../AddCertAuthorityModal/AddCertAuthorityModal";
|
||||
import { INDESFormData } from "../NDESForm/NDESForm";
|
||||
import { ICustomSCEPFormData } from "../CustomSCEPForm/CustomSCEPForm";
|
||||
|
||||
export const useCertAuthorityDataGenerator = (
|
||||
certAuthorityType: ICertificateAuthorityType,
|
||||
|
|
@ -17,7 +20,7 @@ export const useCertAuthorityDataGenerator = (
|
|||
const { config } = useContext(AppContext);
|
||||
|
||||
const generateAddPatchData = useCallback(
|
||||
(formData: IDigicertFormData) => {
|
||||
(formData: ICertFormData) => {
|
||||
if (!config) return null;
|
||||
|
||||
const data: { integrations: Partial<IGlobalIntegrations> } = {
|
||||
|
|
@ -26,22 +29,59 @@ export const useCertAuthorityDataGenerator = (
|
|||
|
||||
switch (certAuthorityType) {
|
||||
case "ndes":
|
||||
// eslint-disable-next-line no-case-declarations
|
||||
const {
|
||||
scepURL: ndesSCEPUrl,
|
||||
adminURL,
|
||||
username,
|
||||
password,
|
||||
} = formData as INDESFormData;
|
||||
data.integrations.ndes_scep_proxy = {
|
||||
url: ndesSCEPUrl,
|
||||
admin_url: adminURL,
|
||||
username,
|
||||
password,
|
||||
};
|
||||
break;
|
||||
case "digicert":
|
||||
// eslint-disable-next-line no-case-declarations
|
||||
const {
|
||||
name: digicertName,
|
||||
url,
|
||||
apiToken,
|
||||
profileId,
|
||||
commonName,
|
||||
userPrincipalName,
|
||||
certificateSeatId,
|
||||
} = formData as IDigicertFormData;
|
||||
data.integrations.digicert = [
|
||||
...(config.integrations.digicert || []),
|
||||
{
|
||||
name: formData.name,
|
||||
url: formData.url,
|
||||
api_token: formData.apiToken,
|
||||
profile_id: formData.profileId,
|
||||
certificate_common_name: formData.commonName,
|
||||
certificate_user_principal_names: [formData.userPrincipalName],
|
||||
certificate_seat_id: formData.certificateSeatId,
|
||||
name: digicertName,
|
||||
url,
|
||||
api_token: apiToken,
|
||||
profile_id: profileId,
|
||||
certificate_common_name: commonName,
|
||||
certificate_user_principal_names: [userPrincipalName],
|
||||
certificate_seat_id: certificateSeatId,
|
||||
},
|
||||
];
|
||||
break;
|
||||
case "custom":
|
||||
// eslint-disable-next-line no-case-declarations
|
||||
const {
|
||||
name: customSCEPName,
|
||||
scepURL: customSCEPUrl,
|
||||
challenge,
|
||||
} = formData as ICustomSCEPFormData;
|
||||
data.integrations.custom_scep_proxy = [
|
||||
...(config.integrations.custom_scep_proxy || []),
|
||||
{
|
||||
name: customSCEPName,
|
||||
url: customSCEPUrl,
|
||||
challenge,
|
||||
},
|
||||
];
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
|
|
@ -82,8 +122,8 @@ export const useCertAuthorityDataGenerator = (
|
|||
data.integrations.custom_scep_proxy = config.integrations.custom_scep_proxy?.filter(
|
||||
(cert) => {
|
||||
return (
|
||||
(certAuthority as ICertificatesIntegrationCustomSCEP).id ===
|
||||
cert.id
|
||||
(certAuthority as ICertificatesIntegrationCustomSCEP).name !==
|
||||
cert.name
|
||||
);
|
||||
}
|
||||
);
|
||||
|
|
@ -101,7 +141,7 @@ export const useCertAuthorityDataGenerator = (
|
|||
* have to generate the correct data for the PATCH request.
|
||||
*/
|
||||
const generateEditPatchData = useCallback(
|
||||
(formData: IDigicertFormData) => {
|
||||
(formData: ICertFormData) => {
|
||||
if (!config) return null;
|
||||
|
||||
const data: { integrations: Partial<IGlobalIntegrations> } = {
|
||||
|
|
@ -110,8 +150,31 @@ export const useCertAuthorityDataGenerator = (
|
|||
|
||||
switch (certAuthorityType) {
|
||||
case "ndes":
|
||||
// eslint-disable-next-line no-case-declarations
|
||||
const {
|
||||
scepURL: ndesSCEPUrl,
|
||||
adminURL,
|
||||
username,
|
||||
password,
|
||||
} = formData as INDESFormData;
|
||||
data.integrations.ndes_scep_proxy = {
|
||||
url: ndesSCEPUrl,
|
||||
admin_url: adminURL,
|
||||
username,
|
||||
password,
|
||||
};
|
||||
break;
|
||||
case "digicert":
|
||||
// eslint-disable-next-line no-case-declarations
|
||||
const {
|
||||
name: digicertName,
|
||||
url,
|
||||
apiToken,
|
||||
profileId,
|
||||
commonName,
|
||||
userPrincipalName,
|
||||
certificateSeatId,
|
||||
} = formData as IDigicertFormData;
|
||||
data.integrations.digicert = config.integrations.digicert?.map(
|
||||
(cert) => {
|
||||
// only update the certificate authority that we are editing
|
||||
|
|
@ -120,15 +183,13 @@ export const useCertAuthorityDataGenerator = (
|
|||
cert.name
|
||||
) {
|
||||
return {
|
||||
name: formData.name,
|
||||
url: formData.url,
|
||||
api_token: formData.apiToken,
|
||||
profile_id: formData.profileId,
|
||||
certificate_common_name: formData.commonName,
|
||||
certificate_user_principal_names: [
|
||||
formData.userPrincipalName,
|
||||
],
|
||||
certificate_seat_id: formData.certificateSeatId,
|
||||
name: digicertName,
|
||||
url,
|
||||
api_token: apiToken,
|
||||
profile_id: profileId,
|
||||
certificate_common_name: commonName,
|
||||
certificate_user_principal_names: [userPrincipalName],
|
||||
certificate_seat_id: certificateSeatId,
|
||||
};
|
||||
}
|
||||
return cert;
|
||||
|
|
@ -136,6 +197,28 @@ export const useCertAuthorityDataGenerator = (
|
|||
);
|
||||
break;
|
||||
case "custom":
|
||||
// eslint-disable-next-line no-case-declarations
|
||||
const {
|
||||
name: customSCEPName,
|
||||
scepURL: customSCEPUrl,
|
||||
challenge,
|
||||
} = formData as ICustomSCEPFormData;
|
||||
data.integrations.custom_scep_proxy = config.integrations.custom_scep_proxy?.map(
|
||||
(cert) => {
|
||||
// only update the certificate authority that we are editing
|
||||
if (
|
||||
(certAuthority as ICertificatesIntegrationCustomSCEP).name ===
|
||||
cert.name
|
||||
) {
|
||||
return {
|
||||
name: customSCEPName,
|
||||
url: customSCEPUrl,
|
||||
challenge,
|
||||
};
|
||||
}
|
||||
return cert;
|
||||
}
|
||||
);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
|
|
|
|||
|
|
@ -5,19 +5,23 @@ import { AppContext } from "context/app";
|
|||
import {
|
||||
ICertificateIntegration,
|
||||
isDigicertCertIntegration,
|
||||
isNDESCertIntegration,
|
||||
} from "interfaces/integration";
|
||||
import certificatesAPI from "services/entities/certificates";
|
||||
|
||||
import Modal from "components/Modal";
|
||||
|
||||
import DigicertForm from "../DigicertForm";
|
||||
import {
|
||||
generateDefaultFormData,
|
||||
generateErrorMessage,
|
||||
getCertificateAuthorityType,
|
||||
} from "./helpers";
|
||||
|
||||
import DigicertForm from "../DigicertForm";
|
||||
import { ICertFormData } from "../AddCertAuthorityModal/AddCertAuthorityModal";
|
||||
import { useCertAuthorityDataGenerator } from "../DeleteCertificateAuthorityModal/helpers";
|
||||
import NDESForm from "../NDESForm";
|
||||
import CustomSCEPForm from "../CustomSCEPForm";
|
||||
|
||||
const baseClass = "edit-cert-authority-modal";
|
||||
|
||||
|
|
@ -70,10 +74,13 @@ const EditCertAuthorityModal = ({
|
|||
};
|
||||
|
||||
const getFormComponent = () => {
|
||||
if (isNDESCertIntegration(certAuthority)) {
|
||||
return NDESForm;
|
||||
}
|
||||
if (isDigicertCertIntegration(certAuthority)) {
|
||||
return DigicertForm;
|
||||
}
|
||||
return null;
|
||||
return CustomSCEPForm;
|
||||
};
|
||||
|
||||
const renderForm = () => {
|
||||
|
|
@ -82,6 +89,7 @@ const EditCertAuthorityModal = ({
|
|||
|
||||
return (
|
||||
<FormComponent
|
||||
// @ts-ignore TODO: figure out how to fix this type issue
|
||||
formData={formData}
|
||||
submitBtnText="Save"
|
||||
isSubmitting={isUpdating}
|
||||
|
|
@ -98,6 +106,7 @@ const EditCertAuthorityModal = ({
|
|||
title="Edit certificate authority (CA)"
|
||||
width="large"
|
||||
onExit={onExit}
|
||||
isContentDisabled={isUpdating}
|
||||
>
|
||||
{renderForm()}
|
||||
</Modal>
|
||||
|
|
|
|||
|
|
@ -1,8 +1,11 @@
|
|||
import {
|
||||
ICertificateAuthorityType,
|
||||
ICertificateIntegration,
|
||||
ICertificatesIntegrationCustomSCEP,
|
||||
ICertificatesIntegrationDigicert,
|
||||
ICertificatesIntegrationNDES,
|
||||
isCustomSCEPCertIntegration,
|
||||
isDigicertCertIntegration,
|
||||
isNDESCertIntegration,
|
||||
} from "interfaces/integration";
|
||||
|
||||
|
|
@ -16,21 +19,6 @@ export const generateErrorMessage = (e: unknown) => {
|
|||
return DEFAULT_ERROR_MESSAGE;
|
||||
};
|
||||
|
||||
export const generateDefaultFormData = (
|
||||
certAuthority: ICertificateIntegration
|
||||
): ICertFormData => {
|
||||
const cert = certAuthority as ICertificatesIntegrationDigicert;
|
||||
return {
|
||||
name: cert.name,
|
||||
url: cert.url,
|
||||
apiToken: cert.api_token,
|
||||
profileId: cert.profile_id,
|
||||
commonName: cert.certificate_common_name,
|
||||
userPrincipalName: cert.certificate_user_principal_names[0],
|
||||
certificateSeatId: cert.certificate_seat_id,
|
||||
};
|
||||
};
|
||||
|
||||
export const getCertificateAuthorityType = (
|
||||
certAuthority: ICertificateIntegration
|
||||
): ICertificateAuthorityType => {
|
||||
|
|
@ -38,3 +26,33 @@ export const getCertificateAuthorityType = (
|
|||
if (isCustomSCEPCertIntegration(certAuthority)) return "custom";
|
||||
return "digicert";
|
||||
};
|
||||
|
||||
export const generateDefaultFormData = (
|
||||
certAuthority: ICertificateIntegration
|
||||
): ICertFormData => {
|
||||
if (isNDESCertIntegration(certAuthority)) {
|
||||
return {
|
||||
scepURL: certAuthority.url,
|
||||
adminURL: certAuthority.admin_url,
|
||||
username: certAuthority.username,
|
||||
password: certAuthority.password,
|
||||
};
|
||||
} else if (isDigicertCertIntegration(certAuthority)) {
|
||||
return {
|
||||
name: certAuthority.name,
|
||||
url: certAuthority.url,
|
||||
apiToken: certAuthority.api_token,
|
||||
profileId: certAuthority.profile_id,
|
||||
commonName: certAuthority.certificate_common_name,
|
||||
userPrincipalName: certAuthority.certificate_user_principal_names[0],
|
||||
certificateSeatId: certAuthority.certificate_seat_id,
|
||||
};
|
||||
}
|
||||
|
||||
const customSCEPcert = certAuthority as ICertificatesIntegrationCustomSCEP;
|
||||
return {
|
||||
name: customSCEPcert.name,
|
||||
scepURL: customSCEPcert.url,
|
||||
challenge: customSCEPcert.challenge,
|
||||
};
|
||||
};
|
||||
|
|
|
|||
|
|
@ -0,0 +1,120 @@
|
|||
import React, { useState } from "react";
|
||||
|
||||
// @ts-ignore
|
||||
import InputField from "components/forms/fields/InputField";
|
||||
import Button from "components/buttons/Button";
|
||||
import TooltipWrapper from "components/TooltipWrapper";
|
||||
|
||||
import { INDESFormValidation, validateFormData } from "./helpers";
|
||||
|
||||
const baseClass = "ndes-form";
|
||||
|
||||
export interface INDESFormData {
|
||||
scepURL: string;
|
||||
adminURL: string;
|
||||
username: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
interface INDESFormProps {
|
||||
formData: INDESFormData;
|
||||
submitBtnText: string;
|
||||
isSubmitting: boolean;
|
||||
onChange: (update: { name: string; value: string }) => void;
|
||||
onSubmit: () => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
const NDESForm = ({
|
||||
formData,
|
||||
submitBtnText,
|
||||
isSubmitting,
|
||||
onChange,
|
||||
onSubmit,
|
||||
onCancel,
|
||||
}: INDESFormProps) => {
|
||||
const [formValidation, setFormValidation] = useState<INDESFormValidation>({
|
||||
isValid: false,
|
||||
});
|
||||
|
||||
const { scepURL, adminURL, username, password } = formData;
|
||||
|
||||
const onSubmitForm = (evt: React.FormEvent<HTMLFormElement>) => {
|
||||
evt.preventDefault();
|
||||
onSubmit();
|
||||
};
|
||||
|
||||
const onInputChange = (update: { name: string; value: string }) => {
|
||||
setFormValidation(
|
||||
validateFormData({ ...formData, [update.name]: update.value })
|
||||
);
|
||||
onChange(update);
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={onSubmitForm}>
|
||||
<div className={`${baseClass}__fields`}>
|
||||
<InputField
|
||||
label="SCEP URL"
|
||||
name="scepURL"
|
||||
value={scepURL}
|
||||
onChange={onInputChange}
|
||||
parseTarget
|
||||
placeholder="https://example.com/certsrv/mscep/mscep.dll"
|
||||
helpText="The URL used by client devices to request and retrieve certificates."
|
||||
/>
|
||||
<InputField
|
||||
label="Admin URL"
|
||||
name="adminURL"
|
||||
value={adminURL}
|
||||
onChange={onInputChange}
|
||||
parseTarget
|
||||
placeholder="https://example.com/certsrv/mscep_admin/"
|
||||
helpText="The admin interface for managing the SCEP service and viewing configuration details."
|
||||
/>
|
||||
<InputField
|
||||
label="Username"
|
||||
name="username"
|
||||
value={username}
|
||||
onChange={onInputChange}
|
||||
parseTarget
|
||||
placeholder="username@example.microsoft.com"
|
||||
helpText="The username in the down-level logon name format required to log in to the SCEP admin page."
|
||||
/>
|
||||
<InputField
|
||||
label="Password"
|
||||
name="password"
|
||||
value={password}
|
||||
type="password"
|
||||
onChange={onInputChange}
|
||||
parseTarget
|
||||
placeholder="••••••••"
|
||||
blockAutoComplete
|
||||
helpText="The password required to log in to the SCEP admin page."
|
||||
/>
|
||||
</div>
|
||||
<div className={`${baseClass}__cta`}>
|
||||
<TooltipWrapper
|
||||
tipContent="Complete all required fields to save."
|
||||
underline={false}
|
||||
position="top"
|
||||
disableTooltip={formValidation.isValid}
|
||||
showArrow
|
||||
>
|
||||
<Button
|
||||
type="submit"
|
||||
isLoading={isSubmitting}
|
||||
disabled={!formValidation.isValid || isSubmitting}
|
||||
>
|
||||
{submitBtnText}
|
||||
</Button>
|
||||
</TooltipWrapper>
|
||||
<Button variant="inverse" onClick={onCancel}>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
export default NDESForm;
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
.ndes-form {
|
||||
&__fields {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $pad-large;
|
||||
}
|
||||
|
||||
&__cta {
|
||||
display: flex;
|
||||
flex-direction: row-reverse;
|
||||
gap: $pad-medium;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,135 @@
|
|||
import { getErrorReason } from "interfaces/errors";
|
||||
import { INDESFormData } from "./NDESForm";
|
||||
|
||||
// TODO: create a validator abstraction for this and the other form validation files
|
||||
|
||||
export interface INDESFormValidation {
|
||||
isValid: boolean;
|
||||
scepURL?: { isValid: boolean };
|
||||
adminURL?: { isValid: boolean };
|
||||
username?: { isValid: boolean };
|
||||
password?: { isValid: boolean };
|
||||
}
|
||||
|
||||
type IMessageFunc = (formData: INDESFormData) => string;
|
||||
type IValidationMessage = string | IMessageFunc;
|
||||
type IFormValidationKey = keyof Omit<INDESFormValidation, "isValid">;
|
||||
|
||||
interface IValidation {
|
||||
name: string;
|
||||
isValid: (formData: INDESFormData) => boolean;
|
||||
message?: IValidationMessage;
|
||||
}
|
||||
|
||||
const FORM_VALIDATIONS: Record<
|
||||
IFormValidationKey,
|
||||
{ validations: IValidation[] }
|
||||
> = {
|
||||
scepURL: {
|
||||
validations: [
|
||||
{
|
||||
name: "required",
|
||||
isValid: (formData: INDESFormData) => {
|
||||
return formData.scepURL.length > 0;
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
adminURL: {
|
||||
validations: [
|
||||
{
|
||||
name: "required",
|
||||
isValid: (formData: INDESFormData) => {
|
||||
return formData.adminURL.length > 0;
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
username: {
|
||||
validations: [
|
||||
{
|
||||
name: "required",
|
||||
isValid: (formData: INDESFormData) => {
|
||||
return formData.username.length > 0;
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
password: {
|
||||
validations: [
|
||||
{
|
||||
name: "required",
|
||||
isValid: (formData: INDESFormData) => {
|
||||
return formData.password.length > 0;
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
// eslint-disable-next-line import/prefer-default-export
|
||||
export const validateFormData = (formData: INDESFormData) => {
|
||||
const formValidation: INDESFormValidation = {
|
||||
isValid: true,
|
||||
};
|
||||
|
||||
Object.keys(FORM_VALIDATIONS).forEach((key) => {
|
||||
const objKey = key as keyof typeof FORM_VALIDATIONS;
|
||||
const failedValidation = FORM_VALIDATIONS[objKey].validations.find(
|
||||
(validation) => !validation.isValid(formData)
|
||||
);
|
||||
|
||||
if (!failedValidation) {
|
||||
formValidation[objKey] = {
|
||||
isValid: true,
|
||||
};
|
||||
} else {
|
||||
formValidation.isValid = false;
|
||||
formValidation[objKey] = {
|
||||
isValid: false,
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
return formValidation;
|
||||
};
|
||||
|
||||
const BAD_SCEP_URL_ERROR = "Invalid SCEP URL. Please correct and try again.";
|
||||
const BAD_CREDENTIALS_ERROR =
|
||||
"Couldn't add. Admin URL or credentials are invalid.";
|
||||
const CACHE_ERROR =
|
||||
"The NDES password cache is full. Please increase the number of cached passwords in NDES and try again. By default, NDES caches 5 passwords and they expire 60 minutes after they are created.";
|
||||
const INSUFFICIENT_PERMISSIONS_ERROR =
|
||||
"Couldn't add. This account doesn't have sufficient permissions. Please use the account with enroll permission.";
|
||||
const SCEP_URL_TIMEOUT_ERROR =
|
||||
"Couldn't add. Request to NDES (SCEP URL) timed out. Please try again.";
|
||||
const ADMIN_URL_TIMEOUT_ERROR =
|
||||
"Couldn't add. Request to NDES (admin URL) timed out. Please try again.";
|
||||
const DEFAULT_ERROR =
|
||||
"Something went wrong updating your SCEP server. Please try again.";
|
||||
|
||||
export const getErrorMessage = (err: unknown, formData: INDESFormData) => {
|
||||
const reason = getErrorReason(err);
|
||||
|
||||
if (reason.includes("invalid admin URL or credentials")) {
|
||||
return BAD_CREDENTIALS_ERROR;
|
||||
} else if (reason.includes("the password cache is full")) {
|
||||
return CACHE_ERROR;
|
||||
} else if (reason.includes("does not have sufficient permissions")) {
|
||||
INSUFFICIENT_PERMISSIONS_ERROR;
|
||||
} else if (
|
||||
reason.includes(formData.scepURL) &&
|
||||
reason.includes("context deadline exceeded")
|
||||
) {
|
||||
return SCEP_URL_TIMEOUT_ERROR;
|
||||
} else if (
|
||||
reason.includes(formData.adminURL) &&
|
||||
reason.includes("context deadline exceeded")
|
||||
) {
|
||||
return ADMIN_URL_TIMEOUT_ERROR;
|
||||
} else if (reason.includes("invalid SCEP URL")) {
|
||||
return BAD_SCEP_URL_ERROR;
|
||||
}
|
||||
|
||||
return DEFAULT_ERROR;
|
||||
};
|
||||
|
|
@ -0,0 +1 @@
|
|||
export { default } from "./NDESForm";
|
||||
|
|
@ -2,7 +2,6 @@ import {
|
|||
ICertificatesIntegrationCustomSCEP,
|
||||
ICertificatesIntegrationDigicert,
|
||||
ICertificatesIntegrationNDES,
|
||||
isNDESCertIntegration,
|
||||
} from "interfaces/integration";
|
||||
|
||||
export interface ICertAuthorityListData {
|
||||
|
|
@ -40,7 +39,7 @@ export const generateListData = (
|
|||
if (customProxies?.length) {
|
||||
customProxies.forEach((cert) => {
|
||||
listData.push({
|
||||
id: `custom-scep-proxy-${cert.id}`,
|
||||
id: `custom-scep-proxy-${cert.name}`,
|
||||
name: cert.name,
|
||||
description: "Custom Simple Certificate Enrollment Protocol (SCEP)",
|
||||
});
|
||||
|
|
@ -91,8 +90,7 @@ export const getCertificateAuthority = (
|
|||
if (id.includes("custom-scep-proxy") && customProxies) {
|
||||
return (
|
||||
customProxies?.find(
|
||||
// TODO: remove custom scep id
|
||||
(cert) => id.split("custom-scep-proxy-")[1] === cert.id.toString()
|
||||
(cert) => id.split("custom-scep-proxy-")[1] === cert.name
|
||||
) ?? null
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -133,12 +133,6 @@ const MdmSettings = ({ router }: IMdmSettingsProps) => {
|
|||
isVppOn={!noVppTokenUploaded}
|
||||
isPremiumTier={!!isPremiumTier}
|
||||
/>
|
||||
{/* TODO: digicert update: remove scep section when digicert feature is ready */}
|
||||
<ScepSection
|
||||
router={router}
|
||||
isScepOn={!noScepCredentials}
|
||||
isPremiumTier={!!isPremiumTier}
|
||||
/>
|
||||
{isPremiumTier && !!config?.mdm.apple_bm_enabled_and_configured && (
|
||||
<>
|
||||
<IdpSection />
|
||||
|
|
|
|||
|
|
@ -1,135 +0,0 @@
|
|||
import React from "react";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
|
||||
import { createMockRouter } from "test/test-utils";
|
||||
|
||||
import createMockConfig, { createMockMdmConfig } from "__mocks__/configMock";
|
||||
|
||||
import { ScepCertificateContent } from "./ScepPage";
|
||||
|
||||
const FORM_DATA = { scepUrl: "", adminUrl: "", username: "", password: "" };
|
||||
|
||||
describe("Scep Page", () => {
|
||||
it("renders PremiumFeatureMessage for non-premium tier", () => {
|
||||
render(
|
||||
<ScepCertificateContent
|
||||
router={createMockRouter()}
|
||||
onFormSubmit={jest.fn()}
|
||||
formData={FORM_DATA}
|
||||
formErrors={{}}
|
||||
onInputChange={jest.fn()}
|
||||
onBlur={jest.fn()}
|
||||
config={createMockConfig()}
|
||||
isLoading={false}
|
||||
isSaving={false}
|
||||
showDataError={false}
|
||||
isPremiumTier={false} // test
|
||||
/>
|
||||
);
|
||||
expect(
|
||||
screen.getByText("This feature is included in Fleet Premium.")
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
it("renders TurnOnMdmMessage when MDM is not enabled", () => {
|
||||
render(
|
||||
<ScepCertificateContent
|
||||
router={createMockRouter()}
|
||||
onFormSubmit={jest.fn()}
|
||||
formData={FORM_DATA}
|
||||
formErrors={{}}
|
||||
onInputChange={jest.fn()}
|
||||
onBlur={jest.fn()}
|
||||
config={createMockConfig({
|
||||
mdm: createMockMdmConfig({ enabled_and_configured: false }),
|
||||
})}
|
||||
isLoading={false}
|
||||
isSaving={false}
|
||||
showDataError={false}
|
||||
isPremiumTier
|
||||
/>
|
||||
);
|
||||
expect(screen.getByText("Turn on Apple MDM")).toBeInTheDocument();
|
||||
});
|
||||
it("renders Spinner when loading", () => {
|
||||
render(
|
||||
<ScepCertificateContent
|
||||
router={createMockRouter()}
|
||||
onFormSubmit={jest.fn()}
|
||||
formData={FORM_DATA}
|
||||
formErrors={{}}
|
||||
onInputChange={jest.fn()}
|
||||
onBlur={jest.fn()}
|
||||
config={createMockConfig()}
|
||||
isLoading // test
|
||||
isSaving={false}
|
||||
showDataError={false}
|
||||
isPremiumTier
|
||||
/>
|
||||
);
|
||||
expect(screen.getByTestId("spinner")).toBeInTheDocument();
|
||||
});
|
||||
it("renders DataError when showDataError is true", () => {
|
||||
render(
|
||||
<ScepCertificateContent
|
||||
router={createMockRouter()}
|
||||
onFormSubmit={jest.fn()}
|
||||
formData={{ scepUrl: "", adminUrl: "", username: "", password: "" }}
|
||||
formErrors={{}}
|
||||
onInputChange={jest.fn()}
|
||||
onBlur={jest.fn()}
|
||||
config={createMockConfig()}
|
||||
isLoading={false}
|
||||
isSaving={false}
|
||||
showDataError // test
|
||||
isPremiumTier
|
||||
/>
|
||||
);
|
||||
expect(screen.getByText("Something's gone wrong.")).toBeInTheDocument();
|
||||
});
|
||||
it("renders form fields correctly", () => {
|
||||
render(
|
||||
<ScepCertificateContent
|
||||
router={createMockRouter()}
|
||||
onFormSubmit={jest.fn()}
|
||||
formData={FORM_DATA}
|
||||
formErrors={{}}
|
||||
onInputChange={jest.fn()}
|
||||
onBlur={jest.fn()}
|
||||
config={createMockConfig()}
|
||||
isLoading={false}
|
||||
isSaving={false}
|
||||
showDataError={false}
|
||||
isPremiumTier
|
||||
/>
|
||||
);
|
||||
expect(screen.getByLabelText("SCEP URL")).toBeInTheDocument();
|
||||
expect(screen.getByLabelText("Admin URL")).toBeInTheDocument();
|
||||
expect(screen.getByLabelText("Username")).toBeInTheDocument();
|
||||
expect(screen.getByLabelText("Password")).toBeInTheDocument();
|
||||
});
|
||||
it("displays error messages for invalid inputs", () => {
|
||||
const FORM_ERRORS = { scepUrl: "Invalid URL", adminUrl: "Invalid URL" };
|
||||
const INVALID_FORM_DATA = {
|
||||
scepUrl: "invalid",
|
||||
adminUrl: "invalid",
|
||||
username: "",
|
||||
password: "",
|
||||
};
|
||||
render(
|
||||
<ScepCertificateContent
|
||||
router={createMockRouter()}
|
||||
onFormSubmit={jest.fn()}
|
||||
formData={INVALID_FORM_DATA}
|
||||
formErrors={FORM_ERRORS}
|
||||
onInputChange={jest.fn()}
|
||||
onBlur={jest.fn()}
|
||||
config={createMockConfig()}
|
||||
isLoading={false}
|
||||
isSaving={false}
|
||||
showDataError={false}
|
||||
isPremiumTier
|
||||
/>
|
||||
);
|
||||
expect(screen.getAllByLabelText("Invalid URL").length).toBe(2);
|
||||
});
|
||||
});
|
||||
|
|
@ -1,425 +0,0 @@
|
|||
import React, { useContext, useState } from "react";
|
||||
import { useQuery } from "react-query";
|
||||
import { InjectedRouter } from "react-router";
|
||||
|
||||
import PATHS from "router/paths";
|
||||
import configAPI from "services/entities/config";
|
||||
import { IConfig } from "interfaces/config";
|
||||
import { getErrorReason } from "interfaces/errors";
|
||||
import { NotificationContext } from "context/notification";
|
||||
import { AppContext } from "context/app";
|
||||
|
||||
import MainContent from "components/MainContent/MainContent";
|
||||
import Button from "components/buttons/Button";
|
||||
import BackLink from "components/BackLink/BackLink";
|
||||
import CustomLink from "components/CustomLink";
|
||||
import Card from "components/Card";
|
||||
import validateUrl from "components/forms/validators/valid_url";
|
||||
// @ts-ignore
|
||||
import InputField from "components/forms/fields/InputField";
|
||||
import TooltipWrapper from "components/TooltipWrapper";
|
||||
import PremiumFeatureMessage from "components/PremiumFeatureMessage";
|
||||
import Spinner from "components/Spinner";
|
||||
import DataError from "components/DataError";
|
||||
import TurnOnMdmMessage from "components/TurnOnMdmMessage";
|
||||
import GitOpsModeTooltipWrapper from "components/GitOpsModeTooltipWrapper";
|
||||
|
||||
import { SCEP_SERVER_TIP_CONTENT } from "../components/ScepSection/ScepSection";
|
||||
|
||||
const baseClass = "scep-page";
|
||||
|
||||
const BAD_SCEP_URL_ERROR = "Invalid SCEP URL. Please correct and try again.";
|
||||
const BAD_CREDENTIALS_ERROR =
|
||||
"Couldn't add. Admin URL or credentials are invalid.";
|
||||
const CACHE_ERROR =
|
||||
"The NDES password cache is full. Please increase the number of cached passwords in NDES and try again. By default, NDES caches 5 passwords and they expire 60 minutes after they are created.";
|
||||
const INSUFFICIENT_PERMISSIONS_ERROR =
|
||||
"Couldn't add. This account doesn't have sufficient permissions. Please use the account with enroll permission.";
|
||||
const SCEP_URL_TIMEOUT_ERROR =
|
||||
"Couldn't add. Request to NDES (SCEP URL) timed out. Please try again.";
|
||||
const ADMIN_URL_TIMEOUT_ERROR =
|
||||
"Couldn't add. Request to NDES (admin URL) timed out. Please try again.";
|
||||
const DEFAULT_ERROR =
|
||||
"Something went wrong updating your SCEP server. Please try again.";
|
||||
|
||||
interface IScepCertificateContentProps {
|
||||
router: InjectedRouter;
|
||||
onFormSubmit: (evt: React.MouseEvent<HTMLFormElement>) => Promise<void>;
|
||||
formData: INdesFormData;
|
||||
formErrors: INdesFormErrors;
|
||||
onInputChange: ({ name, value }: IFormField) => void;
|
||||
onBlur: (name: string, value: string) => void;
|
||||
config: IConfig | null;
|
||||
isPremiumTier: boolean;
|
||||
isLoading: boolean;
|
||||
isSaving: boolean;
|
||||
showDataError: boolean;
|
||||
}
|
||||
|
||||
export const ScepCertificateContent = ({
|
||||
router,
|
||||
onFormSubmit,
|
||||
formData,
|
||||
formErrors,
|
||||
onInputChange,
|
||||
onBlur,
|
||||
config,
|
||||
isPremiumTier,
|
||||
isLoading,
|
||||
isSaving,
|
||||
showDataError,
|
||||
}: IScepCertificateContentProps) => {
|
||||
if (!isPremiumTier) {
|
||||
return <PremiumFeatureMessage />;
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return <Spinner />;
|
||||
}
|
||||
|
||||
if (!config?.mdm.enabled_and_configured) {
|
||||
return (
|
||||
<TurnOnMdmMessage
|
||||
router={router}
|
||||
header="Turn on Apple MDM"
|
||||
info="To help your end users connect to Wi-Fi, first turn on Apple MDM."
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// TODO: error UI
|
||||
if (showDataError) {
|
||||
return (
|
||||
<div>
|
||||
<DataError />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const gitOpsModeEnabled = config?.gitops.gitops_mode_enabled;
|
||||
|
||||
const disableSave =
|
||||
// all fields aren't empty
|
||||
!Object.values(formData).every((val) => val === "") &&
|
||||
// all fields aren't complete
|
||||
!Object.values(formData).every((val) => val !== "");
|
||||
|
||||
return (
|
||||
<>
|
||||
<p>
|
||||
To help your end users connect to Wi-Fi you can add your{" "}
|
||||
<TooltipWrapper tipContent={SCEP_SERVER_TIP_CONTENT}>
|
||||
SCEP server
|
||||
</TooltipWrapper>
|
||||
.
|
||||
</p>
|
||||
<div>
|
||||
<ol className={`${baseClass}__steps`}>
|
||||
<li>
|
||||
<div>
|
||||
Connect to your Network Device Enrollment Service (
|
||||
<CustomLink
|
||||
url="https://www.fleetdm.com/learn-more-about/ndes"
|
||||
text="NDES"
|
||||
/>
|
||||
) admin account:
|
||||
</div>
|
||||
<Card>
|
||||
<form onSubmit={onFormSubmit} autoComplete="off">
|
||||
<InputField
|
||||
inputWrapperClass={`${baseClass}__scep-url-input`}
|
||||
label="SCEP URL"
|
||||
name="scepUrl"
|
||||
tooltip={
|
||||
<>
|
||||
The URL used by client devices
|
||||
<br /> to request and retrieve certificates.
|
||||
</>
|
||||
}
|
||||
value={formData.scepUrl}
|
||||
onChange={onInputChange}
|
||||
parseTarget
|
||||
error={formErrors.scepUrl}
|
||||
placeholder="https://example.com/certsrv/mscep/mscep.dll"
|
||||
disabled={gitOpsModeEnabled}
|
||||
/>
|
||||
<InputField
|
||||
inputWrapperClass={`${baseClass}__admin-url-input`}
|
||||
label="Admin URL"
|
||||
name="adminUrl"
|
||||
tooltip={
|
||||
<>
|
||||
The admin interface for managing the SCEP
|
||||
<br /> service and viewing configuration details.
|
||||
</>
|
||||
}
|
||||
value={formData.adminUrl}
|
||||
onChange={onInputChange}
|
||||
onBlur={(e: any) => onBlur("adminUrl", e.target.value)}
|
||||
parseTarget
|
||||
error={formErrors.adminUrl}
|
||||
placeholder="https://example.com/certsrv/mscep_admin/"
|
||||
disabled={gitOpsModeEnabled}
|
||||
/>
|
||||
<InputField
|
||||
inputWrapperClass={`${baseClass}__username-input`}
|
||||
label="Username"
|
||||
name="username"
|
||||
tooltip={
|
||||
<>
|
||||
The username in the down-level logon name format
|
||||
<br />
|
||||
required to log in to the SCEP admin page.
|
||||
</>
|
||||
}
|
||||
value={formData.username}
|
||||
onChange={onInputChange}
|
||||
onBlur={(e: any) => onBlur("username", e.target.value)}
|
||||
parseTarget
|
||||
placeholder="username@example.microsoft.com"
|
||||
disabled={gitOpsModeEnabled}
|
||||
/>
|
||||
<InputField
|
||||
inputWrapperClass={`${baseClass}__password-input`}
|
||||
label="Password"
|
||||
name="password"
|
||||
tooltip={
|
||||
<>
|
||||
The password to use to log in
|
||||
<br />
|
||||
to the SCEP admin page.
|
||||
</>
|
||||
}
|
||||
value={formData.password || ""}
|
||||
type="password"
|
||||
onChange={onInputChange}
|
||||
parseTarget
|
||||
placeholder="••••••••"
|
||||
blockAutoComplete
|
||||
error={formErrors.password}
|
||||
disabled={gitOpsModeEnabled}
|
||||
/>
|
||||
<GitOpsModeTooltipWrapper
|
||||
tipOffset={-8}
|
||||
renderChildren={(disableChildren) => (
|
||||
<Button
|
||||
type="submit"
|
||||
variant="brand"
|
||||
className="button-wrap"
|
||||
isLoading={isSaving}
|
||||
disabled={disableSave || disableChildren}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
)}
|
||||
/>
|
||||
</form>
|
||||
</Card>
|
||||
</li>
|
||||
<li>
|
||||
<span>
|
||||
Now head over to{" "}
|
||||
<CustomLink
|
||||
url={PATHS.CONTROLS_CUSTOM_SETTINGS}
|
||||
text="Controls > OS Settings > Custom settings"
|
||||
/>{" "}
|
||||
to configure how SCEP certificates are delivered to your hosts.{" "}
|
||||
<CustomLink
|
||||
url="https://fleetdm.com/learn-more-about/setup-ndes"
|
||||
text="Learn more"
|
||||
newTab
|
||||
/>
|
||||
</span>
|
||||
</li>
|
||||
</ol>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
interface IScepPageProps {
|
||||
router: InjectedRouter;
|
||||
}
|
||||
|
||||
interface INdesFormData {
|
||||
scepUrl: string;
|
||||
adminUrl: string;
|
||||
username: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
interface INdesFormErrors {
|
||||
scepUrl?: string | null;
|
||||
adminUrl?: string | null;
|
||||
password?: string | null;
|
||||
}
|
||||
|
||||
export interface IFormField {
|
||||
name: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
const ScepPage = ({ router }: IScepPageProps) => {
|
||||
const { isPremiumTier, config, setConfig } = useContext(AppContext);
|
||||
|
||||
const { renderFlash } = useContext(NotificationContext);
|
||||
|
||||
const [formData, setFormData] = useState<INdesFormData>({
|
||||
scepUrl: config?.integrations.ndes_scep_proxy?.url || "",
|
||||
adminUrl: config?.integrations.ndes_scep_proxy?.admin_url || "",
|
||||
username: config?.integrations.ndes_scep_proxy?.username || "",
|
||||
password: config?.integrations.ndes_scep_proxy?.password || "",
|
||||
});
|
||||
|
||||
const [formErrors, setFormErrors] = useState<INdesFormErrors>({});
|
||||
const [isUpdatingNdesScepProxy, setIsUpdatingNdesScepProxy] = useState(false);
|
||||
|
||||
const {
|
||||
data: appConfig,
|
||||
isLoading: isLoadingAppConfig,
|
||||
refetch: refetchConfig,
|
||||
isError: isErrorAppConfig,
|
||||
} = useQuery<IConfig, Error, IConfig>(["config"], () => configAPI.loadAll(), {
|
||||
select: (data: IConfig) => data,
|
||||
onSuccess: (data) => {
|
||||
setConfig(data);
|
||||
},
|
||||
});
|
||||
|
||||
const onInputChange = ({ name, value }: IFormField) => {
|
||||
setFormErrors((prev) => ({ ...prev, [name]: null }));
|
||||
setFormData((prev) => ({ ...prev, [name]: value }));
|
||||
};
|
||||
|
||||
const handleBlur = (name: string, value: string) => {
|
||||
// If the value of admin url or username has changed and
|
||||
// it was not originally empty, prompt user to re-enter password
|
||||
if (
|
||||
(name === "adminUrl" &&
|
||||
value !== config?.integrations.ndes_scep_proxy?.admin_url &&
|
||||
config?.integrations.ndes_scep_proxy?.admin_url !== "") ||
|
||||
(name === "username" &&
|
||||
value !== config?.integrations.ndes_scep_proxy?.username &&
|
||||
config?.integrations.ndes_scep_proxy?.username !== "")
|
||||
) {
|
||||
setFormErrors((prev: INdesFormErrors) => ({
|
||||
...prev,
|
||||
password:
|
||||
"Please re-enter your password due to changes in admin URL or username",
|
||||
}));
|
||||
setFormData((prev: INdesFormData) => ({ ...prev, password: "" }));
|
||||
}
|
||||
};
|
||||
|
||||
const onFormSubmit = async (evt: React.MouseEvent<HTMLFormElement>) => {
|
||||
evt.preventDefault();
|
||||
|
||||
const scepUrlValid = validateUrl({ url: formData.scepUrl });
|
||||
const adminUrlValid = validateUrl({ url: formData.adminUrl });
|
||||
const newFormErrors = {
|
||||
scepUrl:
|
||||
scepUrlValid || formData.scepUrl === ""
|
||||
? undefined
|
||||
: "Must be a valid URL.",
|
||||
adminUrl:
|
||||
adminUrlValid || formData.adminUrl === ""
|
||||
? undefined
|
||||
: "Must be a valid URL.",
|
||||
};
|
||||
|
||||
setFormErrors(newFormErrors);
|
||||
|
||||
// Remove when all fields set to empty
|
||||
const isRemovingNdesScepProxy = Object.values(formData).every(
|
||||
(val) => val === ""
|
||||
);
|
||||
|
||||
if (!isRemovingNdesScepProxy && (!scepUrlValid || !adminUrlValid)) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsUpdatingNdesScepProxy(true);
|
||||
|
||||
// Format for API
|
||||
const formDataToSubmit = isRemovingNdesScepProxy
|
||||
? null
|
||||
: {
|
||||
url: formData.scepUrl,
|
||||
admin_url: formData.adminUrl,
|
||||
username: formData.username,
|
||||
password: formData.password,
|
||||
};
|
||||
// Update integrations.ndes_scep_proxy only
|
||||
const destination = {
|
||||
ndes_scep_proxy: formDataToSubmit,
|
||||
};
|
||||
|
||||
try {
|
||||
await configAPI.update({ integrations: destination });
|
||||
renderFlash(
|
||||
"success",
|
||||
`Successfully ${
|
||||
isRemovingNdesScepProxy ? "removed" : "added"
|
||||
} your SCEP server.`
|
||||
);
|
||||
refetchConfig();
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
const reason = getErrorReason(error);
|
||||
if (reason.includes("invalid admin URL or credentials")) {
|
||||
renderFlash("error", BAD_CREDENTIALS_ERROR);
|
||||
} else if (reason.includes("the password cache is full")) {
|
||||
renderFlash("error", CACHE_ERROR);
|
||||
} else if (reason.includes("does not have sufficient permissions")) {
|
||||
renderFlash("error", INSUFFICIENT_PERMISSIONS_ERROR);
|
||||
} else if (
|
||||
reason.includes(formData.scepUrl) &&
|
||||
reason.includes("context deadline exceeded")
|
||||
) {
|
||||
renderFlash("error", SCEP_URL_TIMEOUT_ERROR);
|
||||
} else if (
|
||||
reason.includes(formData.adminUrl) &&
|
||||
reason.includes("context deadline exceeded")
|
||||
) {
|
||||
renderFlash("error", ADMIN_URL_TIMEOUT_ERROR);
|
||||
} else if (reason.includes("invalid SCEP URL")) {
|
||||
renderFlash("error", BAD_SCEP_URL_ERROR);
|
||||
} else renderFlash("error", DEFAULT_ERROR);
|
||||
} finally {
|
||||
setIsUpdatingNdesScepProxy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<MainContent className={baseClass}>
|
||||
<>
|
||||
<BackLink
|
||||
text="Back to MDM"
|
||||
path={PATHS.ADMIN_INTEGRATIONS_MDM}
|
||||
className={`${baseClass}__back-to-mdm`}
|
||||
/>
|
||||
<div className={`${baseClass}__page-content`}>
|
||||
<div className={`${baseClass}__page-header-section`}>
|
||||
<h1>Simple Certificate Enrollment Protocol (SCEP)</h1>
|
||||
</div>
|
||||
|
||||
<ScepCertificateContent
|
||||
router={router}
|
||||
onFormSubmit={onFormSubmit}
|
||||
formData={formData}
|
||||
formErrors={formErrors}
|
||||
onInputChange={onInputChange}
|
||||
onBlur={handleBlur}
|
||||
config={appConfig || null}
|
||||
isPremiumTier={isPremiumTier || false}
|
||||
isLoading={isLoadingAppConfig}
|
||||
isSaving={isUpdatingNdesScepProxy}
|
||||
showDataError={isErrorAppConfig}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
</MainContent>
|
||||
);
|
||||
};
|
||||
|
||||
export default ScepPage;
|
||||
|
|
@ -1,68 +0,0 @@
|
|||
.scep-page {
|
||||
&__back-to-mdm {
|
||||
margin-bottom: $pad-xlarge;
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin-bottom: $pad-xxlarge;
|
||||
font-size: $x-large;
|
||||
}
|
||||
|
||||
p {
|
||||
font-size: $x-small;
|
||||
margin: 0 0 $pad-large;
|
||||
}
|
||||
|
||||
&__steps {
|
||||
font-size: $x-small;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
max-width: 660px;
|
||||
counter-reset: step-counter;
|
||||
list-style-type: none;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $pad-large;
|
||||
|
||||
form {
|
||||
gap: $pad-small;
|
||||
|
||||
button {
|
||||
align-self: flex-end;
|
||||
}
|
||||
}
|
||||
|
||||
li {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex-wrap: wrap;
|
||||
gap: $pad-small;
|
||||
position: relative;
|
||||
padding-left: $pad-medium;
|
||||
|
||||
&::before {
|
||||
content: counter(step-counter) ".";
|
||||
counter-increment: step-counter;
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
> span {
|
||||
flex: 1 0 100%;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.url-inputs-wrapper {
|
||||
flex: 1 0 100%;
|
||||
gap: $pad-small;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1 +0,0 @@
|
|||
export { default } from "./ScepPage";
|
||||
|
|
@ -65,8 +65,6 @@ import SetupExperience from "pages/ManageControlsPage/SetupExperience/SetupExper
|
|||
import WindowsMdmPage from "pages/admin/IntegrationsPage/cards/MdmSettings/WindowsMdmPage";
|
||||
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";
|
||||
|
|
@ -201,8 +199,6 @@ 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
|
||||
from="integrations/automatic-enrollment/apple"
|
||||
|
|
|
|||
|
|
@ -49,7 +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_CERTIFICATE_AUTHORITIES: `${URL_PREFIX}/settings/integrations/certificates`,
|
||||
ADMIN_INTEGRATIONS_VPP: `${URL_PREFIX}/settings/integrations/mdm/vpp`,
|
||||
ADMIN_INTEGRATIONS_VPP_SETUP: `${URL_PREFIX}/settings/integrations/vpp/setup`,
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,3 @@
|
|||
import EditCertAuthorityModal from "pages/admin/IntegrationsPage/cards/CertificateAuthorities/components/EditCertAuthorityModal";
|
||||
import configAPI from "./config";
|
||||
|
||||
export default {
|
||||
|
|
|
|||
Loading…
Reference in a new issue