From 85487eb5b252e8ffcec49d0cf175f6ecfedc49a8 Mon Sep 17 00:00:00 2001 From: RachelElysia <71795832+RachelElysia@users.noreply.github.com> Date: Fri, 21 Jan 2022 12:06:58 -0500 Subject: [PATCH] Settings > Organization Settings: Code revamp (#3580) --- ...issue-2477-3487-app-settings-config-revamp | 1 + .../integration/all/app/settingsflow.spec.ts | 6 +- cypress/integration/free/admin.spec.ts | 7 +- cypress/integration/premium/admin.spec.ts | 7 +- docs/01-Using-Fleet/03-REST-API.md | 6 +- .../EnrollSecretTable/EnrollSecretTable.tsx | 2 +- frontend/components/FleetAce/FleetAce.tsx | 2 +- .../components/forms/FormField/_styles.scss | 1 + .../admin/AppConfigForm/AppConfigForm.jsx | 739 ----------- .../AppConfigForm/AppConfigForm.tests.jsx | 110 -- .../admin/AppConfigForm/AppConfigForm.tsx | 1079 +++++++++++++++++ .../forms/admin/AppConfigForm/constants.ts | 87 ++ .../AppConfigForm/{index.js => index.ts} | 0 .../forms/admin/AppConfigForm/validate.js | 109 -- .../admin/AppConfigForm/validate.tests.js | 218 ---- .../forms/fields/Checkbox/Checkbox.tsx | 10 + .../forms/fields/Dropdown/Dropdown.jsx | 21 +- .../forms/fields/InputField/InputField.jsx | 15 +- .../forms/fields/InputField/_styles.scss | 1 + frontend/interfaces/config.ts | 130 +- .../admin/AppSettingsPage/AppSettingsPage.jsx | 129 -- .../AppSettingsPage/AppSettingsPage.tests.jsx | 19 - .../admin/AppSettingsPage/AppSettingsPage.tsx | 131 ++ .../AppSettingsPage/{index.js => index.ts} | 0 frontend/services/entities/config.ts | 20 +- 25 files changed, 1480 insertions(+), 1370 deletions(-) create mode 100644 changes/issue-2477-3487-app-settings-config-revamp delete mode 100644 frontend/components/forms/admin/AppConfigForm/AppConfigForm.jsx delete mode 100644 frontend/components/forms/admin/AppConfigForm/AppConfigForm.tests.jsx create mode 100644 frontend/components/forms/admin/AppConfigForm/AppConfigForm.tsx create mode 100644 frontend/components/forms/admin/AppConfigForm/constants.ts rename frontend/components/forms/admin/AppConfigForm/{index.js => index.ts} (100%) delete mode 100644 frontend/components/forms/admin/AppConfigForm/validate.js delete mode 100644 frontend/components/forms/admin/AppConfigForm/validate.tests.js delete mode 100644 frontend/pages/admin/AppSettingsPage/AppSettingsPage.jsx delete mode 100644 frontend/pages/admin/AppSettingsPage/AppSettingsPage.tests.jsx create mode 100644 frontend/pages/admin/AppSettingsPage/AppSettingsPage.tsx rename frontend/pages/admin/AppSettingsPage/{index.js => index.ts} (100%) diff --git a/changes/issue-2477-3487-app-settings-config-revamp b/changes/issue-2477-3487-app-settings-config-revamp new file mode 100644 index 0000000000..9b61ab9b55 --- /dev/null +++ b/changes/issue-2477-3487-app-settings-config-revamp @@ -0,0 +1 @@ +* App Settings Page uses new frontend patterns and removes flattening of config object \ No newline at end of file diff --git a/cypress/integration/all/app/settingsflow.spec.ts b/cypress/integration/all/app/settingsflow.spec.ts index ff73d4b292..ea7952d4ec 100644 --- a/cypress/integration/all/app/settingsflow.spec.ts +++ b/cypress/integration/all/app/settingsflow.spec.ts @@ -59,7 +59,7 @@ describe("Settings flow", () => { .click() .type("localhost"); - cy.get("#port").click().type("{selectall}{backspace}1025"); + cy.get("#smtpPort").click().type("{selectall}{backspace}1025"); cy.findByLabelText(/use ssl\/tls/i).check({ force: true }); @@ -107,7 +107,7 @@ describe("Settings flow", () => { // Update settings cy.findByRole("button", { name: /update settings/i }).click(); - cy.findByText(/settings updated/i).should("exist"); + cy.findByText(/updated settings/i).should("exist"); cy.visit("/settings/organization"); @@ -149,7 +149,7 @@ describe("Settings flow", () => { cy.findByLabelText(/smtp server/i).should("have.value", "localhost"); - cy.get("#port").should("have.value", "1025"); + cy.get("#smtpPort").should("have.value", "1025"); cy.findByLabelText(/smtp username/i).should( "have.value", diff --git a/cypress/integration/free/admin.spec.ts b/cypress/integration/free/admin.spec.ts index 9b5d915454..029eb9786c 100644 --- a/cypress/integration/free/admin.spec.ts +++ b/cypress/integration/free/admin.spec.ts @@ -144,11 +144,10 @@ describe( // On the Settings pages, they should… // See everything except for the “Teams” pages - cy.visit("/settings/organization"); - cy.wait(1000); // eslint-disable-line cypress/no-unnecessary-waiting + cy.contains("a", "Settings").click(); - cy.findByText(/teams/i).should("not.exist"); - cy.get(".react-tabs").within(() => { + cy.getAttached(".react-tabs").within(() => { + cy.findByText(/teams/i).should("not.exist"); cy.findByText(/organization settings/i).should("exist"); cy.findByText(/users/i).click(); }); diff --git a/cypress/integration/premium/admin.spec.ts b/cypress/integration/premium/admin.spec.ts index 76eab82db3..b7811548ba 100644 --- a/cypress/integration/premium/admin.spec.ts +++ b/cypress/integration/premium/admin.spec.ts @@ -249,9 +249,8 @@ describe( // On the Settings pages, they should… // See the “Teams” navigation item and access the Settings - Teams page cy.visit("/settings/organization"); - cy.wait(1000); // eslint-disable-line cypress/no-unnecessary-waiting - cy.get(".react-tabs").within(() => { + cy.getAttached(".react-tabs").within(() => { cy.findByText(/teams/i).click(); }); cy.wait(1000); // eslint-disable-line cypress/no-unnecessary-waiting @@ -263,8 +262,8 @@ describe( // See the “Team” section in the create user modal. This modal is summoned when the “Create user” button is selected cy.visit("/settings/organization"); - cy.wait(1000); // eslint-disable-line cypress/no-unnecessary-waiting - cy.get(".react-tabs").within(() => { + + cy.getAttached(".react-tabs").within(() => { cy.findByText(/users/i).click(); }); cy.findByRole("button", { name: /create user/i }).click(); diff --git a/docs/01-Using-Fleet/03-REST-API.md b/docs/01-Using-Fleet/03-REST-API.md index 67010e785b..c64a780d1b 100644 --- a/docs/01-Using-Fleet/03-REST-API.md +++ b/docs/01-Using-Fleet/03-REST-API.md @@ -4513,7 +4513,7 @@ None. "webhook_settings": { "host_status_webhook": { "enable_host_status_webhook": true, - "destination_url": "https://server.com", + "destination_url": "https://server.com", "host_percentage": 5, "days_count": 7 }, @@ -4675,10 +4675,6 @@ Modifies the Fleet's configuration with the supplied information. "tier": "free", "expiration": "0001-01-01T00:00:00Z" }, - "license": { - "tier": "free", - "expiration": "0001-01-01T00:00:00Z" - }, "agent_options": { "spec": { "config": { diff --git a/frontend/components/EnrollSecretTable/EnrollSecretTable.tsx b/frontend/components/EnrollSecretTable/EnrollSecretTable.tsx index f970e08b84..60dfe0dfe6 100644 --- a/frontend/components/EnrollSecretTable/EnrollSecretTable.tsx +++ b/frontend/components/EnrollSecretTable/EnrollSecretTable.tsx @@ -9,7 +9,7 @@ interface IEnrollSecretRowProps { secrets: IEnrollSecret[] | undefined; toggleSecretEditorModal?: () => void; toggleDeleteSecretModal?: () => void; - setSelectedSecret: React.Dispatch< + setSelectedSecret?: React.Dispatch< React.SetStateAction >; } diff --git a/frontend/components/FleetAce/FleetAce.tsx b/frontend/components/FleetAce/FleetAce.tsx index 54d8d1e6f9..c397ef15cb 100644 --- a/frontend/components/FleetAce/FleetAce.tsx +++ b/frontend/components/FleetAce/FleetAce.tsx @@ -12,7 +12,7 @@ import "./mode"; import "./theme"; export interface IFleetAceProps { - error?: string; + error?: string | null; fontSize?: number; label?: string; name?: string; diff --git a/frontend/components/forms/FormField/_styles.scss b/frontend/components/forms/FormField/_styles.scss index 73fc8f338e..1b05658ce0 100644 --- a/frontend/components/forms/FormField/_styles.scss +++ b/frontend/components/forms/FormField/_styles.scss @@ -10,6 +10,7 @@ &--error { font-weight: $bold; + color: $ui-error; } } diff --git a/frontend/components/forms/admin/AppConfigForm/AppConfigForm.jsx b/frontend/components/forms/admin/AppConfigForm/AppConfigForm.jsx deleted file mode 100644 index b66a4f4b77..0000000000 --- a/frontend/components/forms/admin/AppConfigForm/AppConfigForm.jsx +++ /dev/null @@ -1,739 +0,0 @@ -import React, { Component } from "react"; -import PropTypes from "prop-types"; -import { syntaxHighlight } from "fleet/helpers"; - -import Button from "components/buttons/Button"; -import Checkbox from "components/forms/fields/Checkbox"; -import Dropdown from "components/forms/fields/Dropdown"; -import Form from "components/forms/Form"; -import formFieldInterface from "interfaces/form_field"; -import enrollSecretInterface from "interfaces/enroll_secret"; -import EnrollSecretTable from "components/EnrollSecretTable"; -import InputField from "components/forms/fields/InputField"; -import OrgLogoIcon from "components/icons/OrgLogoIcon"; -import validate from "components/forms/admin/AppConfigForm/validate"; -import IconToolTip from "components/IconToolTip"; -import InfoBanner from "components/InfoBanner/InfoBanner"; -import YamlAce from "components/YamlAce"; -import Modal from "components/Modal"; -import OpenNewTabIcon from "../../../../../assets/images/open-new-tab-12x12@2x.png"; - -const authMethodOptions = [ - { label: "Plain", value: "authmethod_plain" }, - { label: "Cram MD5", value: "authmethod_cram_md5" }, - { label: "Login", value: "authmethod_login" }, -]; -const authTypeOptions = [ - { label: "Username and Password", value: "authtype_username_password" }, - { label: "None", value: "authtype_none" }, -]; -const percentageOfHosts = [ - { label: "1%", value: 1 }, - { label: "5%", value: 5 }, - { label: "10%", value: 10 }, - { label: "25%", value: 25 }, -]; -const numberOfDays = [ - { label: "1 day", value: 1 }, - { label: "3 days", value: 3 }, - { label: "7 days", value: 7 }, - { label: "14 days", value: 14 }, -]; - -const baseClass = "app-config-form"; -const formFields = [ - "authentication_method", - "authentication_type", - "domain", - "enable_ssl_tls", - "enable_start_tls", - "server_url", - "org_logo_url", - "org_name", - "osquery_enroll_secret", - "password", - "port", - "sender_address", - "server", - "user_name", - "verify_ssl_certs", - "idp_name", - "entity_id", - "issuer_uri", - "idp_image_url", - "metadata", - "metadata_url", - "enable_sso", - "enable_sso_idp_login", - "enable_smtp", - "host_expiry_enabled", - "host_expiry_window", - "live_query_disabled", - "agent_options", - "enable_host_status_webhook", - "destination_url", - "host_percentage", - "days_count", - "enable_analytics", -]; -class AppConfigForm extends Component { - static propTypes = { - fields: PropTypes.shape({ - authentication_method: formFieldInterface.isRequired, - authentication_type: formFieldInterface.isRequired, - domain: formFieldInterface.isRequired, - enable_ssl_tls: formFieldInterface.isRequired, - enable_start_tls: formFieldInterface.isRequired, - server_url: formFieldInterface.isRequired, - org_logo_url: formFieldInterface.isRequired, - org_name: formFieldInterface.isRequired, - password: formFieldInterface.isRequired, - port: formFieldInterface.isRequired, - sender_address: formFieldInterface.isRequired, - server: formFieldInterface.isRequired, - user_name: formFieldInterface.isRequired, - verify_ssl_certs: formFieldInterface.isRequired, - entity_id: formFieldInterface.isRequired, - issuer_uri: formFieldInterface.isRequired, - idp_image_url: formFieldInterface.isRequired, - metadata: formFieldInterface.isRequired, - metadata_url: formFieldInterface.isRequired, - idp_name: formFieldInterface.isRequired, - enable_sso: formFieldInterface.isRequired, - enable_sso_idp_login: formFieldInterface.isRequired, - enable_smtp: formFieldInterface.isRequired, - host_expiry_enabled: formFieldInterface.isRequired, - host_expiry_window: formFieldInterface.isRequired, - live_query_disabled: formFieldInterface.isRequired, - agent_options: formFieldInterface.isRequired, - enable_host_status_webhook: formFieldInterface.isRequired, - destination_url: formFieldInterface, - host_percentage: formFieldInterface, - days_count: formFieldInterface, - enable_analytics: formFieldInterface.isRequired, - }).isRequired, - enrollSecret: PropTypes.arrayOf(enrollSecretInterface).isRequired, - handleSubmit: PropTypes.func.isRequired, - smtpConfigured: PropTypes.bool.isRequired, - }; - - constructor(props) { - super(props); - - this.state = { - showHostStatusWebhookPreviewModal: false, - showUsageStatsPreviewModal: false, - }; - } - - toggleHostStatusWebhookPreviewModal = () => { - const { showHostStatusWebhookPreviewModal } = this.state; - this.setState({ - showHostStatusWebhookPreviewModal: !showHostStatusWebhookPreviewModal, - }); - }; - - toggleUsageStatsPreviewModal = () => { - const { showUsageStatsPreviewModal } = this.state; - this.setState({ - showUsageStatsPreviewModal: !showUsageStatsPreviewModal, - }); - }; - - renderAdvancedOptions = () => { - const { fields } = this.props; - - return ( -
-

- Most users do not need to modify these options. -

-
-
-
- - If you need to specify a HELO domain,
you can do it here (Default: Blank)

' - } - /> -
-
- Verify SSL certs - Turn this off (not recommended)
if you use a self-signed certificate
(Default: On)

' - } - /> -
-
- Enable STARTTLS - Detects if STARTTLS is enabled
in your SMTP server and starts
to use it. (Default: On)

' - } - /> -
-
- Host expiry - When enabled, allows automatic cleanup
of hosts that have not communicated with Fleet
in some number of days. (Default: Off)

' - } - /> -
-
- - If a host has not communicated with Fleet
in the specified number of days, it will be removed.

" - } - /> -
-
- - Disable live queries - - When enabled, disables the ability to run live queries
(ad hoc queries executed via the UI or fleetctl). (Default: Off)

' - } - /> -
-
-
-
- ); - }; - - renderSmtpSection = () => { - const { fields } = this.props; - - if (fields.authentication_type.value === "authtype_none") { - return false; - } - - return ( -
- - - -
- ); - }; - - renderHostStatusWebhookPreviewModal = () => { - const { toggleHostStatusWebhookPreviewModal } = this; - const { showHostStatusWebhookPreviewModal } = this.state; - - if (!showHostStatusWebhookPreviewModal) { - return null; - } - - const json = { - text: - "More than X% of your hosts have not checked into Fleet for more than Y days. You’ve been sent this message because the Host status webhook is enabled in your Fleet instance.", - data: { - unseen_hosts: 1, - total_hosts: 2, - days_unseen: 3, - }, - }; - - return ( - -

- An example request sent to your configured Destination URL. -

-
-
-        
-
- -
-
- ); - }; - - renderUsageStatsPreviewModal = () => { - const { toggleUsageStatsPreviewModal } = this; - const { showUsageStatsPreviewModal } = this.state; - - if (!showUsageStatsPreviewModal) { - return null; - } - - const stats = { - anonymousIdentifier: "9pnzNmrES3mQG66UQtd29cYTiX2+fZ4CYxDvh495720=", - fleetVersion: "x.x.x", - licenseTier: "free", - numHostsEnrolled: 12345, - numUsers: 12, - numTeams: 3, - numPolicies: 5, - numLabels: 20, - softwareInventoryEnabled: true, - vulnDetectionEnabled: true, - systemUsersEnabled: true, - hostStatusWebhookEnabled: true, - }; - - return ( - -

An example JSON payload sent to Fleet Device Management Inc.

-
-        
- -
- - ); - }; - - render() { - const { fields, handleSubmit, smtpConfigured, enrollSecret } = this.props; - const { - renderAdvancedOptions, - renderSmtpSection, - toggleHostStatusWebhookPreviewModal, - toggleUsageStatsPreviewModal, - renderHostStatusWebhookPreviewModal, - renderUsageStatsPreviewModal, - } = this; - - return ( - <> -
-
-

- Organization info -

-
- - -
-
- -
-
-
-

- Fleet web address -

-
- - Include base path only (eg. no /v1) - - } - /> -
-
- -
-
- -
-

- SAML single sign on options -

- -
- Enable single sign on -
- -
- -
-
- -
- -
- - The URI you provide here must exactly match the Entity ID - field used in identity provider configuration. - - } - /> -
-
- -
- -
- -
-
- -
- -
- -
-
- -
- -
- -
-
- -
- -
- - If available from the identity provider, this is the - preferred means of providing metadata. - - } - /> -
-
- -
- -
- - Allow SSO login initiated by Identity Provider - -
-
- -
-

