From 52d0078bbcd2888846885b783bb143624bccdd88 Mon Sep 17 00:00:00 2001 From: Gabriel Hernandez Date: Tue, 14 Feb 2023 17:00:36 +0000 Subject: [PATCH] check disk encryption key from host details page (#9691) related to https://github.com/fleetdm/fleet/issues/8708 This allows a user to check a disk encryption key for a host on the host details page. - [x] Changes file added for user-visible changes in `changes/` or `orbit/changes/`. See [Changes files](https://fleetdm.com/docs/contributing/committing-changes#changes-files) for more information. - [x] Manual QA for all new/changed functionality --- .vscode/typescriptreact.code-snippets | 11 +- assets/images/icon-action-close-16x15@2x.png | Bin 477 -> 0 bytes assets/images/icon-action-delete-14x14@2x.png | Bin 564 -> 0 bytes assets/images/icon-action-query-16x16@2x.png | Bin 997 -> 0 bytes cypress/integration/all/app/hosts.spec.ts | 25 --- cypress/integration/free/admin.spec.ts | 12 -- cypress/integration/free/maintainer.spec.ts | 6 - cypress/integration/pages/hostDetailsPage.ts | 62 -------- cypress/integration/premium/admin.spec.ts | 10 -- .../integration/premium/maintainer.spec.ts | 10 -- cypress/integration/premium/observer.spec.ts | 3 - .../integration/premium/team_admin.spec.ts | 6 - frontend/__mocks__/hostMock.ts | 1 + frontend/__mocks__/macAdminsMock.ts | 1 + frontend/__mocks__/mdmMock.ts | 1 + .../EnrollSecretRow/EnrollSecretRow.tsx | 1 + .../EnrollSecretRow/_styles.scss | 2 +- .../InputFieldHiddenContent.tsx | 91 +++++++++++ .../InputFieldHiddenContent/_styles.scss | 63 ++++++++ .../fields/InputFieldHiddenContent/index.ts | 1 + frontend/interfaces/activity.ts | 1 + frontend/interfaces/host.ts | 9 ++ .../ActivityItem/ActivityItem.tsx | 13 ++ .../HostActionsDropdown.tsx | 67 ++++++++ .../HostActionsDropdown/_styles.scss | 3 + .../HostActionsDropdown/helpers.ts | 132 ++++++++++++++++ .../HostActionsDropdown/index.ts | 1 + .../HostDetailsPage/HostDetailsPage.tsx | 146 ++++++------------ .../details/HostDetailsPage/_styles.scss | 6 - .../DiskEncryptionKeyModal.tsx | 64 ++++++++ .../DiskEncryptionKeyModal/_styles.scss | 3 + .../modals/DiskEncryptionKeyModal/index.ts | 1 + .../modals/UnenrollMdmModal/_styles.scss | 0 .../details/cards/HostSummary/HostSummary.tsx | 4 +- frontend/services/entities/hosts.ts | 5 + frontend/utilities/endpoints.ts | 2 + 36 files changed, 518 insertions(+), 245 deletions(-) delete mode 100644 assets/images/icon-action-close-16x15@2x.png delete mode 100644 assets/images/icon-action-delete-14x14@2x.png delete mode 100644 assets/images/icon-action-query-16x16@2x.png create mode 100644 frontend/components/forms/fields/InputFieldHiddenContent/InputFieldHiddenContent.tsx create mode 100644 frontend/components/forms/fields/InputFieldHiddenContent/_styles.scss create mode 100644 frontend/components/forms/fields/InputFieldHiddenContent/index.ts create mode 100644 frontend/pages/hosts/details/HostDetailsPage/HostActionsDropdown/HostActionsDropdown.tsx create mode 100644 frontend/pages/hosts/details/HostDetailsPage/HostActionsDropdown/_styles.scss create mode 100644 frontend/pages/hosts/details/HostDetailsPage/HostActionsDropdown/helpers.ts create mode 100644 frontend/pages/hosts/details/HostDetailsPage/HostActionsDropdown/index.ts create mode 100644 frontend/pages/hosts/details/HostDetailsPage/modals/DiskEncryptionKeyModal/DiskEncryptionKeyModal.tsx create mode 100644 frontend/pages/hosts/details/HostDetailsPage/modals/DiskEncryptionKeyModal/_styles.scss create mode 100644 frontend/pages/hosts/details/HostDetailsPage/modals/DiskEncryptionKeyModal/index.ts delete mode 100644 frontend/pages/hosts/details/HostDetailsPage/modals/UnenrollMdmModal/_styles.scss diff --git a/.vscode/typescriptreact.code-snippets b/.vscode/typescriptreact.code-snippets index 4bba69a4ac..6cde7b7e8d 100644 --- a/.vscode/typescriptreact.code-snippets +++ b/.vscode/typescriptreact.code-snippets @@ -5,15 +5,24 @@ "body": [ "import React from \"react\";", "", + "const baseClass = \"${TM_FILENAME_BASE/(.*)/${1:/pascalcase}/}\";", + "", "interface I${TM_FILENAME_BASE}Props {}", "", "const $TM_FILENAME_BASE = ({}: I${TM_FILENAME_BASE}Props) => {", - " return <>;", + "\treturn
;", "};", "", "export default $TM_FILENAME_BASE;", "", ], "description": "Creates a React stateless component with the typescrip interface setup" + }, + "Fleet - baseClass classname": { + "scope": "typescriptreact,javascriptreact", + "prefix": "bc", + "body": [ + "`\\${baseClass}__$0`" + ] } } diff --git a/assets/images/icon-action-close-16x15@2x.png b/assets/images/icon-action-close-16x15@2x.png deleted file mode 100644 index e574603fc53d46a01677b0e46ad6b75881e0a1b5..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 477 zcmV<30V4j1P)%6f?3j%cK3DV7oit z*LJ#DfG+<%C=Ctub0z_+L<&4App{64s{~>aKW&r-R|_1M5VVwV zlYmyj0yhi9B&=|s42(g{=P%XfjS>Q^E8E6H< zNyZ3|N$|aKEOax(0#tWWU;DcMl9PX1M+^S^OS*HQA9R51oP$47| z+1CgZYy+zGTKugf&FFj;xZWuuq_%go5r~UF8t53@fu^_WXj3505ILlIww8mxN$c_2 zyS1DizelOZa~O)XAdg27634vuy^QX?8Q;-)A=9YBeEcZUoRDtnWsi{>37du_L`c99 zxZEXRBy1SJC5^GgI3*;aH*f^b+{8%OG-OE8VMs~87ztt9XV^3Zp(aYe4fqKpZCUN` zVvt!sCruEqwtCVqQUN_lbJjVr7= z5!4!+pX=0ZX!KV&LJ%&L50000~6;Af^JK1E>IVFc@#!OkiMYcSiHZb;yKr($`$E#=JCrM^ z3p^el&UT9gEeog(h~mzZVqXK&EfI}LR3^#In$4Gb6-B_u@IKtnR3NZ%b)D~$lz=w7 zx_J^{iEe_?#%Da}bZ`m_p!TDbVFr;YyJIuY9o3Wx(Y>#n&aV5F4wS%LL8IOJ8v)_D z@v1hDr_HQ=ItFyKyOB-BCHzjFP2X}V?U|O*rYE)ln0se?gTZ?Armr$#k|yK8B_Q0?8w_xw ze&Z_k=37psdu?)vo0DgH&Kg!eF_$u2Sp$zid8qPw-)vHyZ*^nqhEca)31I~YCsHA~ z<#oS0VjsF`6RZh(bh0KQfoEBVx7#mnKbiUj5F%@VAPq_`M2!p*7L?!fvW8}=PZ<9> zmV5$05B8GWG5;+jk>Ph6Xr-MRTwDnuQ95ad`=_T=D-Mp9Ux?44@4V3^9(8|+uUYcxJfJF_H;`}#;{yZLoQnE`iks&rT{*pa&(^%m z+h>hrS*|cdha7l6X9Q7FE#oN&>WGpnBL^J%l`h>~Kh5G& zP7kWcWaW7giBbgKiyRVDPR%dNwBwc0>s5zrp#x`GTk*dTFiGg(R$tW=kM92fw3W$# TmII6x00000NkvXXu0mjfNU^!} diff --git a/cypress/integration/all/app/hosts.spec.ts b/cypress/integration/all/app/hosts.spec.ts index dde78e407d..9011b8fbac 100644 --- a/cypress/integration/all/app/hosts.spec.ts +++ b/cypress/integration/all/app/hosts.spec.ts @@ -161,27 +161,6 @@ describe("Hosts flow", () => { cy.getAttached(".button--text-link").first().click(); }); }); - it("runs query on an existing host", () => { - cy.getAttached(".host-details__action-button-container").within(() => { - cy.getAttached('img[alt="Query host icon"]').click(); - }); - - cy.getAttached(".select-query-modal__modal").within(() => { - cy.getAttached(".modal-query-button").eq(2).click(); - }); - - cy.getAttached(".query-form__button-wrap--new-query").within(() => { - cy.findByText(/run query/i) - .should("exist") - .click(); - }); - cy.getAttached(".query-page__wrapper").within(() => { - cy.getAttached(".data-table").within(() => { - cy.findByText(hostname).should("exist"); - }); - cy.findByText(/run/i).click(); - }); - }); it("renders and searches the host's users", () => { cy.getAttached(".section--users").within(() => { cy.getAttached("tbody>tr").should("have.length.greaterThan", 0); @@ -278,9 +257,5 @@ describe("Hosts flow", () => { }); } ); - it("deletes an existing host", () => { - hostDetailsPage.allowsDeleteHost(); - hostDetailsPage.verifiesDeletedHost(hostname); - }); }); }); diff --git a/cypress/integration/free/admin.spec.ts b/cypress/integration/free/admin.spec.ts index d82493359a..e7acc5083e 100644 --- a/cypress/integration/free/admin.spec.ts +++ b/cypress/integration/free/admin.spec.ts @@ -104,24 +104,12 @@ describe( hostDetailsPage.verifiesTeamsisDisabled(); hostDetailsPage.hidesButton("Transfer"); }); - it("allows admin to delete the host", () => { - hostDetailsPage.allowsDeleteHost(); - }); - it("allows admin to custom query the host", () => { - hostDetailsPage.allowsCustomQueryHost(); - }); }); describe("Manage software page", () => { beforeEach(() => { cy.loginWithCySession("anna@organization.com", GOOD_PASSWORD); manageSoftwarePage.visitManageSoftwarePage(); }); - // it(`displays "Vulnerabilities" column`, () => { - // cy.getAttached("thead").within(() => { - // cy.findByText(/vulnerabilities/i).should("exist"); - // cy.findByText(/probability of exploit/i).should("not.exist"); - // }); - // }); it("allows admin to click 'Manage automations' button", () => { manageSoftwarePage.allowsManageAutomations(); }); diff --git a/cypress/integration/free/maintainer.spec.ts b/cypress/integration/free/maintainer.spec.ts index 8a2866ea47..c2e25f8ff1 100644 --- a/cypress/integration/free/maintainer.spec.ts +++ b/cypress/integration/free/maintainer.spec.ts @@ -99,12 +99,6 @@ describe( it("allows maintainer to create an operating system policy", () => { hostDetailsPage.allowsCreateOsPolicy(); }); - it("allows maintainer to custom query the host", () => { - hostDetailsPage.allowsCustomQueryHost(); - }); - it("allows maintainer to delete the host", () => { - hostDetailsPage.allowsDeleteHost(); - }); }); describe("Manage software page", () => { beforeEach(() => manageSoftwarePage.visitManageSoftwarePage()); diff --git a/cypress/integration/pages/hostDetailsPage.ts b/cypress/integration/pages/hostDetailsPage.ts index 1c193c5676..5b240f4c67 100644 --- a/cypress/integration/pages/hostDetailsPage.ts +++ b/cypress/integration/pages/hostDetailsPage.ts @@ -18,68 +18,6 @@ const hostDetailsPage = { cy.contains("button", text).should("not.exist"); }, - allowsDeleteHost: () => { - cy.findByRole("button", { name: /delete/i }).click(); - cy.getAttached(".modal__modal_container").within(() => { - cy.findByRole("button", { name: /delete/i }).should("be.enabled"); - }); - }, - - verifiesDeletedHost: (hostname: string) => { - cy.getAttached(".modal__modal_container") - .within(() => { - cy.findByRole("button", { name: /delete/i }).click(); - }) - .then(() => { - cy.findByText(/add your devices to fleet/i).should("exist"); - cy.findByText(/add hosts/i).should("exist"); - cy.findByText(/about this host/i).should("not.exist"); - cy.findByText(hostname).should("not.exist"); - }); - }, - - allowsTransferHost: (create?: boolean) => { - cy.findByRole("button", { name: /transfer/i }).click(); - if (create) { - cy.findByText(/create a team/i).should("exist"); - } else { - cy.findByText(/create a team/i).should("not.exist"); - } - cy.getAttached(".Select-control").click(); - cy.getAttached(".Select-menu").within(() => { - cy.findByText(/no team/i).should("exist"); - cy.findByText(/oranges/i).should("exist"); - cy.findByText(/apples/i).click(); - }); - cy.getAttached(".transfer-host-modal .modal-cta-wrap") - .contains("button", /transfer/i) - .should("be.enabled"); - }, - - verifiesTransferredHost: () => { - cy.getAttached(".transfer-host-modal .modal-cta-wrap") - .contains("button", /transfer/i) - .click(); - cy.findByText(/transferred to apples/i).should("exist"); - cy.findByText(/team/i).next().contains("Apples"); - }, - - allowsCustomQueryHost: () => { - cy.findByRole("button", { name: /query/i }).click(); - cy.findByRole("button", { name: /create custom query/i }).should( - "be.enabled" - ); - cy.getAttached(".modal__ex").within(() => { - cy.findByRole("button").click(); - }); - }, - - hidesCustomQueryHost: () => { - cy.findByRole("button", { name: /query/i }).click(); - cy.contains("button", /create custom query/i).should("not.exist"); - cy.getAttached(".modal__ex").click(); - }, - allowsCreateOsPolicy: () => { cy.getAttached(".info-flex").within(() => { cy.findByText(/ubuntu/i).should("exist"); diff --git a/cypress/integration/premium/admin.spec.ts b/cypress/integration/premium/admin.spec.ts index 58503cc2ca..5b9e371f23 100644 --- a/cypress/integration/premium/admin.spec.ts +++ b/cypress/integration/premium/admin.spec.ts @@ -386,19 +386,9 @@ describe("Premium tier - Global Admin user", () => { }); describe("Host details page", () => { beforeEach(() => hostDetailsPage.visitsHostDetailsPage(1)); - it("allows global admin to transfer host to an existing team", () => { - hostDetailsPage.allowsTransferHost("andCreate"); - hostDetailsPage.verifiesTransferredHost(); - }); it("allows global admin to create an operating system policy", () => { hostDetailsPage.allowsCreateOsPolicy(); }); - it("allows global admin to custom query a host", () => { - hostDetailsPage.allowsCustomQueryHost(); - }); - it("allows global admin to delete a host", () => { - hostDetailsPage.allowsDeleteHost(); - }); }); describe("Manage software page", () => { beforeEach(() => { diff --git a/cypress/integration/premium/maintainer.spec.ts b/cypress/integration/premium/maintainer.spec.ts index 27f665f253..76f8b82a7d 100644 --- a/cypress/integration/premium/maintainer.spec.ts +++ b/cypress/integration/premium/maintainer.spec.ts @@ -82,19 +82,9 @@ describe("Premium tier - Maintainer user", () => { beforeEach(() => { hostDetailsPage.visitsHostDetailsPage(1); }); - it("allows global maintainer to transfer host to an existing team", () => { - hostDetailsPage.allowsTransferHost(); - hostDetailsPage.verifiesTransferredHost(); - }); it("allows global maintainer to create an operating system policy", () => { hostDetailsPage.allowsCreateOsPolicy(); }); - it("allows global maintainer to custom query a host", () => { - hostDetailsPage.allowsCustomQueryHost(); - }); - it("allows global maintainer to delete a host", () => { - hostDetailsPage.allowsDeleteHost(); - }); }); describe("Manage software page", () => { beforeEach(() => manageSoftwarePage.visitManageSoftwarePage()); diff --git a/cypress/integration/premium/observer.spec.ts b/cypress/integration/premium/observer.spec.ts index 786608c07d..c255501bef 100644 --- a/cypress/integration/premium/observer.spec.ts +++ b/cypress/integration/premium/observer.spec.ts @@ -85,9 +85,6 @@ describe("Premium tier - Observer user", () => { beforeEach(() => hostDetailsPage.visitsHostDetailsPage(1)); it("should render elements according to role-based access controls", () => { hostDetailsPage.verifiesTeam("Apples"); - hostDetailsPage.hidesButton("Transfer"); - hostDetailsPage.hidesButton("Delete"); - hostDetailsPage.hidesCustomQueryHost(); hostDetailsPage.hidesCreateOSPolicy(); }); }); diff --git a/cypress/integration/premium/team_admin.spec.ts b/cypress/integration/premium/team_admin.spec.ts index 4a6ebcff87..e4861e146f 100644 --- a/cypress/integration/premium/team_admin.spec.ts +++ b/cypress/integration/premium/team_admin.spec.ts @@ -87,12 +87,6 @@ describe("Premium tier - Team Admin user", () => { it("allows team admin to create an operating system policy", () => { hostDetailsPage.allowsCreateOsPolicy(); }); - it("allows team admin to query host, delete host but not transfer host", () => { - hostDetailsPage.allowsCustomQueryHost(); - hostDetailsPage.allowsDeleteHost(); - hostDetailsPage.verifiesDeletedHost; - hostDetailsPage.hidesButton("Transfer"); - }); }); describe("Manage software page", () => { beforeEach(() => manageSoftwarePage.visitManageSoftwarePage()); diff --git a/frontend/__mocks__/hostMock.ts b/frontend/__mocks__/hostMock.ts index 0e11c777b1..af722c18c4 100644 --- a/frontend/__mocks__/hostMock.ts +++ b/frontend/__mocks__/hostMock.ts @@ -33,6 +33,7 @@ const DEFAULT_HOST_MOCK: IHost = { hardware_serial: "", computer_name: "9b20fc72a247", mdm: { + encryption_key_available: false, enrollment_status: "Off", server_url: "https://www.example.com/1", }, diff --git a/frontend/__mocks__/macAdminsMock.ts b/frontend/__mocks__/macAdminsMock.ts index 53550acf88..ee8178121b 100644 --- a/frontend/__mocks__/macAdminsMock.ts +++ b/frontend/__mocks__/macAdminsMock.ts @@ -3,6 +3,7 @@ import { IMacadminsResponse } from "interfaces/host"; const DEFAULT_MAC_ADMINS_MOCK: IMacadminsResponse = { macadmins: { mobile_device_management: { + encryption_key_available: false, enrollment_status: "On (manual)", server_url: "https://kandji.com/2", name: "Kandji", diff --git a/frontend/__mocks__/mdmMock.ts b/frontend/__mocks__/mdmMock.ts index 81ee177a39..028895f7ce 100644 --- a/frontend/__mocks__/mdmMock.ts +++ b/frontend/__mocks__/mdmMock.ts @@ -15,6 +15,7 @@ export const createMockMdmSolution = ( }; const DEFAULT_HOST_MDM_DATA: IHostMdmData = { + encryption_key_available: false, enrollment_status: "On (automatic)", server_url: "http://mdmsolution.com", name: "MDM Solution", diff --git a/frontend/components/EnrollSecrets/EnrollSecretTable/EnrollSecretRow/EnrollSecretRow.tsx b/frontend/components/EnrollSecrets/EnrollSecretTable/EnrollSecretRow/EnrollSecretRow.tsx index bb89325b13..a8a9346826 100644 --- a/frontend/components/EnrollSecrets/EnrollSecretTable/EnrollSecretRow/EnrollSecretRow.tsx +++ b/frontend/components/EnrollSecrets/EnrollSecretTable/EnrollSecretRow/EnrollSecretRow.tsx @@ -98,6 +98,7 @@ const EnrollSecretRow = ({ key={uniqueId()} data-testid="osquery-secret" > + {/* TODO: replace with InputFieldHiddenContent component */} { + const [copyMessage, setCopyMessage] = useState(""); + const [showSecret, setShowSecret] = useState(false); + + const classNames = classnames(baseClass, className); + + const onCopySecret = (evt: React.MouseEvent) => { + evt.preventDefault(); + + stringToClipboard(value) + .then(() => setCopyMessage("Copied!")) + .catch(() => setCopyMessage("Copy failed")); + + // Clear message after 1 second + setTimeout(() => setCopyMessage(""), 1000); + + return false; + }; + + const onToggleSecret = (evt: React.MouseEvent) => { + evt.preventDefault(); + + setShowSecret(!showSecret); + return false; + }; + + const renderLabel = () => { + return ( + + + {copyMessage && ( + {`${copyMessage} `} + )} + + + + + ); + }; + + return ( +
+ +
+ ); +}; + +export default InputFieldHiddenContent; diff --git a/frontend/components/forms/fields/InputFieldHiddenContent/_styles.scss b/frontend/components/forms/fields/InputFieldHiddenContent/_styles.scss new file mode 100644 index 0000000000..c7ee1c90a9 --- /dev/null +++ b/frontend/components/forms/fields/InputFieldHiddenContent/_styles.scss @@ -0,0 +1,63 @@ +.input-field-hidden-content { + &__secret { + display: flex; + align-items: center; + margin-bottom: $pad-medium; + } + + .form-field { + margin-bottom: 0; + } + + &__secret-input { + .form-field__label { + position: relative; + font-size: $x-small; + font-weight: $bold; + margin-bottom: 0; + // TODO: figure out width when pulling out to common component + height: 0; + min-height: 0; + } + + .input-field { + &--disabled { + letter-spacing: 0; + } + + &--password { + letter-spacing: 4px; + } + } + } + + &__copy-message { + position: absolute; + right: 65px; + background-color: $ui-light-grey; + border: solid 1px #e2e4ea; + border-radius: 10px; + padding: 2px 6px; + } + + .buttons { + display: flex; + align-items: center; + position: absolute; + right: 16px; + top: 12px; + height: 16px; + + span { + font-weight: $regular; + } + } + + &__show-secret-icon, + &__copy-secret-icon, + &__edit-secret-icon, + &__delete-secret-icon { + padding: 0 $pad-small; + margin-left: $pad-xsmall; + } +} diff --git a/frontend/components/forms/fields/InputFieldHiddenContent/index.ts b/frontend/components/forms/fields/InputFieldHiddenContent/index.ts new file mode 100644 index 0000000000..cd67ef8dc5 --- /dev/null +++ b/frontend/components/forms/fields/InputFieldHiddenContent/index.ts @@ -0,0 +1 @@ +export { default } from "./InputFieldHiddenContent"; diff --git a/frontend/interfaces/activity.ts b/frontend/interfaces/activity.ts index 845e3bc19e..bbaabd7e4e 100644 --- a/frontend/interfaces/activity.ts +++ b/frontend/interfaces/activity.ts @@ -32,6 +32,7 @@ export enum ActivityType { MdmEnrolled = "mdm_enrolled", MdmUnenrolled = "mdm_unenrolled", EditedMacosMinVersion = "edited_macos_min_version", + ReadHostDiskEncryptionKey = "read_host_disk_encryption_key", } export interface IActivity { created_at: string; diff --git a/frontend/interfaces/host.ts b/frontend/interfaces/host.ts index dc190cc12d..a83882e469 100644 --- a/frontend/interfaces/host.ts +++ b/frontend/interfaces/host.ts @@ -87,6 +87,7 @@ export interface IMunkiData { } export interface IHostMdmData { + encryption_key_available: boolean; enrollment_status: MdmEnrollmentStatus | null; server_url: string; id?: number; @@ -149,6 +150,14 @@ export interface IDeviceUserResponse { global_config: IDeviceGlobalConfig; } +export interface IHostEncrpytionKeyResponse { + host_id: number; + encryption_key: { + updated_at: string; + key: string; + }; +} + export interface IHost { created_at: string; updated_at: string; diff --git a/frontend/pages/DashboardPage/cards/ActivityFeed/ActivityItem/ActivityItem.tsx b/frontend/pages/DashboardPage/cards/ActivityFeed/ActivityItem/ActivityItem.tsx index c9de1dffe5..8eafd963bf 100644 --- a/frontend/pages/DashboardPage/cards/ActivityFeed/ActivityItem/ActivityItem.tsx +++ b/frontend/pages/DashboardPage/cards/ActivityFeed/ActivityItem/ActivityItem.tsx @@ -200,6 +200,16 @@ const TAGGED_TEMPLATES = { ); }, + readHostDiskEncryptionKey: (activity: IActivity) => { + return ( + <> + {" "} + viewed the disk encryption key for {activity.details?.host_display_name} + . + + ); + }, + defaultActivityTemplate: (activity: IActivity) => { const entityName = find(activity.details, (_, key) => key.includes("_name") @@ -280,6 +290,9 @@ const getDetail = ( case ActivityType.EditedMacosMinVersion: { return TAGGED_TEMPLATES.editedMacosMinVersion(activity); } + case ActivityType.ReadHostDiskEncryptionKey: { + return TAGGED_TEMPLATES.readHostDiskEncryptionKey(activity); + } default: { return TAGGED_TEMPLATES.defaultActivityTemplate(activity); } diff --git a/frontend/pages/hosts/details/HostDetailsPage/HostActionsDropdown/HostActionsDropdown.tsx b/frontend/pages/hosts/details/HostDetailsPage/HostActionsDropdown/HostActionsDropdown.tsx new file mode 100644 index 0000000000..005a24012d --- /dev/null +++ b/frontend/pages/hosts/details/HostDetailsPage/HostActionsDropdown/HostActionsDropdown.tsx @@ -0,0 +1,67 @@ +import React, { useContext } from "react"; + +import { MdmEnrollmentStatus } from "interfaces/mdm"; +import permissionUtils from "utilities/permissions"; +import { AppContext } from "context/app"; + +// @ts-ignore +import Dropdown from "components/forms/fields/Dropdown"; +import { generateHostActionOptions } from "./helpers"; + +const baseClass = "host-actions-dropdown"; + +interface IHostActionsDropdownProps { + onSelect: (value: string) => void; + teamId: number | null; + hostStatus: string; + hostMdmEnrollemntStatus: MdmEnrollmentStatus | null; + doesStoreEncryptionKey?: boolean; +} + +const HostActionsDropdown = ({ + onSelect, + teamId, + hostStatus, + hostMdmEnrollemntStatus, + doesStoreEncryptionKey, +}: IHostActionsDropdownProps) => { + const { + currentUser, + isPremiumTier = false, + isGlobalAdmin = false, + isGlobalMaintainer = false, + } = useContext(AppContext); + + const options = generateHostActionOptions({ + isPremiumTier, + isGlobalAdmin, + isGlobalMaintainer, + isTeamAdmin: permissionUtils.isTeamAdmin(currentUser, teamId ?? null), + isTeamMaintainer: permissionUtils.isTeamMaintainer( + currentUser, + teamId ?? null + ), + isHostOnline: hostStatus === "online", + isEnrolledInMdm: ["On (automatic)", "On (manual)"].includes( + hostMdmEnrollemntStatus ?? "" + ), + doesStoreEncryptionKey: doesStoreEncryptionKey ?? false, + }); + + // No options to render. Exit early + if (options.length === 0) return null; + + return ( +
+ +
+ ); +}; + +export default HostActionsDropdown; diff --git a/frontend/pages/hosts/details/HostDetailsPage/HostActionsDropdown/_styles.scss b/frontend/pages/hosts/details/HostDetailsPage/HostActionsDropdown/_styles.scss new file mode 100644 index 0000000000..b7d1fbf802 --- /dev/null +++ b/frontend/pages/hosts/details/HostDetailsPage/HostActionsDropdown/_styles.scss @@ -0,0 +1,3 @@ +.host-actions-dropdown { + width: 204px +} diff --git a/frontend/pages/hosts/details/HostDetailsPage/HostActionsDropdown/helpers.ts b/frontend/pages/hosts/details/HostDetailsPage/HostActionsDropdown/helpers.ts new file mode 100644 index 0000000000..5ebcdd0f23 --- /dev/null +++ b/frontend/pages/hosts/details/HostDetailsPage/HostActionsDropdown/helpers.ts @@ -0,0 +1,132 @@ +import { IDropdownOption } from "interfaces/dropdownOption"; +import { cloneDeep } from "lodash"; + +const DEFAULT_OPTIONS: IDropdownOption[] = [ + { + label: "Transfer", + value: "transfer", + disabled: false, + }, + { + label: "Query", + value: "query", + disabled: false, + }, + { + label: "Show disk encryption key", + value: "diskEncryption", + disabled: false, + }, + { + label: "Turn off MDM", + value: "mdmOff", + disabled: false, + }, + { + label: "Delete", + disabled: false, + value: "delete", + }, +]; + +// eslint-disable-next-line import/prefer-default-export +interface IHostActionConfigOptions { + isPremiumTier: boolean; + isGlobalAdmin: boolean; + isGlobalMaintainer: boolean; + isTeamAdmin: boolean; + isTeamMaintainer: boolean; + isHostOnline: boolean; + isEnrolledInMdm: boolean; + doesStoreEncryptionKey: boolean; +} + +const canTransferTeam = (config: IHostActionConfigOptions) => { + const { isPremiumTier, isGlobalAdmin, isGlobalMaintainer } = config; + return isPremiumTier && (isGlobalAdmin || isGlobalMaintainer); +}; + +const canEditMdm = (config: IHostActionConfigOptions) => { + const { + isGlobalAdmin, + isGlobalMaintainer, + isTeamAdmin, + isTeamMaintainer, + isEnrolledInMdm, + } = config; + return ( + isEnrolledInMdm && + (isGlobalAdmin || isGlobalMaintainer || isTeamAdmin || isTeamMaintainer) + ); +}; + +const canDeleteHost = (config: IHostActionConfigOptions) => { + const { + isGlobalAdmin, + isGlobalMaintainer, + isTeamAdmin, + isTeamMaintainer, + } = config; + return isGlobalAdmin || isGlobalMaintainer || isTeamAdmin || isTeamMaintainer; +}; + +const canShowDiskEncryption = (config: IHostActionConfigOptions) => { + const { isPremiumTier, doesStoreEncryptionKey } = config; + return isPremiumTier && doesStoreEncryptionKey; +}; + +const filterOutOptions = ( + options: IDropdownOption[], + config: IHostActionConfigOptions +) => { + if (!canTransferTeam(config)) { + options = options.filter((option) => option.value !== "transfer"); + } + + if (!canShowDiskEncryption(config)) { + options = options.filter((option) => option.value !== "diskEncryption"); + } + + if (!canEditMdm(config)) { + options = options.filter((option) => option.value !== "mdmOff"); + } + + if (!canDeleteHost(config)) { + options = options.filter((option) => option.value !== "delete"); + } + + return options; +}; + +const setOptionsAsDisabled = ( + options: IDropdownOption[], + isHostOnline: boolean +) => { + if (!isHostOnline) { + const disableOptions = options.filter( + (option) => option.value === "query" || option.value === "mdmOff" + ); + disableOptions.forEach((option) => { + option.disabled = true; + }); + } + + return options; +}; + +/** + * Generates the host actions options depending on the configuration. There are + * many variations of the options that are shown/not shown or disabled/enabled + * which are all controlled by the configurations options argument. + */ +// eslint-disable-next-line import/prefer-default-export +export const generateHostActionOptions = (config: IHostActionConfigOptions) => { + // deep clone to always start with a fresh copy of the default options. + let options = cloneDeep(DEFAULT_OPTIONS); + options = filterOutOptions(options, config); + + if (options.length === 0) return options; + + options = setOptionsAsDisabled(options, config.isHostOnline); + return options; +}; diff --git a/frontend/pages/hosts/details/HostDetailsPage/HostActionsDropdown/index.ts b/frontend/pages/hosts/details/HostDetailsPage/HostActionsDropdown/index.ts new file mode 100644 index 0000000000..9c9746dfa3 --- /dev/null +++ b/frontend/pages/hosts/details/HostDetailsPage/HostActionsDropdown/index.ts @@ -0,0 +1 @@ +export { default } from "./HostActionsDropdown"; diff --git a/frontend/pages/hosts/details/HostDetailsPage/HostDetailsPage.tsx b/frontend/pages/hosts/details/HostDetailsPage/HostDetailsPage.tsx index 7ba96cf64a..ff193a7095 100644 --- a/frontend/pages/hosts/details/HostDetailsPage/HostDetailsPage.tsx +++ b/frontend/pages/hosts/details/HostDetailsPage/HostDetailsPage.tsx @@ -28,12 +28,8 @@ import { IQuery, IFleetQueriesResponse } from "interfaces/query"; import { IQueryStats } from "interfaces/query_stats"; import { ISoftware } from "interfaces/software"; import { ITeam } from "interfaces/team"; -import { IUser } from "interfaces/user"; -import permissionUtils from "utilities/permissions"; -import ReactTooltip from "react-tooltip"; import Spinner from "components/Spinner"; -import Button from "components/buttons/Button"; import TabsWrapper from "components/TabsWrapper"; import MainContent from "components/MainContent"; import InfoBanner from "components/InfoBanner"; @@ -63,10 +59,9 @@ import TransferHostModal from "../../components/TransferHostModal"; import DeleteHostModal from "../../components/DeleteHostModal"; import parseOsVersion from "./modals/OSPolicyModal/helpers"; -import DeleteIcon from "../../../../../assets/images/icon-action-delete-14x14@2x.png"; -import QueryIcon from "../../../../../assets/images/icon-action-query-16x16@2x.png"; -import TransferIcon from "../../../../../assets/images/icon-action-transfer-16x16@2x.png"; -import CloseIcon from "../../../../../assets/images/icon-action-close-16x15@2x.png"; + +import DiskEncryptionKeyModal from "./modals/DiskEncryptionKeyModal"; +import HostActionDropdown from "./HostActionsDropdown/HostActionsDropdown"; const baseClass = "host-details"; @@ -111,11 +106,9 @@ const HostDetailsPage = ({ const hostIdFromURL = parseInt(host_id, 10); const { config, - currentUser, - isGlobalAdmin, - isPremiumTier, + isGlobalAdmin = false, + isPremiumTier = false, isOnlyObserver, - isGlobalMaintainer, filteredHostsPath, } = useContext(AppContext); const { @@ -128,17 +121,6 @@ const HostDetailsPage = ({ } = useContext(PolicyContext); const { renderFlash } = useContext(NotificationContext); const handlePageError = useErrorHandler(); - const canTransferTeam = - isPremiumTier && (isGlobalAdmin || isGlobalMaintainer); - - const canDeleteHost = (user: IUser, host: IHost) => { - return ( - isGlobalAdmin || - isGlobalMaintainer || - permissionUtils.isTeamAdmin(user, host.team_id) || - permissionUtils.isTeamMaintainer(user, host.team_id) - ); - }; const [showDeleteHostModal, setShowDeleteHostModal] = useState(false); const [showTransferHostModal, setShowTransferHostModal] = useState(false); @@ -146,6 +128,7 @@ const HostDetailsPage = ({ const [showPolicyDetailsModal, setPolicyDetailsModal] = useState(false); const [showOSPolicyModal, setShowOSPolicyModal] = useState(false); const [showUnenrollMdmModal, setShowUnenrollMdmModal] = useState(false); + const [showDiskEncryptionModal, setShowDiskEncryptionModal] = useState(false); const [selectedPolicy, setSelectedPolicy] = useState( null ); @@ -333,19 +316,6 @@ const HostDetailsPage = ({ } ); - const canEditMdm = (() => { - const userHasPermission = - !!currentUser && - !!host && - (isGlobalAdmin || - isGlobalMaintainer || - permissionUtils.isTeamMaintainerOrTeamAdmin(currentUser, host.team_id)); - const hostEnrolled = ["On (automatic)", "On (manual)"].includes( - host?.mdm.enrollment_status ?? "" - ); - return userHasPermission && hostEnrolled; - })(); - const featuresConfig = host?.team_id ? teams?.find((t) => t.id === host.team_id)?.features : config?.features; @@ -360,7 +330,7 @@ const HostDetailsPage = ({ }) || [] ); }); - }, [usersSearchString]); + }, [usersSearchString, host?.users]); const titleData = normalizeEmptyValues( pick(host, [ @@ -536,72 +506,40 @@ const HostDetailsPage = ({ [] ); + const onSelectHostAction = (action: string) => { + switch (action) { + case "transfer": + setShowTransferHostModal(true); + break; + case "query": + setShowQueryHostModal(true); + break; + case "diskEncryption": + setShowDiskEncryptionModal(true); + break; + case "mdmOff": + toggleUnenrollMdmModal(); + break; + case "delete": + setShowDeleteHostModal(true); + break; + default: + } + }; + const renderActionButtons = () => { - const isOnline = host?.status === "online"; + if (!host) { + return null; + } + return ( -
- {canTransferTeam && ( - - )} -
- -
- - - You can’t query
an offline host. -
-
- {canEditMdm && !hideEditMdm && ( - - )} - {currentUser && host && canDeleteHost(currentUser, host) && ( - - )} -
+ ); }; @@ -808,6 +746,12 @@ const HostDetailsPage = ({ }} /> )} + {showDiskEncryptionModal && host && ( + setShowDiskEncryptionModal(false)} + /> + )} ); diff --git a/frontend/pages/hosts/details/HostDetailsPage/_styles.scss b/frontend/pages/hosts/details/HostDetailsPage/_styles.scss index 4e1831ecf9..7e936b4a92 100644 --- a/frontend/pages/hosts/details/HostDetailsPage/_styles.scss +++ b/frontend/pages/hosts/details/HostDetailsPage/_styles.scss @@ -348,12 +348,6 @@ margin: $pad-xxlarge 0 0; } - &__action-button-container { - display: flex; - align-items: center; - gap: $pad-large; - } - &__device_mapping { .device_mapping--tooltip { flex-direction: column; diff --git a/frontend/pages/hosts/details/HostDetailsPage/modals/DiskEncryptionKeyModal/DiskEncryptionKeyModal.tsx b/frontend/pages/hosts/details/HostDetailsPage/modals/DiskEncryptionKeyModal/DiskEncryptionKeyModal.tsx new file mode 100644 index 0000000000..508fe27c4b --- /dev/null +++ b/frontend/pages/hosts/details/HostDetailsPage/modals/DiskEncryptionKeyModal/DiskEncryptionKeyModal.tsx @@ -0,0 +1,64 @@ +import React from "react"; +import { useQuery } from "react-query"; + +import { IHostEncrpytionKeyResponse } from "interfaces/host"; +import hostAPI from "services/entities/hosts"; + +import Modal from "components/Modal"; +import CustomLink from "components/CustomLink"; +import Button from "components/buttons/Button"; +import InputFieldHiddenContent from "components/forms/fields/InputFieldHiddenContent"; +import DataError from "components/DataError"; + +const baseClass = "disk-encryption-key-modal"; + +interface IDiskEncryptionKeyModal { + hostId: number; + onCancel: () => void; +} + +const DiskEncryptionKeyModal = ({ + hostId, + onCancel, +}: IDiskEncryptionKeyModal) => { + const { data: encrpytionKey, error: encryptionKeyError } = useQuery< + IHostEncrpytionKeyResponse, + unknown, + string + >("hostEncrpytionKey", () => hostAPI.getEncryptionKey(hostId), { + refetchOnMount: false, + refetchOnReconnect: false, + refetchOnWindowFocus: false, + retry: false, + select: (data) => data.encryption_key.key, + }); + + return ( + + {encryptionKeyError ? ( + + ) : ( + <> + +

+ The disk encryption key refers to the FileVault recovery key for + macOS. +

+

+ Use this key to log in to the host if you forgot the password.{" "} + +

+
+ +
+ + )} +
+ ); +}; + +export default DiskEncryptionKeyModal; diff --git a/frontend/pages/hosts/details/HostDetailsPage/modals/DiskEncryptionKeyModal/_styles.scss b/frontend/pages/hosts/details/HostDetailsPage/modals/DiskEncryptionKeyModal/_styles.scss new file mode 100644 index 0000000000..6072b2bdc5 --- /dev/null +++ b/frontend/pages/hosts/details/HostDetailsPage/modals/DiskEncryptionKeyModal/_styles.scss @@ -0,0 +1,3 @@ +.disk-encryption-key-modal { + width: 500px; +} diff --git a/frontend/pages/hosts/details/HostDetailsPage/modals/DiskEncryptionKeyModal/index.ts b/frontend/pages/hosts/details/HostDetailsPage/modals/DiskEncryptionKeyModal/index.ts new file mode 100644 index 0000000000..aa4d95a872 --- /dev/null +++ b/frontend/pages/hosts/details/HostDetailsPage/modals/DiskEncryptionKeyModal/index.ts @@ -0,0 +1 @@ +export { default } from "./DiskEncryptionKeyModal"; diff --git a/frontend/pages/hosts/details/HostDetailsPage/modals/UnenrollMdmModal/_styles.scss b/frontend/pages/hosts/details/HostDetailsPage/modals/UnenrollMdmModal/_styles.scss deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/frontend/pages/hosts/details/cards/HostSummary/HostSummary.tsx b/frontend/pages/hosts/details/cards/HostSummary/HostSummary.tsx index dd07c0623a..d0fc99c864 100644 --- a/frontend/pages/hosts/details/cards/HostSummary/HostSummary.tsx +++ b/frontend/pages/hosts/details/cards/HostSummary/HostSummary.tsx @@ -28,7 +28,7 @@ interface IHostSummaryProps { onRefetchHost: ( evt: React.MouseEvent ) => void; - renderActionButtons: () => JSX.Element; + renderActionButtons: () => JSX.Element | null; deviceUser?: boolean; } @@ -58,7 +58,7 @@ const HostSummary = ({