simplify OS modal (#43252)

<!-- Add the related story/sub-task/bug number, like Resolves #123, or
remove if NA -->
**Related issue:** Resolves #40702 

New look:
<img width="812" height="350" alt="image"
src="https://github.com/user-attachments/assets/83e82480-b756-4c51-be3f-09a72e736770"
/>


# 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


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## 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

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
Magnus Jensen 2026-04-09 16:30:15 -05:00 committed by GitHub
parent 0cc037f80d
commit 90f75f1644
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
16 changed files with 529 additions and 544 deletions

View file

@ -0,0 +1 @@
* Improved the OS settings modal layout.

View file

@ -161,7 +161,7 @@ const STATUS_CELL_VALUES: Record<DiskEncryptionStatus, IStatusCellValue> = {
"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<DiskEncryptionStatus, IStatusCellValue> = {
),
},
enforcing: {
displayName: "Enforcing (pending)",
displayName: "Enforcing",
statusName: "pendingPartial",
value: "enforcing",
tooltip:
@ -184,7 +184,7 @@ const STATUS_CELL_VALUES: Record<DiskEncryptionStatus, IStatusCellValue> = {
value: "failed",
},
removing_enforcement: {
displayName: "Removing enforcement (pending)",
displayName: "Removing enforcement",
statusName: "pendingPartial",
value: "removing_enforcement",
tooltip:

View file

@ -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);

View file

@ -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 = (
<span className="tooltip__tooltip-text">
<TooltipContent
innerContent={tooltip}
innerProps={{
isDiskEncryptionProfile: isDiskEncryptionProfile(profileName),
}}
/>
</span>
);
} else {
tipContent = (
<span className="tooltip__tooltip-text">
<TooltipContent
innerContent={tooltip}
innerProps={{ isDeviceUser, profileName }}
/>
</span>
);
}
} else if (errorTooltip) {
tipContent = (
<span className="tooltip__tooltip-text">{errorTooltip}</span>
);
}
return (
<span className={baseClass}>
<Icon name={iconName} />
{tooltip ? (
{tipContent ? (
<TooltipWrapper
tipContent={
<span className="tooltip__tooltip-text">
{status !== "action_required" ? (
<TooltipContent
innerContent={tooltip}
innerProps={{
isDiskEncryptionProfile: isDiskEncryptionProfile(
profileName
),
}}
/>
) : (
<TooltipContent
innerContent={tooltip}
innerProps={{ isDeviceUser, profileName }}
/>
)}
</span>
}
tipContent={tipContent}
position="top"
underline={false}
showArrow
tipOffset={8}
clickable
>
<span className={`${baseClass}__status-text`}>{statusText}</span>
</TooltipWrapper>

View file

@ -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(<div>{tooltip}</div>);
};
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");
});
});

View file

@ -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<HTMLButtonElement, 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 (
<Button
disabled={isResending}
onClick={onClick}
variant="inverse"
className={classNames}
size="small"
>
<Icon name="refresh" color="ui-fleet-black-75" size="small" />
{buttonText}
</Button>
);
};
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 (
<Button
disabled={isRotating}
onClick={onClick}
variant="inverse"
className={classNames}
size="small"
>
<Icon name="refresh" color="ui-fleet-black-75" size="small" />
{buttonText}
</Button>
);
};
interface IOSSettingsErrorCellProps {
canResendProfiles: boolean;
canRotateRecoveryLockPassword?: boolean;
profile: IHostMdmProfileWithAddedStatus;
resendRequest: (profileUUID: string) => Promise<void>;
resendCertificateRequest?: (certificateTemplateId: number) => Promise<void>;
rotateRecoveryLockPassword?: () => Promise<void>;
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 (
<div className={baseClass}>
<TooltipTruncatedTextCell
tooltipBreakOnWord
tooltip={tooltip}
value={value}
className={
isFailed || showResendButton || showRotateButton
? `${baseClass}__failed-message`
: undefined
}
/>
{showResendButton && (
<ResendButton isResending={isResending} onClick={onResendProfile} />
)}
{showRotateButton && (
<RotateButton isRotating={isRotating} onClick={onRotatePassword} />
)}
</div>
);
};
export default OSSettingsErrorCell;
export default generateErrorTooltip;