- - SMTP options{" "} - - STATUS:{" "} - {smtpConfigured ? "CONFIGURED" : "NOT CONFIGURED"} - - -

-
- Enable SMTP -
- -
- -
-
- -
- -
- - - - Use SSL/TLS to connect (recommended) - -
-
- -
- -
- - {renderSmtpSection()} -
-
- If your mail server requires authentication, you need to specify the authentication type here.

\ -

No Authentication - Select this if your SMTP is open.

\ -

Username & Password - Select this if your SMTP server requires authentication with a username and password.

\ - " - } - /> -
-
- -
-

- Osquery enrollment secrets -

-
-

- Manage secrets with fleetctl. Active secrets: -

- -
-
- -
-

- Global agent options -

-
-

- This code will be used by osquery when it checks for - configuration options. -
- - Changes to these configuration options will be applied to all - hosts in your organization that do not belong to any team. - -

- - How do global agent options interact with team-level agent - options?  - - Learn more about agent options  - open new tab - - -

- YAML -

- -
-
- -
-

- Host status webhook -

-
-

- Send an alert if a portion of your hosts go offline. -

- - Enable host status webhook - -

- A request will be sent to your configured Destination URL{" "} - if the configured Percentage of hosts have not checked - into Fleet for the configured Number of days. -

