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:
jacobshandling
2026-03-11 14:01:56 -07:00
committed by GitHub
parent b812c8e6c2
commit a6f8c18cc7
22 changed files with 458 additions and 18 deletions
@@ -0,0 +1 @@
- Added ability to set and manually rotate Mac recovery lock passwords.
+3
View File
@@ -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",
@@ -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,
@@ -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)}
/>
)}
@@ -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>
</>
)
@@ -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">
@@ -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: `Couldnt 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}
/>
);
@@ -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>
);
};
@@ -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 (
@@ -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,
@@ -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();
});
});
@@ -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;
@@ -0,0 +1 @@
export { default } from "./RotatedHostRecoveryLockPassword";
+3 -1
View File
@@ -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,
+5
View File
@@ -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));
+2
View File
@@ -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`,