View file

@ -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.",
},

View file

@ -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<void>(() => undefined);
describe("OSSettingsErrorCell", () => {
it("should render a formatted message for windows profiles", () => {
render(
<OSSettingsErrorCell
canResendProfiles
canRotateRecoveryLockPassword={false}
profile={createMockHostMdmProfile({
platform: "windows",
status: "failed",
detail:
"starting encryption: encrypt(C:): error code returned during encryption: -2147024809, error 2: This is another error",
})}
resendRequest={noop}
rotateRecoveryLockPassword={noop}
/>
);
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(
<OSSettingsErrorCell
canResendProfiles
canRotateRecoveryLockPassword={false}
profile={createMockHostMdmProfile({})}
resendRequest={noop}
rotateRecoveryLockPassword={noop}
/>
);
expect(screen.getAllByText("---")[0]).toBeInTheDocument();
});
it("renders a resend button when canResendProfiles is true and profile is failed", () => {
render(
<OSSettingsErrorCell
canResendProfiles
canRotateRecoveryLockPassword={false}
profile={createMockHostMdmProfile({ status: "failed" })}
resendRequest={noop}
rotateRecoveryLockPassword={noop}
/>
);
expect(screen.getByRole("button", { name: "Resend" })).toBeInTheDocument();
});
it("renders a resend button when canResendProfiles is true and profile is verified", () => {
render(
<OSSettingsErrorCell
canResendProfiles
canRotateRecoveryLockPassword={false}
profile={createMockHostMdmProfile({ status: "verified" })}
resendRequest={noop}
rotateRecoveryLockPassword={noop}
/>
);
expect(screen.getByRole("button", { name: "Resend" })).toBeInTheDocument();
});
it("renders a tooltip link when the error message inlcudes info about IDP emails", () => {
render(
<OSSettingsErrorCell
canResendProfiles
canRotateRecoveryLockPassword={false}
profile={createMockHostMdmProfile({
status: "failed",
detail: "There is no IdP email for this host.",
})}
resendRequest={noop}
rotateRecoveryLockPassword={noop}
/>
);
// 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(
<OSSettingsErrorCell
canResendProfiles
canRotateRecoveryLockPassword={false}
profile={createMockHostMdmProfile({
status: "failed",
detail: `Fleet couldn't populate $FLEET_VAR_CUSTOM_SCEP_URL_SCEP_WIFI because SCEP_WIFI certificate authority doesn't exist.`,
})}
resendRequest={noop}
rotateRecoveryLockPassword={noop}
/>
);
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(
<OSSettingsErrorCell
canResendProfiles
canRotateRecoveryLockPassword={false}
profile={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`,
})}
resendRequest={noop}
rotateRecoveryLockPassword={noop}
/>
);
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(
<OSSettingsErrorCell
canResendProfiles
canRotateRecoveryLockPassword={false}
profile={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.`,
})}
resendRequest={noop}
rotateRecoveryLockPassword={noop}
/>
);
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(
<OSSettingsErrorCell
canResendProfiles={false}
canRotateRecoveryLockPassword
profile={createMockHostMdmProfile({
profile_uuid: REC_LOCK_SYNTHETIC_PROFILE_UUID,
status: "verified",
})}
resendRequest={noop}
rotateRecoveryLockPassword={noop}
/>
);
expect(screen.getByRole("button", { name: "Rotate" })).toBeInTheDocument();
});
it("renders a rotate button when canRotateRecoveryLockPassword is true and password status is failed", () => {
render(
<OSSettingsErrorCell
canResendProfiles={false}
canRotateRecoveryLockPassword
profile={createMockHostMdmProfile({
profile_uuid: REC_LOCK_SYNTHETIC_PROFILE_UUID,
status: "failed",
})}
resendRequest={noop}
rotateRecoveryLockPassword={noop}
/>
);
expect(screen.getByRole("button", { name: "Rotate" })).toBeInTheDocument();
});
it("does not render a rotate button when canRotateRecoveryLockPassword is false", () => {
render(
<OSSettingsErrorCell
canResendProfiles={false}
canRotateRecoveryLockPassword={false}
profile={createMockHostMdmProfile({
profile_uuid: REC_LOCK_SYNTHETIC_PROFILE_UUID,
status: "verified",
})}
resendRequest={noop}
rotateRecoveryLockPassword={noop}
/>
);
expect(
screen.queryByRole("button", { name: "Rotate" })
).not.toBeInTheDocument();
});
it("does not render a rotate button when password status is pending", () => {
render(
<OSSettingsErrorCell
canResendProfiles={false}
canRotateRecoveryLockPassword
profile={createMockHostMdmProfile({
profile_uuid: REC_LOCK_SYNTHETIC_PROFILE_UUID,
status: "pending",
})}
resendRequest={noop}
rotateRecoveryLockPassword={noop}
/>
);
expect(
screen.queryByRole("button", { name: "Rotate" })
).not.toBeInTheDocument();
});
it("renders a formatted tooltip when the error message matches digicert token patern", () => {
render(
<OSSettingsErrorCell
canResendProfiles
canRotateRecoveryLockPassword={false}
profile={createMockHostMdmProfile({
status: "failed",
detail: `Couldn't get certificate from DigiCert. The API token configured in DIGICERT_TEST certificate authority is invalid.`,
})}
resendRequest={noop}
rotateRecoveryLockPassword={noop}
/>
);
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();
});
});

