43890 MLAPR frontend (#44739)
<!-- Add the related story/sub-task/bug number, like Resolves #123, or remove if NA --> **Related issue:** Resolves #43890 Frontend for macOS Local Admin Password Rotation Changes file added during past work # 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] Where appropriate, [automated tests simulate multiple hosts and test for host isolation](https://github.com/fleetdm/fleet/blob/main/docs/Contributing/reference/patterns-backend.md#unit-testing) (updates to one hosts's records do not affect another) - [x] QA'd all new/changed functionality manually <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit ## Release Notes * **New Features** * Added ability to rotate managed local account passwords for hosts * Added visibility for auto-rotation scheduling and pending rotation status * New activity feed entries for managed local account password rotation events (successful and failed rotations) * **Improvements** * Enhanced host action menu to display managed account options when password is available * Added real-time status updates and notifications during password rotation operations <!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
@@ -170,6 +170,8 @@ export enum ActivityType {
|
||||
DisabledManagedLocalAccount = "disabled_managed_local_account",
|
||||
ViewedManagedLocalAccount = "read_managed_local_account",
|
||||
CreatedManagedLocalAccount = "created_managed_local_account",
|
||||
RotatedManagedLocalAccountPassword = "rotated_managed_local_account_password",
|
||||
FailedToRotateManagedLocalAccountPassword = "failed_to_rotate_managed_local_account_password",
|
||||
FailedEnrollmentProfileRenewal = "failed_enrollment_profile_renewal",
|
||||
CreatedLabel = "created_label",
|
||||
EditedLabel = "edited_label",
|
||||
@@ -204,6 +206,8 @@ export type IHostPastActivityType =
|
||||
| ActivityType.ClearedPasscode
|
||||
| ActivityType.ViewedManagedLocalAccount
|
||||
| ActivityType.CreatedManagedLocalAccount
|
||||
| ActivityType.RotatedManagedLocalAccountPassword
|
||||
| ActivityType.FailedToRotateManagedLocalAccountPassword
|
||||
| ActivityType.FailedEnrollmentProfileRenewal;
|
||||
|
||||
/** This is a subset of ActivityType that are shown only for the host upcoming activities */
|
||||
@@ -503,6 +507,10 @@ export const ACTIVITY_TYPE_TO_FILTER_LABEL: Record<ActivityType, string> = {
|
||||
"Turned off managed local account",
|
||||
[ActivityType.ViewedManagedLocalAccount]: "Viewed managed account",
|
||||
[ActivityType.CreatedManagedLocalAccount]: "Created managed account",
|
||||
[ActivityType.RotatedManagedLocalAccountPassword]:
|
||||
"Triggered managed local account password rotation",
|
||||
[ActivityType.FailedToRotateManagedLocalAccountPassword]:
|
||||
"Failed to rotate managed local account password",
|
||||
[ActivityType.FailedEnrollmentProfileRenewal]:
|
||||
"Enrollment profile renewal failed",
|
||||
[ActivityType.CreatedLabel]: "Created label",
|
||||
|
||||
@@ -132,6 +132,8 @@ export interface IOSSettings {
|
||||
managed_local_account?: {
|
||||
status: string | null;
|
||||
password_available: boolean;
|
||||
auto_rotate_at?: string;
|
||||
pending_rotation?: boolean;
|
||||
};
|
||||
certificates: IHostAndroidCert[];
|
||||
}
|
||||
@@ -268,6 +270,8 @@ export interface IHostManagedAccountPasswordResponse {
|
||||
username: string;
|
||||
password: string;
|
||||
updated_at: string;
|
||||
auto_rotate_at?: string;
|
||||
pending_rotation?: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
+52
@@ -684,6 +684,58 @@ describe("Activity Feed", () => {
|
||||
expect(screen.getByText("Alex's Macbook Air")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders a 'rotated_managed_local_account_password' type activity", () => {
|
||||
const activity = createMockActivity({
|
||||
type: ActivityType.RotatedManagedLocalAccountPassword,
|
||||
details: { host_display_name: "Marsh's Macbook Air" },
|
||||
});
|
||||
render(<GlobalActivityItem activity={activity} isPremiumTier />);
|
||||
|
||||
expect(
|
||||
screen.getByText(
|
||||
"triggered rotation of the managed local account password for",
|
||||
{ exact: false }
|
||||
)
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText("Marsh's Macbook Air")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders a Fleet-initiated 'rotated_managed_local_account_password' type activity", () => {
|
||||
const activity = createMockActivity({
|
||||
type: ActivityType.RotatedManagedLocalAccountPassword,
|
||||
fleet_initiated: true,
|
||||
details: { host_display_name: "Marsh's Macbook Air" },
|
||||
});
|
||||
render(<GlobalActivityItem activity={activity} isPremiumTier />);
|
||||
|
||||
expect(screen.getByText("Fleet")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText(
|
||||
"triggered rotation of the managed local account password for",
|
||||
{ exact: false }
|
||||
)
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText("Marsh's Macbook Air")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders a 'failed_to_rotate_managed_local_account_password' type activity", () => {
|
||||
const activity = createMockActivity({
|
||||
type: ActivityType.FailedToRotateManagedLocalAccountPassword,
|
||||
fleet_initiated: true,
|
||||
details: { host_display_name: "Marsh's Macbook Air" },
|
||||
});
|
||||
render(<GlobalActivityItem activity={activity} isPremiumTier />);
|
||||
|
||||
expect(screen.getByText("Fleet")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText(
|
||||
"failed to rotate the managed local account password for",
|
||||
{ exact: false }
|
||||
)
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText("Marsh's Macbook Air")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders an 'enabled_recovery_lock_passwords' type activity for a team", () => {
|
||||
const activity = createMockActivity({
|
||||
type: ActivityType.EnabledRecoveryLockPasswords,
|
||||
|
||||
+26
@@ -583,6 +583,24 @@ const TAGGED_TEMPLATES = {
|
||||
</>
|
||||
);
|
||||
},
|
||||
rotatedManagedLocalAccountPassword: (activity: IActivity) => {
|
||||
return (
|
||||
<>
|
||||
{" "}
|
||||
triggered rotation of the managed local account password for{" "}
|
||||
<b>{activity.details?.host_display_name}</b>.
|
||||
</>
|
||||
);
|
||||
},
|
||||
failedToRotateManagedLocalAccountPassword: (activity: IActivity) => {
|
||||
return (
|
||||
<>
|
||||
{" "}
|
||||
failed to rotate the managed local account password for{" "}
|
||||
<b>{activity.details?.host_display_name}</b>.
|
||||
</>
|
||||
);
|
||||
},
|
||||
createdAppleOSProfile: (activity: IActivity, isPremiumTier: boolean) => {
|
||||
const profileName = activity.details?.profile_name;
|
||||
return (
|
||||
@@ -2119,6 +2137,14 @@ const getDetail = (activity: IActivity, isPremiumTier: boolean) => {
|
||||
case ActivityType.CreatedManagedLocalAccount: {
|
||||
return TAGGED_TEMPLATES.createdManagedLocalAccount(activity);
|
||||
}
|
||||
case ActivityType.RotatedManagedLocalAccountPassword: {
|
||||
return TAGGED_TEMPLATES.rotatedManagedLocalAccountPassword(activity);
|
||||
}
|
||||
case ActivityType.FailedToRotateManagedLocalAccountPassword: {
|
||||
return TAGGED_TEMPLATES.failedToRotateManagedLocalAccountPassword(
|
||||
activity
|
||||
);
|
||||
}
|
||||
case ActivityType.CreatedAppleOSProfile: {
|
||||
return TAGGED_TEMPLATES.createdAppleOSProfile(activity, isPremiumTier);
|
||||
}
|
||||
|
||||
+34
@@ -2051,6 +2051,40 @@ describe("Host Actions Dropdown", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("enables the action when status is pending but password is available (e.g. viewed-and-waiting)", async () => {
|
||||
const render = createCustomRenderer({
|
||||
context: {
|
||||
app: {
|
||||
isGlobalAdmin: true,
|
||||
isPremiumTier: true,
|
||||
currentUser: createMockUser(),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const { user } = render(
|
||||
<HostActionsDropdown
|
||||
hostTeamId={null}
|
||||
onSelect={noop}
|
||||
hostStatus="online"
|
||||
hostMdmEnrollmentStatus="On (automatic)"
|
||||
hostMdmDeviceStatus="unlocked"
|
||||
hostScriptsEnabled
|
||||
isConnectedToFleetMdm
|
||||
hostPlatform="darwin"
|
||||
isManagedLocalAccountEnabled
|
||||
managedAccountStatus="pending"
|
||||
managedAccountPasswordAvailable
|
||||
/>
|
||||
);
|
||||
|
||||
await user.click(screen.getByText("Actions"));
|
||||
|
||||
const option = screen.getByText("Show managed account");
|
||||
expect(option).toBeInTheDocument();
|
||||
expect(option).not.toHaveAttribute("aria-disabled", "true");
|
||||
});
|
||||
|
||||
it("renders the action for company-owned ADE enrollment status", async () => {
|
||||
const render = createCustomRenderer({
|
||||
context: {
|
||||
|
||||
+3
@@ -28,6 +28,7 @@ interface IHostActionsDropdownProps {
|
||||
recoveryLockPasswordAvailable?: boolean;
|
||||
isManagedLocalAccountEnabled?: boolean;
|
||||
managedAccountStatus?: string | null;
|
||||
managedAccountPasswordAvailable?: boolean;
|
||||
}
|
||||
|
||||
const HostActionsDropdown = ({
|
||||
@@ -46,6 +47,7 @@ const HostActionsDropdown = ({
|
||||
recoveryLockPasswordAvailable = false,
|
||||
isManagedLocalAccountEnabled = false,
|
||||
managedAccountStatus,
|
||||
managedAccountPasswordAvailable = false,
|
||||
}: IHostActionsDropdownProps) => {
|
||||
const {
|
||||
isPremiumTier = false,
|
||||
@@ -102,6 +104,7 @@ const HostActionsDropdown = ({
|
||||
recoveryLockPasswordAvailable,
|
||||
isManagedLocalAccountEnabled,
|
||||
managedAccountStatus,
|
||||
managedAccountPasswordAvailable,
|
||||
});
|
||||
|
||||
// No options to render. Exit early
|
||||
|
||||
@@ -115,6 +115,7 @@ interface IHostActionConfigOptions {
|
||||
recoveryLockPasswordAvailable: boolean;
|
||||
isManagedLocalAccountEnabled: boolean;
|
||||
managedAccountStatus: string | null | undefined;
|
||||
managedAccountPasswordAvailable: boolean;
|
||||
}
|
||||
|
||||
const canTransferTeam = (config: IHostActionConfigOptions) => {
|
||||
@@ -523,6 +524,7 @@ const modifyOptions = (
|
||||
diskEncryptionProfileStatus,
|
||||
recoveryLockPasswordAvailable,
|
||||
managedAccountStatus,
|
||||
managedAccountPasswordAvailable,
|
||||
}: IHostActionConfigOptions
|
||||
) => {
|
||||
const disableOptions = (optionsToDisable: IDropdownOption[]) => {
|
||||
@@ -632,13 +634,18 @@ const modifyOptions = (
|
||||
}
|
||||
}
|
||||
|
||||
if (managedAccountStatus !== "verified") {
|
||||
// Gate on password_available rather than status === "verified" — a row whose
|
||||
// status is "pending" because of a recent view (or a deferred rotation
|
||||
// waiting on UUID capture) still has a viewable password. Mirrors the
|
||||
// backend gate in GetHostManagedAccountPassword.
|
||||
if (!managedAccountPasswordAvailable) {
|
||||
const managedAccountOption = options.find(
|
||||
(option) => option.value === "managedAccount"
|
||||
);
|
||||
if (managedAccountOption) {
|
||||
managedAccountOption.disabled = true;
|
||||
if (managedAccountStatus === "pending") {
|
||||
// No password yet — the AccountConfiguration command hasn't been acked.
|
||||
managedAccountOption.tooltipContent = (
|
||||
<>
|
||||
The managed account is still being
|
||||
|
||||
@@ -1053,6 +1053,10 @@ const HostDetailsPage = ({
|
||||
managedAccountStatus={
|
||||
host.mdm.os_settings?.managed_local_account?.status
|
||||
}
|
||||
managedAccountPasswordAvailable={
|
||||
host.mdm.os_settings?.managed_local_account?.password_available ??
|
||||
false
|
||||
}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -1657,10 +1661,25 @@ const HostDetailsPage = ({
|
||||
{showManagedAccountModal && host && (
|
||||
<ManagedAccountModal
|
||||
hostId={host.id}
|
||||
canRotatePassword={
|
||||
isGlobalAdmin ||
|
||||
isGlobalMaintainer ||
|
||||
isHostTeamAdmin ||
|
||||
isHostTeamMaintainer
|
||||
}
|
||||
onCancel={() => {
|
||||
setShowManagedAccountModal(false);
|
||||
// Opening the modal triggers a "viewed managed account"
|
||||
// activity server-side, so refetch to show it in the feed.
|
||||
// activity server-side and may set auto_rotate_at; refetch
|
||||
// host details + activities so they reflect the new state.
|
||||
refetchHostDetails();
|
||||
refetchPastActivities();
|
||||
}}
|
||||
onRotate={() => {
|
||||
// The rotation activity and cleared auto_rotate_at land on
|
||||
// host details / activities — refresh both so the banner and
|
||||
// feed are in sync with the newly-rotated state.
|
||||
refetchHostDetails();
|
||||
refetchPastActivities();
|
||||
}}
|
||||
/>
|
||||
|
||||
+237
@@ -0,0 +1,237 @@
|
||||
import React from "react";
|
||||
|
||||
import { screen, waitFor } from "@testing-library/react";
|
||||
import { createCustomRenderer } from "test/test-utils";
|
||||
|
||||
import hostAPI from "services/entities/hosts";
|
||||
|
||||
import ManagedAccountModal from "./ManagedAccountModal";
|
||||
|
||||
jest.mock("services/entities/hosts");
|
||||
|
||||
const mockPasswordResponse = {
|
||||
host_id: 7,
|
||||
managed_account_password: {
|
||||
username: "_fleetadmin",
|
||||
password: "supersecret",
|
||||
updated_at: "2026-04-30T13:00:00Z",
|
||||
},
|
||||
};
|
||||
|
||||
describe("ManagedAccountModal", () => {
|
||||
const render = createCustomRenderer({ withBackendMock: true });
|
||||
|
||||
beforeEach(() => {
|
||||
jest.resetAllMocks();
|
||||
(hostAPI.getManagedAccountPassword as jest.Mock).mockResolvedValue(
|
||||
mockPasswordResponse
|
||||
);
|
||||
});
|
||||
|
||||
it("renders username and password masked input", async () => {
|
||||
render(
|
||||
<ManagedAccountModal
|
||||
hostId={7}
|
||||
canRotatePassword
|
||||
onCancel={jest.fn()}
|
||||
onRotate={jest.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("_fleetadmin")).toBeVisible();
|
||||
});
|
||||
expect(screen.getByText("Username")).toBeVisible();
|
||||
});
|
||||
|
||||
it("shows the auto-rotate banner when auto_rotate_at is in the response", async () => {
|
||||
(hostAPI.getManagedAccountPassword as jest.Mock).mockResolvedValue({
|
||||
...mockPasswordResponse,
|
||||
managed_account_password: {
|
||||
...mockPasswordResponse.managed_account_password,
|
||||
auto_rotate_at: "2026-04-30T14:35:00Z",
|
||||
},
|
||||
});
|
||||
|
||||
render(
|
||||
<ManagedAccountModal
|
||||
hostId={7}
|
||||
canRotatePassword
|
||||
onCancel={jest.fn()}
|
||||
onRotate={jest.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByText(/Password rotates automatically after/i)
|
||||
).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
it("does not show the banner when auto_rotate_at is missing from the response", async () => {
|
||||
render(
|
||||
<ManagedAccountModal
|
||||
hostId={7}
|
||||
canRotatePassword
|
||||
onCancel={jest.fn()}
|
||||
onRotate={jest.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("_fleetadmin")).toBeVisible();
|
||||
});
|
||||
expect(
|
||||
screen.queryByText(/Password rotates automatically after/i)
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("hides the rotate button when canRotatePassword is false", async () => {
|
||||
render(
|
||||
<ManagedAccountModal
|
||||
hostId={7}
|
||||
canRotatePassword={false}
|
||||
onCancel={jest.fn()}
|
||||
onRotate={jest.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("_fleetadmin")).toBeVisible();
|
||||
});
|
||||
expect(screen.queryByText("Rotate password")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows the rotate button when canRotatePassword is true", async () => {
|
||||
render(
|
||||
<ManagedAccountModal
|
||||
hostId={7}
|
||||
canRotatePassword
|
||||
onCancel={jest.fn()}
|
||||
onRotate={jest.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Rotate password")).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
it("calls rotate API and onRotate on success", async () => {
|
||||
(hostAPI.rotateManagedLocalAccountPassword as jest.Mock).mockResolvedValue(
|
||||
undefined
|
||||
);
|
||||
const onRotate = jest.fn();
|
||||
|
||||
const { user } = render(
|
||||
<ManagedAccountModal
|
||||
hostId={7}
|
||||
canRotatePassword
|
||||
onCancel={jest.fn()}
|
||||
onRotate={onRotate}
|
||||
/>
|
||||
);
|
||||
|
||||
const button = await screen.findByText("Rotate password");
|
||||
await user.click(button);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(hostAPI.rotateManagedLocalAccountPassword).toHaveBeenCalledWith(7);
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(onRotate).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it("shows the pending-rotation banner when pending_rotation is in the response", async () => {
|
||||
(hostAPI.getManagedAccountPassword as jest.Mock).mockResolvedValue({
|
||||
...mockPasswordResponse,
|
||||
managed_account_password: {
|
||||
...mockPasswordResponse.managed_account_password,
|
||||
pending_rotation: true,
|
||||
},
|
||||
});
|
||||
|
||||
render(
|
||||
<ManagedAccountModal
|
||||
hostId={7}
|
||||
canRotatePassword
|
||||
onCancel={jest.fn()}
|
||||
onRotate={jest.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByText(
|
||||
"Password will rotate once the host acknowledges the request."
|
||||
)
|
||||
).toBeVisible();
|
||||
});
|
||||
expect(
|
||||
screen.queryByText(/Password rotates automatically after/i)
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows the pending-rotation banner after a successful rotate", async () => {
|
||||
(hostAPI.rotateManagedLocalAccountPassword as jest.Mock).mockResolvedValue(
|
||||
undefined
|
||||
);
|
||||
(hostAPI.getManagedAccountPassword as jest.Mock).mockResolvedValue({
|
||||
...mockPasswordResponse,
|
||||
managed_account_password: {
|
||||
...mockPasswordResponse.managed_account_password,
|
||||
auto_rotate_at: "2026-04-30T14:35:00Z",
|
||||
},
|
||||
});
|
||||
|
||||
const { user } = render(
|
||||
<ManagedAccountModal
|
||||
hostId={7}
|
||||
canRotatePassword
|
||||
onCancel={jest.fn()}
|
||||
onRotate={jest.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
const button = await screen.findByText("Rotate password");
|
||||
await user.click(button);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByText(
|
||||
"Password will rotate once the host acknowledges the request."
|
||||
)
|
||||
).toBeVisible();
|
||||
});
|
||||
// The auto-rotate banner is replaced by the pending-rotation banner.
|
||||
expect(
|
||||
screen.queryByText(/Password rotates automatically after/i)
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("does not call onRotate when rotate API errors", async () => {
|
||||
(hostAPI.rotateManagedLocalAccountPassword as jest.Mock).mockRejectedValue(
|
||||
new Error("boom")
|
||||
);
|
||||
const onRotate = jest.fn();
|
||||
|
||||
const { user } = render(
|
||||
<ManagedAccountModal
|
||||
hostId={7}
|
||||
canRotatePassword
|
||||
onCancel={jest.fn()}
|
||||
onRotate={onRotate}
|
||||
/>
|
||||
);
|
||||
|
||||
const button = await screen.findByText("Rotate password");
|
||||
await user.click(button);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(hostAPI.rotateManagedLocalAccountPassword).toHaveBeenCalledWith(7);
|
||||
});
|
||||
expect(onRotate).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
+68
-1
@@ -1,27 +1,45 @@
|
||||
import React from "react";
|
||||
import React, { useContext, useState } from "react";
|
||||
import { useQuery } from "react-query";
|
||||
|
||||
import { IHostManagedAccountPasswordResponse } 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";
|
||||
import InputFieldHiddenContent from "components/forms/fields/InputFieldHiddenContent";
|
||||
import DataError from "components/DataError";
|
||||
import Spinner from "components/Spinner";
|
||||
import Icon from "components/Icon";
|
||||
import InfoBanner from "components/InfoBanner";
|
||||
import { DEFAULT_USE_QUERY_OPTIONS } from "utilities/constants";
|
||||
import { monthDayTimeFormat } from "utilities/date_format";
|
||||
import { getErrorReason } from "interfaces/errors";
|
||||
|
||||
const baseClass = "managed-account-modal";
|
||||
|
||||
interface IManagedAccountModalProps {
|
||||
hostId: number;
|
||||
// TODO: For this modal the Figma dev note said "Hide option if not Admin or
|
||||
// maintainer role." We're hiding here per the design, but the analogous
|
||||
// RecoveryLockPasswordModal disables-with-tooltip in the same situation.
|
||||
// We deferred this decision for now because this modal only displays for
|
||||
// Admin or Maintainer roles
|
||||
canRotatePassword: boolean;
|
||||
onCancel: () => void;
|
||||
onRotate: () => void;
|
||||
}
|
||||
|
||||
const ManagedAccountModal = ({
|
||||
hostId,
|
||||
canRotatePassword,
|
||||
onCancel,
|
||||
onRotate,
|
||||
}: IManagedAccountModalProps) => {
|
||||
const { renderFlash } = useContext(NotificationContext);
|
||||
const [isRotating, setIsRotating] = useState(false);
|
||||
const [justRotated, setJustRotated] = useState(false);
|
||||
|
||||
const {
|
||||
data: managedAccountData,
|
||||
error: managedAccountError,
|
||||
@@ -41,6 +59,32 @@ const ManagedAccountModal = ({
|
||||
}
|
||||
);
|
||||
|
||||
const onRotatePassword = async () => {
|
||||
setIsRotating(true);
|
||||
try {
|
||||
await hostAPI.rotateManagedLocalAccountPassword(hostId);
|
||||
setJustRotated(true);
|
||||
renderFlash(
|
||||
"success",
|
||||
"Successfully sent request to rotate managed local account password."
|
||||
);
|
||||
// Notify parent so it can refetch host details + activities.
|
||||
onRotate();
|
||||
} catch (e) {
|
||||
const msg = getErrorReason(e);
|
||||
renderFlash(
|
||||
"error",
|
||||
msg ||
|
||||
"Couldn't send request to rotate managed local account password. Please try again."
|
||||
);
|
||||
}
|
||||
setIsRotating(false);
|
||||
};
|
||||
|
||||
const showPendingRotationBanner =
|
||||
justRotated || managedAccountData?.pending_rotation === true;
|
||||
const autoRotateAt = managedAccountData?.auto_rotate_at;
|
||||
|
||||
return (
|
||||
<Modal title="Managed account" onExit={onCancel} className={baseClass}>
|
||||
{isLoading && <Spinner />}
|
||||
@@ -57,8 +101,31 @@ const ManagedAccountModal = ({
|
||||
value={managedAccountData?.password ?? ""}
|
||||
name="Password"
|
||||
/>
|
||||
{showPendingRotationBanner ? (
|
||||
<InfoBanner color="yellow">
|
||||
Password will rotate once the host acknowledges the request.
|
||||
</InfoBanner>
|
||||
) : (
|
||||
autoRotateAt && (
|
||||
<InfoBanner color="yellow">
|
||||
Password rotates automatically after{" "}
|
||||
{monthDayTimeFormat(autoRotateAt)}.
|
||||
</InfoBanner>
|
||||
)
|
||||
)}
|
||||
<div className="modal-cta-wrap">
|
||||
<Button onClick={onCancel}>Close</Button>
|
||||
{canRotatePassword && (
|
||||
<Button
|
||||
variant="inverse"
|
||||
onClick={onRotatePassword}
|
||||
disabled={isRotating}
|
||||
className={`${baseClass}__rotate-button`}
|
||||
>
|
||||
<Icon name="refresh" />
|
||||
{isRotating ? "Rotating..." : "Rotate password"}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
|
||||
@@ -16,4 +16,15 @@
|
||||
font-size: $x-small;
|
||||
color: $ui-fleet-black-75;
|
||||
}
|
||||
|
||||
.info-banner {
|
||||
margin-top: $pad-medium;
|
||||
margin-bottom: $pad-small;
|
||||
}
|
||||
|
||||
&__rotate-button .children-wrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: $pad-small;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,6 +29,8 @@ import ClearedPasscodeActivityItem from "./ActivityItems/ClearedPasscodeActivity
|
||||
import FailedWipeActivityItem from "./ActivityItems/FailedWipeActivityItem";
|
||||
import ViewedManagedLocalAccountActivityItem from "./ActivityItems/ViewedManagedLocalAccountActivityItem/ViewedManagedLocalAccountActivityItem";
|
||||
import CreatedManagedLocalAccountActivityItem from "./ActivityItems/CreatedManagedLocalAccountActivityItem/CreatedManagedLocalAccountActivityItem";
|
||||
import RotatedManagedLocalAccountPasswordActivityItem from "./ActivityItems/RotatedManagedLocalAccountPassword";
|
||||
import FailedToRotateManagedLocalAccountPasswordActivityItem from "./ActivityItems/FailedToRotateManagedLocalAccountPassword";
|
||||
import FailedEnrollmentProfileRenewalActivityItem from "./ActivityItems/FailedEnrollmentProfileRenewalActivityItem";
|
||||
|
||||
/** The component props that all host activity items must adhere to */
|
||||
@@ -79,6 +81,8 @@ export const pastActivityComponentMap: Record<
|
||||
[ActivityType.ClearedPasscode]: ClearedPasscodeActivityItem,
|
||||
[ActivityType.ViewedManagedLocalAccount]: ViewedManagedLocalAccountActivityItem,
|
||||
[ActivityType.CreatedManagedLocalAccount]: CreatedManagedLocalAccountActivityItem,
|
||||
[ActivityType.RotatedManagedLocalAccountPassword]: RotatedManagedLocalAccountPasswordActivityItem,
|
||||
[ActivityType.FailedToRotateManagedLocalAccountPassword]: FailedToRotateManagedLocalAccountPasswordActivityItem,
|
||||
[ActivityType.FailedEnrollmentProfileRenewal]: FailedEnrollmentProfileRenewalActivityItem,
|
||||
};
|
||||
|
||||
|
||||
+58
@@ -0,0 +1,58 @@
|
||||
import React from "react";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { createMockHostPastActivity } from "__mocks__/activityMock";
|
||||
import { ActivityType } from "interfaces/activity";
|
||||
|
||||
import FailedToRotateManagedLocalAccountPasswordActivityItem from "./FailedToRotateManagedLocalAccountPassword";
|
||||
|
||||
describe("FailedToRotateManagedLocalAccountPasswordActivityItem", () => {
|
||||
it("renders Fleet-initiated failed rotation activity content", () => {
|
||||
render(
|
||||
<FailedToRotateManagedLocalAccountPasswordActivityItem
|
||||
activity={createMockHostPastActivity({
|
||||
actor_full_name: "Fleet",
|
||||
fleet_initiated: true,
|
||||
type: ActivityType.FailedToRotateManagedLocalAccountPassword,
|
||||
})}
|
||||
tab="past"
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText("Fleet")).toBeVisible();
|
||||
expect(
|
||||
screen.getByText(
|
||||
/failed to rotate the managed local account password for this host/i
|
||||
)
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
it("does not render the cancel icon", () => {
|
||||
render(
|
||||
<FailedToRotateManagedLocalAccountPasswordActivityItem
|
||||
activity={createMockHostPastActivity({
|
||||
actor_full_name: "Fleet",
|
||||
fleet_initiated: true,
|
||||
type: ActivityType.FailedToRotateManagedLocalAccountPassword,
|
||||
})}
|
||||
tab="past"
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.queryByTestId("close-icon")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("does not render the show details icon", () => {
|
||||
render(
|
||||
<FailedToRotateManagedLocalAccountPasswordActivityItem
|
||||
activity={createMockHostPastActivity({
|
||||
actor_full_name: "Fleet",
|
||||
fleet_initiated: true,
|
||||
type: ActivityType.FailedToRotateManagedLocalAccountPassword,
|
||||
})}
|
||||
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 FailedToRotateManagedLocalAccountPasswordActivityItem = ({
|
||||
activity,
|
||||
}: IHostActivityItemComponentProps) => {
|
||||
return (
|
||||
<ActivityItem activity={activity} hideCancel hideShowDetails>
|
||||
<b>Fleet </b>
|
||||
failed to rotate the managed local account password for this host.
|
||||
</ActivityItem>
|
||||
);
|
||||
};
|
||||
|
||||
export default FailedToRotateManagedLocalAccountPasswordActivityItem;
|
||||
+1
@@ -0,0 +1 @@
|
||||
export { default } from "./FailedToRotateManagedLocalAccountPassword";
|
||||
+74
@@ -0,0 +1,74 @@
|
||||
import React from "react";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { createMockHostPastActivity } from "__mocks__/activityMock";
|
||||
import { ActivityType } from "interfaces/activity";
|
||||
|
||||
import RotatedManagedLocalAccountPasswordActivityItem from "./RotatedManagedLocalAccountPassword";
|
||||
|
||||
describe("RotatedManagedLocalAccountPasswordActivityItem", () => {
|
||||
it("renders user-triggered rotation activity content", () => {
|
||||
render(
|
||||
<RotatedManagedLocalAccountPasswordActivityItem
|
||||
activity={createMockHostPastActivity({
|
||||
actor_full_name: "Test User",
|
||||
type: ActivityType.RotatedManagedLocalAccountPassword,
|
||||
})}
|
||||
tab="past"
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText("Test User")).toBeVisible();
|
||||
expect(
|
||||
screen.getByText(
|
||||
/triggered rotation of the managed local account password/i
|
||||
)
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
it("renders Fleet-initiated (auto) rotation activity content", () => {
|
||||
render(
|
||||
<RotatedManagedLocalAccountPasswordActivityItem
|
||||
activity={createMockHostPastActivity({
|
||||
actor_full_name: "Fleet",
|
||||
type: ActivityType.RotatedManagedLocalAccountPassword,
|
||||
})}
|
||||
tab="past"
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText("Fleet")).toBeVisible();
|
||||
expect(
|
||||
screen.getByText(
|
||||
/triggered rotation of the managed local account password/i
|
||||
)
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
it("does not render the cancel icon", () => {
|
||||
render(
|
||||
<RotatedManagedLocalAccountPasswordActivityItem
|
||||
activity={createMockHostPastActivity({
|
||||
actor_full_name: "Test User",
|
||||
type: ActivityType.RotatedManagedLocalAccountPassword,
|
||||
})}
|
||||
tab="past"
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.queryByTestId("close-icon")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("does not render the show details icon", () => {
|
||||
render(
|
||||
<RotatedManagedLocalAccountPasswordActivityItem
|
||||
activity={createMockHostPastActivity({
|
||||
actor_full_name: "Test User",
|
||||
type: ActivityType.RotatedManagedLocalAccountPassword,
|
||||
})}
|
||||
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 RotatedManagedLocalAccountPasswordActivityItem = ({
|
||||
activity,
|
||||
}: IHostActivityItemComponentProps) => {
|
||||
return (
|
||||
<ActivityItem activity={activity} hideCancel hideShowDetails>
|
||||
<b>{activity.actor_full_name} </b>
|
||||
triggered rotation of the managed local account password for this host.
|
||||
</ActivityItem>
|
||||
);
|
||||
};
|
||||
|
||||
export default RotatedManagedLocalAccountPasswordActivityItem;
|
||||
+1
@@ -0,0 +1 @@
|
||||
export { default } from "./RotatedManagedLocalAccountPassword";
|
||||
@@ -676,6 +676,11 @@ export default {
|
||||
return sendRequest("GET", HOST_MANAGED_ACCOUNT_PASSWORD(id));
|
||||
},
|
||||
|
||||
rotateManagedLocalAccountPassword: (id: number): Promise<void> => {
|
||||
const { HOST_MANAGED_LOCAL_ACCOUNT_ROTATE } = endpoints;
|
||||
return sendRequest("POST", HOST_MANAGED_LOCAL_ACCOUNT_ROTATE(id));
|
||||
},
|
||||
|
||||
lockHost: (id: number) => {
|
||||
const { HOST_LOCK } = endpoints;
|
||||
return sendRequest("POST", HOST_LOCK(id));
|
||||
|
||||
@@ -204,6 +204,8 @@ export default {
|
||||
`/${API_VERSION}/fleet/hosts/${id}/recovery_lock_password/rotate`,
|
||||
HOST_MANAGED_ACCOUNT_PASSWORD: (id: number) =>
|
||||
`/${API_VERSION}/fleet/hosts/${id}/managed_account_password`,
|
||||
HOST_MANAGED_LOCAL_ACCOUNT_ROTATE: (id: number) =>
|
||||
`/${API_VERSION}/fleet/hosts/${id}/managed_account_password/rotate`,
|
||||
|
||||
ME: `/${API_VERSION}/fleet/me`,
|
||||
|
||||
|
||||
Reference in New Issue
Block a user