-
-
- -
-
- -
-
-

Provide a URL to deliver
the webhook request to.

\ - " - } - /> -
-
- -
-
-

Select the minimum percentage of hosts that
must fail to check into Fleet in order to trigger
the webhook request.

\ - " - } - /> -
-
- -
-
-

Select the minimum number of days that the
configured Percentage of hosts must fail to
check into Fleet in order to trigger the
webhook request.

\ - " - } - /> -
-
- -
-

- Usage statistics -

-

- Help improve Fleet by sending anonymous usage statistics. -
-
- This information helps our team better understand feature adoption - and usage, and allows us to see how Fleet is adding value, so that - we can make better product decisions. -
-
- - Learn more about usage statistics  - open new tab - -

-
- - Enable usage statistics - -
-
- -
-
- -
-

- Advanced options -

- {renderAdvancedOptions()} -
- -
- {renderUsageStatsPreviewModal()} - {renderHostStatusWebhookPreviewModal()} - - ); - } -} - -export default Form(AppConfigForm, { - fields: formFields, - validate, -}); diff --git a/frontend/components/forms/admin/AppConfigForm/AppConfigForm.tests.jsx b/frontend/components/forms/admin/AppConfigForm/AppConfigForm.tests.jsx deleted file mode 100644 index 2ca09d3977..0000000000 --- a/frontend/components/forms/admin/AppConfigForm/AppConfigForm.tests.jsx +++ /dev/null @@ -1,110 +0,0 @@ -import React from "react"; -import { mount } from "enzyme"; -import { noop } from "lodash"; - -import AppConfigForm from "components/forms/admin/AppConfigForm"; -import { itBehavesLikeAFormInputElement } from "test/helpers"; - -describe("AppConfigForm - form", () => { - const defaultProps = { - formData: { org_name: "Fleet" }, - handleSubmit: noop, - smtpConfigured: false, - enrollSecret: [ - { secret: "foo_secret" }, - { secret: "bar_secret" }, - { secret: "baz_secret" }, - ], - }; - const form = mount(); - - describe("Organization name input", () => { - it("renders an input field", () => { - itBehavesLikeAFormInputElement(form, "org_name"); - }); - }); - - describe("Organization avatar input", () => { - it("renders an input field", () => { - itBehavesLikeAFormInputElement(form, "org_logo_url"); - }); - }); - - describe("Fleet app URL input", () => { - it("renders an input field", () => { - itBehavesLikeAFormInputElement(form, "server_url"); - }); - }); - - describe("Sender address input", () => { - it("renders an input field", () => { - itBehavesLikeAFormInputElement(form, "sender_address"); - }); - }); - - describe("SMTP server input", () => { - it("renders an input field", () => { - itBehavesLikeAFormInputElement(form, "server"); - }); - }); - - describe("Port input", () => { - it("renders an input field", () => { - itBehavesLikeAFormInputElement(form, "port"); - }); - }); - - describe("Enable SSL/TLS input", () => { - it("renders an input field", () => { - itBehavesLikeAFormInputElement(form, "enable_ssl_tls", "Checkbox"); - }); - }); - - describe("SMTP user name input", () => { - it("renders an input field", () => { - itBehavesLikeAFormInputElement(form, "user_name"); - }); - }); - - describe("SMTP user password input", () => { - it("renders an HTML password input", () => { - const passwordField = form.find('input[name="password"]'); - - expect(passwordField.prop("type")).toEqual("password"); - }); - - it("renders an input field", () => { - itBehavesLikeAFormInputElement(form, "password"); - }); - }); - - describe("Enroll secret", () => { - it("renders enroll secrets table", () => { - expect(form.find("EnrollSecretTable").length).toEqual(1); - }); - }); - - describe("Advanced options", () => { - it("disables host expiry window by default", () => { - const InputField = form.find({ name: "host_expiry_window" }); - const inputElement = InputField.find("input"); - expect(inputElement.length).toEqual(1); - expect(inputElement.hasClass("input-field--disabled")).toBe(true); - }); - - it("enables host expiry window", () => { - form - .find({ name: "host_expiry_enabled" }) - .find("Checkbox") - .simulate("click"); - const InputField = form.find({ name: "host_expiry_window" }); - const inputElement = InputField.find("input"); - expect(inputElement.hasClass("input-field--disabled")).toBe(true); - }); - - it("renders live query disabled input", () => { - form.find({ name: "live_query_disabled" }); - expect(form.length).toEqual(1); - }); - }); -}); diff --git a/frontend/components/forms/admin/AppConfigForm/AppConfigForm.tsx b/frontend/components/forms/admin/AppConfigForm/AppConfigForm.tsx new file mode 100644 index 0000000000..13ed2c8345 --- /dev/null +++ b/frontend/components/forms/admin/AppConfigForm/AppConfigForm.tsx @@ -0,0 +1,1079 @@ +import React, { useState, useEffect } from "react"; +import { syntaxHighlight } from "fleet/helpers"; + +// @ts-ignore +import constructErrorString from "utilities/yaml"; +import yaml from "js-yaml"; + +import { IConfigFormData } from "interfaces/config"; + +import Button from "components/buttons/Button"; +import Checkbox from "components/forms/fields/Checkbox"; +// @ts-ignore +import Dropdown from "components/forms/fields/Dropdown"; +import EnrollSecretTable from "components/EnrollSecretTable"; +// @ts-ignore +import InputField from "components/forms/fields/InputField"; +// @ts-ignore +import OrgLogoIcon from "components/icons/OrgLogoIcon"; +// @ts-ignore +import validateYaml from "components/forms/validators/validate_yaml"; +import IconToolTip from "components/IconToolTip"; +import InfoBanner from "components/InfoBanner/InfoBanner"; +// @ts-ignore +import YamlAce from "components/YamlAce"; +import Modal from "components/Modal"; +import SelectTargetsDropdownStories from "components/forms/fields/SelectTargetsDropdown/SelectTargetsDropdown.stories"; +import OpenNewTabIcon from "../../../../../assets/images/open-new-tab-12x12@2x.png"; +import { + IAppConfigFormProps, + IFormField, + IAppConfigFormErrors, + authMethodOptions, + authTypeOptions, + percentageOfHosts, + numberOfDays, + hostStatusPreview, + usageStatsPreview, +} from "./constants"; + +const baseClass = "app-config-form"; + +const AppConfigFormFunctional = ({ + appConfig, + enrollSecret, + handleSubmit, +}: IAppConfigFormProps): JSX.Element => { + // STATE + const [ + showHostStatusWebhookPreviewModal, + setShowHostStatusWebhookPreviewModal, + ] = useState(false); + const [ + showUsageStatsPreviewModal, + setShowUsageStatsPreviewModal, + ] = useState(false); + + // FORM STATE + const [formData, setFormData] = useState({ + // Formatting of UI not API + // Organization info + orgName: appConfig.org_info.org_name || "", + orgLogoURL: appConfig.org_info.org_logo_url || "", + // Fleet web address + serverURL: appConfig.server_settings.server_url || "", + // SAML single sign on options + enableSSO: appConfig.sso_settings.enable_sso || false, + idpName: appConfig.sso_settings.idp_name || "", + entityID: appConfig.sso_settings.entity_id || "", + issuerURI: appConfig.sso_settings.issuer_uri || "", + idpImageURL: appConfig.sso_settings.idp_image_url || "", + metadata: appConfig.sso_settings.metadata || "", + metadataURL: appConfig.sso_settings.metadata_url || "", + enableSSOIDPLogin: appConfig.sso_settings.enable_sso_idp_login || false, + // SMTP options + enableSMTP: appConfig.smtp_settings.enable_smtp || false, + smtpSenderAddress: appConfig.smtp_settings.sender_address || "", + smtpServer: appConfig.smtp_settings.server || "", + smtpPort: appConfig.smtp_settings.port, + smtpEnableSSLTLS: appConfig.smtp_settings.enable_ssl_tls || false, + smtpAuthenticationType: appConfig.smtp_settings.authentication_type || "", + smtpUsername: appConfig.smtp_settings.user_name || "", + smtpPassword: appConfig.smtp_settings.password || "", + smtpAuthenticationMethod: + appConfig.smtp_settings.authentication_method || "", + // Global agent options + agentOptions: yaml.dump(appConfig.agent_options) || {}, + // Host status webhook + enableHostStatusWebhook: + appConfig.webhook_settings.host_status_webhook + .enable_host_status_webhook || false, + hostStatusWebhookDestinationURL: + appConfig.webhook_settings.host_status_webhook.destination_url || "", + hostStatusWebhookHostPercentage: + appConfig.webhook_settings.host_status_webhook.host_percentage || + undefined, + hostStatusWebhookDaysCount: + appConfig.webhook_settings.host_status_webhook.days_count || undefined, + // Usage statistics + enableUsageStatistics: appConfig.server_settings.enable_analytics, + // Advanced options + domain: appConfig.smtp_settings.domain || "", + verifySSLCerts: appConfig.smtp_settings.verify_ssl_certs || false, + enableStartTLS: appConfig.smtp_settings.enable_start_tls, + enableHostExpiry: + appConfig.host_expiry_settings.host_expiry_enabled || false, + hostExpiryWindow: appConfig.host_expiry_settings.host_expiry_window || 0, + disableLiveQuery: appConfig.server_settings.live_query_disabled || false, + }); + + const { + orgName, + orgLogoURL, + serverURL, + enableSSO, + idpName, + entityID, + issuerURI, + idpImageURL, + metadata, + metadataURL, + enableSSOIDPLogin, + enableSMTP, + smtpSenderAddress, + smtpServer, + smtpPort, + smtpEnableSSLTLS, + smtpAuthenticationType, + smtpUsername, + smtpPassword, + smtpAuthenticationMethod, + agentOptions, + enableHostStatusWebhook, + hostStatusWebhookDestinationURL, + hostStatusWebhookHostPercentage, + hostStatusWebhookDaysCount, + enableUsageStatistics, + domain, + verifySSLCerts, + enableStartTLS, + enableHostExpiry, + hostExpiryWindow, + disableLiveQuery, + } = formData; + + const [formErrors, setFormErrors] = useState({}); + + // FORM CHANGE AND VALIDATIONS + const handleInputChange = ({ name, value }: IFormField) => { + setFormData({ ...formData, [name]: value }); + }; + + const handleAceInputChange = (value: string) => { + setFormData({ ...formData, agentOptions: value }); + }; + + const validateForm = () => { + const errors: IAppConfigFormErrors = {}; + + if (!orgName) { + errors.org_name = "Organization name must be present"; + } + + if (!serverURL) { + errors.server_url = "Fleet server URL must be present"; + } + + if (enableSSO) { + if (metadata === "" && metadataURL === "") { + errors.metadata_url = "Metadata URL must be present"; + } + if (!entityID) { + errors.entity_id = "Entity ID must be present"; + } + if (!idpName) { + errors.idp_name = "Identity provider name must be present"; + } + } + + if (enableSMTP) { + if (!smtpSenderAddress) { + errors.sender_address = "SMTP sender address must be present"; + } + if (!smtpServer) { + errors.server = "SMTP server must be present"; + } + if (!smtpPort) { + errors.server = "SMTP server port must be present"; + errors.server_port = "Port"; + } + if (!smtpServer && !smtpPort) { + errors.server = "SMTP server and server port must be present"; + errors.server_port = "Port"; + } + if (smtpAuthenticationType === "authtype_username_password") { + if (smtpUsername === "") { + errors.user_name = "SMTP username must be present"; + } + if (smtpPassword === "") { + errors.password = "SMTP password must be present"; + } + } + } + + if (enableHostStatusWebhook) { + if (!hostStatusWebhookDestinationURL) { + errors.destination_url = "Destination URL must be present"; + } + } + + if (enableHostExpiry) { + if (!hostExpiryWindow) { + errors.host_expiry_window = + "Host expiry window must be a positive number"; + } + } + + if (agentOptions) { + const { error: yamlError, valid: yamlValid } = validateYaml(agentOptions); + if (!yamlValid) { + errors.agent_options = constructErrorString(yamlError); + } + } + + setFormErrors(errors); + }; + + // Validates forms when certain checkboxes and dropdowns are selected + useEffect(() => { + validateForm(); + }, [ + enableSSO, + enableSMTP, + smtpAuthenticationType, + enableHostStatusWebhook, + enableHostExpiry, + agentOptions, + ]); + + // TOGGLE MODALS + + const toggleHostStatusWebhookPreviewModal = () => { + setShowHostStatusWebhookPreviewModal(!showHostStatusWebhookPreviewModal); + return false; + }; + + const toggleUsageStatsPreviewModal = () => { + setShowUsageStatsPreviewModal(!showUsageStatsPreviewModal); + return false; + }; + + // FORM SUBMIT + const onFormSubmit = (evt: React.MouseEvent) => { + evt.preventDefault(); + + // Formatting of API not UI + const formDataToSubmit = { + org_info: { + org_logo_url: orgLogoURL, + org_name: orgName, + }, + server_settings: { + server_url: serverURL, + live_query_disabled: disableLiveQuery, + enable_analytics: enableUsageStatistics, + }, + smtp_settings: { + enable_smtp: enableSMTP, + sender_address: smtpSenderAddress, + server: smtpServer, + port: Number(smtpPort), + authentication_type: smtpAuthenticationType, + user_name: smtpUsername, + password: smtpPassword, + enable_ssl_tls: smtpEnableSSLTLS, + authentication_method: smtpAuthenticationMethod, + domain, + verify_ssl_certs: verifySSLCerts, + enable_start_tls: enableStartTLS, + }, + sso_settings: { + entity_id: entityID, + issuer_uri: issuerURI, + idp_image_url: idpImageURL, + metadata, + metadata_url: metadataURL, + idp_name: idpName, + enable_sso: enableSSO, + enable_sso_idp_login: enableSSOIDPLogin, + }, + host_expiry_settings: { + host_expiry_enabled: enableHostExpiry, + host_expiry_window: Number(hostExpiryWindow), + }, + agent_options: yaml.load(agentOptions), + webhook_settings: { + host_status_webhook: { + enable_host_status_webhook: enableHostStatusWebhook, + destination_url: hostStatusWebhookDestinationURL, + host_percentage: hostStatusWebhookHostPercentage, + days_count: hostStatusWebhookDaysCount, + }, + }, + }; + + handleSubmit(formDataToSubmit); + }; + + // SECTIONS + const renderOrganizationInfoSection = () => { + return ( +
+