View file

@ -1 +0,0 @@
export { default } from "./OSSettingsErrorCell";

View file

@ -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;
}

View file

@ -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(
<OSSettingsResendCell
canResendProfiles
canRotateRecoveryLockPassword={false}
profile={createMockHostMdmProfile({ status: "failed" })}
resendRequest={noop}
rotateRecoveryLockPassword={noop}
/>
);
expect(screen.getByRole("button", { name: "Resend" })).toBeInTheDocument();
});
it("renders a resend button when canResendProfiles is true and profile is verified", () => {
render(
<OSSettingsResendCell
canResendProfiles
canRotateRecoveryLockPassword={false}
profile={createMockHostMdmProfile({ status: "verified" })}
resendRequest={noop}
rotateRecoveryLockPassword={noop}
/>
);
expect(screen.getByRole("button", { name: "Resend" })).toBeInTheDocument();
});
it("renders a rotate button when canRotateRecoveryLockPassword is true and password status is verified", () => {
render(
<OSSettingsResendCell
canResendProfiles={false}
canRotateRecoveryLockPassword
profile={createMockHostMdmProfile({
profile_uuid: REC_LOCK_SYNTHETIC_PROFILE_UUID,
status: "verified",
})}
resendRequest={noop}
rotateRecoveryLockPassword={noop}
/>
);
expect(screen.getByRole("button", { name: "Rotate" })).toBeInTheDocument();
});
it("renders a rotate button when canRotateRecoveryLockPassword is true and password status is failed", () => {
render(
<OSSettingsResendCell
canResendProfiles={false}
canRotateRecoveryLockPassword
profile={createMockHostMdmProfile({
profile_uuid: REC_LOCK_SYNTHETIC_PROFILE_UUID,
status: "failed",
})}
resendRequest={noop}
rotateRecoveryLockPassword={noop}
/>
);
expect(screen.getByRole("button", { name: "Rotate" })).toBeInTheDocument();
});
it("does not render a rotate button when canRotateRecoveryLockPassword is false", () => {
render(
<OSSettingsResendCell
canResendProfiles={false}
canRotateRecoveryLockPassword={false}
profile={createMockHostMdmProfile({
profile_uuid: REC_LOCK_SYNTHETIC_PROFILE_UUID,
status: "verified",
})}
resendRequest={noop}
rotateRecoveryLockPassword={noop}
/>
);
expect(
screen.queryByRole("button", { name: "Rotate" })
).not.toBeInTheDocument();
});
it("does not render a rotate button when password status is pending", () => {
render(
<OSSettingsResendCell
canResendProfiles={false}
canRotateRecoveryLockPassword
profile={createMockHostMdmProfile({
profile_uuid: REC_LOCK_SYNTHETIC_PROFILE_UUID,
status: "pending",
})}
resendRequest={noop}
rotateRecoveryLockPassword={noop}
/>
);
expect(
screen.queryByRole("button", { name: "Rotate" })
).not.toBeInTheDocument();
});
});

