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:
+215
-211
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
+58
-21
@@ -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>
|
||||
|
||||
+23
-4
@@ -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),
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user