UI: Add ability to manually rotate Mac Recovery Lock passwords (#41420)
<!-- Add the related story/sub-task/bug number, like Resolves #123, or remove if NA --> **Related issue:** Resolves #39781 - [x] Changes file added for user-visible changes in `changes/` - [x] Added/updated automated tests - [ ] QA'd all new/changed functionality manually - TODO with wip backend work - [x] Verified that any relevant UI is disabled when GitOps mode is enabled
This commit is contained in:
@@ -0,0 +1 @@
|
||||
- Added ability to set and manually rotate Mac recovery lock passwords.
|
||||
@@ -48,6 +48,7 @@ export enum ActivityType {
|
||||
ReadHostDiskEncryptionKey = "read_host_disk_encryption_key",
|
||||
ViewedHostRecoveryLockPassword = "viewed_host_recovery_lock_password",
|
||||
SetHostRecoveryLockPassword = "set_host_recovery_lock_password",
|
||||
RotatedHostRecoveryLockPassword = "rotated_host_recovery_lock_password",
|
||||
EnabledRecoveryLockPasswords = "enabled_recovery_lock_passwords",
|
||||
DisabledRecoveryLockPasswords = "disabled_recovery_lock_passwords",
|
||||
/** Note: BE not renamed (yet) from macOS even though activity is also used for iOS and iPadOS */
|
||||
@@ -171,6 +172,7 @@ export type IHostPastActivityType =
|
||||
| ActivityType.ReadHostDiskEncryptionKey
|
||||
| ActivityType.ViewedHostRecoveryLockPassword
|
||||
| ActivityType.SetHostRecoveryLockPassword
|
||||
| ActivityType.RotatedHostRecoveryLockPassword
|
||||
| ActivityType.UnlockedHost
|
||||
| ActivityType.InstalledSoftware
|
||||
| ActivityType.UninstalledSoftware
|
||||
@@ -398,6 +400,7 @@ export const ACTIVITY_TYPE_TO_FILTER_LABEL: Record<ActivityType, string> = {
|
||||
read_host_disk_encryption_key: "Viewed disk encryption key",
|
||||
viewed_host_recovery_lock_password: "Viewed Recovery Lock password",
|
||||
set_host_recovery_lock_password: "Set Recovery Lock password",
|
||||
rotated_host_recovery_lock_password: "Rotated Recovery Lock password",
|
||||
enabled_recovery_lock_passwords: "Turned on Recovery Lock passwords",
|
||||
disabled_recovery_lock_passwords: "Turned off Recovery Lock passwords",
|
||||
resent_configuration_profile: "Resent configuration profile",
|
||||
|
||||
+15
@@ -742,6 +742,21 @@ describe("Activity Feed", () => {
|
||||
expect(screen.getByText("Anna's MacBook Pro")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders a 'rotated_host_recovery_lock_password' type activity", () => {
|
||||
const activity = createMockActivity({
|
||||
type: ActivityType.RotatedHostRecoveryLockPassword,
|
||||
details: { host_display_name: "Alex's Macbook Air" },
|
||||
});
|
||||
render(<GlobalActivityItem activity={activity} isPremiumTier />);
|
||||
|
||||
expect(
|
||||
screen.getByText("rotated the Recovery Lock password for", {
|
||||
exact: false,
|
||||
})
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText("Alex's Macbook Air")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders an 'enabled_recovery_lock_passwords' type activity for a team", () => {
|
||||
const activity = createMockActivity({
|
||||
type: ActivityType.EnabledRecoveryLockPasswords,
|
||||
|
||||
+12
@@ -503,6 +503,15 @@ const TAGGED_TEMPLATES = {
|
||||
</>
|
||||
);
|
||||
},
|
||||
rotatedHostRecoveryLockPassword: (activity: IActivity) => {
|
||||
return (
|
||||
<>
|
||||
{" "}
|
||||
rotated the Recovery Lock password for{" "}
|
||||
<b>{activity.details?.host_display_name}</b>.
|
||||
</>
|
||||
);
|
||||
},
|
||||
createdAppleOSProfile: (activity: IActivity, isPremiumTier: boolean) => {
|
||||
const profileName = activity.details?.profile_name;
|
||||
return (
|
||||
@@ -1858,6 +1867,9 @@ const getDetail = (activity: IActivity, isPremiumTier: boolean) => {
|
||||
case ActivityType.SetHostRecoveryLockPassword: {
|
||||
return TAGGED_TEMPLATES.setHostRecoveryLockPassword(activity);
|
||||
}
|
||||
case ActivityType.RotatedHostRecoveryLockPassword: {
|
||||
return TAGGED_TEMPLATES.rotatedHostRecoveryLockPassword(activity);
|
||||
}
|
||||
case ActivityType.CreatedAppleOSProfile: {
|
||||
return TAGGED_TEMPLATES.createdAppleOSProfile(activity, isPremiumTier);
|
||||
}
|
||||
|
||||
@@ -54,6 +54,7 @@ const Passwords = ({ currentTeamId, onMutation }: IOSSettingsCommonProps) => {
|
||||
const [enableRecoveryLockPassword, setEnableRecoveryLockPassword] = useState<
|
||||
boolean | undefined
|
||||
>(undefined);
|
||||
const [updating, setUpdating] = useState(false);
|
||||
|
||||
const {
|
||||
isLoading: isLoadingTeam,
|
||||
@@ -93,6 +94,7 @@ const Passwords = ({ currentTeamId, onMutation }: IOSSettingsCommonProps) => {
|
||||
!isFormReady || isTeamError || enableRecoveryLockPassword === undefined;
|
||||
|
||||
const onUpdateRecoveryLockPassword = async () => {
|
||||
setUpdating(true);
|
||||
try {
|
||||
if (currentTeamId === API_NO_TEAM_ID) {
|
||||
await configAPI.update({
|
||||
@@ -116,6 +118,8 @@ const Passwords = ({ currentTeamId, onMutation }: IOSSettingsCommonProps) => {
|
||||
getErrorReason(e) ??
|
||||
"Couldn't update Recovery Lock password enforcement. Please try again.";
|
||||
renderFlash("error", errorMsg);
|
||||
} finally {
|
||||
setUpdating(false);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -150,6 +154,7 @@ const Passwords = ({ currentTeamId, onMutation }: IOSSettingsCommonProps) => {
|
||||
renderChildren={(gitopsDisabled) => (
|
||||
<Button
|
||||
disabled={isFormDisabled || gitopsDisabled}
|
||||
isLoading={updating}
|
||||
className={`${baseClass}__save-button`}
|
||||
onClick={onUpdateRecoveryLockPassword}
|
||||
>
|
||||
|
||||
@@ -773,6 +773,13 @@ const HostDetailsPage = ({
|
||||
[host?.id]
|
||||
);
|
||||
|
||||
const rotateRecoveryLockPassword = useCallback((): Promise<void> => {
|
||||
if (!host?.id) {
|
||||
return new Promise(() => undefined);
|
||||
}
|
||||
return hostAPI.rotateRecoveryLockPassword(host.id);
|
||||
}, [host?.id]);
|
||||
|
||||
const onChangeActivityTab = (tabIndex: number) => {
|
||||
setActiveActivityTab(tabIndex === 0 ? "past" : "upcoming");
|
||||
setActivityPage(0);
|
||||
@@ -1565,10 +1572,17 @@ const HostDetailsPage = ({
|
||||
{showOSSettingsModal && (
|
||||
<OSSettingsModal
|
||||
canResendProfiles={canResendProfiles}
|
||||
canRotateRecoveryLockPassword={
|
||||
isGlobalAdmin ||
|
||||
isGlobalMaintainer ||
|
||||
isHostTeamAdmin ||
|
||||
isHostTeamMaintainer
|
||||
}
|
||||
platform={host.platform}
|
||||
hostMDMData={host.mdm}
|
||||
onClose={toggleOSSettingsModal}
|
||||
resendRequest={resendProfile}
|
||||
rotateRecoveryLockPassword={rotateRecoveryLockPassword}
|
||||
onProfileResent={refetchHostDetails}
|
||||
/>
|
||||
)}
|
||||
@@ -1591,6 +1605,12 @@ const HostDetailsPage = ({
|
||||
{showRecoveryLockPasswordModal && host && (
|
||||
<RecoveryLockPasswordModal
|
||||
hostId={host.id}
|
||||
canRotatePassword={
|
||||
isGlobalAdmin ||
|
||||
isGlobalMaintainer ||
|
||||
isHostTeamAdmin ||
|
||||
isHostTeamMaintainer
|
||||
}
|
||||
onCancel={() => setShowRecoveryLockPasswordModal(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
+60
-1
@@ -1,9 +1,10 @@
|
||||
import React from "react";
|
||||
import React, { useContext, useState } from "react";
|
||||
import { useQuery } from "react-query";
|
||||
|
||||
import { getErrorReason } from "interfaces/errors";
|
||||
import { IHostRecoveryLockPasswordResponse } from "interfaces/host";
|
||||
import hostAPI from "services/entities/hosts";
|
||||
import { NotificationContext } from "context/notification";
|
||||
|
||||
import Modal from "components/Modal";
|
||||
import Button from "components/buttons/Button";
|
||||
@@ -11,6 +12,8 @@ import InputFieldHiddenContent from "components/forms/fields/InputFieldHiddenCon
|
||||
import DataError from "components/DataError";
|
||||
import Spinner from "components/Spinner";
|
||||
import CustomLink from "components/CustomLink";
|
||||
import Icon from "components/Icon";
|
||||
import TooltipWrapper from "components/TooltipWrapper";
|
||||
import {
|
||||
DEFAULT_USE_QUERY_OPTIONS,
|
||||
LEARN_MORE_ABOUT_BASE_LINK,
|
||||
@@ -20,13 +23,18 @@ const baseClass = "recovery-lock-password-modal";
|
||||
|
||||
interface IRecoveryLockPasswordModalProps {
|
||||
hostId: number;
|
||||
canRotatePassword: boolean;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
const RecoveryLockPasswordModal = ({
|
||||
hostId,
|
||||
canRotatePassword,
|
||||
onCancel,
|
||||
}: IRecoveryLockPasswordModalProps) => {
|
||||
const { renderFlash } = useContext(NotificationContext);
|
||||
const [isRotating, setIsRotating] = useState(false);
|
||||
|
||||
const {
|
||||
data: recoveryLockPassword,
|
||||
error: recoveryLockPasswordError,
|
||||
@@ -42,6 +50,56 @@ const RecoveryLockPasswordModal = ({
|
||||
}
|
||||
);
|
||||
|
||||
const onRotatePassword = async () => {
|
||||
setIsRotating(true);
|
||||
try {
|
||||
await hostAPI.rotateRecoveryLockPassword(hostId);
|
||||
renderFlash(
|
||||
"success",
|
||||
"Successfully sent request to rotate Recovery Lock password."
|
||||
);
|
||||
onCancel();
|
||||
} catch (e) {
|
||||
renderFlash(
|
||||
"error",
|
||||
"Couldn't send request to rotate Recovery Lock password. Please try again."
|
||||
);
|
||||
}
|
||||
setIsRotating(false);
|
||||
};
|
||||
|
||||
const renderRotateButton = () => {
|
||||
if (canRotatePassword) {
|
||||
return (
|
||||
<Button
|
||||
variant="text-link"
|
||||
onClick={onRotatePassword}
|
||||
disabled={isRotating}
|
||||
className={`${baseClass}__rotate-button`}
|
||||
>
|
||||
<Icon name="refresh" />
|
||||
{isRotating ? "Rotating..." : "Rotate password"}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<span className={`${baseClass}__rotate-button--disabled`}>
|
||||
<TooltipWrapper
|
||||
underline={false}
|
||||
showArrow
|
||||
position="bottom"
|
||||
tipContent="Only users with the maintainer role and above can rotate password."
|
||||
>
|
||||
<span className={`${baseClass}__rotate-button-content`}>
|
||||
<Icon name="refresh" />
|
||||
Rotate password
|
||||
</span>
|
||||
</TooltipWrapper>
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title="Recovery Lock password"
|
||||
@@ -69,6 +127,7 @@ const RecoveryLockPasswordModal = ({
|
||||
</p>
|
||||
<div className="modal-cta-wrap">
|
||||
<Button onClick={onCancel}>Done</Button>
|
||||
{renderRotateButton()}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
|
||||
+30
@@ -2,4 +2,34 @@
|
||||
.input-field {
|
||||
font-family: SourceCodePro, $monospace;
|
||||
}
|
||||
|
||||
.modal-cta-wrap {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
&__rotate-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: $pad-xsmall;
|
||||
|
||||
.icon {
|
||||
margin-right: 0;
|
||||
}
|
||||
}
|
||||
|
||||
&__rotate-button--disabled {
|
||||
color: $ui-fleet-black-50;
|
||||
cursor: default;
|
||||
|
||||
.icon {
|
||||
color: $ui-fleet-black-50;
|
||||
}
|
||||
}
|
||||
|
||||
&__rotate-button-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: $pad-xsmall;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,10 +11,13 @@ interface IOSSettingsModalProps {
|
||||
hostMDMData: IHostMdmData;
|
||||
/** controls showing the action for a user to resend a profile. Defaults to `false` */
|
||||
canResendProfiles?: boolean;
|
||||
/** controls showing the rotate action for the recovery lock password row. Defaults to `false` */
|
||||
canRotateRecoveryLockPassword?: boolean;
|
||||
/** This request method will be called when a user clicks on the resend button.
|
||||
* This behaviour is dynamic based on the page this modal is rendered on
|
||||
* so we allow the request function to be passed in */
|
||||
resendRequest: (profileUUID: string) => Promise<void>;
|
||||
rotateRecoveryLockPassword?: () => Promise<void>;
|
||||
onClose: () => void;
|
||||
/** handler that fires when a profile was reset. Requires `canResendProfiles` prop
|
||||
* to be `true`, otherwise has no effect.
|
||||
@@ -28,8 +31,10 @@ const OSSettingsModal = ({
|
||||
platform,
|
||||
hostMDMData,
|
||||
canResendProfiles = false,
|
||||
canRotateRecoveryLockPassword = false,
|
||||
onClose,
|
||||
resendRequest,
|
||||
rotateRecoveryLockPassword,
|
||||
onProfileResent,
|
||||
}: IOSSettingsModalProps) => {
|
||||
// the caller should ensure that hostMDMData is not undefined and that platform is supported otherwise we will allow an empty modal will be rendered.
|
||||
@@ -49,8 +54,10 @@ const OSSettingsModal = ({
|
||||
>
|
||||
<OSSettingsTable
|
||||
canResendProfiles={canResendProfiles}
|
||||
canRotateRecoveryLockPassword={canRotateRecoveryLockPassword}
|
||||
tableData={memoizedTableData ?? []}
|
||||
resendRequest={resendRequest}
|
||||
rotateRecoveryLockPassword={rotateRecoveryLockPassword}
|
||||
onProfileResent={onProfileResent}
|
||||
/>
|
||||
<div className="modal-cta-wrap">
|
||||
|
||||
+104
-10
@@ -3,20 +3,26 @@ 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={() => new Promise(() => undefined)}
|
||||
resendRequest={noop}
|
||||
rotateRecoveryLockPassword={noop}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -51,8 +57,10 @@ describe("OSSettingsErrorCell", () => {
|
||||
render(
|
||||
<OSSettingsErrorCell
|
||||
canResendProfiles
|
||||
canRotateRecoveryLockPassword={false}
|
||||
profile={createMockHostMdmProfile({})}
|
||||
resendRequest={() => new Promise(() => undefined)}
|
||||
resendRequest={noop}
|
||||
rotateRecoveryLockPassword={noop}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -63,8 +71,10 @@ describe("OSSettingsErrorCell", () => {
|
||||
render(
|
||||
<OSSettingsErrorCell
|
||||
canResendProfiles
|
||||
canRotateRecoveryLockPassword={false}
|
||||
profile={createMockHostMdmProfile({ status: "failed" })}
|
||||
resendRequest={() => new Promise(() => undefined)}
|
||||
resendRequest={noop}
|
||||
rotateRecoveryLockPassword={noop}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -75,8 +85,10 @@ describe("OSSettingsErrorCell", () => {
|
||||
render(
|
||||
<OSSettingsErrorCell
|
||||
canResendProfiles
|
||||
canRotateRecoveryLockPassword={false}
|
||||
profile={createMockHostMdmProfile({ status: "verified" })}
|
||||
resendRequest={() => new Promise(() => undefined)}
|
||||
resendRequest={noop}
|
||||
rotateRecoveryLockPassword={noop}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -87,11 +99,13 @@ describe("OSSettingsErrorCell", () => {
|
||||
render(
|
||||
<OSSettingsErrorCell
|
||||
canResendProfiles
|
||||
canRotateRecoveryLockPassword={false}
|
||||
profile={createMockHostMdmProfile({
|
||||
status: "failed",
|
||||
detail: "There is no IdP email for this host.",
|
||||
})}
|
||||
resendRequest={() => new Promise(() => undefined)}
|
||||
resendRequest={noop}
|
||||
rotateRecoveryLockPassword={noop}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -105,11 +119,13 @@ describe("OSSettingsErrorCell", () => {
|
||||
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={() => new Promise(() => undefined)}
|
||||
resendRequest={noop}
|
||||
rotateRecoveryLockPassword={noop}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -125,11 +141,13 @@ describe("OSSettingsErrorCell", () => {
|
||||
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={() => new Promise(() => undefined)}
|
||||
resendRequest={noop}
|
||||
rotateRecoveryLockPassword={noop}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -145,12 +163,14 @@ describe("OSSettingsErrorCell", () => {
|
||||
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={() => new Promise(() => undefined)}
|
||||
resendRequest={noop}
|
||||
rotateRecoveryLockPassword={noop}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -162,15 +182,89 @@ describe("OSSettingsErrorCell", () => {
|
||||
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.`,
|
||||
detail: `Couldn't get certificate from DigiCert. The API token configured in DIGICERT_TEST certificate authority is invalid.`,
|
||||
})}
|
||||
resendRequest={() => new Promise(() => undefined)}
|
||||
resendRequest={noop}
|
||||
rotateRecoveryLockPassword={noop}
|
||||
/>
|
||||
);
|
||||
|
||||
|
||||
+55
-1
@@ -232,21 +232,52 @@ const generateErrorTooltip = (
|
||||
return cellValue;
|
||||
};
|
||||
|
||||
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>;
|
||||
rotateRecoveryLockPassword?: () => Promise<void>;
|
||||
onProfileResent?: () => void;
|
||||
}
|
||||
|
||||
const OSSettingsErrorCell = ({
|
||||
canResendProfiles,
|
||||
canRotateRecoveryLockPassword = false,
|
||||
profile,
|
||||
resendRequest,
|
||||
rotateRecoveryLockPassword,
|
||||
onProfileResent = noop,
|
||||
}: IOSSettingsErrorCellProps) => {
|
||||
const { renderFlash } = useContext(NotificationContext);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isRotating, setIsRotating] = useState(false);
|
||||
|
||||
const onResendProfile = async () => {
|
||||
setIsLoading(true);
|
||||
@@ -259,9 +290,29 @@ const OSSettingsErrorCell = ({
|
||||
setIsLoading(false);
|
||||
};
|
||||
|
||||
const onRotatePassword = async () => {
|
||||
if (!rotateRecoveryLockPassword) return;
|
||||
setIsRotating(true);
|
||||
try {
|
||||
await rotateRecoveryLockPassword();
|
||||
renderFlash(
|
||||
"success",
|
||||
"Successfully sent request to rotate Recovery Lock password."
|
||||
);
|
||||
} catch (e) {
|
||||
renderFlash(
|
||||
"error",
|
||||
"Couldn't send request to rotate Recovery Lock password. Please try again."
|
||||
);
|
||||
}
|
||||
setIsRotating(false);
|
||||
};
|
||||
|
||||
const isFailed = profile.status === "failed";
|
||||
const isVerified = profile.status === "verified";
|
||||
const showRefetchButton = canResendProfiles && (isFailed || isVerified);
|
||||
const showRotateButton =
|
||||
canRotateRecoveryLockPassword && (isFailed || isVerified);
|
||||
const value = (isFailed && profile.detail) || DEFAULT_EMPTY_CELL_VALUE;
|
||||
|
||||
const tooltip = generateErrorTooltip(value, profile);
|
||||
@@ -275,7 +326,7 @@ const OSSettingsErrorCell = ({
|
||||
// we dont want the default "w250" class so we pass in empty string
|
||||
classes=""
|
||||
className={
|
||||
isFailed || showRefetchButton
|
||||
isFailed || showRefetchButton || showRotateButton
|
||||
? `${baseClass}__failed-message`
|
||||
: undefined
|
||||
}
|
||||
@@ -283,6 +334,9 @@ const OSSettingsErrorCell = ({
|
||||
{showRefetchButton && (
|
||||
<RefetchButton isFetching={isLoading} onClick={onResendProfile} />
|
||||
)}
|
||||
{showRotateButton && (
|
||||
<RotateButton isRotating={isRotating} onClick={onRotatePassword} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
+28
@@ -55,4 +55,32 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&__rotate-button {
|
||||
width: 106px;
|
||||
display: flex;
|
||||
|
||||
.children-wrapper {
|
||||
display: flex;
|
||||
|
||||
.icon {
|
||||
vertical-align: middle;
|
||||
margin-right: 8px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&__rotating {
|
||||
color: $core-vibrant-blue;
|
||||
cursor: default;
|
||||
font-size: $x-small;
|
||||
height: 38px;
|
||||
opacity: 50%;
|
||||
filter: saturate(100%);
|
||||
|
||||
.icon {
|
||||
vertical-align: middle;
|
||||
animation: spin 2s linear infinite;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,22 +9,38 @@ const baseClass = "os-settings-table";
|
||||
|
||||
interface IOSSettingsTableProps {
|
||||
canResendProfiles: boolean;
|
||||
canRotateRecoveryLockPassword?: boolean;
|
||||
tableData: IHostMdmProfileWithAddedStatus[];
|
||||
resendRequest: (profileUUID: string) => Promise<void>;
|
||||
rotateRecoveryLockPassword?: () => Promise<void>;
|
||||
onProfileResent: () => void;
|
||||
}
|
||||
|
||||
const OSSettingsTable = ({
|
||||
canResendProfiles,
|
||||
canRotateRecoveryLockPassword = false,
|
||||
tableData,
|
||||
resendRequest,
|
||||
rotateRecoveryLockPassword,
|
||||
onProfileResent,
|
||||
}: IOSSettingsTableProps) => {
|
||||
// useMemo prevents tooltip flashing during host data refetch
|
||||
const tableConfig = useMemo(
|
||||
() =>
|
||||
generateTableHeaders(canResendProfiles, resendRequest, onProfileResent),
|
||||
[canResendProfiles, resendRequest, onProfileResent]
|
||||
generateTableHeaders(
|
||||
canResendProfiles,
|
||||
resendRequest,
|
||||
onProfileResent,
|
||||
canRotateRecoveryLockPassword,
|
||||
rotateRecoveryLockPassword
|
||||
),
|
||||
[
|
||||
canResendProfiles,
|
||||
resendRequest,
|
||||
onProfileResent,
|
||||
canRotateRecoveryLockPassword,
|
||||
rotateRecoveryLockPassword,
|
||||
]
|
||||
);
|
||||
|
||||
return (
|
||||
|
||||
+12
-1
@@ -22,6 +22,7 @@ import {
|
||||
generateLinuxDiskEncryptionSetting,
|
||||
generateRecoveryLockPasswordSetting,
|
||||
generateWinDiskEncryptionSetting,
|
||||
REC_LOCK_SYNTHETIC_PROFILE_UUID,
|
||||
} from "../../helpers";
|
||||
|
||||
export interface IHostMdmProfileWithAddedStatus
|
||||
@@ -45,7 +46,9 @@ export type OsSettingsTableStatusValue =
|
||||
const generateTableConfig = (
|
||||
canResendProfiles: boolean,
|
||||
resendRequest: (profileUUID: string) => Promise<void>,
|
||||
onProfileResent: () => void
|
||||
onProfileResent: () => void,
|
||||
canRotateRecoveryLockPassword?: boolean,
|
||||
rotateRecoveryLockPassword?: () => Promise<void>
|
||||
): ITableColumnConfig[] => {
|
||||
return [
|
||||
{
|
||||
@@ -95,14 +98,22 @@ const generateTableConfig = (
|
||||
isAppleDevice(platform) && !isDDMProfile(cellProps.row.original);
|
||||
const isWindowsProfile = platform === "windows";
|
||||
|
||||
const isRecoveryLockRow =
|
||||
cellProps.row.original.profile_uuid ===
|
||||
REC_LOCK_SYNTHETIC_PROFILE_UUID;
|
||||
|
||||
return (
|
||||
<OSSettingsErrorCell
|
||||
canResendProfiles={
|
||||
canResendProfiles &&
|
||||
(isWindowsProfile || isAppleMobileConfigProfile)
|
||||
}
|
||||
canRotateRecoveryLockPassword={
|
||||
isRecoveryLockRow && canRotateRecoveryLockPassword
|
||||
}
|
||||
profile={cellProps.row.original}
|
||||
resendRequest={resendRequest}
|
||||
rotateRecoveryLockPassword={rotateRecoveryLockPassword}
|
||||
onProfileResent={onProfileResent}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -7,9 +7,9 @@
|
||||
width: initial;
|
||||
}
|
||||
tbody td.detail__cell {
|
||||
// these styles are a little trick that allows the cell to
|
||||
// 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
|
||||
// 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%;
|
||||
@@ -43,4 +43,16 @@
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
// row hover effect for rotate button, matching resend pattern
|
||||
.rotate-link:not(.os-settings-error-cell__rotating) {
|
||||
opacity: 0;
|
||||
transition: opacity 250ms;
|
||||
}
|
||||
|
||||
tr:hover {
|
||||
.rotate-link:not(.os-settings-error-cell__rotating) {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ import UnlockedHostActivityItem from "./ActivityItems/UnlockedHostActivityItem";
|
||||
import ReadHostDiskEncryptionKeyActivityItem from "./ActivityItems/ReadHostDiskEncryptionKey";
|
||||
import ViewedHostRecoveryLockPasswordActivityItem from "./ActivityItems/ViewedHostRecoveryLockPassword";
|
||||
import SetHostRecoveryLockPasswordActivityItem from "./ActivityItems/SetHostRecoveryLockPassword";
|
||||
import RotatedHostRecoveryLockPasswordActivityItem from "./ActivityItems/RotatedHostRecoveryLockPassword";
|
||||
import InstalledSoftwareActivityItem from "./ActivityItems/InstalledSoftwareActivityItem";
|
||||
import CanceledRunScriptActivityItem from "./ActivityItems/CanceledRunScriptActivityItem";
|
||||
import CanceledInstallSoftwareActivityItem from "./ActivityItems/CanceledInstallSoftwareActivityItem";
|
||||
@@ -54,6 +55,7 @@ export const pastActivityComponentMap: Record<
|
||||
[ActivityType.ReadHostDiskEncryptionKey]: ReadHostDiskEncryptionKeyActivityItem,
|
||||
[ActivityType.ViewedHostRecoveryLockPassword]: ViewedHostRecoveryLockPasswordActivityItem,
|
||||
[ActivityType.SetHostRecoveryLockPassword]: SetHostRecoveryLockPasswordActivityItem,
|
||||
[ActivityType.RotatedHostRecoveryLockPassword]: RotatedHostRecoveryLockPasswordActivityItem,
|
||||
[ActivityType.UnlockedHost]: UnlockedHostActivityItem,
|
||||
[ActivityType.InstalledSoftware]: InstalledSoftwareActivityItem,
|
||||
[ActivityType.UninstalledSoftware]: InstalledSoftwareActivityItem,
|
||||
|
||||
+43
@@ -0,0 +1,43 @@
|
||||
import React from "react";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { createMockHostPastActivity } from "__mocks__/activityMock";
|
||||
|
||||
import RotatedHostRecoveryLockPasswordActivityItem from "./RotatedHostRecoveryLockPassword";
|
||||
|
||||
describe("RotatedHostRecoveryLockPasswordActivityItem", () => {
|
||||
it("renders the activity content", () => {
|
||||
render(
|
||||
<RotatedHostRecoveryLockPasswordActivityItem
|
||||
activity={createMockHostPastActivity({ actor_full_name: "Test User" })}
|
||||
tab="past"
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText("Test User")).toBeVisible();
|
||||
expect(
|
||||
screen.getByText(/rotated the Recovery Lock password/i)
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
it("does not render the cancel icon", () => {
|
||||
render(
|
||||
<RotatedHostRecoveryLockPasswordActivityItem
|
||||
activity={createMockHostPastActivity({ actor_full_name: "Test User" })}
|
||||
tab="past"
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.queryByTestId("close-icon")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("does not render the show details icon", () => {
|
||||
render(
|
||||
<RotatedHostRecoveryLockPasswordActivityItem
|
||||
activity={createMockHostPastActivity({ actor_full_name: "Test User" })}
|
||||
tab="past"
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.queryByTestId("info-outline-icon")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
import React from "react";
|
||||
|
||||
import ActivityItem from "components/ActivityItem";
|
||||
|
||||
import { IHostActivityItemComponentProps } from "../../ActivityConfig";
|
||||
|
||||
const RotatedHostRecoveryLockPasswordActivityItem = ({
|
||||
activity,
|
||||
}: IHostActivityItemComponentProps) => {
|
||||
return (
|
||||
<ActivityItem activity={activity} hideCancel hideShowDetails>
|
||||
<b>{activity.actor_full_name} </b>
|
||||
rotated the Recovery Lock password for this host.
|
||||
</ActivityItem>
|
||||
);
|
||||
};
|
||||
|
||||
export default RotatedHostRecoveryLockPasswordActivityItem;
|
||||
+1
@@ -0,0 +1 @@
|
||||
export { default } from "./RotatedHostRecoveryLockPassword";
|
||||
@@ -72,12 +72,14 @@ export const generateLinuxDiskEncryptionSetting = (
|
||||
};
|
||||
};
|
||||
|
||||
export const REC_LOCK_SYNTHETIC_PROFILE_UUID = "rec_lock_dummy";
|
||||
|
||||
export const generateRecoveryLockPasswordSetting = (
|
||||
status: RecoveryLockPasswordStatus,
|
||||
detail: string
|
||||
): IHostMdmProfile => {
|
||||
return {
|
||||
profile_uuid: "rec_lock_dummy",
|
||||
profile_uuid: REC_LOCK_SYNTHETIC_PROFILE_UUID,
|
||||
platform: "darwin",
|
||||
name: "Recovery Lock password",
|
||||
status,
|
||||
|
||||
@@ -614,6 +614,11 @@ export default {
|
||||
return sendRequest("GET", HOST_RECOVERY_LOCK_PASSWORD(id));
|
||||
},
|
||||
|
||||
rotateRecoveryLockPassword: (id: number): Promise<void> => {
|
||||
const { HOST_RECOVERY_LOCK_PASSWORD_ROTATE } = endpoints;
|
||||
return sendRequest("POST", HOST_RECOVERY_LOCK_PASSWORD_ROTATE(id));
|
||||
},
|
||||
|
||||
lockHost: (id: number) => {
|
||||
const { HOST_LOCK } = endpoints;
|
||||
return sendRequest("POST", HOST_LOCK(id));
|
||||
|
||||
@@ -187,6 +187,8 @@ export default {
|
||||
`/${API_VERSION}/fleet/hosts/${id}/encryption_key`,
|
||||
HOST_RECOVERY_LOCK_PASSWORD: (id: number) =>
|
||||
`/${API_VERSION}/fleet/hosts/${id}/recovery_lock_password`,
|
||||
HOST_RECOVERY_LOCK_PASSWORD_ROTATE: (id: number) =>
|
||||
`/${API_VERSION}/fleet/hosts/${id}/recovery_lock_password/rotate`,
|
||||
|
||||
ME: `/${API_VERSION}/fleet/me`,
|
||||
|
||||
|
||||
Reference in New Issue
Block a user