View file

@ -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<HTMLButtonElement, MouseEvent>) => void;
}
const ResendButton = ({ isResending, onClick }: IResendButtonProps) => {
const classNames = classnames(`${baseClass}__resend-button`, "resend-link", {
[`${baseClass}__resending`]: isResending,
});
const buttonText = isResending ? "Resending..." : "Resend";
return (
<Button
disabled={isResending}
onClick={onClick}
variant="inverse"
className={classNames}
size="small"
>
<Icon name="refresh" color="ui-fleet-black-75" size="small" />
{buttonText}
</Button>
);
};
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 (
<Button
disabled={isRotating}
onClick={onClick}
variant="inverse"
className={classNames}
size="small"
>
<Icon name="refresh" color="ui-fleet-black-75" size="small" />
{buttonText}
</Button>
);
};
interface IOSSettingsResendCellProps {
canResendProfiles: boolean;
canRotateRecoveryLockPassword?: boolean;
profile: IHostMdmProfileWithAddedStatus;
resendRequest: (profileUUID: string) => Promise<void>;
resendCertificateRequest?: (certificateTemplateId: number) => Promise<void>;
rotateRecoveryLockPassword?: () => Promise<void>;
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 (
<div className={baseClass}>
{showResendButton && (
<ResendButton isResending={isResending} onClick={onResendProfile} />
)}
{showRotateButton && (
<RotateButton isRotating={isRotating} onClick={onRotatePassword} />
)}
</div>
);
};
export default OSSettingsResendCell;

View file

@ -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;

View file

@ -0,0 +1 @@
export { default } from "./OSSettingsResendCell";

View file

@ -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: <span className="sr-only">Actions</span>,
disableSortBy: true,
accessor: "detail",
Cell: (cellProps: ITableStringCellProps) => {
@ -109,7 +110,7 @@ const generateTableConfig = (
REC_LOCK_SYNTHETIC_PROFILE_UUID;
return (
<OSSettingsErrorCell
<OSSettingsResendCell
canResendProfiles={
canResendProfiles &&
(isWindowsProfile ||

View file

@ -6,22 +6,21 @@
&__wrapper {
width: initial;
}
tbody td.detail__cell {
// these styles are a little trick that allows the cell to
// shrink while still follwing the auto width behavior of the table.
// This is needed to show the error text and button in the
// error cell correctly without overflowing outside of the table.
max-width: 0;
width: 100%;
&__table {
table-layout: fixed;
}
tbody td {
.os-settings-name-cell {
max-width: 166px;
}
.os-settings-error-cell {
min-width: 0;
width: 100%;
}
th.detail__header {
border-left: none;
width: 120px;
}
th.status__header {
width: 200px;
}
// Name column absorbs remaining space and truncates with ellipsis.
tbody td.name__cell {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
@ -33,25 +32,25 @@
// row hover effect for resend button. we dont want this behavior when the
// button is resending
.resend-link:not(.os-settings-error-cell__resending) {
.resend-link:not(.os-settings-resend-cell__resending) {
opacity: 0;
transition: opacity 250ms;
}
tr:hover {
.resend-link:not(.os-settings-error-cell__resending) {
.resend-link:not(.os-settings-resend-cell__resending) {
opacity: 1;
}
}
// row hover effect for rotate button, matching resend pattern
.rotate-link:not(.os-settings-error-cell__rotating) {
.rotate-link:not(.os-settings-resend-cell__rotating) {
opacity: 0;
transition: opacity 250ms;
}
tr:hover {
.rotate-link:not(.os-settings-error-cell__rotating) {
.rotate-link:not(.os-settings-resend-cell__rotating) {
opacity: 1;
}
}