+ Organization info +

+
+ + +
+
+ +
+
+ ); + }; + + const renderFleetWebAddressSection = () => { + return ( +
+

+ Fleet web address +

+
+ + Include base path only (eg. no /v1) + + } + onChange={handleInputChange} + name="serverURL" + value={serverURL} + parseTarget + onBlur={validateForm} + error={formErrors.server_url} + /> +
+
+ +
+
+ ); + }; + + const renderSAMLSingleSignOnOptionsSection = () => { + return ( +
+

+ SAML single sign on options +

+
+ + Enable single sign on + +
+
+ +
+
+ +
+
+ + The URI you provide here must exactly match the Entity ID field + used in identity provider configuration. + + } + onChange={handleInputChange} + name="entityID" + value={entityID} + parseTarget + onBlur={validateForm} + error={formErrors.entity_id} + /> +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ + If available from the identity provider, this is the preferred + means of providing metadata. + + } + onChange={handleInputChange} + name="metadataURL" + value={metadataURL} + parseTarget + onBlur={validateForm} + error={formErrors.metadata_url} + /> +
+
+ +
+ +
+ + Allow SSO login initiated by Identity Provider + +
+
+ ); + }; + + const renderSMTPOptionsSection = () => { + const renderSmtpSection = () => { + if (smtpAuthenticationType === "authtype_none") { + return false; + } + + return ( +
+ + + +
+ ); + }; + + return ( +
+

+ + SMTP options{" "} + + STATUS:{" "} + + {appConfig.smtp_settings.configured + ? "CONFIGURED" + : "NOT CONFIGURED"} + + + +

+
+ + Enable SMTP + +
+
+ +
+
+ +
+
+ + + + Use SSL/TLS to connect (recommended) + +
+
+ +
+
+ + {renderSmtpSection()} +
+
+ If your mail server requires authentication, you need to specify the authentication type here.

\ +

No Authentication - Select this if your SMTP is open.

\ +

Username & Password - Select this if your SMTP server requires authentication with a username and password.

\ + " + } + /> +
+
+ ); + }; + + const renderOsqueryEnrollmentSecretsSection = () => { + return ( +
+

+ Osquery enrollment secrets +

+
+

+ Manage secrets with fleetctl. Active secrets: +

+ +
+
+ ); + }; + + const renderGlobalAgentOptionsSection = () => { + return ( +
+

+ Global agent options +

+
+

+ This code will be used by osquery when it checks for configuration + options. +
+ + Changes to these configuration options will be applied to all + hosts in your organization that do not belong to any team. + +

+ + How do global agent options interact with team-level agent + options?  + + Learn more about agent options  + open new tab + + +

+ YAML +

+ {/* */} + +
+
+ ); + }; + + const renderHostStatusWebhookSection = () => { + return ( +
+

+ Host status webhook +

+
+

+ Send an alert if a portion of your hosts go offline. +

+ + Enable host status webhook + +

+ A request will be sent to your configured Destination URL if + the configured Percentage of hosts have not checked into + Fleet for the configured Number of days. +

+
+
+ +
+
+ +
+
+

Provide a URL to deliver
the webhook request to.

\ + " + } + /> +
+
+ +
+
+

Select the minimum percentage of hosts that
must fail to check into Fleet in order to trigger
the webhook request.

\ + " + } + /> +
+
+ +
+
+

Select the minimum number of days that the
configured Percentage of hosts must fail to
check into Fleet in order to trigger the
webhook request.

\ + " + } + /> +
+
+ ); + }; + + const renderUsageStatistics = () => { + return ( +
+

+ Usage statistics +

+

+ Help improve Fleet by sending anonymous usage statistics. +
+
+ This information helps our team better understand feature adoption and + usage, and allows us to see how Fleet is adding value, so that we can + make better product decisions. +
+
+ + Learn more about usage statistics  + open new tab + +

+
+ + Enable usage statistics + +
+
+ +
+
+ ); + }; + + const renderAdvancedOptions = () => { + return ( +
+

+ Advanced options +

+
+

+ Most users do not need to modify these options. +

+
+
+
+ + If you need to specify a HELO domain,
you can do it here (Default: Blank)

' + } + /> +
+
+ + Verify SSL certs + + Turn this off (not recommended)
if you use a self-signed certificate
(Default: On)

' + } + /> +
+
+ + Enable STARTTLS + + Detects if STARTTLS is enabled
in your SMTP server and starts
to use it. (Default: On)

' + } + /> +
+
+ + Host expiry + + When enabled, allows automatic cleanup
of hosts that have not communicated with Fleet
in some number of days. (Default: Off)

' + } + /> +
+
+ + If a host has not communicated with Fleet
in the specified number of days, it will be removed.

" + } + /> +
+
+ + Disable live queries + + When enabled, disables the ability to run live queries
(ad hoc queries executed via the UI or fleetctl). (Default: Off)

' + } + /> +
+
+
+
+
+ ); + }; + + // MODALS + + const renderHostStatusWebhookPreviewModal = () => { + if (!showHostStatusWebhookPreviewModal) { + return null; + } + + return ( + + <> +

