diff --git a/frontend/components/AddHostsModal/PlatformWrapper/_styles.scss b/frontend/components/AddHostsModal/PlatformWrapper/_styles.scss index 7182a6968bac..6af47c2b727d 100644 --- a/frontend/components/AddHostsModal/PlatformWrapper/_styles.scss +++ b/frontend/components/AddHostsModal/PlatformWrapper/_styles.scss @@ -51,12 +51,7 @@ } &__copy-message { - font-weight: $regular; - vertical-align: top; - background-color: $ui-light-grey; - border: solid 1px #e2e4ea; - border-radius: 10px; - padding: 2px 6px; + @include copy-message; } .buttons { @@ -122,9 +117,6 @@ } &__copy-message { - background-color: $ui-light-grey; - border: solid 1px #e2e4ea; - border-radius: 10px; - padding: 2px 6px; + @include copy-message; } } diff --git a/frontend/components/EnrollSecrets/EnrollSecretTable/EnrollSecretRow/_styles.scss b/frontend/components/EnrollSecrets/EnrollSecretTable/EnrollSecretRow/_styles.scss index c400a9f658be..1b5d033a573d 100644 --- a/frontend/components/EnrollSecrets/EnrollSecretTable/EnrollSecretRow/_styles.scss +++ b/frontend/components/EnrollSecrets/EnrollSecretTable/EnrollSecretRow/_styles.scss @@ -40,10 +40,7 @@ } &__copy-message { - background-color: $ui-light-grey; - border: solid 1px #e2e4ea; - border-radius: 10px; - padding: 2px 6px; + @include copy-message; } &__action-overlay { diff --git a/frontend/components/forms/fields/InputFieldHiddenContent/_styles.scss b/frontend/components/forms/fields/InputFieldHiddenContent/_styles.scss index 89b07fdaf200..4ac1e32a41f4 100644 --- a/frontend/components/forms/fields/InputFieldHiddenContent/_styles.scss +++ b/frontend/components/forms/fields/InputFieldHiddenContent/_styles.scss @@ -43,9 +43,6 @@ } &__copy-message { - background-color: $ui-light-grey; - border: solid 1px #e2e4ea; - border-radius: 10px; - padding: 2px 6px; + @include copy-message; } } diff --git a/frontend/interfaces/integration.ts b/frontend/interfaces/integration.ts index 49156d6277f0..aea79f99eec4 100644 --- a/frontend/interfaces/integration.ts +++ b/frontend/interfaces/integration.ts @@ -61,9 +61,8 @@ export interface IIntegrationFormErrors { } export interface IGlobalCalendarIntegration { - email: string; - private_key: string; domain: string; + api_key_json: string; } interface ITeamCalendarSettings { diff --git a/frontend/pages/AccountPage/APITokenModal/TokenSecretField/_styles.scss b/frontend/pages/AccountPage/APITokenModal/TokenSecretField/_styles.scss index fee0ee67cc5f..bca40979df6e 100644 --- a/frontend/pages/AccountPage/APITokenModal/TokenSecretField/_styles.scss +++ b/frontend/pages/AccountPage/APITokenModal/TokenSecretField/_styles.scss @@ -31,12 +31,7 @@ } &__copy-message { - font-weight: $regular; - vertical-align: top; - background-color: $ui-light-grey; - border: solid 1px #e2e4ea; - border-radius: 10px; - padding: 2px 6px; + @include copy-message; } &__secret-download-icon { diff --git a/frontend/pages/SoftwarePage/components/ManageSoftwareAutomationsModal/ManageSoftwareAutomationsModal.tsx b/frontend/pages/SoftwarePage/components/ManageSoftwareAutomationsModal/ManageSoftwareAutomationsModal.tsx index 002bca8924ca..2869dd799ea9 100644 --- a/frontend/pages/SoftwarePage/components/ManageSoftwareAutomationsModal/ManageSoftwareAutomationsModal.tsx +++ b/frontend/pages/SoftwarePage/components/ManageSoftwareAutomationsModal/ManageSoftwareAutomationsModal.tsx @@ -8,7 +8,7 @@ import { IJiraIntegration, IZendeskIntegration, IIntegration, - IIntegrations, + IGlobalIntegrations, IIntegrationType, } from "interfaces/integration"; import { @@ -124,7 +124,7 @@ const ManageAutomationsModal = ({ } }, [destinationUrl]); - const { data: integrations } = useQuery( + const { data: integrations } = useQuery( ["integrations"], () => configAPI.loadAll(), { diff --git a/frontend/pages/admin/IntegrationsPage/IntegrationNavItems.tsx b/frontend/pages/admin/IntegrationsPage/IntegrationNavItems.tsx index 870444974bb5..a3f8734da8c0 100644 --- a/frontend/pages/admin/IntegrationsPage/IntegrationNavItems.tsx +++ b/frontend/pages/admin/IntegrationsPage/IntegrationNavItems.tsx @@ -4,32 +4,34 @@ import { ISideNavItem } from "../components/SideNav/SideNav"; import Integrations from "./cards/Integrations"; import Mdm from "./cards/MdmSettings/MdmSettings"; import AutomaticEnrollment from "./cards/AutomaticEnrollment/AutomaticEnrollment"; +import Calendars from "./cards/Calendars/Calendars"; -const getFilteredIntegrationSettingsNavItems = ( - isSandboxMode = false -): ISideNavItem[] => { - return [ - // TODO: types - { - title: "Ticket destinations", - urlSection: "ticket-destinations", - path: PATHS.ADMIN_INTEGRATIONS_TICKET_DESTINATIONS, - Card: Integrations, - }, - { - title: "Mobile device management (MDM)", - urlSection: "mdm", - path: PATHS.ADMIN_INTEGRATIONS_MDM, - Card: Mdm, - exclude: isSandboxMode, - }, - { - title: "Automatic enrollment", - urlSection: "automatic-enrollment", - path: PATHS.ADMIN_INTEGRATIONS_AUTOMATIC_ENROLLMENT, - Card: AutomaticEnrollment, - }, - ].filter((navItem) => !navItem.exclude); -}; +const integrationSettingsNavItems: ISideNavItem[] = [ + // TODO: types + { + title: "Ticket destinations", + urlSection: "ticket-destinations", + path: PATHS.ADMIN_INTEGRATIONS_TICKET_DESTINATIONS, + Card: Integrations, + }, + { + title: "Mobile device management (MDM)", + urlSection: "mdm", + path: PATHS.ADMIN_INTEGRATIONS_MDM, + Card: Mdm, + }, + { + title: "Automatic enrollment", + urlSection: "automatic-enrollment", + path: PATHS.ADMIN_INTEGRATIONS_AUTOMATIC_ENROLLMENT, + Card: AutomaticEnrollment, + }, + { + title: "Calendars", + urlSection: "calendars", + path: PATHS.ADMIN_INTEGRATIONS_CALENDARS, + Card: Calendars, + }, +]; -export default getFilteredIntegrationSettingsNavItems; +export default integrationSettingsNavItems; diff --git a/frontend/pages/admin/IntegrationsPage/IntegrationsPage.tsx b/frontend/pages/admin/IntegrationsPage/IntegrationsPage.tsx index 019a219d5164..bae02c33ca5e 100644 --- a/frontend/pages/admin/IntegrationsPage/IntegrationsPage.tsx +++ b/frontend/pages/admin/IntegrationsPage/IntegrationsPage.tsx @@ -1,9 +1,8 @@ -import { AppContext } from "context/app"; -import React, { useContext } from "react"; +import React from "react"; import { InjectedRouter, Params } from "react-router/lib/Router"; import SideNav from "../components/SideNav"; -import getFilteredIntegrationSettingsNavItems from "./IntegrationNavItems"; +import integrationSettingsNavItems from "./IntegrationNavItems"; const baseClass = "integrations"; @@ -16,9 +15,8 @@ const IntegrationsPage = ({ router, params, }: IIntegrationSettingsPageProps) => { - const { isSandboxMode } = useContext(AppContext); const { section } = params; - const navItems = getFilteredIntegrationSettingsNavItems(isSandboxMode); + const navItems = integrationSettingsNavItems; const DEFAULT_SETTINGS_SECTION = navItems[0]; const currentSection = navItems.find((item) => item.urlSection === section) ?? diff --git a/frontend/pages/admin/IntegrationsPage/cards/Calendars/Calendars.tsx b/frontend/pages/admin/IntegrationsPage/cards/Calendars/Calendars.tsx new file mode 100644 index 000000000000..de7c79a139b0 --- /dev/null +++ b/frontend/pages/admin/IntegrationsPage/cards/Calendars/Calendars.tsx @@ -0,0 +1,401 @@ +import React, { useState, useContext, useCallback } from "react"; +import { useQuery } from "react-query"; + +import { IConfig } from "interfaces/config"; +import { NotificationContext } from "context/notification"; +import { AppContext } from "context/app"; +import configAPI from "services/entities/config"; +// @ts-ignore +import { stringToClipboard } from "utilities/copy_text"; + +// @ts-ignore +import InputField from "components/forms/fields/InputField"; +import Button from "components/buttons/Button"; +import SectionHeader from "components/SectionHeader"; +import CustomLink from "components/CustomLink"; +import Spinner from "components/Spinner"; +import DataError from "components/DataError"; +import PremiumFeatureMessage from "components/PremiumFeatureMessage/PremiumFeatureMessage"; +import Icon from "components/Icon"; + +const CREATING_SERVICE_ACCOUNT = + "https://www.fleetdm.com/learn-more-about/creating-service-accounts"; +const GOOGLE_WORKSPACE_DOMAINS = + "https://www.fleetdm.com/learn-more-about/google-workspace-domains"; +const DOMAIN_WIDE_DELEGATION = + "https://www.fleetdm.com/learn-more-about/domain-wide-delegation"; +const ENABLING_CALENDAR_API = + "fleetdm.com/learn-more-about/enabling-calendar-api"; +const OAUTH_SCOPES = + "https://www.googleapis.com/auth/calendar.events,https://www.googleapis.com/auth/calendar.settings.readonly"; + +const API_KEY_JSON_PLACEHOLDER = `{ + "type": "service_account", + "project_id": "fleet-in-your-calendar", + "private_key_id": "", + "private_key": "-----BEGIN PRIVATE KEY-----\n\n-----END PRIVATE KEY-----\n", + "client_email": "fleet-calendar-events@fleet-in-your-calendar.iam.gserviceaccount.com", + "client_id": "", + "auth_uri": "https://accounts.google.com/o/oauth2/auth", + "token_uri": "https://oauth2.googleapis.com/token", + "auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs", + "client_x509_cert_url": "https://www.googleapis.com/robot/v1/metadata/x509/fleet-calendar-events%40fleet-in-your-calendar.iam.gserviceaccount.com", + "universe_domain": "googleapis.com" +}`; + +interface IFormField { + name: string; + value: string | boolean | number; +} + +interface ICalendarsFormErrors { + domain?: string | null; + apiKeyJson?: string | null; +} + +interface ICalendarsFormData { + domain?: string; + apiKeyJson?: string; +} + +const baseClass = "calendars-integration"; + +const Calendars = (): JSX.Element => { + const { renderFlash } = useContext(NotificationContext); + const { isPremiumTier } = useContext(AppContext); + + const [formData, setFormData] = useState({ + domain: "", + apiKeyJson: "", + }); + const [isUpdatingSettings, setIsUpdatingSettings] = useState(false); + const [formErrors, setFormErrors] = useState({}); + const [copyMessage, setCopyMessage] = useState(""); + + const { + isLoading: isLoadingAppConfig, + refetch: refetchConfig, + error: errorAppConfig, + } = useQuery(["config"], () => configAPI.loadAll(), { + select: (data: IConfig) => data, + onSuccess: (data) => { + if (data.integrations.google_calendar) { + setFormData({ + domain: data.integrations.google_calendar[0].domain, + // Formats string for better UI readability + apiKeyJson: JSON.stringify( + data.integrations.google_calendar[0].api_key_json, + null, + "\t" + ), + }); + } + }, + }); + + const { apiKeyJson, domain } = formData; + + const validateForm = (curFormData: ICalendarsFormData) => { + const errors: ICalendarsFormErrors = {}; + + // Must set all keys or no keys at all + if (!curFormData.apiKeyJson && !!curFormData.domain) { + errors.apiKeyJson = "API key JSON must be present"; + } + if (!curFormData.domain && !!curFormData.apiKeyJson) { + errors.apiKeyJson = "Domain must be present"; + } + return errors; + }; + + const onInputChange = useCallback( + ({ name, value }: IFormField) => { + const newFormData = { ...formData, [name]: value }; + setFormData(newFormData); + setFormErrors(validateForm(newFormData)); + }, + [formData] + ); + + const onFormSubmit = async (evt: React.MouseEvent) => { + setIsUpdatingSettings(true); + + evt.preventDefault(); + + // Format for API + const formDataToSubmit = + formData.apiKeyJson === "" && formData.domain === "" + ? [] // Send empty array if no keys are set + : [ + { + domain: formData.domain, + api_key_json: + (formData.apiKeyJson && JSON.parse(formData.apiKeyJson)) || + null, + }, + ]; + + // Update integrations.google_calendar only + const destination = { + google_calendar: formDataToSubmit, + }; + + try { + await configAPI.update({ integrations: destination }); + renderFlash( + "success", + "Successfully saved calendar integration settings" + ); + refetchConfig(); + } catch (e) { + renderFlash("error", "Could not save calendar integration settings"); + } finally { + setIsUpdatingSettings(false); + } + }; + + const renderOauthLabel = () => { + const onCopyOauthScopes = (evt: React.MouseEvent) => { + evt.preventDefault(); + + stringToClipboard(OAUTH_SCOPES) + .then(() => setCopyMessage(() => "Copied!")) + .catch(() => setCopyMessage(() => "Copy failed")); + + // Clear message after 1 second + setTimeout(() => setCopyMessage(() => ""), 1000); + + return false; + }; + + return ( + + + {copyMessage && ( + {copyMessage} + )} + + ); + }; + + const renderForm = () => { + return ( + <> + +

+ To create calendar events for end users with failing policies, + you'll need to configure a dedicated Google Workspace service + account. +

+
+

+ 1. Go to the Service Accounts page in Google Cloud Platform.{" "} + +

+

+ 2. Create a new project for your service account. +

    +
  • + Click Create project. +
  • +
  • + Enter "Fleet calendar events" as the project name. +
  • +
  • + For "Organization" and "Location", select + your calendar's organization. +
  • +
+

+ +

+ 3. Create the service account. +

    +
  • + Click Create service account. +
  • +
  • + Set the service account name to "Fleet calendar + events". +
  • +
  • + Set the service account ID to "fleet-calendar-events". +
  • +
  • + Click Create and continue. +
  • +
  • + Click Done at the bottom of the form. (No need to + complete the optional steps.) +
  • +
+

+

+ 4. Create an API key.{" "} +

    +
  • + Click the Actions menu for your new service account. +
  • +
  • + Select Manage keys. +
  • +
  • + Click Add key > Create new key. +
  • +
  • Select the JSON key type.
  • +
  • + Click Create to create the key & download a JSON file. +
  • +
  • + Configure your service account integration in Fleet using the + form below: +
    + + Paste the full contents of the JSON file downloaded{" "} +
    + when creating your service account API key. + + } + placeholder={API_KEY_JSON_PLACEHOLDER} + ignore1password + inputClassName={`${baseClass}__api-key-json`} + /> + + If the end user is signed into multiple Google accounts, + this will be used to identify their work calendar. + + } + placeholder="example.com" + helpText={ + <> + You can find your primary domain in Google Workspace{" "} + + + } + /> + + +
  • +
