Android cert SAN frontend (#44809)

<!-- Add the related story/sub-task/bug number, like Resolves #123, or
remove if NA -->
**Related issue:** Resolves #41472 

-
[Figma](https://www.figma.com/design/2jRQoXofC1caxyNhWl8F0m/Android-certificates--support-for-subject-alternative-name--SAN--attributes-in-certificates?node-id=2-130&p=f&m=dev)
- Needs to merge to main after
https://github.com/fleetdm/fleet/pull/44690

# Checklist for submitter

- [x] Input data is properly validated, `SELECT *` is avoided, SQL
injection is prevented (using placeholders for values in statements), JS
inline code is prevented especially for url redirects, and untrusted
data interpolated into shell scripts/commands is validated against shell
metacharacters.

## Testing

- [x] Added/updated automated tests
- [x] QA'd all new/changed functionality manually


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

* **New Features**
* Added Subject Alternative Name (SAN) field to the certificate
management modal with comprehensive validation support.

* **Tests**
* Expanded test coverage for the certificate modal with enhanced
validation scenarios and server response handling.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
Victor Lyuboslavsky
2026-05-07 14:59:10 -04:00
committed by GitHub
parent e11dee70d4
commit d9b17cf551
4 changed files with 308 additions and 237 deletions
@@ -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<Record<string, unknown>> = [];
const addCertHandler = http.post(
baseUrl("/certificates"),
async ({ request }) => {
addCertCalls.push((await request.json()) as Record<string, unknown>);
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(
<AddCertModal
existingCerts={existingCerts}
onExit={mockOnExit}
onSuccess={mockOnSuccess}
/>
);
await screen.findByPlaceholderText(NAME_PLACEHOLDER);
return result;
};
// Pick the SCEP CA option from the dropdown.
const selectScepCa = async (user: {
click: (el: Element) => Promise<void>;
}) => {
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(
<AddCertModal
existingCerts={[]}
onExit={mockOnExit}
onSuccess={mockOnSuccess}
/>
);
await waitFor(() => {
expect(screen.queryByTestId("spinner")).not.toBeInTheDocument();
});
expect(screen.getByText("Add certificate")).toBeInTheDocument();
it("renders the SAN field alongside the existing fields", async () => {
await renderModal();
expect(
await screen.findByPlaceholderText("VPN certificate")
screen.getByText("Subject alternative name (SAN)")
).toBeInTheDocument();
expect(screen.getByText("Certificate authority (CA)")).toBeInTheDocument();
expect(
screen.getByPlaceholderText(
"CN=$FLEET_VAR_HOST_END_USER_IDP_USERNAME, O=Your Organization"
)
).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(
<AddCertModal
existingCerts={[]}
onExit={mockOnExit}
onSuccess={mockOnSuccess}
/>
);
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 waitFor(() => {
expect(screen.queryByTestId("spinner")).not.toBeInTheDocument();
});
const addButton = screen.getByRole("button", { name: /Add/i });
expect(addButton).toBeDisabled();
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(
<AddCertModal
existingCerts={[]}
onExit={mockOnExit}
onSuccess={mockOnSuccess}
/>
it("clicking Add with only Subject name empty shows exactly that one inline error", async () => {
const { user } = await renderModal();
await user.type(
screen.getByPlaceholderText(NAME_PLACEHOLDER),
"Valid Name"
);
await selectScepCa(user);
await user.click(screen.getByRole("button", { name: /Add/i }));
await waitFor(() => {
expect(screen.queryByTestId("spinner")).not.toBeInTheDocument();
expect(screen.getByText(SUBJECT_NAME_REQUIRED_MSG)).toBeInTheDocument();
});
expect(screen.queryByText(NAME_REQUIRED_MSG)).not.toBeInTheDocument();
expect(screen.queryByText(CA_REQUIRED_MSG)).not.toBeInTheDocument();
expect(addCertCalls).toHaveLength(0);
});
const nameInput = await screen.findByPlaceholderText("VPN certificate");
await user.type(nameInput, "Invalid@Name#");
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.getByText(INVALID_NAME_MSG)).toBeInTheDocument();
});
const addButton = screen.getByRole("button", { name: /Add/i });
expect(addButton).toBeDisabled();
});
it("shows error for Name that already exists and disables Add button", async () => {
const render = createCustomRenderer({
withBackendMock: true,
});
const { user } = render(
<AddCertModal
existingCerts={mockExistingCerts}
onExit={mockOnExit}
onSuccess={mockOnSuccess}
/>
it("shows inline error for duplicate Name as user types", async () => {
const { user } = await renderModal({ existingCerts: mockExistingCerts });
await user.type(
screen.getByPlaceholderText(NAME_PLACEHOLDER),
"Existing Certificate"
);
await waitFor(() => {
expect(screen.queryByTestId("spinner")).not.toBeInTheDocument();
});
const nameInput = await screen.findByPlaceholderText("VPN certificate");
await user.type(nameInput, "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(
<AddCertModal
existingCerts={mockExistingCerts}
onExit={mockOnExit}
onSuccess={mockOnSuccess}
/>
);
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(
<AddCertModal
existingCerts={mockExistingCerts}
onExit={mockOnExit}
onSuccess={mockOnSuccess}
/>
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);
});
const nameInput = await screen.findByPlaceholderText("VPN certificate");
await user.type(nameInput, "Valid Name");
const subjectNameInput = screen.getByPlaceholderText(
"CN=$FLEET_VAR_HOST_END_USER_IDP_USERNAME, O=Your Organization"
);
await user.type(subjectNameInput, "/CN=test/O=Org");
const addButton = screen.getByRole("button", { name: /Add/i });
expect(addButton).toBeDisabled();
expect(addCertCalls).toHaveLength(1);
expect(addCertCalls[0]).not.toHaveProperty("subject_alternative_name");
});
it("disables Add button when Subject name is empty", async () => {
const render = createCustomRenderer({
withBackendMock: true,
});
const { user } = render(
<AddCertModal
existingCerts={[]}
onExit={mockOnExit}
onSuccess={mockOnSuccess}
/>
it("submits successfully with SAN (field included in 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 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"));
expect(screen.queryByText("Select certificate authority")).toBeNull();
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(
<AddCertModal
existingCerts={[]}
onExit={mockOnExit}
onSuccess={mockOnSuccess}
/>
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 }
);
})
);
await waitFor(() => {
expect(screen.queryByTestId("spinner")).not.toBeInTheDocument();
});
const { user } = await renderModal();
// Fill in all fields with valid data
const nameInput = await screen.findByPlaceholderText("VPN certificate");
await user.type(nameInput, "Valid Name");
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 caDropdown = screen.getByText("Select certificate authority");
await user.click(caDropdown);
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.getByText("TEST_SCEP_CA")).toBeInTheDocument();
expect(screen.getByText(SERVER_SAN_ERR)).toBeInTheDocument();
});
await user.click(screen.getByText("TEST_SCEP_CA"));
expect(screen.queryByText("Select certificate authority")).toBeNull();
expect(mockOnSuccess).not.toHaveBeenCalled();
// 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();
});
});
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<void>((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(),
});
})
);
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);
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);
expect(addButton).toBeDisabled();
resolveServer();
await waitFor(() => {
expect(mockOnSuccess).toHaveBeenCalledTimes(1);
});
});
it("calls onExit when Cancel button is clicked", async () => {
const render = createCustomRenderer({
withBackendMock: true,
});
const { user } = render(
<AddCertModal
existingCerts={[]}
onExit={mockOnExit}
onSuccess={mockOnSuccess}
/>
);
await waitFor(() => {
expect(screen.queryByTestId("spinner")).not.toBeInTheDocument();
});
const cancelButton = await screen.findByText("Cancel");
await user.click(cancelButton);
const { user } = await renderModal();
await user.click(screen.getByText("Cancel"));
expect(mockOnExit).toHaveBeenCalledTimes(1);
});
});
@@ -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<IAddCertFormData>({
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<IAddCertFormValidation>(
() => 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<CustomOptionType>) => {
@@ -97,25 +119,37 @@ const AddCertModal = ({
certAuthorityId: newValue?.value ?? "",
};
setFormData(updatedFormData);
setFormValidation(validateFormData(updatedFormData, validations));
};
const onSubmitForm = async (evt: React.FormEvent<HTMLFormElement>) => {
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
/>
<DropdownWrapper
label="Certificate authority (CA)"
@@ -174,22 +209,24 @@ const AddCertModal = ({
parseTarget
placeholder="CN=$FLEET_VAR_HOST_END_USER_IDP_USERNAME, O=Your Organization"
/>
<InputField
name="subjectAlternativeName"
label="Subject alternative name (SAN)"
type="textarea"
value={formData.subjectAlternativeName}
onChange={onInputChange}
error={
serverErrors.subjectAlternativeName ??
formValidation.subjectAlternativeName?.message
}
helpText='Optional. Separate fields by ", " using format KEY=value. Allowed keys: DNS, EMAIL, UPN, IP, URI.'
parseTarget
placeholder="UPN=$FLEET_VAR_HOST_END_USER_IDP_USERNAME, EMAIL=$FLEET_VAR_HOST_END_USER_IDP_USERNAME"
/>
<div className="modal-cta-wrap">
<TooltipWrapper
tipContent="Complete all fields to save."
underline={false}
position="top"
disableTooltip={formValidation.isValid}
showArrow
>
<Button
isLoading={isUpdating}
disabled={!formValidation.isValid || isUpdating}
type="submit"
>
Add
</Button>
</TooltipWrapper>
<Button isLoading={isUpdating} disabled={isUpdating} type="submit">
Add
</Button>
<Button variant="inverse" onClick={onExit}>
Cancel
</Button>
@@ -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),
};
}
});
+12 -1
View File
@@ -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);