+ An example request sent to your configured Destination URL. +

+
+
+          
+
+ +
+ +
+ ); + }; + + const renderUsageStatsPreviewModal = () => { + if (!showUsageStatsPreviewModal) { + return null; + } + + return ( + + <> +

An example JSON payload sent to Fleet Device Management Inc.

+
+          
+ +
+ + + ); + }; + + // RENDER + return ( + <> +
+ {renderOrganizationInfoSection()} + {renderFleetWebAddressSection()} + {renderSAMLSingleSignOnOptionsSection()} + {renderSMTPOptionsSection()} + {renderOsqueryEnrollmentSecretsSection()} + {renderGlobalAgentOptionsSection()} + {renderHostStatusWebhookSection()} + {renderUsageStatistics()} + {renderAdvancedOptions()} + +
+ {renderUsageStatsPreviewModal()} + {renderHostStatusWebhookPreviewModal()} + + ); +}; + +export default AppConfigFormFunctional; diff --git a/frontend/components/forms/admin/AppConfigForm/constants.ts b/frontend/components/forms/admin/AppConfigForm/constants.ts new file mode 100644 index 0000000000..44c328ff8b --- /dev/null +++ b/frontend/components/forms/admin/AppConfigForm/constants.ts @@ -0,0 +1,87 @@ +import { IConfigNested } from "interfaces/config"; +import { IEnrollSecret } from "interfaces/enroll_secret"; + +export interface IAppConfigFormProps { + appConfig: IConfigNested; + enrollSecret: IEnrollSecret[] | undefined; + handleSubmit: any; +} + +export interface IFormField { + name: string; + value: string | boolean | number; +} + +export interface IAppConfigFormErrors { + metadata_url?: string | null; + entity_id?: string | null; + idp_name?: string | null; + server_url?: string | null; + org_name?: string | null; + sender_address?: string | null; + server?: string | null; + server_port?: string | null; + user_name?: string | null; + password?: string | null; + destination_url?: string | null; + host_expiry_window?: string | null; + agent_options?: string | null; +} + +export const authMethodOptions = [ + { label: "Plain", value: "authmethod_plain" }, + { label: "Cram MD5", value: "authmethod_cram_md5" }, + { label: "Login", value: "authmethod_login" }, +]; + +export const authTypeOptions = [ + { label: "Username and Password", value: "authtype_username_password" }, + { label: "None", value: "authtype_none" }, +]; + +export const percentageOfHosts = [ + { label: "1%", value: 1 }, + { label: "5%", value: 5 }, + { label: "10%", value: 10 }, + { label: "25%", value: 25 }, +]; + +export const numberOfDays = [ + { label: "1 day", value: 1 }, + { label: "3 days", value: 3 }, + { label: "7 days", value: 7 }, + { label: "14 days", value: 14 }, +]; + +export const hostStatusPreview = { + text: + "More than X% of your hosts have not checked into Fleet for more than Y days. You’ve been sent this message because the Host status webhook is enabled in your Fleet instance.", + data: { + unseen_hosts: 1, + total_hosts: 2, + days_unseen: 3, + }, +}; +export const usageStatsPreview = { + anonymousIdentifier: "9pnzNmrES3mQG66UQtd29cYTiX2+fZ4CYxDvh495720=", + fleetVersion: "x.x.x", + licenseTier: "free", + numHostsEnrolled: 12345, + numUsers: 12, + numTeams: 3, + numPolicies: 5, + numLabels: 20, + softwareInventoryEnabled: true, + vulnDetectionEnabled: true, + systemUsersEnabled: true, + hostStatusWebhookEnabled: true, +}; + +export default { + authMethodOptions, + authTypeOptions, + percentageOfHosts, + numberOfDays, + hostStatusPreview, + usageStatsPreview, +}; diff --git a/frontend/components/forms/admin/AppConfigForm/index.js b/frontend/components/forms/admin/AppConfigForm/index.ts similarity index 100% rename from frontend/components/forms/admin/AppConfigForm/index.js rename to frontend/components/forms/admin/AppConfigForm/index.ts diff --git a/frontend/components/forms/admin/AppConfigForm/validate.js b/frontend/components/forms/admin/AppConfigForm/validate.js deleted file mode 100644 index 5fb9b1a5c1..0000000000 --- a/frontend/components/forms/admin/AppConfigForm/validate.js +++ /dev/null @@ -1,109 +0,0 @@ -import { size } from "lodash"; -import validateYaml from "components/forms/validators/validate_yaml"; -import constructErrorString from "utilities/yaml"; - -export default (formData) => { - const errors = {}; - const { - authentication_type: authType, - server_url: kolideServerUrl, - org_name: orgName, - enable_smtp: enableSMTP, - password: smtpPassword, - sender_address: smtpSenderAddress, - server: smtpServer, - port: smtpServerPort, - user_name: smtpUserName, - enable_sso: enableSSO, - metadata, - metadata_url: metadataURL, - entity_id: entityID, - idp_name: idpName, - host_expiry_enabled: hostExpiryEnabled, - host_expiry_window: hostExpiryWindow = 0, - agent_options: agentOptions, - enable_host_status_webhook: enableHostStatusWebhook, - destination_url: destinationUrl, - host_percentage: hostPercentage, - days_count: daysCount, - } = formData; - - if (enableSSO) { - if (!metadata && !metadataURL) { - errors.metadata_url = "Metadata URL must be present"; - } - if (!entityID) { - errors.entity_id = "Entity ID must be present"; - } - if (!idpName) { - errors.idp_name = "Identity Provider Name must be present"; - } - } - - if (!kolideServerUrl) { - errors.server_url = "Fleet server URL must be present"; - } - - if (!orgName) { - errors.org_name = "Organization name must be present"; - } - - if (enableSMTP) { - if (!smtpSenderAddress) { - errors.sender_address = "SMTP sender address must be present"; - } - - if (!smtpServer) { - errors.server = "SMTP server must be present"; - } - - if (!smtpServerPort) { - errors.server = "SMTP server port must be present"; - } - - if (authType !== "authtype_none") { - if (!smtpUserName) { - errors.user_name = "SMTP username must be present"; - } - - if (!smtpPassword) { - errors.password = "SMTP password must be present"; - } - } - } - - if (enableHostStatusWebhook) { - if (!destinationUrl) { - errors.destination_url = "Destination URL must be present"; - } - - if (!hostPercentage) { - errors.host_percentage = "Host percentage must be present"; - } - - if (!daysCount) { - errors.days_count = "Days count must be present"; - } - } - - if (hostExpiryEnabled) { - if (isNaN(hostExpiryWindow) || Number(hostExpiryWindow) <= 0) { - errors.host_expiry_window = - "Host expiry window must be a positive number"; - } - } - - if (agentOptions) { - const { error: yamlError, valid: yamlValid } = validateYaml( - formData.agent_options - ); - - if (!yamlValid) { - errors.agent_options = constructErrorString(yamlError); - } - } - - const valid = !size(errors); - - return { valid, errors }; -}; diff --git a/frontend/components/forms/admin/AppConfigForm/validate.tests.js b/frontend/components/forms/admin/AppConfigForm/validate.tests.js deleted file mode 100644 index 0abd969792..0000000000 --- a/frontend/components/forms/admin/AppConfigForm/validate.tests.js +++ /dev/null @@ -1,218 +0,0 @@ -import validate from "components/forms/admin/AppConfigForm/validate"; - -describe("AppConfigForm - validations", () => { - const validFormData = { - org_name: "The Gnar Co.", - authentication_type: "username_password", - server_url: "https://gnar.dog", - sender_address: "hi@gnar.dog", - enable_smtp: true, - server: "192.168.99.100", - port: "1025", - user_name: "gnardog", - password: "p@ssw0rd", - host_expiry_enabled: true, - host_expiry_window: "42", - }; - - it("returns a valid object when the form data is valid", () => { - expect(validate(validFormData)).toEqual({ valid: true, errors: {} }); - }); - - it("validates presence of the org_name field", () => { - const invalidFormData = { - ...validFormData, - org_name: "", - }; - - expect(validate(invalidFormData)).toEqual({ - valid: false, - errors: { - org_name: "Organization name must be present", - }, - }); - }); - - it("validates presence of the server_url field", () => { - const invalidFormData = { - ...validFormData, - server_url: "", - }; - - expect(validate(invalidFormData)).toEqual({ - valid: false, - errors: { - server_url: "Fleet server URL must be present", - }, - }); - }); - - describe("smtp configurations", () => { - it("validates the sender address", () => { - const invalidFormData = { - ...validFormData, - sender_address: "", - }; - - expect(validate(invalidFormData)).toEqual({ - valid: false, - errors: { - sender_address: "SMTP sender address must be present", - }, - }); - }); - - it("validates the smtp server", () => { - const invalidFormData = { - ...validFormData, - server: "", - }; - - expect(validate(invalidFormData)).toEqual({ - valid: false, - errors: { - server: "SMTP server must be present", - }, - }); - }); - - it("validates the smtp port", () => { - const invalidFormData = { - ...validFormData, - port: "", - }; - - expect(validate(invalidFormData)).toEqual({ - valid: false, - errors: { - server: "SMTP server port must be present", - }, - }); - }); - - it('validates the password if auth type is not "none"', () => { - const invalidFormData = { - ...validFormData, - password: "", - }; - - expect(validate(invalidFormData)).toEqual({ - valid: false, - errors: { - password: "SMTP password must be present", - }, - }); - }); - - it('validates the user_name if auth type is not "none"', () => { - const invalidFormData = { - ...validFormData, - user_name: "", - }; - - expect(validate(invalidFormData)).toEqual({ - valid: false, - errors: { - user_name: "SMTP username must be present", - }, - }); - }); - - it("does not validate smtp config if smtp not enabled", () => { - const formData = { - ...validFormData, - enable_smtp: false, - user_name: "", - server: "", - sender_address: "", - password: "********", - port: "587", - }; - const invalidFormData = { - ...validFormData, - user_name: "", - server: "", - sender_address: "", - password: "newPassword", - port: "587", - }; - const missingPortFormData = { - ...validFormData, - port: "", - }; - - expect(validate(formData)).toEqual({ - valid: true, - errors: {}, - }); - - expect(validate(invalidFormData)).toEqual({ - valid: false, - errors: { - sender_address: "SMTP sender address must be present", - server: "SMTP server must be present", - user_name: "SMTP username must be present", - }, - }); - - expect(validate(missingPortFormData)).toEqual({ - valid: false, - errors: { - server: "SMTP server port must be present", - }, - }); - }); - - it('does not validate the user_name and password if the auth type is "none"', () => { - const formData = { - ...validFormData, - authentication_type: "authtype_none", - password: "", - user_name: "", - }; - - expect(validate(formData)).toEqual({ valid: true, errors: {} }); - }); - }); - - describe("host expiry settings", () => { - it("does not validate missing expiry window", () => { - const formData = { - ...validFormData, - }; - delete formData.host_expiry_window; - expect(validate(formData)).toEqual({ - valid: false, - errors: { - host_expiry_window: "Host expiry window must be a positive number", - }, - }); - }); - - it("does not validate NaN expiry window", () => { - const formData = { - ...validFormData, - host_expiry_window: "abcd", - }; - expect(validate(formData)).toEqual({ - valid: false, - errors: { - host_expiry_window: "Host expiry window must be a positive number", - }, - }); - }); - - it("does not validate negative expiry window", () => { - const formData = { - ...validFormData, - host_expiry_window: "-21", - }; - expect(validate(formData)).toEqual({ - valid: false, - errors: { - host_expiry_window: "Host expiry window must be a positive number", - }, - }); - }); - }); -}); diff --git a/frontend/components/forms/fields/Checkbox/Checkbox.tsx b/frontend/components/forms/fields/Checkbox/Checkbox.tsx index f9e9d8abec..89bfb0db59 100644 --- a/frontend/components/forms/fields/Checkbox/Checkbox.tsx +++ b/frontend/components/forms/fields/Checkbox/Checkbox.tsx @@ -13,9 +13,11 @@ export interface ICheckboxProps { disabled?: boolean; name?: string; onChange?: any; // TODO: meant to be an event; figure out type for this + onBlur?: any; value?: boolean; wrapperClassName?: string; indeterminate?: boolean; + parseTarget?: boolean; } const Checkbox = (props: ICheckboxProps) => { @@ -25,12 +27,19 @@ const Checkbox = (props: ICheckboxProps) => { disabled = false, name, onChange = noop, + onBlur = noop, value, wrapperClassName, indeterminate, + parseTarget, } = props; const handleChange = () => { + if (parseTarget) { + // Returns both name and value + return onChange({ name, value: !value }); + } + return onChange(!value); }; @@ -56,6 +65,7 @@ const Checkbox = (props: ICheckboxProps) => { id={name} name={name} onChange={handleChange} + onBlur={onBlur} type="checkbox" ref={(element) => { element && indeterminate && (element.indeterminate = indeterminate); diff --git a/frontend/components/forms/fields/Dropdown/Dropdown.jsx b/frontend/components/forms/fields/Dropdown/Dropdown.jsx index 52ce5c96c9..75a3fba15c 100644 --- a/frontend/components/forms/fields/Dropdown/Dropdown.jsx +++ b/frontend/components/forms/fields/Dropdown/Dropdown.jsx @@ -31,6 +31,7 @@ class Dropdown extends Component { PropTypes.number, ]), wrapperClassName: PropTypes.string, + parseTarget: PropTypes.bool, }; static defaultProps = { @@ -43,6 +44,7 @@ class Dropdown extends Component { multi: false, name: "targets", placeholder: "Select One...", + parseTarget: false, }; onMenuOpen = () => { @@ -56,15 +58,24 @@ class Dropdown extends Component { }; handleChange = (selected) => { - const { multi, onChange, clearable } = this.props; + const { multi, onChange, clearable, name, parseTarget } = this.props; + + if (parseTarget) { + // Returns both name and value + return onChange({ value: selected.value, name }); + } if (clearable && selected === null) { onChange(null); - } else if (multi) { - onChange(selected.map((obj) => obj.value).join(",")); - } else { - onChange(selected.value); + return; } + + if (multi) { + onChange(selected.map((obj) => obj.value).join(",")); + return; + } + + onChange(selected.value); }; renderLabel = () => { diff --git a/frontend/components/forms/fields/InputField/InputField.jsx b/frontend/components/forms/fields/InputField/InputField.jsx index c0e4b45599..9212dfb948 100644 --- a/frontend/components/forms/fields/InputField/InputField.jsx +++ b/frontend/components/forms/fields/InputField/InputField.jsx @@ -17,6 +17,7 @@ class InputField extends Component { inputOptions: PropTypes.object, // eslint-disable-line react/forbid-prop-types name: PropTypes.string, onChange: PropTypes.func, + onBlur: PropTypes.func, onFocus: PropTypes.func, placeholder: PropTypes.string, type: PropTypes.string, @@ -26,6 +27,7 @@ class InputField extends Component { PropTypes.string, PropTypes.number, ]).isRequired, + parseTarget: PropTypes.bool, }; static defaultProps = { @@ -35,9 +37,11 @@ class InputField extends Component { label: null, labelClassName: "", onFocus: noop, + onBlur: noop, type: "text", blockAutoComplete: false, value: "", + parseTarget: false, }; componentDidMount() { @@ -54,8 +58,13 @@ class InputField extends Component { onInputChange = (evt) => { evt.preventDefault(); - const { value } = evt.target; - const { onChange } = this.props; + const { value, name } = evt.target; + const { onChange, parseTarget } = this.props; + + if (parseTarget) { + // Returns both name and value + return onChange({ value, name }); + } return onChange(value); }; @@ -69,6 +78,7 @@ class InputField extends Component { inputWrapperClass, name, onFocus, + onBlur, placeholder, type, blockAutoComplete, @@ -118,6 +128,7 @@ class InputField extends Component { id={name} onChange={onInputChange} onFocus={onFocus} + onBlur={onBlur} className={inputClasses} placeholder={placeholder} ref={(r) => { diff --git a/frontend/components/forms/fields/InputField/_styles.scss b/frontend/components/forms/fields/InputField/_styles.scss index 89c91e1c14..872a8bc735 100644 --- a/frontend/components/forms/fields/InputField/_styles.scss +++ b/frontend/components/forms/fields/InputField/_styles.scss @@ -51,6 +51,7 @@ &--error { font-weight: $bold; + color: $ui-error; } } diff --git a/frontend/interfaces/config.ts b/frontend/interfaces/config.ts index 2462ef3b18..a43bd63589 100644 --- a/frontend/interfaces/config.ts +++ b/frontend/interfaces/config.ts @@ -26,6 +26,7 @@ export default PropTypes.shape({ issuer_uri: PropTypes.string, idp_image_url: PropTypes.string, metadata: PropTypes.string, + metadata_url: PropTypes.string, idp_name: PropTypes.string, enable_sso: PropTypes.bool, enable_sso_idp_login: PropTypes.bool, @@ -89,6 +90,7 @@ export interface IConfig { issuer_uri: string; idp_image_url: string; metadata: string; + metadata_url: string; idp_name: string; enable_sso: boolean; enable_sso_idp_login: boolean; @@ -107,6 +109,131 @@ export interface IConfig { destination_url: string; host_percentage: number; days_count: number; + logging?: { + debug: boolean; + json: boolean; + result: { + plugin: string; + config: { + status_log_file: string; + result_log_file: string; + enable_log_rotation: boolean; + enable_log_compression: boolean; + }; + }; + status: { + plugin: string; + config: { + status_log_file: string; + result_log_file: string; + enable_log_rotation: boolean; + enable_log_compression: boolean; + }; + }; + }; + webhook_settings?: { + failing_policies_webhook: IWebhookFailingPolicies; + }; +} + +export interface IConfigFormData { + smtpAuthenticationMethod: string; + smtpAuthenticationType: string; + domain: string; + smtpEnableSSLTLS: boolean; + enableStartTLS: boolean; + serverURL: string; + orgLogoURL: string; + orgName: string; + smtpPassword: string; + smtpPort?: number; + smtpSenderAddress: string; + smtpServer: string; + smtpUsername: string; + verifySSLCerts: boolean; + entityID: string; + issuerURI: string; + idpImageURL: string; + metadata: string; + metadataURL: string; + idpName: string; + enableSSO: boolean; + enableSSOIDPLogin: boolean; + enableSMTP: boolean; + enableHostExpiry: boolean; + hostExpiryWindow: number; + disableLiveQuery: boolean; + agentOptions: any; + enableHostStatusWebhook: boolean; + hostStatusWebhookDestinationURL?: string; + hostStatusWebhookHostPercentage?: number; + hostStatusWebhookDaysCount?: number; + enableUsageStatistics: boolean; +} + +export interface IConfigNested { + org_info: { + org_name: string; + org_logo_url: string; + }; + server_settings: { + server_url: string; + live_query_disabled: boolean; + enable_analytics: boolean; + }; + smtp_settings: { + enable_smtp: boolean; + configured: boolean; + sender_address: string; + server: string; + port?: number; + authentication_type: string; + user_name: string; + password: string; + enable_ssl_tls: boolean; + authentication_method: string; + domain: string; + verify_ssl_certs: boolean; + enable_start_tls: boolean; + }; + sso_settings: { + entity_id: string; + issuer_uri: string; + idp_image_url: string; + metadata: string; + metadata_url: string; + idp_name: string; + enable_sso: boolean; + enable_sso_idp_login: boolean; + }; + host_expiry_settings: { + host_expiry_enabled: boolean; + host_expiry_window: number; + }; + agent_options: string; + update_interval: { + osquery_detail: number; + osquery_policy: number; + }; + license: { + organization: string; + device_count: number; + tier: string; + expiration: string; + note: string; + }; + vulnerability_settings: { + databases_path: string; + }; + webhook_settings: { + host_status_webhook: { + enable_host_status_webhook: boolean; + destination_url: string; + host_percentage: number; + days_count: number; + }; + failing_policies_webhook: IWebhookFailingPolicies; + }; logging: { debug: boolean; json: boolean; @@ -129,7 +256,4 @@ export interface IConfig { }; }; }; - webhook_settings: { - failing_policies_webhook: IWebhookFailingPolicies; - }; } diff --git a/frontend/pages/admin/AppSettingsPage/AppSettingsPage.jsx b/frontend/pages/admin/AppSettingsPage/AppSettingsPage.jsx deleted file mode 100644 index 4f448745bb..0000000000 --- a/frontend/pages/admin/AppSettingsPage/AppSettingsPage.jsx +++ /dev/null @@ -1,129 +0,0 @@ -import React, { Component } from "react"; -import PropTypes from "prop-types"; -import { connect } from "react-redux"; -import { size } from "lodash"; - -import AppConfigForm from "components/forms/admin/AppConfigForm"; -import configInterface from "interfaces/config"; -import enrollSecretInterface from "interfaces/enroll_secret"; -import deepDifference from "utilities/deep_difference"; -import { renderFlash } from "redux/nodes/notifications/actions"; -import { updateConfig } from "redux/nodes/app/actions"; - -export const baseClass = "app-settings"; -class AppSettingsPage extends Component { - static propTypes = { - appConfig: configInterface, - dispatch: PropTypes.func.isRequired, - error: PropTypes.object, // eslint-disable-line react/forbid-prop-types - enrollSecret: PropTypes.arrayOf(enrollSecretInterface), - }; - - onFormSubmit = (formData) => { - const { appConfig, dispatch } = this.props; - const diff = deepDifference(formData, appConfig); - - dispatch(updateConfig(diff, true)) - .then(() => { - dispatch(renderFlash("success", "Settings updated.")); - - return false; - }) - .catch((errors) => { - if (errors.base) { - dispatch(renderFlash("error", errors.base)); - } - - return false; - }); - - return false; - }; - - render() { - const { appConfig, error, enrollSecret } = this.props; - const { onFormSubmit } = this; - const { configured: smtpConfigured } = appConfig; - - if (!size(appConfig)) { - return false; - } - - const formData = { ...appConfig, enable_smtp: smtpConfigured }; - - const scrollTo = (elementId) => { - document.getElementById(elementId).scrollIntoView(true); - }; - - return ( - - ); - } -} - -const mapStateToProps = ({ app }) => { - const { config: appConfig, error, enrollSecret } = app; - - return { appConfig, error, enrollSecret }; -}; - -export default connect(mapStateToProps)(AppSettingsPage); diff --git a/frontend/pages/admin/AppSettingsPage/AppSettingsPage.tests.jsx b/frontend/pages/admin/AppSettingsPage/AppSettingsPage.tests.jsx deleted file mode 100644 index dc5a027aca..0000000000 --- a/frontend/pages/admin/AppSettingsPage/AppSettingsPage.tests.jsx +++ /dev/null @@ -1,19 +0,0 @@ -import { mount } from "enzyme"; - -import AppSettingsPage from "pages/admin/AppSettingsPage"; -import { flatConfigStub } from "test/stubs"; -import testHelpers from "test/helpers"; - -const { connectedComponent, reduxMockStore } = testHelpers; -const baseStore = { - app: { config: flatConfigStub, enrollSecret: [] }, -}; - -describe("AppSettingsPage - component", () => { - it("renders", () => { - const mockStore = reduxMockStore(baseStore); - const page = mount(connectedComponent(AppSettingsPage, { mockStore })); - - expect(page.find("AppSettingsPage").length).toEqual(1); - }); -}); diff --git a/frontend/pages/admin/AppSettingsPage/AppSettingsPage.tsx b/frontend/pages/admin/AppSettingsPage/AppSettingsPage.tsx new file mode 100644 index 0000000000..697d209e64 --- /dev/null +++ b/frontend/pages/admin/AppSettingsPage/AppSettingsPage.tsx @@ -0,0 +1,131 @@ +import React, { useCallback, useContext, useEffect } from "react"; +import { useDispatch } from "react-redux"; +import { useQuery } from "react-query"; +// @ts-ignore +import { getConfig } from "redux/nodes/app/actions"; +// @ts-ignore +import { renderFlash } from "redux/nodes/notifications/actions"; + +import { AppContext } from "context/app"; +import enrollSecretsAPI from "services/entities/enroll_secret"; +import configAPI from "services/entities/config"; + +// @ts-ignore +import deepDifference from "utilities/deep_difference"; +import { IConfig, IConfigNested } from "interfaces/config"; +import { + IEnrollSecret, + IEnrollSecretsResponse, +} from "interfaces/enroll_secret"; + +// @ts-ignore +import AppConfigForm from "components/forms/admin/AppConfigForm"; + +export const baseClass = "app-settings"; + +const AppSettingsPage = (): JSX.Element => { + const dispatch = useDispatch(); + + const { config, setConfig } = useContext(AppContext); + + const { + data: appConfig, + isLoading: isLoadingConfig, + refetch: refetchConfig, + } = useQuery( + ["config"], + () => configAPI.loadAll(), + { + select: (data: IConfigNested) => data, + } + ); + + const { data: globalSecrets } = useQuery< + IEnrollSecretsResponse, + Error, + IEnrollSecret[] + >(["global secrets"], () => enrollSecretsAPI.getGlobalEnrollSecrets(), { + enabled: true, + select: (data: IEnrollSecretsResponse) => data.secrets, + }); + + const onFormSubmit = useCallback( + (formData: IConfigNested) => { + const diff = deepDifference(formData, appConfig); + + configAPI + .update(diff) + .then(() => { + dispatch(renderFlash("success", "Successfully updated settings.")); + }) + .catch((errors: any) => { + if (errors.base) { + dispatch(renderFlash("error", errors.base)); + } + }) + .finally(() => { + refetchConfig(); + // Config must be updated in both Redux and AppContext + dispatch(getConfig()) + .then((configState: IConfig) => { + setConfig(configState); + }) + .catch(() => false); + }); + }, + [dispatch, appConfig, getConfig, setConfig] + ); + + return ( +
+

+ Set your organization information, Configure SAML and SMTP, and view + host enroll secrets. +

+ +
+ ); +}; + +export default AppSettingsPage; diff --git a/frontend/pages/admin/AppSettingsPage/index.js b/frontend/pages/admin/AppSettingsPage/index.ts similarity index 100% rename from frontend/pages/admin/AppSettingsPage/index.js rename to frontend/pages/admin/AppSettingsPage/index.ts diff --git a/frontend/services/entities/config.ts b/frontend/services/entities/config.ts index 3c906c0e9a..cb2b5c5d87 100644 --- a/frontend/services/entities/config.ts +++ b/frontend/services/entities/config.ts @@ -1,12 +1,9 @@ /* eslint-disable @typescript-eslint/explicit-module-boundary-types */ -import { get } from "lodash"; import sendRequest from "services"; import endpoints from "fleet/endpoints"; -import helpers from "fleet/helpers"; -// import { IConfig } from "interfaces/host"; -// TODO add other methods from "fleet/entities/config" +// TODO: add other methods from "fleet/entities/config" export default { loadAll: () => { @@ -36,19 +33,6 @@ export default { update: (formData: any) => { const { CONFIG } = endpoints; - // Failing policies webhook does not use flatten <> nest config helper - if (formData.webhook_settings.failing_policies_webhook) { - return sendRequest("PATCH", CONFIG, formData); - } - - const configData = helpers.formatConfigDataForServer(formData); - - if (get(configData, "smtp_settings.port")) { - configData.smtp_settings.port = parseInt( - configData.smtp_settings.port, - 10 - ); - } - return sendRequest("PATCH", CONFIG, configData); + return sendRequest("PATCH", CONFIG, formData); }, };