diff --git a/frontend/pages/ManageControlsPage/OSSettings/cards/Certificates/components/AddCertificateModal/AddCertificateModal.tests.tsx b/frontend/pages/ManageControlsPage/OSSettings/cards/Certificates/components/AddCertificateModal/AddCertificateModal.tests.tsx index 740782da97f..bad681e01ef 100644 --- a/frontend/pages/ManageControlsPage/OSSettings/cards/Certificates/components/AddCertificateModal/AddCertificateModal.tests.tsx +++ b/frontend/pages/ManageControlsPage/OSSettings/cards/Certificates/components/AddCertificateModal/AddCertificateModal.tests.tsx @@ -7,11 +7,24 @@ import mockServer from "test/mock-server"; import { baseUrl, createCustomRenderer } from "test/test-utils"; import AddCertModal from "./AddCertificateModal"; -import { INVALID_NAME_MSG, NAME_TOO_LONG_MSG, USED_NAME_MSG } from "./helpers"; +import { + CA_REQUIRED_MSG, + INVALID_NAME_MSG, + NAME_REQUIRED_MSG, + NAME_TOO_LONG_MSG, + SUBJECT_NAME_REQUIRED_MSG, + USED_NAME_MSG, +} from "./helpers"; const mockOnExit = jest.fn(); const mockOnSuccess = jest.fn(); +const NAME_PLACEHOLDER = "VPN certificate"; +const SUBJECT_NAME_PLACEHOLDER = + "CN=$FLEET_VAR_HOST_END_USER_IDP_USERNAME, O=Your Organization"; +const SAN_PLACEHOLDER = + "UPN=$FLEET_VAR_HOST_END_USER_IDP_USERNAME, EMAIL=$FLEET_VAR_HOST_END_USER_IDP_USERNAME"; + const getCAsHandler = http.get(baseUrl("/certificate_authorities"), () => { return HttpResponse.json({ certificate_authorities: [ @@ -24,15 +37,21 @@ const getCAsHandler = http.get(baseUrl("/certificate_authorities"), () => { }); }); -const addCertHandler = http.post(baseUrl("/certificates"), () => { - return HttpResponse.json({ - id: 123, - name: "New Certificate", - certificate_authority_id: 1, - subject_name: "Test subject name", - created_at: new Date().toISOString(), - }); -}); +// Captures every POST /certificates body so multi-call tests can inspect the full sequence. +const addCertCalls: Array> = []; +const addCertHandler = http.post( + baseUrl("/certificates"), + async ({ request }) => { + addCertCalls.push((await request.json()) as Record); + return HttpResponse.json({ + id: 123, + name: "New Certificate", + certificate_authority_id: 1, + subject_name: "Test subject name", + created_at: new Date().toISOString(), + }); + } +); const mockExistingCerts: ICertificate[] = [ { @@ -44,8 +63,38 @@ const mockExistingCerts: ICertificate[] = [ }, ]; +// Renders the modal and waits for the form to be interactive (the Name input present). +// Returns userEvent + the rendered scope. +const renderModal = async ({ existingCerts = [] as ICertificate[] } = {}) => { + const render = createCustomRenderer({ withBackendMock: true }); + const result = render( + + ); + await screen.findByPlaceholderText(NAME_PLACEHOLDER); + return result; +}; + +// Pick the SCEP CA option from the dropdown. +const selectScepCa = async (user: { + click: (el: Element) => Promise; +}) => { + const caDropdown = screen.getByText("Select certificate authority"); + await user.click(caDropdown); + await waitFor(() => { + expect(screen.getByText("TEST_SCEP_CA")).toBeInTheDocument(); + }); + await user.click(screen.getByText("TEST_SCEP_CA")); +}; + describe("AddCertModal", () => { beforeEach(() => { + addCertCalls.length = 0; + mockOnExit.mockClear(); + mockOnSuccess.mockClear(); mockServer.use(getCAsHandler); mockServer.use(addCertHandler); }); @@ -53,268 +102,223 @@ describe("AddCertModal", () => { mockServer.resetHandlers(); }); - it("renders the modal with all form fields", async () => { - const render = createCustomRenderer({ - withBackendMock: true, - }); - render( - - ); - - await waitFor(() => { - expect(screen.queryByTestId("spinner")).not.toBeInTheDocument(); - }); - - expect(screen.getByText("Add certificate")).toBeInTheDocument(); - expect( - await screen.findByPlaceholderText("VPN certificate") - ).toBeInTheDocument(); - expect(screen.getByText("Certificate authority (CA)")).toBeInTheDocument(); + it("renders the SAN field alongside the existing fields", async () => { + await renderModal(); expect( - screen.getByPlaceholderText( - "CN=$FLEET_VAR_HOST_END_USER_IDP_USERNAME, O=Your Organization" - ) + screen.getByText("Subject alternative name (SAN)") ).toBeInTheDocument(); - expect(screen.getByText("Add")).toBeInTheDocument(); - expect(screen.getByText("Cancel")).toBeInTheDocument(); + expect(screen.getByPlaceholderText(SAN_PLACEHOLDER)).toBeInTheDocument(); }); - it("disables Add button when Name field is empty", async () => { - const render = createCustomRenderer({ - withBackendMock: true, - }); - const { user } = render( - - ); - - await waitFor(() => { - expect(screen.queryByTestId("spinner")).not.toBeInTheDocument(); - }); - - const addButton = screen.getByRole("button", { name: /Add/i }); - expect(addButton).toBeDisabled(); + it("clicking Add with all required fields empty shows three inline errors and does not call the API", async () => { + const { user } = await renderModal(); + await user.click(screen.getByRole("button", { name: /Add/i })); - await user.hover(addButton); await waitFor(() => { - expect( - screen.getByText("Complete all fields to save.") - ).toBeInTheDocument(); + expect(screen.getByText(NAME_REQUIRED_MSG)).toBeInTheDocument(); }); + expect(screen.getByText(CA_REQUIRED_MSG)).toBeInTheDocument(); + expect(screen.getByText(SUBJECT_NAME_REQUIRED_MSG)).toBeInTheDocument(); + expect(addCertCalls).toHaveLength(0); + expect(mockOnSuccess).not.toHaveBeenCalled(); }); - it("shows error for Name with invalid characters and disables Add button", async () => { - const render = createCustomRenderer({ - withBackendMock: true, - }); - const { user } = render( - - ); + it("clicking Add with only Subject name empty shows exactly that one inline error", async () => { + const { user } = await renderModal(); - await waitFor(() => { - expect(screen.queryByTestId("spinner")).not.toBeInTheDocument(); - }); + await user.type( + screen.getByPlaceholderText(NAME_PLACEHOLDER), + "Valid Name" + ); + await selectScepCa(user); - const nameInput = await screen.findByPlaceholderText("VPN certificate"); - await user.type(nameInput, "Invalid@Name#"); + await user.click(screen.getByRole("button", { name: /Add/i })); await waitFor(() => { - expect(screen.getByText(INVALID_NAME_MSG)).toBeInTheDocument(); + expect(screen.getByText(SUBJECT_NAME_REQUIRED_MSG)).toBeInTheDocument(); }); - - const addButton = screen.getByRole("button", { name: /Add/i }); - expect(addButton).toBeDisabled(); + expect(screen.queryByText(NAME_REQUIRED_MSG)).not.toBeInTheDocument(); + expect(screen.queryByText(CA_REQUIRED_MSG)).not.toBeInTheDocument(); + expect(addCertCalls).toHaveLength(0); }); - it("shows error for Name that already exists and disables Add button", async () => { - const render = createCustomRenderer({ - withBackendMock: true, - }); - const { user } = render( - + it("shows inline error for Name with invalid characters as user types (no submit needed)", async () => { + const { user } = await renderModal(); + + await user.type( + screen.getByPlaceholderText(NAME_PLACEHOLDER), + "Invalid@Name#" ); await waitFor(() => { - expect(screen.queryByTestId("spinner")).not.toBeInTheDocument(); + expect(screen.getByText(INVALID_NAME_MSG)).toBeInTheDocument(); }); + }); + + it("shows inline error for duplicate Name as user types", async () => { + const { user } = await renderModal({ existingCerts: mockExistingCerts }); - const nameInput = await screen.findByPlaceholderText("VPN certificate"); - await user.type(nameInput, "Existing Certificate"); + await user.type( + screen.getByPlaceholderText(NAME_PLACEHOLDER), + "Existing Certificate" + ); await waitFor(() => { expect(screen.getByText(USED_NAME_MSG)).toBeInTheDocument(); }); - - const addButton = screen.getByRole("button", { name: /Add/i }); - expect(addButton).toBeDisabled(); }); - it("shows error for Name with more than 255 characters and disables Add button", async () => { - const render = createCustomRenderer({ - withBackendMock: true, - }); - const { user } = render( - - ); + it("shows inline error for Name longer than 255 characters as user types", async () => { + const { user } = await renderModal(); - await waitFor(() => { - expect(screen.queryByTestId("spinner")).not.toBeInTheDocument(); - }); + // Paste rather than type to keep the test fast (256 simulated keypresses is slow). + await user.click(screen.getByPlaceholderText(NAME_PLACEHOLDER)); + await user.paste("a".repeat(256)); - const nameInput = await screen.findByPlaceholderText("VPN certificate"); - - const longName = "a".repeat(256); - await user.type(nameInput, longName); await waitFor(() => { expect(screen.getByText(NAME_TOO_LONG_MSG)).toBeInTheDocument(); }); - - const addButton = screen.getByRole("button", { name: /Add/i }); - expect(addButton).toBeDisabled(); }); - it("disables Add button when Certificate authority is not selected", async () => { - const render = createCustomRenderer({ - withBackendMock: true, - }); - const { user } = render( - + it("submits successfully without SAN (field omitted from request body)", async () => { + const { user } = await renderModal(); + + await user.type( + screen.getByPlaceholderText(NAME_PLACEHOLDER), + "Valid Name" ); + await user.type( + screen.getByPlaceholderText(SUBJECT_NAME_PLACEHOLDER), + "/CN=test/O=Org" + ); + await selectScepCa(user); + await user.click(screen.getByRole("button", { name: /Add/i })); await waitFor(() => { - expect(screen.queryByTestId("spinner")).not.toBeInTheDocument(); + expect(mockOnSuccess).toHaveBeenCalledTimes(1); }); + expect(addCertCalls).toHaveLength(1); + expect(addCertCalls[0]).not.toHaveProperty("subject_alternative_name"); + }); - const nameInput = await screen.findByPlaceholderText("VPN certificate"); - await user.type(nameInput, "Valid Name"); + it("submits successfully with SAN (field included in request body)", async () => { + const { user } = await renderModal(); - const subjectNameInput = screen.getByPlaceholderText( - "CN=$FLEET_VAR_HOST_END_USER_IDP_USERNAME, O=Your Organization" + await user.type( + screen.getByPlaceholderText(NAME_PLACEHOLDER), + "Valid Name" ); - await user.type(subjectNameInput, "/CN=test/O=Org"); - - const addButton = screen.getByRole("button", { name: /Add/i }); - expect(addButton).toBeDisabled(); - }); - - it("disables Add button when Subject name is empty", async () => { - const render = createCustomRenderer({ - withBackendMock: true, - }); - const { user } = render( - + await user.type( + screen.getByPlaceholderText(SUBJECT_NAME_PLACEHOLDER), + "/CN=test/O=Org" + ); + await user.type( + screen.getByPlaceholderText(SAN_PLACEHOLDER), + "DNS=host.example.com, EMAIL=user@example.com" ); + await selectScepCa(user); + await user.click(screen.getByRole("button", { name: /Add/i })); await waitFor(() => { - expect(screen.queryByTestId("spinner")).not.toBeInTheDocument(); + expect(mockOnSuccess).toHaveBeenCalledTimes(1); }); - - const nameInput = await screen.findByPlaceholderText("VPN certificate"); - await user.type(nameInput, "Valid Name"); - - const caDropdown = screen.getByText("Select certificate authority"); - await user.click(caDropdown); - - await waitFor(() => { - expect(screen.getByText("TEST_SCEP_CA")).toBeInTheDocument(); + expect(addCertCalls[0]).toMatchObject({ + subject_alternative_name: "DNS=host.example.com, EMAIL=user@example.com", }); + }); - await user.click(screen.getByText("TEST_SCEP_CA")); + it("surfaces a 422 server error against the SAN input inline", async () => { + const SERVER_SAN_ERR = + 'subject_alternative_name has unsupported key "FOO". Allowed keys are DNS, EMAIL, UPN, IP, URI'; + mockServer.use( + http.post(baseUrl("/certificates"), () => { + return HttpResponse.json( + { + message: "Validation Failed", + errors: [ + { name: "subject_alternative_name", reason: SERVER_SAN_ERR }, + ], + }, + { status: 422 } + ); + }) + ); - expect(screen.queryByText("Select certificate authority")).toBeNull(); + const { user } = await renderModal(); - const addButton = screen.getByRole("button", { name: /Add/i }); - expect(addButton).toBeDisabled(); - }); - - it("full flow is okay when all fields are valid", async () => { - const render = createCustomRenderer({ - withBackendMock: true, - }); - const { user } = render( - + await user.type( + screen.getByPlaceholderText(NAME_PLACEHOLDER), + "Valid Name" ); + await user.type( + screen.getByPlaceholderText(SUBJECT_NAME_PLACEHOLDER), + "/CN=test/O=Org" + ); + const sanInput = screen.getByPlaceholderText(SAN_PLACEHOLDER); + await user.type(sanInput, "FOO=bar"); + await selectScepCa(user); + await user.click(screen.getByRole("button", { name: /Add/i })); await waitFor(() => { - expect(screen.queryByTestId("spinner")).not.toBeInTheDocument(); + expect(screen.getByText(SERVER_SAN_ERR)).toBeInTheDocument(); }); + expect(mockOnSuccess).not.toHaveBeenCalled(); - // Fill in all fields with valid data - const nameInput = await screen.findByPlaceholderText("VPN certificate"); - await user.type(nameInput, "Valid Name"); + // Editing the SAN clears the server error. + await user.type(sanInput, ", DNS=host.example.com"); + await waitFor(() => { + expect(screen.queryByText(SERVER_SAN_ERR)).not.toBeInTheDocument(); + }); + }); - const subjectNameInput = screen.getByPlaceholderText( - "CN=$FLEET_VAR_HOST_END_USER_IDP_USERNAME, O=Your Organization" + it("Add button is disabled while the POST is in flight, then re-enabled on response", async () => { + // Replace the default handler with one that won't resolve until we say so. + let resolveServer!: () => void; + const serverGate = new Promise((resolve) => { + resolveServer = resolve; + }); + mockServer.use( + http.post(baseUrl("/certificates"), async () => { + await serverGate; + return HttpResponse.json({ + id: 123, + name: "New Certificate", + certificate_authority_id: 1, + subject_name: "Test subject name", + created_at: new Date().toISOString(), + }); + }) ); - await user.type(subjectNameInput, "/CN=test/O=Org"); - const caDropdown = screen.getByText("Select certificate authority"); - await user.click(caDropdown); + const { user } = await renderModal(); - await waitFor(() => { - expect(screen.getByText("TEST_SCEP_CA")).toBeInTheDocument(); - }); - await user.click(screen.getByText("TEST_SCEP_CA")); - expect(screen.queryByText("Select certificate authority")).toBeNull(); + await user.type( + screen.getByPlaceholderText(NAME_PLACEHOLDER), + "Valid Name" + ); + await user.type( + screen.getByPlaceholderText(SUBJECT_NAME_PLACEHOLDER), + "/CN=test/O=Org" + ); + await selectScepCa(user); const addButton = screen.getByRole("button", { name: /Add/i }); - expect(addButton).not.toBeDisabled(); - + // user.click awaits internal pointer events but the click handler kicks + // off the POST without awaiting it, so the disabled flip happens + // synchronously before resolveServer() is called. await user.click(addButton); - expect(mockOnSuccess).toHaveBeenCalledTimes(1); - }); - - it("calls onExit when Cancel button is clicked", async () => { - const render = createCustomRenderer({ - withBackendMock: true, - }); - const { user } = render( - - ); + expect(addButton).toBeDisabled(); + resolveServer(); await waitFor(() => { - expect(screen.queryByTestId("spinner")).not.toBeInTheDocument(); + expect(mockOnSuccess).toHaveBeenCalledTimes(1); }); + }); - const cancelButton = await screen.findByText("Cancel"); - await user.click(cancelButton); - + it("calls onExit when Cancel button is clicked", async () => { + const { user } = await renderModal(); + await user.click(screen.getByText("Cancel")); expect(mockOnExit).toHaveBeenCalledTimes(1); }); }); diff --git a/frontend/pages/ManageControlsPage/OSSettings/cards/Certificates/components/AddCertificateModal/AddCertificateModal.tsx b/frontend/pages/ManageControlsPage/OSSettings/cards/Certificates/components/AddCertificateModal/AddCertificateModal.tsx index 5070a37dfd2..01b4ab796bf 100644 --- a/frontend/pages/ManageControlsPage/OSSettings/cards/Certificates/components/AddCertificateModal/AddCertificateModal.tsx +++ b/frontend/pages/ManageControlsPage/OSSettings/cards/Certificates/components/AddCertificateModal/AddCertificateModal.tsx @@ -8,11 +8,11 @@ import paths from "router/paths"; import { NotificationContext } from "context/notification"; import certificatesAPI, { ICertificate } from "services/entities/certificates"; +import { getErrorReason } from "interfaces/errors"; import InputField from "components/forms/fields/InputField"; import Button from "components/buttons/Button"; import Modal from "components/Modal"; -import TooltipWrapper from "components/TooltipWrapper"; import Spinner from "components/Spinner"; import DataError from "components/DataError"; import CustomLink from "components/CustomLink"; @@ -31,6 +31,7 @@ export interface IAddCertFormData { name: string; certAuthorityId: string; subjectName: string; + subjectAlternativeName: string; } interface IAddCertModalProps { @@ -49,19 +50,32 @@ const AddCertModal = ({ const { renderFlash } = useContext(NotificationContext); const [isUpdating, setIsUpdating] = useState(false); + const [attemptedSubmit, setAttemptedSubmit] = useState(false); const [formData, setFormData] = useState({ name: "", certAuthorityId: "", subjectName: "", + subjectAlternativeName: "", }); + // Server-side validation errors keyed by form field; cleared when the user + // edits the corresponding input. Today only SAN can come back with a + // field-targeted 422; other fields fall through to the generic flash. + const [serverErrors, setServerErrors] = useState<{ + subjectAlternativeName?: string; + }>({}); const validations = useMemo( () => generateFormValidations(existingCTs || []), [existingCTs] ); - const [formValidation, setFormValidation] = useState( - () => validateFormData(formData, validations) + // formValidation is derived from formData + attemptedSubmit; computing it during render via + // useMemo keeps it in lockstep with its inputs without scattering setFormValidation calls + // across handlers. + // See https://react.dev/learn/choosing-the-state-structure#avoid-redundant-state. + const formValidation: IAddCertFormValidation = useMemo( + () => validateFormData(formData, validations, attemptedSubmit), + [formData, validations, attemptedSubmit] ); const { @@ -88,7 +102,15 @@ const AddCertModal = ({ const onInputChange = (update: { name: string; value: string }) => { const updatedFormData = { ...formData, [update.name]: update.value }; setFormData(updatedFormData); - setFormValidation(validateFormData(updatedFormData, validations)); + if ( + update.name === "subjectAlternativeName" && + serverErrors.subjectAlternativeName + ) { + setServerErrors((prev) => ({ + ...prev, + subjectAlternativeName: undefined, + })); + } }; const onChangeCA = (newValue: SingleValue) => { @@ -97,25 +119,37 @@ const AddCertModal = ({ certAuthorityId: newValue?.value ?? "", }; setFormData(updatedFormData); - setFormValidation(validateFormData(updatedFormData, validations)); }; const onSubmitForm = async (evt: React.FormEvent) => { evt.preventDefault(); + if (!formValidation.isValid) { + setAttemptedSubmit(true); + return; + } + setIsUpdating(true); try { await certificatesAPI.addCert({ name: formData.name, certAuthorityId: parseInt(formData.certAuthorityId, 10), subjectName: formData.subjectName, + subjectAlternativeName: formData.subjectAlternativeName, teamId: currentTeamId, }); renderFlash("success", "Successfully added your certificate."); onSuccess(); onExit(); } catch (e) { - renderFlash("error", "Couldn't add certificate. Please try again."); + const sanReason = getErrorReason(e, { + nameEquals: "subject_alternative_name", + }); + if (sanReason) { + setServerErrors({ subjectAlternativeName: sanReason }); + } else { + renderFlash("error", "Couldn't add certificate. Please try again."); + } } finally { setIsUpdating(false); } @@ -141,6 +175,7 @@ const AddCertModal = ({ parseTarget placeholder="VPN certificate" autofocus + ignore1password /> +
- - - + diff --git a/frontend/pages/ManageControlsPage/OSSettings/cards/Certificates/components/AddCertificateModal/helpers.ts b/frontend/pages/ManageControlsPage/OSSettings/cards/Certificates/components/AddCertificateModal/helpers.ts index 3d19a2a0006..194c82c709e 100644 --- a/frontend/pages/ManageControlsPage/OSSettings/cards/Certificates/components/AddCertificateModal/helpers.ts +++ b/frontend/pages/ManageControlsPage/OSSettings/cards/Certificates/components/AddCertificateModal/helpers.ts @@ -6,12 +6,16 @@ export interface IAddCertFormValidation { name?: { isValid: boolean; message?: string }; certAuthorityId?: { isValid: boolean; message?: string }; subjectName?: { isValid: boolean; message?: string }; + subjectAlternativeName?: { isValid: boolean; message?: string }; } export const INVALID_NAME_MSG = "Invalid characters. Only letters, numbers, spaces, dashes, and underscores allowed."; export const USED_NAME_MSG = "Name is already used by another certificate."; export const NAME_TOO_LONG_MSG = "Name is too long. Maximum is 255 characters."; +export const NAME_REQUIRED_MSG = "Name must be completed."; +export const CA_REQUIRED_MSG = "Certificate authority must be completed."; +export const SUBJECT_NAME_REQUIRED_MSG = "Subject name must be completed."; type IMessageFunc = (formData: IAddCertFormData) => string; type IValidationMessage = string | IMessageFunc; @@ -21,6 +25,9 @@ interface IValidation { name: string; isValid: (formData: IAddCertFormData) => boolean; message?: IValidationMessage; + // required validations only render their message after the first submit attempt; + // non-required (format) errors render as the user types. + required?: boolean; } type IFormValidations = Record< @@ -36,9 +43,11 @@ export const generateFormValidations = ( validations: [ { name: "required", + required: true, isValid: (formData: IAddCertFormData) => { return formData.name.trim().length > 0; }, + message: NAME_REQUIRED_MSG, }, { name: "invalidCharacters", @@ -72,10 +81,11 @@ export const generateFormValidations = ( validations: [ { name: "required", + required: true, isValid: (formData: IAddCertFormData) => { return formData.certAuthorityId !== ""; }, - // no error message specified + message: CA_REQUIRED_MSG, }, ], }, @@ -83,13 +93,18 @@ export const generateFormValidations = ( validations: [ { name: "required", + required: true, isValid: (formData: IAddCertFormData) => { - return formData.subjectName.length > 0; + return formData.subjectName.trim().length > 0; }, + message: SUBJECT_NAME_REQUIRED_MSG, }, // accept any value, let the server handle any errors ], }, + // SAN is optional; format and length are validated server-side and surfaced + // back to the user via the 422 error path in AddCertificateModal.tsx. + subjectAlternativeName: { validations: [] }, }; return FORM_VALIDATIONS; }; @@ -106,7 +121,8 @@ const getErrorMessage = ( export const validateFormData = ( formData: IAddCertFormData, - validationConfig: IFormValidations + validationConfig: IFormValidations, + attemptedSubmit = false ): IAddCertFormValidation => { const formValidation: IAddCertFormValidation = { isValid: true, @@ -124,9 +140,12 @@ export const validateFormData = ( }; } else { formValidation.isValid = false; + const suppressMessage = failedValidation.required && !attemptedSubmit; formValidation[objKey] = { isValid: false, - message: getErrorMessage(formData, failedValidation.message), + message: suppressMessage + ? undefined + : getErrorMessage(formData, failedValidation.message), }; } }); diff --git a/frontend/services/entities/certificates.ts b/frontend/services/entities/certificates.ts index d149e546e7b..1cfb2c99a73 100644 --- a/frontend/services/entities/certificates.ts +++ b/frontend/services/entities/certificates.ts @@ -58,6 +58,7 @@ export interface ICertificate { name: string; certificate_authority_id: number; certificate_authority_name: string; + subject_alternative_name?: string; created_at: string; } export interface IGetCertsResponse { @@ -69,6 +70,7 @@ export interface IAddCert { name: string; certAuthorityId: number; subjectName: string; + subjectAlternativeName?: string; teamId?: number; } @@ -125,12 +127,21 @@ export default { queryString ? CERTIFICATES.concat(`?${queryString}`) : CERTIFICATES ); }, - addCert: ({ name, certAuthorityId, subjectName, teamId }: IAddCert) => { + addCert: ({ + name, + certAuthorityId, + subjectName, + subjectAlternativeName, + teamId, + }: IAddCert) => { const { CERTIFICATES } = endpoints; + const trimmedSAN = subjectAlternativeName?.trim() ?? ""; const requestBody = { name, certificate_authority_id: certAuthorityId, subject_name: subjectName, + // omit when empty so the server treats it as "no SAN" + ...(trimmedSAN !== "" && { subject_alternative_name: trimmedSAN }), fleet_id: teamId === APP_CONTEXT_ALL_TEAMS_ID ? API_ALL_TEAMS_ID : teamId, }; return sendRequest("POST", CERTIFICATES, requestBody);