From 90f75f16442a5744e8b2c2bf02a3426a96298219 Mon Sep 17 00:00:00 2001 From: Magnus Jensen Date: Thu, 9 Apr 2026 16:30:15 -0500 Subject: [PATCH] simplify OS modal (#43252) **Related issue:** Resolves #40702 New look: image # Checklist for submitter If some of the following don't apply, delete the relevant line. - [x] Changes file added for user-visible changes in `changes/`, `orbit/changes/` or `ee/fleetd-chrome/changes`. See [Changes files](https://github.com/fleetdm/fleet/blob/main/docs/Contributing/guides/committing-changes.md#changes-files) for more information. - [x] Input data is properly validated, `SELECT *` is avoided, SQL injection is prevented (using placeholders for values in statements), JS inline code is prevented especially for url redirects, and untrusted data interpolated into shell scripts/commands is validated against shell metacharacters. - [x] Timeouts are implemented and retries are limited to avoid infinite loops - [x] If paths of existing endpoints are modified without backwards compatibility, checked the frontend/CLI for any necessary changes ## Testing - [x] Added/updated automated tests - [x] QA'd all new/changed functionality manually ## Summary by CodeRabbit * **Bug Fixes** * Simplified pending status labels in OS Settings modal by removing "(pending)" suffix from states like "Enforcing" and "Removing enforcement" * Improved OS Settings modal table layout and styling * **New Features** * Added dedicated action buttons to resend MDM profiles and rotate Recovery Lock password * Enhanced error tooltip handling for failed profile states --- changes/40702-simplif-os-modal | 1 + .../DiskEncryptionTableConfig.tsx | 6 +- .../OSSettingStatusCell.tests.tsx | 10 +- .../OSSettingStatusCell.tsx | 68 +++-- .../errorTooltipHelpers.tests.tsx | 163 ++++++++++ .../errorTooltipHelpers.tsx} | 191 +----------- .../OSSettingStatusCell/helpers.ts | 16 +- .../OSSettingsErrorCell.tests.tsx | 278 ------------------ .../OSSettingsErrorCell/index.ts | 1 - .../OSSettingsNameCell/_styles.scss | 6 +- .../OSSettingsResendCell.tests.tsx | 112 +++++++ .../OSSettingsResendCell.tsx | 160 ++++++++++ .../_styles.scss | 16 +- .../OSSettingsResendCell/index.ts | 1 + .../OSSettingsTable/OSSettingsTableConfig.tsx | 7 +- .../OSSettingsTable/_styles.scss | 37 ++- 16 files changed, 529 insertions(+), 544 deletions(-) create mode 100644 changes/40702-simplif-os-modal create mode 100644 frontend/pages/hosts/details/OSSettingsModal/OSSettingsTable/OSSettingStatusCell/errorTooltipHelpers.tests.tsx rename frontend/pages/hosts/details/OSSettingsModal/OSSettingsTable/{OSSettingsErrorCell/OSSettingsErrorCell.tsx => OSSettingStatusCell/errorTooltipHelpers.tsx} (50%) delete mode 100644 frontend/pages/hosts/details/OSSettingsModal/OSSettingsTable/OSSettingsErrorCell/OSSettingsErrorCell.tests.tsx delete mode 100644 frontend/pages/hosts/details/OSSettingsModal/OSSettingsTable/OSSettingsErrorCell/index.ts create mode 100644 frontend/pages/hosts/details/OSSettingsModal/OSSettingsTable/OSSettingsResendCell/OSSettingsResendCell.tests.tsx create mode 100644 frontend/pages/hosts/details/OSSettingsModal/OSSettingsTable/OSSettingsResendCell/OSSettingsResendCell.tsx rename frontend/pages/hosts/details/OSSettingsModal/OSSettingsTable/{OSSettingsErrorCell => OSSettingsResendCell}/_styles.scss (75%) create mode 100644 frontend/pages/hosts/details/OSSettingsModal/OSSettingsTable/OSSettingsResendCell/index.ts diff --git a/changes/40702-simplif-os-modal b/changes/40702-simplif-os-modal new file mode 100644 index 0000000000..5bf1fa1f71 --- /dev/null +++ b/changes/40702-simplif-os-modal @@ -0,0 +1 @@ +* Improved the OS settings modal layout. \ No newline at end of file diff --git a/frontend/pages/ManageControlsPage/OSSettings/cards/DiskEncryption/components/DiskEncryptionTable/DiskEncryptionTableConfig.tsx b/frontend/pages/ManageControlsPage/OSSettings/cards/DiskEncryption/components/DiskEncryptionTable/DiskEncryptionTableConfig.tsx index 6930b1cd59..2b01c4c8a3 100644 --- a/frontend/pages/ManageControlsPage/OSSettings/cards/DiskEncryption/components/DiskEncryptionTable/DiskEncryptionTableConfig.tsx +++ b/frontend/pages/ManageControlsPage/OSSettings/cards/DiskEncryption/components/DiskEncryptionTable/DiskEncryptionTableConfig.tsx @@ -161,7 +161,7 @@ const STATUS_CELL_VALUES: Record = { "osquery and retrieving the disk encryption key. This may take up to one hour.", }, action_required: { - displayName: "Action required (pending)", + displayName: "Action required", statusName: "pendingPartial", value: "action_required", tooltip: ( @@ -172,7 +172,7 @@ const STATUS_CELL_VALUES: Record = { ), }, enforcing: { - displayName: "Enforcing (pending)", + displayName: "Enforcing", statusName: "pendingPartial", value: "enforcing", tooltip: @@ -184,7 +184,7 @@ const STATUS_CELL_VALUES: Record = { value: "failed", }, removing_enforcement: { - displayName: "Removing enforcement (pending)", + displayName: "Removing enforcement", statusName: "pendingPartial", value: "removing_enforcement", tooltip: diff --git a/frontend/pages/hosts/details/OSSettingsModal/OSSettingsTable/OSSettingStatusCell/OSSettingStatusCell.tests.tsx b/frontend/pages/hosts/details/OSSettingsModal/OSSettingsTable/OSSettingStatusCell/OSSettingStatusCell.tests.tsx index d334482c95..8af1bf79f7 100644 --- a/frontend/pages/hosts/details/OSSettingsModal/OSSettingsTable/OSSettingStatusCell/OSSettingStatusCell.tests.tsx +++ b/frontend/pages/hosts/details/OSSettingsModal/OSSettingsTable/OSSettingStatusCell/OSSettingStatusCell.tests.tsx @@ -59,7 +59,7 @@ describe("OS setting status cell", () => { /> ); - const statusText = screen.getByText("Enforcing (pending)"); + const statusText = screen.getByText("Enforcing"); expect(statusText).toBeInTheDocument(); await user.hover(statusText); @@ -82,7 +82,7 @@ describe("OS setting status cell", () => { /> ); - const statusText = screen.getByText("Enforcing (pending)"); + const statusText = screen.getByText("Enforcing"); expect(statusText).toBeInTheDocument(); await user.hover(statusText); @@ -105,7 +105,7 @@ describe("OS setting status cell", () => { /> ); - const statusText = screen.getByText("Enforcing (pending)"); + const statusText = screen.getByText("Enforcing"); expect(statusText).toBeInTheDocument(); await user.hover(statusText); @@ -128,7 +128,7 @@ describe("OS setting status cell", () => { /> ); - const statusText = screen.getByText("Removing enforcement (pending)"); + const statusText = screen.getByText("Removing enforcement"); expect(statusText).toBeInTheDocument(); await user.hover(statusText); @@ -151,7 +151,7 @@ describe("OS setting status cell", () => { /> ); - const statusText = screen.getByText("Removing enforcement (pending)"); + const statusText = screen.getByText("Removing enforcement"); expect(statusText).toBeInTheDocument(); await user.hover(statusText); diff --git a/frontend/pages/hosts/details/OSSettingsModal/OSSettingsTable/OSSettingStatusCell/OSSettingStatusCell.tsx b/frontend/pages/hosts/details/OSSettingsModal/OSSettingsTable/OSSettingStatusCell/OSSettingStatusCell.tsx index aaa3c8c4f9..c02950cbe7 100644 --- a/frontend/pages/hosts/details/OSSettingsModal/OSSettingsTable/OSSettingStatusCell/OSSettingStatusCell.tsx +++ b/frontend/pages/hosts/details/OSSettingsModal/OSSettingsTable/OSSettingStatusCell/OSSettingStatusCell.tsx @@ -13,8 +13,12 @@ import { } from "interfaces/mdm"; import TooltipWrapper from "components/TooltipWrapper"; -import { OsSettingsTableStatusValue } from "../OSSettingsTableConfig"; +import { + IHostMdmProfileWithAddedStatus, + OsSettingsTableStatusValue, +} from "../OSSettingsTableConfig"; import TooltipContent from "./components/Tooltip/TooltipContent"; +import generateErrorTooltip from "./errorTooltipHelpers"; import { isDiskEncryptionProfile, LINUX_DISK_ENCRYPTION_DISPLAY_CONFIG, @@ -34,6 +38,7 @@ interface IOSSettingStatusCellProps { profileName: string; hostPlatform?: ProfilePlatform; profileUUID?: string; + profile?: IHostMdmProfileWithAddedStatus; } const OSSettingStatusCell = ({ @@ -42,6 +47,7 @@ const OSSettingStatusCell = ({ profileName = "", hostPlatform, profileUUID, + profile, }: IOSSettingStatusCellProps) => { let displayOption: ProfileDisplayOption = null; if (hostPlatform === "linux") { @@ -65,14 +71,14 @@ const OSSettingStatusCell = ({ case "delivered": if (operationType === "install") { displayOption = { - statusText: "Enforcing (pending)", + statusText: "Enforcing", iconName: "pending-outline", tooltip: "The host is running the command to apply settings or will run it when the host comes online.", }; } else { displayOption = { - statusText: "Removing enforcement (pending)", + statusText: "Removing enforcement", iconName: "pending-outline", tooltip: "The host is running the command to remove settings or will run it when the host comes online.", @@ -120,34 +126,50 @@ const OSSettingStatusCell = ({ if (displayOption) { const { statusText, iconName, tooltip } = displayOption; + + // For failed status, use the error detail as tooltip content + const errorTooltip = profile ? generateErrorTooltip(profile) : null; + + let tipContent: React.ReactNode; + if (tooltip) { + if (status !== "action_required") { + tipContent = ( + + + + ); + } else { + tipContent = ( + + + + ); + } + } else if (errorTooltip) { + tipContent = ( + {errorTooltip} + ); + } + return ( - {tooltip ? ( + {tipContent ? ( - {status !== "action_required" ? ( - - ) : ( - - )} - - } + tipContent={tipContent} position="top" underline={false} showArrow tipOffset={8} + clickable > {statusText} diff --git a/frontend/pages/hosts/details/OSSettingsModal/OSSettingsTable/OSSettingStatusCell/errorTooltipHelpers.tests.tsx b/frontend/pages/hosts/details/OSSettingsModal/OSSettingsTable/OSSettingStatusCell/errorTooltipHelpers.tests.tsx new file mode 100644 index 0000000000..cffd1c3b84 --- /dev/null +++ b/frontend/pages/hosts/details/OSSettingsModal/OSSettingsTable/OSSettingStatusCell/errorTooltipHelpers.tests.tsx @@ -0,0 +1,163 @@ +import React from "react"; +import { render, screen } from "@testing-library/react"; + +import { createMockHostMdmProfile } from "__mocks__/hostMock"; + +import generateErrorTooltip from "./errorTooltipHelpers"; + +// Helper to render the JSX returned by generateErrorTooltip +const renderTooltip = (tooltip: React.ReactNode) => { + return render(
{tooltip}
); +}; + +describe("generateErrorTooltip", () => { + it("returns null for non-failed profiles", () => { + const result = generateErrorTooltip( + createMockHostMdmProfile({ status: "verified" }) + ); + expect(result).toBeNull(); + }); + + it("returns null for failed profiles with no detail", () => { + const result = generateErrorTooltip( + createMockHostMdmProfile({ status: "failed", detail: "" }) + ); + expect(result).toBeNull(); + }); + + it("formats a windows profile error with key-value pairs", () => { + const tooltip = generateErrorTooltip( + createMockHostMdmProfile({ + platform: "windows", + status: "failed", + detail: + "starting encryption: encrypt(C:): error code returned during encryption: -2147024809, error 2: This is another error", + }) + ); + + renderTooltip(tooltip); + + const firstErrorKey = screen.getByText( + (content) => content === "starting encryption:" + ); + const firstErrorValue = screen.getByText( + (content) => + content === + "encrypt(C:): error code returned during encryption: -2147024809," + ); + + expect(firstErrorKey).toBeInTheDocument(); + expect(firstErrorKey.tagName.toLowerCase()).toBe("b"); + expect(firstErrorValue).toBeInTheDocument(); + + const secondErrorKey = screen.getByText( + (content) => content === "error 2:" + ); + const secondErrorValue = screen.getByText( + (content) => content === "This is another error" + ); + + expect(secondErrorKey).toBeInTheDocument(); + expect(secondErrorKey.tagName.toLowerCase()).toBe("b"); + expect(secondErrorValue).toBeInTheDocument(); + }); + + it("renders a tooltip link for IDP email errors", () => { + const tooltip = generateErrorTooltip( + createMockHostMdmProfile({ + status: "failed", + detail: "There is no IdP email for this host.", + }) + ); + + renderTooltip(tooltip); + + expect(screen.getByText(/Learn more/)).toBeInTheDocument(); + expect(screen.getByText(/Learn more/).tagName.toLowerCase()).toBe("a"); + }); + + it("formats a custom SCEP certificate error", () => { + const tooltip = generateErrorTooltip( + createMockHostMdmProfile({ + status: "failed", + detail: `Fleet couldn't populate $FLEET_VAR_CUSTOM_SCEP_URL_SCEP_WIFI because SCEP_WIFI certificate authority doesn't exist.`, + }) + ); + + renderTooltip(tooltip); + + expect( + screen.getByText("Settings > Integrations > Certificates") + ).toBeInTheDocument(); + expect( + screen.getByText(/add it and resend the configuration profile/) + ).toBeInTheDocument(); + }); + + it("formats a DigiCert profile ID error (410)", () => { + const tooltip = generateErrorTooltip( + createMockHostMdmProfile({ + status: "failed", + detail: `Couldn't get certificate from DigiCert for WIFI_CERTIFICATE. unexpected DigiCert status code for POST request: 410, errors: Profile with id {test-id} was deleted`, + }) + ); + + renderTooltip(tooltip); + + expect( + screen.getByText("Settings > Integrations > Certificates") + ).toBeInTheDocument(); + expect(screen.getByText(/correct it and resend/)).toBeInTheDocument(); + expect(screen.getByText("WIFI_CERTIFICATE")).toBeInTheDocument(); + expect(screen.getByText("Profile GUID")).toBeInTheDocument(); + }); + + it("formats a DigiCert deleted/suspended profile error (400)", () => { + const tooltip = generateErrorTooltip( + createMockHostMdmProfile({ + status: "failed", + detail: `Couldn't get certificate from DigiCert for WIFI_CERTIFICATE. unexpected DigiCert status code for POST request: 400, errors: Enrollment creation and Certificate issuance/renewal for deleted or suspended Profile are not supported. + Please contact system Administrator.`, + }) + ); + + renderTooltip(tooltip); + + expect( + screen.getByText("Settings > Integrations > Certificates") + ).toBeInTheDocument(); + expect(screen.getByText(/correct it and resend/)).toBeInTheDocument(); + expect(screen.getByText("WIFI_CERTIFICATE")).toBeInTheDocument(); + expect(screen.getByText("Profile GUID")).toBeInTheDocument(); + }); + + it("formats a DigiCert token error", () => { + const tooltip = generateErrorTooltip( + createMockHostMdmProfile({ + status: "failed", + detail: `Couldn't get certificate from DigiCert. The API token configured in DIGICERT_TEST certificate authority is invalid.`, + }) + ); + + renderTooltip(tooltip); + + expect( + screen.getByText("Settings > Integrations > Certificates") + ).toBeInTheDocument(); + expect(screen.getByText(/correct it and resend/)).toBeInTheDocument(); + expect(screen.getByText("DIGICERT_TEST")).toBeInTheDocument(); + expect(screen.getByText("API token")).toBeInTheDocument(); + }); + + it("returns the raw detail string for unrecognized darwin errors", () => { + const result = generateErrorTooltip( + createMockHostMdmProfile({ + platform: "darwin", + status: "failed", + detail: "Some unknown error occurred", + }) + ); + + expect(result).toBe("Some unknown error occurred"); + }); +}); diff --git a/frontend/pages/hosts/details/OSSettingsModal/OSSettingsTable/OSSettingsErrorCell/OSSettingsErrorCell.tsx b/frontend/pages/hosts/details/OSSettingsModal/OSSettingsTable/OSSettingStatusCell/errorTooltipHelpers.tsx similarity index 50% rename from frontend/pages/hosts/details/OSSettingsModal/OSSettingsTable/OSSettingsErrorCell/OSSettingsErrorCell.tsx rename to frontend/pages/hosts/details/OSSettingsModal/OSSettingsTable/OSSettingStatusCell/errorTooltipHelpers.tsx index 9c314cd7bf..719af5fddd 100644 --- a/frontend/pages/hosts/details/OSSettingsModal/OSSettingsTable/OSSettingsErrorCell/OSSettingsErrorCell.tsx +++ b/frontend/pages/hosts/details/OSSettingsModal/OSSettingsTable/OSSettingStatusCell/errorTooltipHelpers.tsx @@ -1,54 +1,10 @@ -import React, { useContext, useState } from "react"; -import classnames from "classnames"; -import { noop } from "lodash"; +import React from "react"; -import { REC_LOCK_SYNTHETIC_PROFILE_UUID } from "pages/hosts/details/helpers"; - -import { DEFAULT_EMPTY_CELL_VALUE } from "utilities/constants"; -import { NotificationContext } from "context/notification"; -import { - FLEET_ANDROID_CERTIFICATE_TEMPLATE_PROFILE_ID, - IHostMdmProfile, -} from "interfaces/mdm"; -import { getErrorReason } from "interfaces/errors"; - -import TooltipTruncatedTextCell from "components/TableContainer/DataTable/TooltipTruncatedTextCell"; -import Button from "components/buttons/Button"; -import Icon from "components/Icon"; import CustomLink from "components/CustomLink"; +import { IHostMdmProfile } from "interfaces/mdm"; import { IHostMdmProfileWithAddedStatus } from "../OSSettingsTableConfig"; -const baseClass = "os-settings-error-cell"; - -interface IResendButtonProps { - isResending: boolean; - onClick: (evt: React.MouseEvent) => void; -} - -const ResendButton = ({ isResending, onClick }: IResendButtonProps) => { - const classNames = classnames(`${baseClass}__resend-button`, "resend-link", { - [`${baseClass}__resending`]: isResending, - }); - - const buttonText = isResending ? "Resending..." : "Resend"; - - // add additional props when we need to display a tooltip for the button - - return ( - - ); -}; - const formatAndroidProfileNotAppliedError = ( detail: IHostMdmProfile["detail"] ) => { @@ -207,10 +163,9 @@ const formatDetailWindowsProfile = (detail: string) => { * unformatted. */ const generateErrorTooltip = ( - cellValue: string, profile: IHostMdmProfileWithAddedStatus -) => { - if (profile.status !== "failed") return null; +): React.ReactNode => { + if (profile.status !== "failed" || !profile.detail) return null; // Special case to handle IdP email errors const idpEmailError = formatDetailIdpEmailError(profile.detail); @@ -235,141 +190,7 @@ const generateErrorTooltip = ( return formatDetailWindowsProfile(profile.detail); } - return cellValue; + return profile.detail; }; -interface IRotateButtonProps { - isRotating: boolean; - onClick: () => void; -} - -const RotateButton = ({ isRotating, onClick }: IRotateButtonProps) => { - const classNames = classnames(`${baseClass}__rotate-button`, "rotate-link", { - [`${baseClass}__rotating`]: isRotating, - }); - - const buttonText = isRotating ? "Rotating..." : "Rotate"; - - return ( - - ); -}; - -interface IOSSettingsErrorCellProps { - canResendProfiles: boolean; - canRotateRecoveryLockPassword?: boolean; - profile: IHostMdmProfileWithAddedStatus; - resendRequest: (profileUUID: string) => Promise; - resendCertificateRequest?: (certificateTemplateId: number) => Promise; - rotateRecoveryLockPassword?: () => Promise; - onProfileResent?: () => void; -} - -const OSSettingsErrorCell = ({ - canResendProfiles, - canRotateRecoveryLockPassword = false, - profile, - resendRequest, - resendCertificateRequest, - rotateRecoveryLockPassword, - onProfileResent = noop, -}: IOSSettingsErrorCellProps) => { - const { renderFlash } = useContext(NotificationContext); - const [isResending, setIsResending] = useState(false); - const [isRotating, setIsRotating] = useState(false); - - const isAndroidCertificate = - profile.profile_uuid === FLEET_ANDROID_CERTIFICATE_TEMPLATE_PROFILE_ID; - - const onResendProfile = async () => { - setIsResending(true); - try { - if ( - isAndroidCertificate && - resendCertificateRequest && - profile.certificate_template_id !== undefined - ) { - await resendCertificateRequest(profile.certificate_template_id); - renderFlash( - "success", - "Successfully sent request to resend certificate." - ); - onProfileResent(); - } else if (!isAndroidCertificate) { - await resendRequest(profile.profile_uuid); - onProfileResent(); - } - } catch (e) { - renderFlash("error", "Couldn't resend. Please try again."); - } - setIsResending(false); - }; - - const onRotatePassword = async () => { - if (!rotateRecoveryLockPassword) return; - setIsRotating(true); - try { - await rotateRecoveryLockPassword(); - renderFlash( - "success", - "Successfully sent request to rotate Recovery Lock password." - ); - } catch (e) { - const msg = getErrorReason(e).includes("already in progress") - ? "Recovery lock password rotation is already in progress for this host." - : "Couldn't send request to rotate Recovery Lock password. Please try again."; - - renderFlash("error", msg); - } - setIsRotating(false); - }; - - const isFailed = profile.status === "failed"; - const isPending = - profile.status === "pending" || - profile.status === "delivering" || - profile.status === "delivered"; - const isVerified = profile.status === "verified"; - const showResendButton = - canResendProfiles && - (isFailed || isVerified) && - profile.profile_uuid !== REC_LOCK_SYNTHETIC_PROFILE_UUID; - const showRotateButton = - canRotateRecoveryLockPassword && (isFailed || isVerified); - const value = - ((isFailed || isPending) && profile.detail) || DEFAULT_EMPTY_CELL_VALUE; - - const tooltip = generateErrorTooltip(value, profile); - - return ( -
- - {showResendButton && ( - - )} - {showRotateButton && ( - - )} -
- ); -}; - -export default OSSettingsErrorCell; +export default generateErrorTooltip; diff --git a/frontend/pages/hosts/details/OSSettingsModal/OSSettingsTable/OSSettingStatusCell/helpers.ts b/frontend/pages/hosts/details/OSSettingsModal/OSSettingsTable/OSSettingStatusCell/helpers.ts index 11705df68e..160f7a69d7 100644 --- a/frontend/pages/hosts/details/OSSettingsModal/OSSettingsTable/OSSettingStatusCell/helpers.ts +++ b/frontend/pages/hosts/details/OSSettingsModal/OSSettingsTable/OSSettingStatusCell/helpers.ts @@ -64,7 +64,7 @@ export const PROFILE_DISPLAY_CONFIG: ProfileDisplayConfig = { verifying: MAC_PROFILE_VERIFYING_DISPLAY_CONFIG, acknowledged: MAC_PROFILE_VERIFYING_DISPLAY_CONFIG, pending: { - statusText: "Enforcing (pending)", + statusText: "Enforcing", iconName: "pending-outline", tooltip: (innerProps) => innerProps.isDiskEncryptionProfile @@ -74,7 +74,7 @@ export const PROFILE_DISPLAY_CONFIG: ProfileDisplayConfig = { "when the host comes online.", }, action_required: { - statusText: "Action required (pending)", + statusText: "Action required", iconName: "pending-outline", tooltip: TooltipInnerContentActionRequired as TooltipInnerContentFunc, }, @@ -86,7 +86,7 @@ export const PROFILE_DISPLAY_CONFIG: ProfileDisplayConfig = { }, remove: { pending: { - statusText: "Removing enforcement (pending)", + statusText: "Removing enforcement", iconName: "pending-outline", tooltip: (innerProps) => innerProps.isDiskEncryptionProfile @@ -133,13 +133,13 @@ export const WINDOWS_DISK_ENCRYPTION_DISPLAY_CONFIG: WindowsDiskEncryptionDispla "osquery and retrieving the disk encryption key. This may take up to one hour.", }, pending: { - statusText: "Enforcing (pending)", + statusText: "Enforcing", iconName: "pending-outline", tooltip: () => "The host will receive the MDM command to turn on disk encryption when the host comes online.", }, action_required: { - statusText: "Action required (pending)", + statusText: "Action required", iconName: "pending-outline", tooltip: () => "Disk encryption is on, but the user has not set a BitLocker PIN yet.", @@ -169,7 +169,7 @@ export const LINUX_DISK_ENCRYPTION_DISPLAY_CONFIG: LinuxDiskEncryptionDisplayCon tooltip: null, }, action_required: { - statusText: "Action required (pending)", + statusText: "Action required", iconName: "pending-outline", tooltip: TooltipInnerContentActionRequired as TooltipInnerContentFunc, }, @@ -185,12 +185,12 @@ export const RECOVERY_LOCK_PASSWORD_DISPLAY_CONFIG: Record< tooltip: "Fleet set a recovery lock password for the host.", }, pending: { - statusText: "Enforcing (pending)", + statusText: "Enforcing", iconName: "pending-outline", tooltip: "Fleet is setting a recovery lock password for the host.", }, removing_enforcement: { - statusText: "Removing enforcement (pending)", + statusText: "Removing enforcement", iconName: "pending-outline", tooltip: "Fleet is unsetting the recovery lock password for the host.", }, diff --git a/frontend/pages/hosts/details/OSSettingsModal/OSSettingsTable/OSSettingsErrorCell/OSSettingsErrorCell.tests.tsx b/frontend/pages/hosts/details/OSSettingsModal/OSSettingsTable/OSSettingsErrorCell/OSSettingsErrorCell.tests.tsx deleted file mode 100644 index 13a32850b0..0000000000 --- a/frontend/pages/hosts/details/OSSettingsModal/OSSettingsTable/OSSettingsErrorCell/OSSettingsErrorCell.tests.tsx +++ /dev/null @@ -1,278 +0,0 @@ -import React from "react"; -import { render, screen } from "@testing-library/react"; - -import { createMockHostMdmProfile } from "__mocks__/hostMock"; - -import { REC_LOCK_SYNTHETIC_PROFILE_UUID } from "pages/hosts/details/helpers"; - -import OSSettingsErrorCell from "./OSSettingsErrorCell"; - -const noop = () => new Promise(() => undefined); - -describe("OSSettingsErrorCell", () => { - it("should render a formatted message for windows profiles", () => { - render( - - ); - - const firstErrorKey = screen.getByText( - (content) => content === "starting encryption:" - ); - const firstErrorValue = screen.getByText( - (content) => - content === - "encrypt(C:): error code returned during encryption: -2147024809," - ); - - // assert that the tooltip errors are rendered and the key is bolded - expect(firstErrorKey).toBeInTheDocument(); - expect(firstErrorKey.tagName.toLowerCase()).toBe("b"); - expect(firstErrorValue).toBeInTheDocument(); - - const secondErrorKey = screen.getByText( - (content) => content === "error 2:" - ); - const secondErrorValue = screen.getByText( - (content) => content === "This is another error" - ); - - // assert the second error is rendered with the key bolded - expect(secondErrorKey).toBeInTheDocument(); - expect(secondErrorKey.tagName.toLowerCase()).toBe("b"); - expect(secondErrorValue).toBeInTheDocument(); - }); - - it("renders a default empty cell when the status is not failed", () => { - render( - - ); - - expect(screen.getAllByText("---")[0]).toBeInTheDocument(); - }); - - it("renders a resend button when canResendProfiles is true and profile is failed", () => { - render( - - ); - - expect(screen.getByRole("button", { name: "Resend" })).toBeInTheDocument(); - }); - - it("renders a resend button when canResendProfiles is true and profile is verified", () => { - render( - - ); - - expect(screen.getByRole("button", { name: "Resend" })).toBeInTheDocument(); - }); - - it("renders a tooltip link when the error message inlcudes info about IDP emails", () => { - render( - - ); - - // couldnt get getByRole to work for this link. Thinking it may be a jest issue - // TODO: explore why getByRole is not working for links - expect(screen.getByText(/Learn more/)).toBeInTheDocument(); - expect(screen.getByText(/Learn more/).tagName.toLowerCase()).toBe("a"); - }); - - it("renders a formatted tooltip when the error message matches custom scep error patern", () => { - render( - - ); - - expect( - screen.getByText("Settings > Integrations > Certificates") - ).toBeInTheDocument(); - expect( - screen.getByText(/add it and resend the configuration profile/) - ).toBeInTheDocument(); - }); - - it("renders a formatted tooltip when the error message matches digicert profile id error", () => { - render( - - ); - - expect( - screen.getByText("Settings > Integrations > Certificates") - ).toBeInTheDocument(); - expect(screen.getByText(/correct it and resend/)).toBeInTheDocument(); - expect(screen.getByText("WIFI_CERTIFICATE")).toBeInTheDocument(); - expect(screen.getByText("Profile GUID")).toBeInTheDocument(); - }); - - it("renders a formatted tooltip when the error message matches digicert deleted profile error", () => { - render( - - ); - - expect( - screen.getByText("Settings > Integrations > Certificates") - ).toBeInTheDocument(); - expect(screen.getByText(/correct it and resend/)).toBeInTheDocument(); - expect(screen.getByText("WIFI_CERTIFICATE")).toBeInTheDocument(); - expect(screen.getByText("Profile GUID")).toBeInTheDocument(); - }); - - it("renders a rotate button when canRotateRecoveryLockPassword is true and password status is verified", () => { - render( - - ); - - expect(screen.getByRole("button", { name: "Rotate" })).toBeInTheDocument(); - }); - - it("renders a rotate button when canRotateRecoveryLockPassword is true and password status is failed", () => { - render( - - ); - - expect(screen.getByRole("button", { name: "Rotate" })).toBeInTheDocument(); - }); - - it("does not render a rotate button when canRotateRecoveryLockPassword is false", () => { - render( - - ); - - expect( - screen.queryByRole("button", { name: "Rotate" }) - ).not.toBeInTheDocument(); - }); - - it("does not render a rotate button when password status is pending", () => { - render( - - ); - - expect( - screen.queryByRole("button", { name: "Rotate" }) - ).not.toBeInTheDocument(); - }); - - it("renders a formatted tooltip when the error message matches digicert token patern", () => { - render( - - ); - - expect( - screen.getByText("Settings > Integrations > Certificates") - ).toBeInTheDocument(); - expect(screen.getByText(/correct it and resend/)).toBeInTheDocument(); - expect(screen.getByText("DIGICERT_TEST")).toBeInTheDocument(); - expect(screen.getByText("API token")).toBeInTheDocument(); - }); -}); diff --git a/frontend/pages/hosts/details/OSSettingsModal/OSSettingsTable/OSSettingsErrorCell/index.ts b/frontend/pages/hosts/details/OSSettingsModal/OSSettingsTable/OSSettingsErrorCell/index.ts deleted file mode 100644 index aabe7454f5..0000000000 --- a/frontend/pages/hosts/details/OSSettingsModal/OSSettingsTable/OSSettingsErrorCell/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { default } from "./OSSettingsErrorCell"; diff --git a/frontend/pages/hosts/details/OSSettingsModal/OSSettingsTable/OSSettingsNameCell/_styles.scss b/frontend/pages/hosts/details/OSSettingsModal/OSSettingsTable/OSSettingsNameCell/_styles.scss index 3ae9b3bc61..0ba0010ab7 100644 --- a/frontend/pages/hosts/details/OSSettingsModal/OSSettingsTable/OSSettingsNameCell/_styles.scss +++ b/frontend/pages/hosts/details/OSSettingsModal/OSSettingsTable/OSSettingsNameCell/_styles.scss @@ -7,8 +7,4 @@ } } -// need to do some overrides here to get the name text content to be the right size -.data-table-block .data-table tbody td .os-settings-name-cell__name-tooltip { - max-width: 142px; - min-width: auto; -} + diff --git a/frontend/pages/hosts/details/OSSettingsModal/OSSettingsTable/OSSettingsResendCell/OSSettingsResendCell.tests.tsx b/frontend/pages/hosts/details/OSSettingsModal/OSSettingsTable/OSSettingsResendCell/OSSettingsResendCell.tests.tsx new file mode 100644 index 0000000000..0444f1ba99 --- /dev/null +++ b/frontend/pages/hosts/details/OSSettingsModal/OSSettingsTable/OSSettingsResendCell/OSSettingsResendCell.tests.tsx @@ -0,0 +1,112 @@ +import React from "react"; +import { render, screen } from "@testing-library/react"; + +import { createMockHostMdmProfile } from "__mocks__/hostMock"; + +import { REC_LOCK_SYNTHETIC_PROFILE_UUID } from "pages/hosts/details/helpers"; + +import OSSettingsResendCell from "./OSSettingsResendCell"; + +const noop = () => Promise.resolve(); + +describe("OSSettingsResendCell", () => { + it("renders a resend button when canResendProfiles is true and profile is failed", () => { + render( + + ); + + expect(screen.getByRole("button", { name: "Resend" })).toBeInTheDocument(); + }); + + it("renders a resend button when canResendProfiles is true and profile is verified", () => { + render( + + ); + + expect(screen.getByRole("button", { name: "Resend" })).toBeInTheDocument(); + }); + + it("renders a rotate button when canRotateRecoveryLockPassword is true and password status is verified", () => { + render( + + ); + + expect(screen.getByRole("button", { name: "Rotate" })).toBeInTheDocument(); + }); + + it("renders a rotate button when canRotateRecoveryLockPassword is true and password status is failed", () => { + render( + + ); + + expect(screen.getByRole("button", { name: "Rotate" })).toBeInTheDocument(); + }); + + it("does not render a rotate button when canRotateRecoveryLockPassword is false", () => { + render( + + ); + + expect( + screen.queryByRole("button", { name: "Rotate" }) + ).not.toBeInTheDocument(); + }); + + it("does not render a rotate button when password status is pending", () => { + render( + + ); + + expect( + screen.queryByRole("button", { name: "Rotate" }) + ).not.toBeInTheDocument(); + }); +}); diff --git a/frontend/pages/hosts/details/OSSettingsModal/OSSettingsTable/OSSettingsResendCell/OSSettingsResendCell.tsx b/frontend/pages/hosts/details/OSSettingsModal/OSSettingsTable/OSSettingsResendCell/OSSettingsResendCell.tsx new file mode 100644 index 0000000000..370d9cf961 --- /dev/null +++ b/frontend/pages/hosts/details/OSSettingsModal/OSSettingsTable/OSSettingsResendCell/OSSettingsResendCell.tsx @@ -0,0 +1,160 @@ +import React, { useContext, useState } from "react"; +import classnames from "classnames"; +import { noop } from "lodash"; + +import { REC_LOCK_SYNTHETIC_PROFILE_UUID } from "pages/hosts/details/helpers"; + +import { NotificationContext } from "context/notification"; +import { FLEET_ANDROID_CERTIFICATE_TEMPLATE_PROFILE_ID } from "interfaces/mdm"; +import { getErrorReason } from "interfaces/errors"; + +import Button from "components/buttons/Button"; +import Icon from "components/Icon"; + +import { IHostMdmProfileWithAddedStatus } from "../OSSettingsTableConfig"; + +const baseClass = "os-settings-resend-cell"; + +interface IResendButtonProps { + isResending: boolean; + onClick: (evt: React.MouseEvent) => void; +} + +const ResendButton = ({ isResending, onClick }: IResendButtonProps) => { + const classNames = classnames(`${baseClass}__resend-button`, "resend-link", { + [`${baseClass}__resending`]: isResending, + }); + + const buttonText = isResending ? "Resending..." : "Resend"; + + return ( + + ); +}; + +interface IRotateButtonProps { + isRotating: boolean; + onClick: () => void; +} + +const RotateButton = ({ isRotating, onClick }: IRotateButtonProps) => { + const classNames = classnames(`${baseClass}__rotate-button`, "rotate-link", { + [`${baseClass}__rotating`]: isRotating, + }); + + const buttonText = isRotating ? "Rotating..." : "Rotate"; + + return ( + + ); +}; + +interface IOSSettingsResendCellProps { + canResendProfiles: boolean; + canRotateRecoveryLockPassword?: boolean; + profile: IHostMdmProfileWithAddedStatus; + resendRequest: (profileUUID: string) => Promise; + resendCertificateRequest?: (certificateTemplateId: number) => Promise; + rotateRecoveryLockPassword?: () => Promise; + onProfileResent?: () => void; +} + +const OSSettingsResendCell = ({ + canResendProfiles, + canRotateRecoveryLockPassword = false, + profile, + resendRequest, + resendCertificateRequest, + rotateRecoveryLockPassword, + onProfileResent = noop, +}: IOSSettingsResendCellProps) => { + const { renderFlash } = useContext(NotificationContext); + const [isResending, setIsResending] = useState(false); + const [isRotating, setIsRotating] = useState(false); + + const isAndroidCertificate = + profile.profile_uuid === FLEET_ANDROID_CERTIFICATE_TEMPLATE_PROFILE_ID; + + const onResendProfile = async () => { + setIsResending(true); + try { + if ( + isAndroidCertificate && + resendCertificateRequest && + profile.certificate_template_id !== undefined + ) { + await resendCertificateRequest(profile.certificate_template_id); + renderFlash( + "success", + "Successfully sent request to resend certificate." + ); + onProfileResent(); + } else if (!isAndroidCertificate) { + await resendRequest(profile.profile_uuid); + onProfileResent(); + } + } catch (e) { + renderFlash("error", "Couldn't resend. Please try again."); + } + setIsResending(false); + }; + + const onRotatePassword = async () => { + if (!rotateRecoveryLockPassword) return; + setIsRotating(true); + try { + await rotateRecoveryLockPassword(); + renderFlash( + "success", + "Successfully sent request to rotate Recovery Lock password." + ); + } catch (e) { + const msg = getErrorReason(e).includes("already in progress") + ? "Recovery lock password rotation is already in progress for this host." + : "Couldn't send request to rotate Recovery Lock password. Please try again."; + + renderFlash("error", msg); + } + setIsRotating(false); + }; + + const isFailed = profile.status === "failed"; + const isVerified = profile.status === "verified"; + const showResendButton = + canResendProfiles && + (isFailed || isVerified) && + profile.profile_uuid !== REC_LOCK_SYNTHETIC_PROFILE_UUID; + const showRotateButton = + canRotateRecoveryLockPassword && (isFailed || isVerified); + + return ( +
+ {showResendButton && ( + + )} + {showRotateButton && ( + + )} +
+ ); +}; + +export default OSSettingsResendCell; diff --git a/frontend/pages/hosts/details/OSSettingsModal/OSSettingsTable/OSSettingsErrorCell/_styles.scss b/frontend/pages/hosts/details/OSSettingsModal/OSSettingsTable/OSSettingsResendCell/_styles.scss similarity index 75% rename from frontend/pages/hosts/details/OSSettingsModal/OSSettingsTable/OSSettingsErrorCell/_styles.scss rename to frontend/pages/hosts/details/OSSettingsModal/OSSettingsTable/OSSettingsResendCell/_styles.scss index fd94f47d51..3ce88d22ce 100644 --- a/frontend/pages/hosts/details/OSSettingsModal/OSSettingsTable/OSSettingsErrorCell/_styles.scss +++ b/frontend/pages/hosts/details/OSSettingsModal/OSSettingsTable/OSSettingsResendCell/_styles.scss @@ -1,21 +1,9 @@ -.os-settings-error-cell { +.os-settings-resend-cell { display: flex; - justify-content: space-between; + justify-content: flex-end; align-items: center; gap: $pad-small; - &__failed-message { - // allows the message to shrink and not push the - // resend button outside of the table cell - min-width: 0; - - .data-table__tooltip-truncated-text--cell { - // for some reason this is need to vertically align the text and - // the resend button - display: block; - } - } - &__resend-button { width: 106px; display: flex; diff --git a/frontend/pages/hosts/details/OSSettingsModal/OSSettingsTable/OSSettingsResendCell/index.ts b/frontend/pages/hosts/details/OSSettingsModal/OSSettingsTable/OSSettingsResendCell/index.ts new file mode 100644 index 0000000000..a2f5aa28d9 --- /dev/null +++ b/frontend/pages/hosts/details/OSSettingsModal/OSSettingsTable/OSSettingsResendCell/index.ts @@ -0,0 +1 @@ +export { default } from "./OSSettingsResendCell"; diff --git a/frontend/pages/hosts/details/OSSettingsModal/OSSettingsTable/OSSettingsTableConfig.tsx b/frontend/pages/hosts/details/OSSettingsModal/OSSettingsTable/OSSettingsTableConfig.tsx index 7c034fed23..eb75358caa 100644 --- a/frontend/pages/hosts/details/OSSettingsModal/OSSettingsTable/OSSettingsTableConfig.tsx +++ b/frontend/pages/hosts/details/OSSettingsModal/OSSettingsTable/OSSettingsTableConfig.tsx @@ -17,7 +17,7 @@ import { isAppleDevice, isIPadOrIPhone } from "interfaces/platform"; import OSSettingsNameCell from "./OSSettingsNameCell"; import OSSettingStatusCell from "./OSSettingStatusCell"; -import OSSettingsErrorCell from "./OSSettingsErrorCell"; +import OSSettingsResendCell from "./OSSettingsResendCell"; import { generateLinuxDiskEncryptionSetting, @@ -85,12 +85,13 @@ const generateTableConfig = ( profileName={cellProps.row.original.name} hostPlatform={cellProps.row.original.platform} profileUUID={cellProps.row.original.profile_uuid} + profile={cellProps.row.original} /> ); }, }, { - Header: "Details", + Header: Actions, disableSortBy: true, accessor: "detail", Cell: (cellProps: ITableStringCellProps) => { @@ -109,7 +110,7 @@ const generateTableConfig = ( REC_LOCK_SYNTHETIC_PROFILE_UUID; return ( -