Update UI for Smallstep CA feature (#33448)

This commit is contained in:
Sarah Gillespie 2025-09-26 09:26:57 -05:00 committed by GitHub
parent 3a3a0ca480
commit f2eb991644
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
15 changed files with 595 additions and 8 deletions

View file

@ -0,0 +1 @@
- Updated UI to add Smallstep certificate authority integration.

View file

@ -60,6 +60,9 @@ export enum ActivityType {
AddedHydrant = "added_hydrant",
DeletedHydrant = "deleted_hydrant",
EditedHydrant = "edited_hydrant",
AddedSmallstep = "added_smallstep",
DeletedSmallstep = "deleted_smallstep",
EditedSmallstep = "edited_smallstep",
CreatedWindowsProfile = "created_windows_profile",
DeletedWindowsProfile = "deleted_windows_profile",
EditedWindowsProfile = "edited_windows_profile",

View file

@ -78,18 +78,30 @@ export interface ICertificatesCustomSCEP {
challenge: string;
}
export interface ICertificatesSmallstep {
id?: number;
type?: "smallstep";
name: string;
url: string;
challenge_url: string;
username: string;
password: string;
}
export type ICertificateAuthorityType =
| "ndes_scep_proxy"
| "digicert"
| "custom_scep_proxy"
| "hydrant";
| "hydrant"
| "smallstep";
/** all the types of certificates */
export type ICertificateAuthority =
| ICertificatesNDES
| ICertificatesDigicert
| ICertificatesHydrant
| ICertificatesCustomSCEP;
| ICertificatesCustomSCEP
| ICertificatesSmallstep;
export const isNDESCertAuthority = (
integration: ICertificateAuthority
@ -130,3 +142,15 @@ export const isCustomSCEPCertAuthority = (
"name" in integration && "url" in integration && "challenge" in integration
);
};
export const isSmallstepCertAuthority = (
integration: ICertificateAuthority
): integration is ICertificatesSmallstep => {
return (
"name" in integration &&
"url" in integration &&
"challenge_url" in integration &&
"username" in integration &&
"password" in integration
);
};

View file

@ -1656,19 +1656,22 @@ const getDetail = (activity: IActivity, isPremiumTier: boolean) => {
}
case ActivityType.AddedCustomScepProxy:
case ActivityType.AddedDigicert:
case ActivityType.AddedHydrant: {
case ActivityType.AddedHydrant:
case ActivityType.AddedSmallstep: {
return TAGGED_TEMPLATES.addedCertificateAuthority(activity.details?.name);
}
case ActivityType.DeletedCustomScepProxy:
case ActivityType.DeletedDigicert:
case ActivityType.DeletedHydrant: {
case ActivityType.DeletedHydrant:
case ActivityType.DeletedSmallstep: {
return TAGGED_TEMPLATES.deletedCertificateAuthority(
activity.details?.name
);
}
case ActivityType.EditedCustomScepProxy:
case ActivityType.EditedDigicert:
case ActivityType.EditedHydrant: {
case ActivityType.EditedHydrant:
case ActivityType.EditedSmallstep: {
return TAGGED_TEMPLATES.editedCertificateAuthority(
activity.details?.name
);

View file

@ -25,12 +25,14 @@ import CustomSCEPForm from "../CustomSCEPForm";
import { ICustomSCEPFormData } from "../CustomSCEPForm/CustomSCEPForm";
import HydrantForm from "../HydrantForm";
import { IHydrantFormData } from "../HydrantForm/HydrantForm";
import { ISmallstepFormData } from "../SmallstepForm/SmallstepForm";
export type ICertFormData =
| IDigicertFormData
| IHydrantFormData
| INDESFormData
| ICustomSCEPFormData;
| ICustomSCEPFormData
| ISmallstepFormData;
const baseClass = "add-cert-authority-modal";
@ -79,6 +81,16 @@ const AddCertAuthorityModal = ({
scepURL: "",
challenge: "",
});
const [
smallstepFormData,
setSmallstepFormData,
] = useState<ISmallstepFormData>({
name: "",
scepURL: "",
challengeURL: "",
username: "",
password: "",
});
const onChangeDropdown = (value: ICertificateAuthorityType) => {
setCertAuthorityType(value);
@ -104,6 +116,10 @@ const AddCertAuthorityModal = ({
setFormData = setCustomSCEPFormData;
formData = customSCEPFormData;
break;
case "smallstep":
setFormData = setSmallstepFormData;
formData = smallstepFormData;
break;
default:
return;
}
@ -129,6 +145,9 @@ const AddCertAuthorityModal = ({
case "custom_scep_proxy":
formData = customSCEPFormData;
break;
case "smallstep":
formData = smallstepFormData;
break;
default:
return;
}

View file

@ -13,7 +13,9 @@ import { ICertFormData } from "../AddCertAuthorityModal/AddCertAuthorityModal";
import { INDESFormData } from "../NDESForm/NDESForm";
import { ICustomSCEPFormData } from "../CustomSCEPForm/CustomSCEPForm";
import { IHydrantFormData } from "../HydrantForm/HydrantForm";
import { ISmallstepFormData } from "../SmallstepForm/SmallstepForm";
// FIXME: do we care about the order of these? Should we alphabetize them or something?
const DEFAULT_CERT_AUTHORITY_OPTIONS: IDropdownOption[] = [
{ label: "DigiCert", value: "digicert" },
{
@ -28,6 +30,7 @@ const DEFAULT_CERT_AUTHORITY_OPTIONS: IDropdownOption[] = [
label: "Custom SCEP (Simple Certificate Enrollment Protocol)",
value: "custom_scep_proxy",
},
{ label: "Smallstep", value: "smallstep" },
];
/**
@ -131,6 +134,24 @@ export const generateAddCertAuthorityData = (
client_secret: clientSecret,
},
};
case "smallstep":
// eslint-disable-next-line no-case-declarations
const {
name: smallstepName,
scepURL: smallstepScepURL,
challengeURL,
username: smallstepUsername,
password: smallstepPassword,
} = formData as ISmallstepFormData;
return {
smallstep: {
name: smallstepName,
url: smallstepScepURL,
challenge_url: challengeURL,
username: smallstepUsername,
password: smallstepPassword,
},
};
default:
return undefined;
}

View file

@ -34,6 +34,9 @@ export const generateListData = (
case "hydrant":
description = "Hydrant (EST - Enrollment Over Secure Transport) ";
break;
case "smallstep":
description = "Smallstep";
break;
default:
description = "Unknown Certificate Authority Type";
}

View file

@ -22,6 +22,7 @@ import { ICertFormData } from "../AddCertAuthorityModal/AddCertAuthorityModal";
import NDESForm from "../NDESForm";
import CustomSCEPForm from "../CustomSCEPForm";
import HydrantForm from "../HydrantForm";
import SmallstepForm from "../SmallstepForm";
const baseClass = "edit-cert-authority-modal";
@ -92,6 +93,16 @@ const EditCertAuthorityModal = ({
if (certAuthority.type === "hydrant") {
return HydrantForm;
}
if (certAuthority.type === "smallstep") {
return SmallstepForm;
}
// FIXME: seems like we have some competing patterns in here where we sometimes do switch
// statements with a default and sometimes do if or if/else if with a final default return. We
// should probably standardize on one or the other. Also, do we really want this to be the
// default? Why not have an explicit check for custom_scep_proxy and have the final
// else throw an error?
return CustomSCEPForm;
};

View file

@ -11,6 +11,7 @@ import { IDigicertFormData } from "../DigicertForm/DigicertForm";
import { INDESFormData } from "../NDESForm/NDESForm";
import { ICustomSCEPFormData } from "../CustomSCEPForm/CustomSCEPForm";
import { IHydrantFormData } from "../HydrantForm/HydrantForm";
import { ISmallstepFormData } from "../SmallstepForm/SmallstepForm";
export const generateDefaultFormData = (
certAuthority: ICertificateAuthority
@ -40,8 +41,22 @@ export const generateDefaultFormData = (
clientId: certAuthority.client_id,
clientSecret: certAuthority.client_secret,
};
} else if (certAuthority.type === "smallstep") {
return {
name: certAuthority.name,
scepURL: certAuthority.url,
challengeURL: certAuthority.challenge_url,
username: certAuthority.username,
password: certAuthority.password,
};
}
// FIXME: seems like we have some competing patterns in here where we sometimes do switch
// statements with a default and sometimes do if or if/else if with a final default return. We
// should probably standardize on one or the other. Also, do we really want this to be the
// default? Why not have an explicit check for custom_scep_proxy and have the final
// else throw an error?
const customSCEPcert = certAuthority as ICertificatesCustomSCEP;
return {
name: customSCEPcert.name,
@ -122,6 +137,30 @@ export const generateEditCertAuthorityData = (
certAuthWithoutType
),
};
case "smallstep":
// eslint-disable-next-line no-case-declarations
const {
name: smallstepName,
scepURL: smallstepURL,
challengeURL: smallstepChallengeURL,
username: smallstepUsername,
password: smallstepPassword,
} = formData as ISmallstepFormData;
return {
smallstep: deepDifference(
{
name: smallstepName,
scep_url: smallstepURL,
challenge_url: smallstepChallengeURL,
username: smallstepUsername,
password: smallstepPassword,
},
certAuthWithoutType
),
};
// FIXME: do we really want this to be the default? why not have an explicit case for
// custom_scep_proxy and have the default throw an error?
default:
// custom_scep_proxy
// eslint-disable-next-line no-case-declarations
@ -198,6 +237,19 @@ export const updateFormData = (
formData.clientSecret === "********" ? "" : formData.clientSecret,
};
}
} else if (certAuthority.type === "smallstep") {
const formData = prevFormData as ISmallstepFormData;
if (
update.name === "name" ||
update.name === "scepURL" ||
update.name === "challengeURL" ||
update.name === "username"
) {
return {
...newData,
password: formData.password === "********" ? "" : formData.password,
};
}
}
return newData;

View file

@ -0,0 +1,113 @@
import React from "react";
import { noop } from "lodash";
import { render, screen } from "@testing-library/react";
import SmallstepForm, { ISmallstepFormData } from "./SmallstepForm";
const createTestFormData = (overrides?: Partial<ISmallstepFormData>) => ({
name: "TEST_NAME",
scepURL: "https://test.com",
challengeURL: "https://test.com/challenge",
username: "testuser",
password: "testpassword",
...overrides,
});
describe("SmallstepForm", () => {
it("render the custom button text", () => {
render(
<SmallstepForm
formData={createTestFormData()}
isSubmitting={false}
submitBtnText="Submit"
onChange={noop}
onSubmit={noop}
onCancel={noop}
/>
);
expect(screen.getByRole("button", { name: "Submit" })).toBeVisible();
});
it("enables submission depending on the form validation", async () => {
const testData = createTestFormData();
render(
<SmallstepForm
formData={testData}
isSubmitting={false}
submitBtnText="Submit"
onChange={noop}
onSubmit={noop}
onCancel={noop}
/>
);
// data is valid, so submit should be enabled
expect(screen.getByRole("button", { name: "Submit" })).toBeEnabled();
});
it("disables submission when form is invalid", async () => {
const testData = createTestFormData();
// make name invalid by setting it to an empty string
testData.name = "";
render(
<SmallstepForm
formData={testData}
isSubmitting={false}
submitBtnText="Submit"
onChange={noop}
onSubmit={noop}
onCancel={noop}
/>
);
// name is required, so submit should be disabled
expect(screen.getByRole("button", { name: "Submit" })).toBeDisabled();
});
it("disables submit when isSubmitting is set to true", () => {
render(
<SmallstepForm
formData={createTestFormData()}
isSubmitting
submitBtnText="Submit"
onChange={noop}
onSubmit={noop}
onCancel={noop}
/>
);
expect(screen.getByRole("button", { name: "Submit" })).toBeDisabled();
});
it("submit button is disabled if isDirty is false", () => {
render(
<SmallstepForm
formData={createTestFormData()}
isSubmitting={false}
submitBtnText="Submit"
isDirty={false}
onChange={noop}
onSubmit={noop}
onCancel={noop}
/>
);
expect(screen.getByRole("button", { name: "Submit" })).toBeDisabled();
});
it("submit button is enabled if isDirty", () => {
render(
<SmallstepForm
formData={createTestFormData()}
isSubmitting={false}
submitBtnText="Submit"
isDirty
onChange={noop}
onSubmit={noop}
onCancel={noop}
/>
);
expect(screen.getByRole("button", { name: "Submit" })).toBeEnabled();
});
});

View file

@ -0,0 +1,151 @@
import React, { useMemo } from "react";
import { ICertificateAuthorityPartial } from "interfaces/certificates";
// @ts-ignore
import InputField from "components/forms/fields/InputField";
import Button from "components/buttons/Button";
import TooltipWrapper from "components/TooltipWrapper";
import { generateFormValidations, validateFormData } from "./helpers";
const baseClass = "smallstep-form";
export interface ISmallstepFormData {
name: string;
scepURL: string;
challengeURL: string;
username: string;
password: string;
}
interface ISmallstepFormProps {
certAuthorities?: ICertificateAuthorityPartial[];
formData: ISmallstepFormData;
submitBtnText: string;
isSubmitting: boolean;
isEditing?: boolean;
isDirty?: boolean;
onChange: (update: { name: string; value: string }) => void;
onSubmit: () => void;
onCancel: () => void;
}
const SmallstepForm = ({
certAuthorities,
formData,
submitBtnText,
isSubmitting,
isEditing = false,
isDirty = true,
onChange,
onSubmit,
onCancel,
}: ISmallstepFormProps) => {
const validationsConfig = useMemo(() => {
return generateFormValidations(certAuthorities ?? [], isEditing);
}, [certAuthorities, isEditing]);
const validations = useMemo(() => {
return validateFormData(formData, validationsConfig);
}, [formData, validationsConfig]);
const { name, scepURL, challengeURL, username, password } = formData;
const onSubmitForm = (evt: React.FormEvent<HTMLFormElement>) => {
evt.preventDefault();
onSubmit();
};
return (
<form onSubmit={onSubmitForm}>
<div className={`${baseClass}__fields`}>
<InputField
label="Name"
name="name"
value={name}
error={validations.name?.message}
onChange={onChange}
parseTarget
placeholder="WIFI_CERTIFICATE"
helpText="Letters, numbers, and underscores only. Fleet will create configuration profile variables with the name as suffix (e.g. $FLEET_VAR_SMALLSTEP_DATA_WIFI_CERTIFICATE)."
/>
<InputField
label="SCEP URL"
name="scepURL"
value={scepURL}
error={validations.scepURL?.message}
onChange={onChange}
parseTarget
placeholder="https://example.scep.smallstep.com/p/agents/integration-fleet-xr9f4db7"
/>
<InputField
label="Challenge URL"
name="challengeURL"
value={challengeURL}
error={validations.challengeURL?.message}
onChange={onChange}
parseTarget
placeholder="https://example.scep.smallstep.com/fleet/xr9f4db7-83f1-48ab-8982-8b6870d4fl85/challenge"
helpText={
<>
Smallstep calls this the <b>SCEP Challenge URL</b>.
</>
}
/>
<InputField
label="Username"
name="username"
value={username}
error={validations.username?.message}
onChange={onChange}
parseTarget
placeholder={"r9c5faea-af93-4679-922c-5548c6254438"}
helpText={
<>
Smallstep calls this the{" "}
<b>Challenge Basic Authentication Username</b>.
</>
}
/>
<InputField
type="password"
label="Password"
name="password"
value={password}
error={validations.password?.message}
onChange={onChange}
parseTarget
helpText={
<>
Smallstep calls this the{" "}
<b>Challenge Basic Authentication Password</b>.
</>
}
/>
</div>
<div className={`${baseClass}__cta`}>
<TooltipWrapper
tipContent="Complete all required fields to save."
underline={false}
position="top"
disableTooltip={validations.isValid}
showArrow
>
<Button
type="submit"
isLoading={isSubmitting}
disabled={!validations.isValid || isSubmitting || !isDirty}
>
{submitBtnText}
</Button>
</TooltipWrapper>
<Button variant="inverse" onClick={onCancel}>
Cancel
</Button>
</div>
</form>
);
};
export default SmallstepForm;

View file

@ -0,0 +1,13 @@
.smallstep-form {
&__fields {
display: flex;
flex-direction: column;
gap: $pad-large;
}
&__cta {
display: flex;
flex-direction: row-reverse;
gap: $pad-medium;
}
}

View file

@ -0,0 +1,169 @@
import { ICertificateAuthorityPartial } from "interfaces/certificates";
import valid_url from "components/forms/validators/valid_url";
import { ISmallstepFormData } from "./SmallstepForm";
// TODO: create a validator abstraction for this and the other form validation files
export interface ISmallstepFormValidation {
isValid: boolean;
name?: { isValid: boolean; message?: string };
scepURL?: { isValid: boolean; message?: string };
challengeURL?: { isValid: boolean; message?: string };
username?: { isValid: boolean; message?: string };
password?: { isValid: boolean; message?: string };
}
type IMessageFunc = (formData: ISmallstepFormData) => string;
type IValidationMessage = string | IMessageFunc;
type IFormValidationKey = keyof Omit<ISmallstepFormValidation, "isValid">;
interface IValidation {
name: string;
isValid: (formData: ISmallstepFormData) => boolean;
message?: IValidationMessage;
}
type IFormValidations = Record<
IFormValidationKey,
{ validations: IValidation[] }
>;
export const generateFormValidations = (
smallstepIntegrations: ICertificateAuthorityPartial[],
isEditing: boolean
) => {
const FORM_VALIDATIONS: IFormValidations = {
name: {
validations: [
{
name: "required",
isValid: (formData: ISmallstepFormData) => {
return formData.name.length > 0;
},
},
{
name: "invalidCharacters",
isValid: (formData: ISmallstepFormData) => {
return /^[a-zA-Z0-9_]+$/.test(formData.name);
},
message:
"Invalid characters. Only letters, numbers and underscores allowed.",
},
{
name: "unique",
isValid: (formData: ISmallstepFormData) => {
return (
isEditing ||
smallstepIntegrations.find(
(cert) => cert.name === formData.name
) === undefined
);
},
message: "Name is already used by another custom SCEP CA.",
},
],
},
scepURL: {
validations: [
{
name: "required",
isValid: (formData: ISmallstepFormData) => {
return formData.scepURL.length > 0;
},
},
{
name: "validUrl",
isValid: (formData: ISmallstepFormData) => {
return valid_url({ url: formData.scepURL });
},
message: "Must be a valid URL.",
},
],
},
challengeURL: {
validations: [
{
name: "required",
isValid: (formData: ISmallstepFormData) => {
return formData.challengeURL.length > 0;
},
},
{
name: "validUrl",
isValid: (formData: ISmallstepFormData) => {
return valid_url({ url: formData.challengeURL });
},
message: "Must be a valid URL.",
},
],
},
username: {
validations: [
{
name: "required",
isValid: (formData: ISmallstepFormData) => {
return formData.username.length > 0;
},
},
],
},
password: {
validations: [
{
name: "required",
isValid: (formData: ISmallstepFormData) => {
return formData.password.length > 0;
},
},
],
},
};
return FORM_VALIDATIONS;
};
const getErrorMessage = (
formData: ISmallstepFormData,
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: ISmallstepFormData,
validationConfig: IFormValidations
) => {
const formValidation: ISmallstepFormValidation = {
isValid: true,
};
Object.keys(validationConfig).forEach((key) => {
const objKey = key as keyof typeof validationConfig;
const failedValidation = validationConfig[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),
};
}
console.log("objKey", objKey);
console.log("formValidation", formValidation);
});
return formValidation;
};

View file

@ -0,0 +1 @@
export { default } from "./SmallstepForm";

View file

@ -7,6 +7,7 @@ import {
ICertificatesDigicert,
ICertificatesHydrant,
ICertificatesNDES,
ICertificatesSmallstep,
} from "interfaces/certificates";
type IGetCertAuthoritiesListResponse = {
@ -25,13 +26,15 @@ export type IAddCertAuthorityBody =
| { digicert: ICertificatesDigicert }
| { ndes_scep_proxy: ICertificatesNDES }
| { custom_scep_proxy: ICertificatesCustomSCEP }
| { hydrant: ICertificatesHydrant };
| { hydrant: ICertificatesHydrant }
| { smallstep: ICertificatesSmallstep };
export type IEditCertAuthorityBody =
| { digicert: Partial<ICertificatesDigicert> }
| { ndes_scep_proxy: Partial<ICertificatesNDES> }
| { custom_scep_proxy: Partial<ICertificatesCustomSCEP> }
| { hydrant: Partial<ICertificatesHydrant> };
| { hydrant: Partial<ICertificatesHydrant> }
| { smallstep: Partial<ICertificatesSmallstep> };
export default {
getCertificateAuthoritiesList: (): Promise<IGetCertAuthoritiesListResponse> => {