fixes for various UI issues with cert authority feature (#27341)

For #26606

various fixes to the UI for the cert authority feature

- [x] Manual QA for all new/changed functionality
This commit is contained in:
Gabriel Hernandez 2025-03-20 19:10:48 +00:00 committed by GitHub
parent 49d4c1f7d2
commit 549c41e53d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 322 additions and 187 deletions

View file

@ -1,5 +1,4 @@
.add-cert-authority-modal {
&__cert-authority-dropdown {
margin-bottom: $pad-large;
}

View file

@ -1,4 +1,8 @@
import React from "react";
import CustomLink from "components/CustomLink";
import { IDropdownOption } from "interfaces/dropdownOption";
import { getErrorReason } from "interfaces/errors";
const DEFAULT_CERT_AUTHORITY_OPTIONS: IDropdownOption[] = [
{ label: "Digicert", value: "digicert" },
@ -24,10 +28,66 @@ export const generateDropdownOptions = (hasNDESCert: boolean) => {
return DEFAULT_CERT_AUTHORITY_OPTIONS;
};
const DEFAULT_ERROR_MESSAGE =
"Couldn't add certificate authority. Please try again.";
/**
* errors used in the add certificate authority flow
*/
const DEFAULT_ERROR = "Please try again.";
const INVALID_API_TOKEN_ERROR =
"Invalid API token. Please correct and try again.";
const INVALID_PROFILE_GUID_ERROR =
"Invalid profile GUID. Please correct and try again.";
const INVALID_URL_ERROR = "Invalid URL. Please correct and try again.";
const PRIVATE_KEY_NOT_CONFIGURED_ERROR = (
<>
Private key must be configured.{" "}
<CustomLink
text="Learn more"
url="https://learn-more-about/fleet-server-private-key"
newTab
variant="flash-message-link"
/>
</>
);
const INVALID_SCEP_URL_ERROR =
"Invalid SCEP URL. Please correct and try again.";
const INVALID_ADMIN_URL_OR_CREDENTIALS_ERROR =
"Invalid admin URL or credentials. Please correct and try again.";
const NDES_PASSWORD_CACHE_FULL_ERROR =
"The NDES password cache is full. Please increase the number of cached passwords in NDES and try again.";
const INVALID_CHALLENGE_ERROR =
"Invalid challenge. Please correct and try again.";
// eslint-disable-next-line import/prefer-default-export
export const getErrorMessage = (e: unknown) => {
return DEFAULT_ERROR_MESSAGE;
/**
* Gets the error message we want to display from the api error message.
* This is used in both add and edit certificate authority flows.
*/
export const getDisplayErrMessage = (err: unknown) => {
let message: string | JSX.Element = DEFAULT_ERROR;
const reason = getErrorReason(err);
if (reason.includes("invalid API token")) {
message = INVALID_API_TOKEN_ERROR;
} else if (reason.includes("invalid profile GUID")) {
message = INVALID_PROFILE_GUID_ERROR;
} else if (reason.includes("invalid URL")) {
message = INVALID_URL_ERROR;
} else if (reason.includes("private key")) {
message = PRIVATE_KEY_NOT_CONFIGURED_ERROR;
} else if (reason.includes("invalid SCEP URL")) {
message = INVALID_SCEP_URL_ERROR;
} else if (reason.includes("invalid admin URL or credentials")) {
message = INVALID_ADMIN_URL_OR_CREDENTIALS_ERROR;
} else if (reason.includes("password cache is full")) {
message = NDES_PASSWORD_CACHE_FULL_ERROR;
} else if (reason.includes("invalid challenge")) {
message = INVALID_CHALLENGE_ERROR;
} else {
message = DEFAULT_ERROR;
}
return message;
};
export const getErrorMessage = (err: unknown) => {
return `Couldn't add certificate authority. ${getDisplayErrMessage(err)}`;
};

View file

@ -1,11 +1,17 @@
import React, { useState } from "react";
import React, { useContext, useMemo, useState } from "react";
import { AppContext } from "context/app";
// @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";
import {
generateFormValidations,
ICustomSCEPFormValidation,
validateFormData,
} from "./helpers";
const baseClass = "ndes-form";
@ -19,6 +25,7 @@ interface ICustomSCEPFormProps {
formData: ICustomSCEPFormData;
submitBtnText: string;
isSubmitting: boolean;
isEditing?: boolean;
onChange: (update: { name: string; value: string }) => void;
onSubmit: () => void;
onCancel: () => void;
@ -28,10 +35,20 @@ const CustomSCEPForm = ({
formData,
submitBtnText,
isSubmitting,
isEditing = false,
onChange,
onSubmit,
onCancel,
}: ICustomSCEPFormProps) => {
const { config } = useContext(AppContext);
const validations = useMemo(
() =>
generateFormValidations(
config?.integrations.custom_scep_proxy ?? [],
isEditing
),
[config?.integrations.custom_scep_proxy]
);
const [
formValidation,
setFormValidation,
@ -48,7 +65,10 @@ const CustomSCEPForm = ({
const onInputChange = (update: { name: string; value: string }) => {
setFormValidation(
validateFormData({ ...formData, [update.name]: update.value })
validateFormData(
{ ...formData, [update.name]: update.value },
validations
)
);
onChange(update);
};

View file

@ -1,3 +1,5 @@
import { ICertificatesIntegrationCustomSCEP } from "interfaces/integration";
import valid_url from "components/forms/validators/valid_url";
import { ICustomSCEPFormData } from "./CustomSCEPForm";
@ -21,56 +23,76 @@ interface IValidation {
message?: IValidationMessage;
}
const FORM_VALIDATIONS: Record<
type IFormValidations = Record<
IFormValidationKey,
{ validations: IValidation[] }
> = {
name: {
validations: [
{
name: "required",
isValid: (formData: ICustomSCEPFormData) => {
return formData.name.length > 0;
>;
export const generateFormValidations = (
customSCEPIntegrations: ICertificatesIntegrationCustomSCEP[],
isEditing: boolean
) => {
const FORM_VALIDATIONS: IFormValidations = {
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);
{
name: "invalidCharacters",
isValid: (formData: ICustomSCEPFormData) => {
return /^[a-zA-Z0-9_]+$/.test(formData.name);
},
message:
"Invalid characters. Only letters, numbers and underscores allowed.",
},
message:
"Inalid characters. Only letters, numbers and underscores allowed.",
},
],
},
scepURL: {
validations: [
{
name: "required",
isValid: (formData: ICustomSCEPFormData) => {
return formData.scepURL.length > 0;
{
name: "unique",
isValid: (formData: ICustomSCEPFormData) => {
return (
isEditing ||
customSCEPIntegrations.find(
(cert) => cert.name === formData.name
) === undefined
);
},
message: "Name is already used by another custom SCEP CA.",
},
},
{
name: "validUrl",
isValid: (formData: ICustomSCEPFormData) => {
return valid_url({ url: formData.scepURL });
],
},
scepURL: {
validations: [
{
name: "required",
isValid: (formData: ICustomSCEPFormData) => {
return formData.scepURL.length > 0;
},
},
message: (formData: ICustomSCEPFormData) =>
`${formData.scepURL} is not a valid URL`,
},
],
},
challenge: {
validations: [
{
name: "required",
isValid: (formData: ICustomSCEPFormData) => {
return formData.challenge.length > 0;
{
name: "validUrl",
isValid: (formData: ICustomSCEPFormData) => {
return valid_url({ url: formData.scepURL });
},
message: "Must be a valid URL.",
},
},
],
},
],
},
challenge: {
validations: [
{
name: "required",
isValid: (formData: ICustomSCEPFormData) => {
return formData.challenge.length > 0;
},
},
],
},
};
return FORM_VALIDATIONS;
};
const getErrorMessage = (
@ -84,14 +106,17 @@ const getErrorMessage = (
};
// eslint-disable-next-line import/prefer-default-export
export const validateFormData = (formData: ICustomSCEPFormData) => {
export const validateFormData = (
formData: ICustomSCEPFormData,
validationConfig: IFormValidations
) => {
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(
Object.keys(validationConfig).forEach((key) => {
const objKey = key as keyof typeof validationConfig;
const failedValidation = validationConfig[objKey].validations.find(
(validation) => !validation.isValid(formData)
);
@ -110,39 +135,3 @@ export const validateFormData = (formData: ICustomSCEPFormData) => {
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;
// };

View file

@ -1,11 +1,17 @@
import React, { useState } from "react";
import React, { useContext, useMemo, useState } from "react";
import { AppContext } from "context/app";
// @ts-ignore
import InputField from "components/forms/fields/InputField";
import Button from "components/buttons/Button";
import CustomLink from "components/CustomLink";
import TooltipWrapper from "components/TooltipWrapper";
import { validateFormData, IDigicertFormValidation } from "./helpers";
import {
validateFormData,
IDigicertFormValidation,
generateFormValidations,
} from "./helpers";
const baseClass = "digicert-form";
@ -23,6 +29,7 @@ interface IDigicertFormProps {
formData: IDigicertFormData;
submitBtnText: string;
isSubmitting: boolean;
isEditing?: boolean;
onChange: (update: { name: string; value: string }) => void;
onSubmit: () => void;
onCancel: () => void;
@ -32,10 +39,18 @@ const DigicertForm = ({
formData,
submitBtnText,
isSubmitting,
isEditing = false,
onChange,
onSubmit,
onCancel,
}: IDigicertFormProps) => {
const { config } = useContext(AppContext);
const validations = useMemo(
() =>
generateFormValidations(config?.integrations.digicert ?? [], isEditing),
[config?.integrations.digicert, isEditing]
);
const [formValidation, setFormValidation] = useState<IDigicertFormValidation>(
{
isValid: false,
@ -59,7 +74,10 @@ const DigicertForm = ({
const onInputChange = (update: { name: string; value: string }) => {
setFormValidation(
validateFormData({ ...formData, [update.name]: update.value })
validateFormData(
{ ...formData, [update.name]: update.value },
validations
)
);
onChange(update);
};

View file

@ -1,4 +1,7 @@
import { ICertificatesIntegrationDigicert } from "interfaces/integration";
import valid_url from "components/forms/validators/valid_url";
import { IDigicertFormData } from "./DigicertForm";
// TODO: create a validator abstraction for this and the other form validation files
@ -23,86 +26,105 @@ interface IValidation {
message?: IValidationMessage;
}
const FORM_VALIDATIONS: Record<
type IFormValidations = Record<
IFormValidationKey,
{ validations: IValidation[] }
> = {
name: {
validations: [
{
name: "required",
isValid: (formData: IDigicertFormData) => {
return formData.name.length > 0;
>;
export const generateFormValidations = (
digicertIntegrations: ICertificatesIntegrationDigicert[],
isEditing: boolean
) => {
const FORM_VALIDATIONS: IFormValidations = {
name: {
validations: [
{
name: "required",
isValid: (formData: IDigicertFormData) => {
return formData.name.length > 0;
},
},
},
{
name: "invalidCharacters",
isValid: (formData: IDigicertFormData) => {
return /^[a-zA-Z0-9_]+$/.test(formData.name);
{
name: "invalidCharacters",
isValid: (formData: IDigicertFormData) => {
return /^[a-zA-Z0-9_]+$/.test(formData.name);
},
message:
"Invalid characters. Only letters, numbers and underscores allowed.",
},
message:
"Inalid characters. Only letters, numbers and underscores allowed.",
},
],
},
url: {
validations: [
{
name: "required",
isValid: (formData: IDigicertFormData) => {
return formData.url.length > 0;
{
name: "unique",
isValid: (formData: IDigicertFormData) => {
return (
isEditing ||
digicertIntegrations.find(
(cert) => cert.name === formData.name
) === undefined
);
},
message: "Name is already used by another DigiCert CA.",
},
},
{
name: "validUrl",
isValid: (formData: IDigicertFormData) => {
return valid_url({ url: formData.url });
],
},
url: {
validations: [
{
name: "required",
isValid: (formData: IDigicertFormData) => {
return formData.url.length > 0;
},
},
message: (formData: IDigicertFormData) =>
`${formData.url} is not a valid URL`,
},
],
},
apiToken: {
validations: [
{
name: "required",
isValid: (formData: IDigicertFormData) => {
return formData.apiToken.length > 0;
{
name: "validUrl",
isValid: (formData: IDigicertFormData) => {
return valid_url({ url: formData.url });
},
message: "Must be a valid URL.",
},
},
],
},
profileId: {
validations: [
{
name: "required",
isValid: (formData: IDigicertFormData) => {
return formData.profileId.length > 0;
],
},
apiToken: {
validations: [
{
name: "required",
isValid: (formData: IDigicertFormData) => {
return formData.apiToken.length > 0;
},
},
},
],
},
commonName: {
validations: [
{
name: "required",
isValid: (formData: IDigicertFormData) => {
return formData.commonName.length > 0;
],
},
profileId: {
validations: [
{
name: "required",
isValid: (formData: IDigicertFormData) => {
return formData.profileId.length > 0;
},
},
},
],
},
certificateSeatId: {
validations: [
{
name: "required",
isValid: (formData: IDigicertFormData) => {
return formData.certificateSeatId.length > 0;
],
},
commonName: {
validations: [
{
name: "required",
isValid: (formData: IDigicertFormData) => {
return formData.commonName.length > 0;
},
},
},
],
},
],
},
certificateSeatId: {
validations: [
{
name: "required",
isValid: (formData: IDigicertFormData) => {
return formData.certificateSeatId.length > 0;
},
},
],
},
};
return FORM_VALIDATIONS;
};
const getErrorMessage = (
@ -115,15 +137,17 @@ const getErrorMessage = (
return message(formData);
};
// eslint-disable-next-line import/prefer-default-export
export const validateFormData = (formData: IDigicertFormData) => {
export const validateFormData = (
formData: IDigicertFormData,
validationConfig: IFormValidations
) => {
const formValidation: IDigicertFormValidation = {
isValid: true,
};
Object.keys(FORM_VALIDATIONS).forEach((key) => {
const objKey = key as keyof typeof FORM_VALIDATIONS;
const failedValidation = FORM_VALIDATIONS[objKey].validations.find(
Object.keys(validationConfig).forEach((key) => {
const objKey = key as keyof typeof validationConfig;
const failedValidation = validationConfig[objKey].validations.find(
(validation) => !validation.isValid(formData)
);

View file

@ -13,7 +13,7 @@ import Modal from "components/Modal";
import {
generateDefaultFormData,
generateErrorMessage,
getErrorMessage,
getCertificateAuthorityType,
} from "./helpers";
@ -68,7 +68,7 @@ const EditCertAuthorityModal = ({
onExit();
setConfig(newConfig);
} catch (e) {
renderFlash("error", generateErrorMessage(e));
renderFlash("error", getErrorMessage(e));
}
setIsUpdating(false);
};
@ -93,6 +93,7 @@ const EditCertAuthorityModal = ({
formData={formData}
submitBtnText="Save"
isSubmitting={isUpdating}
isEditing
onChange={onChangeForm}
onSubmit={onEditCertAuthority}
onCancel={onExit}

View file

@ -2,22 +2,13 @@ import {
ICertificateAuthorityType,
ICertificateIntegration,
ICertificatesIntegrationCustomSCEP,
ICertificatesIntegrationDigicert,
ICertificatesIntegrationNDES,
isCustomSCEPCertIntegration,
isDigicertCertIntegration,
isNDESCertIntegration,
} from "interfaces/integration";
import { ICertFormData } from "../AddCertAuthorityModal/AddCertAuthorityModal";
const DEFAULT_ERROR_MESSAGE =
"Couldn't edit certificate authority. Please try again.";
// eslint-disable-next-line import/prefer-default-export
export const generateErrorMessage = (e: unknown) => {
return DEFAULT_ERROR_MESSAGE;
};
import { getDisplayErrMessage } from "../AddCertAuthorityModal/helpers";
export const getCertificateAuthorityType = (
certAuthority: ICertificateIntegration
@ -56,3 +47,7 @@ export const generateDefaultFormData = (
challenge: customSCEPcert.challenge,
};
};
export const getErrorMessage = (err: unknown) => {
return `Couldn't edit certificate authority. ${getDisplayErrMessage(err)}`;
};

View file

@ -20,6 +20,7 @@ interface INDESFormProps {
formData: INDESFormData;
submitBtnText: string;
isSubmitting: boolean;
isEditing?: boolean;
onChange: (update: { name: string; value: string }) => void;
onSubmit: () => void;
onCancel: () => void;
@ -29,6 +30,7 @@ const NDESForm = ({
formData,
submitBtnText,
isSubmitting,
isEditing = false,
onChange,
onSubmit,
onCancel,
@ -58,6 +60,7 @@ const NDESForm = ({
label="SCEP URL"
name="scepURL"
value={scepURL}
error={formValidation.scepURL?.message}
onChange={onInputChange}
parseTarget
placeholder="https://example.com/certsrv/mscep/mscep.dll"
@ -67,6 +70,7 @@ const NDESForm = ({
label="Admin URL"
name="adminURL"
value={adminURL}
error={formValidation.adminURL?.message}
onChange={onInputChange}
parseTarget
placeholder="https://example.com/certsrv/mscep_admin/"

View file

@ -1,12 +1,15 @@
import { getErrorReason } from "interfaces/errors";
import valid_url from "components/forms/validators/valid_url";
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 };
scepURL?: { isValid: boolean; message?: string };
adminURL?: { isValid: boolean; message?: string };
username?: { isValid: boolean };
password?: { isValid: boolean };
}
@ -33,6 +36,13 @@ const FORM_VALIDATIONS: Record<
return formData.scepURL.length > 0;
},
},
{
name: "validUrl",
isValid: (formData: INDESFormData) => {
return valid_url({ url: formData.scepURL });
},
message: "Must be a valid URL.",
},
],
},
adminURL: {
@ -43,6 +53,13 @@ const FORM_VALIDATIONS: Record<
return formData.adminURL.length > 0;
},
},
{
name: "validUrl",
isValid: (formData: INDESFormData) => {
return valid_url({ url: formData.adminURL });
},
message: "Must be a valid URL",
},
],
},
username: {
@ -67,6 +84,16 @@ const FORM_VALIDATIONS: Record<
},
};
const getValifationErrorMessage = (
formData: INDESFormData,
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: INDESFormData) => {
const formValidation: INDESFormValidation = {
@ -87,6 +114,7 @@ export const validateFormData = (formData: INDESFormData) => {
formValidation.isValid = false;
formValidation[objKey] = {
isValid: false,
message: getValifationErrorMessage(formData, failedValidation.message),
};
}
});