+

+

+ 5. Authorize the service account via domain-wide delegation. +

    +
  • + In Google Workspace, go to{" "} + + Security > Access and data control > API controls > + Manage Domain Wide Delegation + + .{" "} + +
  • +
  • + Under API clients, click Add new. +
  • +
  • + Enter the client ID for the service account. You can find this + in your downloaded API key JSON file ( + client_id + ), or under Advanced Settings when viewing the service + account. +
  • +
  • + For the OAuth scopes, paste the following value: + +
  • +
  • + Click Authorize. +
  • +
+

+

+ 6. Enable the Google Calendar API. +

    +
  • + In the Google Cloud console API library, go to the Google + Calendar API.{" "} + +
  • +
  • + Make sure the "Fleet calendar events" project is + selected at the top of the page. +
  • +
  • + Click Enable. +
  • +
+

+
+ + ); + }; + + if (!isPremiumTier) return ; + + if (isLoadingAppConfig) { +
+ +
; + } + + if (errorAppConfig) { + return ; + } + + return
{renderForm()}
; +}; + +export default Calendars; diff --git a/frontend/pages/admin/IntegrationsPage/cards/Calendars/_styles.scss b/frontend/pages/admin/IntegrationsPage/cards/Calendars/_styles.scss new file mode 100644 index 000000000000..7045a443ef2f --- /dev/null +++ b/frontend/pages/admin/IntegrationsPage/cards/Calendars/_styles.scss @@ -0,0 +1,57 @@ +.calendars-integration { + &__page-description { + font-size: $x-small; + color: $core-fleet-black; + } + + ui { + margin-block-start: $pad-small; + } + + li { + margin: $pad-small 0; + } + + form { + margin-top: $pad-large; + } + + &__configuration { + button { + align-self: flex-end; + } + } + + &__api-key-json { + min-width: 100%; // resize vertically only + height: 294px; + font-size: $x-small; + } + + #oauth-scopes { + font-family: "SourceCodePro", $monospace; + min-height: 80px; + padding: $pad-medium; + padding-right: $pad-xxlarge; + resize: none; + } + + &__oauth-scopes-copy-icon-wrapper { + display: flex; + flex-direction: row-reverse; + align-items: center; + position: relative; + top: 36px; + right: 16px; + height: 0; + gap: 0.5rem; + } + + &__copy-message { + @include copy-message; + } + + &__code { + font-family: "SourceCodePro", $monospace; + } +} diff --git a/frontend/pages/admin/IntegrationsPage/cards/Calendars/index.ts b/frontend/pages/admin/IntegrationsPage/cards/Calendars/index.ts new file mode 100644 index 000000000000..99dcd737cf68 --- /dev/null +++ b/frontend/pages/admin/IntegrationsPage/cards/Calendars/index.ts @@ -0,0 +1 @@ +export { default } from "./Calendars"; diff --git a/frontend/pages/admin/IntegrationsPage/cards/Integrations/Integrations.tsx b/frontend/pages/admin/IntegrationsPage/cards/Integrations/Integrations.tsx index 75f95f836bdb..abe8aef4d6c0 100644 --- a/frontend/pages/admin/IntegrationsPage/cards/Integrations/Integrations.tsx +++ b/frontend/pages/admin/IntegrationsPage/cards/Integrations/Integrations.tsx @@ -8,7 +8,7 @@ import { IZendeskIntegration, IIntegration, IIntegrationTableData, - IIntegrations, + IGlobalIntegrations, } from "interfaces/integration"; import { IApiError } from "interfaces/errors"; @@ -69,7 +69,7 @@ const Integrations = (): JSX.Element => { isLoading: isLoadingIntegrations, error: loadingIntegrationsError, refetch: refetchIntegrations, - } = useQuery( + } = useQuery( ["integrations"], () => configAPI.loadAll(), { @@ -133,9 +133,15 @@ const Integrations = (): JSX.Element => { // Updates either integrations.jira or integrations.zendesk const destination = () => { if (integrationDestination === "jira") { - return { jira: integrationSubmitData, zendesk: zendeskIntegrations }; + return { + jira: integrationSubmitData, + zendesk: zendeskIntegrations, + }; } - return { zendesk: integrationSubmitData, jira: jiraIntegrations }; + return { + zendesk: integrationSubmitData, + jira: jiraIntegrations, + }; }; setTestingConnection(true); diff --git a/frontend/pages/admin/components/SideNav/SideNav.tsx b/frontend/pages/admin/components/SideNav/SideNav.tsx index 1242cdcba9e3..333f3f3f93ae 100644 --- a/frontend/pages/admin/components/SideNav/SideNav.tsx +++ b/frontend/pages/admin/components/SideNav/SideNav.tsx @@ -12,7 +12,6 @@ export interface ISideNavItem { urlSection: string; path: string; Card: (props: T) => JSX.Element; - exclude?: boolean; } interface ISideNavProps { diff --git a/frontend/pages/policies/ManagePoliciesPage/components/OtherWorkflowsModal/OtherWorkflowsModal.tsx b/frontend/pages/policies/ManagePoliciesPage/components/OtherWorkflowsModal/OtherWorkflowsModal.tsx index 907f4edb4f03..54622a692725 100644 --- a/frontend/pages/policies/ManagePoliciesPage/components/OtherWorkflowsModal/OtherWorkflowsModal.tsx +++ b/frontend/pages/policies/ManagePoliciesPage/components/OtherWorkflowsModal/OtherWorkflowsModal.tsx @@ -3,7 +3,12 @@ import { Link } from "react-router"; import { isEmpty, noop, omit } from "lodash"; import { IAutomationsConfig, IWebhookSettings } from "interfaces/config"; -import { IIntegration, IIntegrations } from "interfaces/integration"; +import { + IGlobalIntegrations, + IIntegration, + IIntegrations, + ITeamIntegrations, +} from "interfaces/integration"; import { IPolicy } from "interfaces/policy"; import { ITeamAutomationsConfig } from "interfaces/team"; import PATHS from "router/paths"; @@ -26,13 +31,13 @@ import ExamplePayload from "../ExamplePayload"; interface IOtherWorkflowsModalProps { automationsConfig: IAutomationsConfig | ITeamAutomationsConfig; - availableIntegrations: IIntegrations; + availableIntegrations: IGlobalIntegrations | ITeamIntegrations; availablePolicies: IPolicy[]; isUpdatingAutomations: boolean; onExit: () => void; handleSubmit: (formData: { webhook_settings: Pick; - integrations: IIntegrations; + integrations: IGlobalIntegrations | ITeamIntegrations; }) => void; } @@ -256,6 +261,7 @@ const OtherWorkflowsModal = ({ integrations: { jira: newJira, zendesk: newZendesk, + google_calendar: null, // When null, the backend does not update google_calendar }, }); diff --git a/frontend/router/paths.ts b/frontend/router/paths.ts index 114e42dd8e54..4a1752c65276 100644 --- a/frontend/router/paths.ts +++ b/frontend/router/paths.ts @@ -32,6 +32,7 @@ export default { ADMIN_INTEGRATIONS_MDM_WINDOWS: `${URL_PREFIX}/settings/integrations/mdm/windows`, ADMIN_INTEGRATIONS_AUTOMATIC_ENROLLMENT: `${URL_PREFIX}/settings/integrations/automatic-enrollment`, ADMIN_INTEGRATIONS_AUTOMATIC_ENROLLMENT_WINDOWS: `${URL_PREFIX}/settings/integrations/automatic-enrollment/windows`, + ADMIN_INTEGRATIONS_CALENDARS: `${URL_PREFIX}/settings/integrations/calendars`, ADMIN_TEAMS: `${URL_PREFIX}/settings/teams`, ADMIN_ORGANIZATION: `${URL_PREFIX}/settings/organization`, ADMIN_ORGANIZATION_INFO: `${URL_PREFIX}/settings/organization/info`, diff --git a/frontend/styles/var/mixins.scss b/frontend/styles/var/mixins.scss index a5c2d855466d..3fe3ba30cc38 100644 --- a/frontend/styles/var/mixins.scss +++ b/frontend/styles/var/mixins.scss @@ -175,6 +175,15 @@ $max-width: 2560px; } } +@mixin copy-message { + font-weight: $regular; + vertical-align: top; + background-color: $ui-light-grey; + border: solid 1px #e2e4ea; + border-radius: 10px; + padding: 2px 6px; +} + @mixin color-contrasted-sections { background-color: $ui-off-white; .section {