UI for wiping a host (#16874)

# Checklist for submitter

add ability in the UI to wipe a host managed by the fleet mdm. This
includes:

**new wipe host action dropdown option:**


![image](https://github.com/fleetdm/fleet/assets/1153709/a5c01e45-d494-4762-8504-1e1963093809)

**new wipe modal to initiate wiping a host:**


![image](https://github.com/fleetdm/fleet/assets/1153709/829c8dfb-a60f-427b-b6b8-2804924c0b71)

**wipe and wiping host status tags: **


![image](https://github.com/fleetdm/fleet/assets/1153709/de947160-7273-409d-bcfd-c219e887bb9d)


![image](https://github.com/fleetdm/fleet/assets/1153709/2a13e79a-2bcd-4aa5-b15f-5bb57348d191)

- [x] Changes file added for user-visible changes in `changes/` or
`orbit/changes/`.
- [x] Manual QA for all new/changed functionality
This commit is contained in:
Gabriel Hernandez 2024-02-26 14:26:30 +00:00 committed by GitHub
parent 762cd076d7
commit 5d20ee85fc
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
38 changed files with 650 additions and 225 deletions

View File

@ -0,0 +1 @@
- add UI for wiping a host with fleet mdm.

View File

@ -106,7 +106,8 @@ type InitialStateType = {
isSandboxMode?: boolean;
isFreeTier?: boolean;
isPremiumTier?: boolean;
isMdmEnabledAndConfigured?: boolean;
isMacMdmEnabledAndConfigured?: boolean;
isWindowsMdmEnabledAndConfigured?: boolean;
isGlobalAdmin?: boolean;
isGlobalMaintainer?: boolean;
isGlobalObserver?: boolean;
@ -156,7 +157,8 @@ export const initialState = {
isSandboxMode: false,
isFreeTier: undefined,
isPremiumTier: undefined,
isMdmEnabledAndConfigured: undefined,
isMacMdmEnabledAndConfigured: undefined,
isWindowsMdmEnabledAndConfigured: undefined,
isGlobalAdmin: undefined,
isGlobalMaintainer: undefined,
isGlobalObserver: undefined,
@ -212,7 +214,12 @@ const setPermissions = (
isSandboxMode: permissions.isSandboxMode(config),
isFreeTier: permissions.isFreeTier(config),
isPremiumTier: permissions.isPremiumTier(config),
isMdmEnabledAndConfigured: permissions.isMdmEnabledAndConfigured(config),
isMacMdmEnabledAndConfigured: permissions.isMacMdmEnabledAndConfigured(
config
),
isWindowsMdmEnabledAndConfigured: permissions.isWindowsMdmEnabledAndConfigured(
config
),
isGlobalAdmin: permissions.isGlobalAdmin(user),
isGlobalMaintainer: permissions.isGlobalMaintainer(user),
isGlobalObserver: permissions.isGlobalObserver(user),
@ -365,7 +372,8 @@ const AppProvider = ({ children }: Props): JSX.Element => {
isSandboxMode: state.isSandboxMode,
isFreeTier: state.isFreeTier,
isPremiumTier: state.isPremiumTier,
isMdmEnabledAndConfigured: state.isMdmEnabledAndConfigured,
isMacMdmEnabledAndConfigured: state.isMacMdmEnabledAndConfigured,
isWindowsMdmEnabledAndConfigured: state.isWindowsMdmEnabledAndConfigured,
isGlobalAdmin: state.isGlobalAdmin,
isGlobalMaintainer: state.isGlobalMaintainer,
isGlobalObserver: state.isGlobalObserver,

View File

@ -66,7 +66,15 @@ export enum ActivityType {
EditedWindowsUpdates = "edited_windows_updates",
LockedHost = "locked_host",
UnlockedHost = "unlocked_host",
WipedHost = "wiped_host",
}
// This is a subset of ActivityType that are shown only for the host past activities
export type IHostPastActivityType =
| ActivityType.RanScript
| ActivityType.LockedHost
| ActivityType.UnlockedHost;
export interface IActivity {
created_at: string;
id: number;
@ -77,6 +85,11 @@ export interface IActivity {
type: ActivityType;
details?: IActivityDetails;
}
export type IPastActivity = Omit<IActivity, "type"> & {
type: IHostPastActivityType;
};
export interface IActivityDetails {
pack_id?: number;
pack_name?: string;

View File

@ -157,8 +157,8 @@ interface IMdmMacOsSetup {
bootstrap_package_name: string;
}
export type HostMdmDeviceStatus = "unlocked" | "locked";
export type HostMdmPendingAction = "unlock" | "lock" | "";
export type HostMdmDeviceStatus = "unlocked" | "locked" | "wiped";
export type HostMdmPendingAction = "unlock" | "lock" | "wipe" | "";
export interface IHostMdmData {
encryption_key_available: boolean;

View File

@ -1165,4 +1165,17 @@ describe("Activity Feed", () => {
screen.getByText("deleted multiple queries", { exact: false })
).toBeInTheDocument();
});
// test for wipe activity
it("renders a 'wiped_host' type activity for a team", () => {
const activity = createMockActivity({
type: ActivityType.WipedHost,
details: {
host_display_name: "Foo Host",
},
});
render(<ActivityItem activity={activity} isPremiumTier />);
expect(screen.getByText("wiped", { exact: false })).toBeInTheDocument();
expect(screen.getByText("Foo Host", { exact: false })).toBeInTheDocument();
});
});

View File

@ -755,6 +755,14 @@ const TAGGED_TEMPLATES = {
</>
);
},
wipedHost: (activity: IActivity) => {
return (
<>
{" "}
wiped <b>{activity.details?.host_display_name}</b>.
</>
);
},
};
const getDetail = (
@ -907,6 +915,9 @@ const getDetail = (
case ActivityType.UnlockedHost: {
return TAGGED_TEMPLATES.unlockedHost(activity);
}
case ActivityType.WipedHost: {
return TAGGED_TEMPLATES.wipedHost(activity);
}
default: {
return TAGGED_TEMPLATES.defaultActivityTemplate(activity);
}

View File

@ -22,7 +22,7 @@ describe("Integrations Page", () => {
const render = createCustomRenderer({
withBackendMock: true,
context: {
app: { isMdmEnabledAndConfigured: true },
app: { isMacMdmEnabledAndConfigured: true },
},
});

View File

@ -94,7 +94,7 @@ describe("Host Actions Dropdown", () => {
const render = createCustomRenderer({
context: {
app: {
isMdmEnabledAndConfigured: true,
isMacMdmEnabledAndConfigured: true,
isGlobalAdmin: true,
currentUser: createMockUser(),
},
@ -122,7 +122,7 @@ describe("Host Actions Dropdown", () => {
const render = createCustomRenderer({
context: {
app: {
isMdmEnabledAndConfigured: true,
isMacMdmEnabledAndConfigured: true,
isGlobalMaintainer: true,
currentUser: createMockUser(),
},
@ -150,7 +150,7 @@ describe("Host Actions Dropdown", () => {
const render = createCustomRenderer({
context: {
app: {
isMdmEnabledAndConfigured: true,
isMacMdmEnabledAndConfigured: true,
currentUser: createMockUser({
teams: [createMockTeam({ id: 1, role: "admin" })],
}),
@ -179,7 +179,7 @@ describe("Host Actions Dropdown", () => {
const render = createCustomRenderer({
context: {
app: {
isMdmEnabledAndConfigured: true,
isMacMdmEnabledAndConfigured: true,
currentUser: createMockUser({
teams: [createMockTeam({ id: 1, role: "maintainer" })],
}),
@ -208,7 +208,7 @@ describe("Host Actions Dropdown", () => {
const render = createCustomRenderer({
context: {
app: {
isMdmEnabledAndConfigured: true,
isMacMdmEnabledAndConfigured: true,
currentUser: createMockUser(),
},
},
@ -235,7 +235,7 @@ describe("Host Actions Dropdown", () => {
const render = createCustomRenderer({
context: {
app: {
isMdmEnabledAndConfigured: true,
isMacMdmEnabledAndConfigured: true,
isGlobalAdmin: true,
currentUser: createMockUser(),
},
@ -267,7 +267,7 @@ describe("Host Actions Dropdown", () => {
const render = createCustomRenderer({
context: {
app: {
isMdmEnabledAndConfigured: true,
isMacMdmEnabledAndConfigured: true,
isGlobalAdmin: true,
currentUser: createMockUser(),
},
@ -402,7 +402,7 @@ describe("Host Actions Dropdown", () => {
context: {
app: {
isPremiumTier: true,
isMdmEnabledAndConfigured: true,
isMacMdmEnabledAndConfigured: true,
isGlobalAdmin: true,
currentUser: createMockUser(),
},
@ -431,7 +431,7 @@ describe("Host Actions Dropdown", () => {
context: {
app: {
isPremiumTier: true,
isMdmEnabledAndConfigured: true,
isMacMdmEnabledAndConfigured: true,
isGlobalAdmin: true,
currentUser: createMockUser(),
},
@ -460,7 +460,7 @@ describe("Host Actions Dropdown", () => {
context: {
app: {
isPremiumTier: true,
isMdmEnabledAndConfigured: true,
isMacMdmEnabledAndConfigured: true,
isGlobalAdmin: true,
currentUser: createMockUser(),
},
@ -491,7 +491,7 @@ describe("Host Actions Dropdown", () => {
context: {
app: {
isPremiumTier: true,
isMdmEnabledAndConfigured: true,
isMacMdmEnabledAndConfigured: true,
isGlobalAdmin: true,
currentUser: createMockUser(),
},
@ -520,7 +520,7 @@ describe("Host Actions Dropdown", () => {
context: {
app: {
isPremiumTier: true,
isMdmEnabledAndConfigured: true,
isMacMdmEnabledAndConfigured: true,
isGlobalAdmin: true,
currentUser: createMockUser(),
},
@ -549,7 +549,7 @@ describe("Host Actions Dropdown", () => {
context: {
app: {
isPremiumTier: true,
isMdmEnabledAndConfigured: true,
isMacMdmEnabledAndConfigured: true,
isGlobalAdmin: true,
currentUser: createMockUser(),
},
@ -578,7 +578,7 @@ describe("Host Actions Dropdown", () => {
context: {
app: {
isPremiumTier: true,
isMdmEnabledAndConfigured: true,
isMacMdmEnabledAndConfigured: true,
isGlobalAdmin: true,
currentUser: createMockUser(),
},
@ -601,5 +601,126 @@ describe("Host Actions Dropdown", () => {
expect(screen.queryByText("Unlock")).not.toBeInTheDocument();
});
it("does not renders when a mac host but does not have Fleet mac mdm enabled and configured", async () => {
const render = createCustomRenderer({
context: {
app: {
isPremiumTier: true,
isMacMdmEnabledAndConfigured: false,
isWindowsMdmEnabledAndConfigured: true,
isGlobalAdmin: true,
currentUser: createMockUser(),
},
},
});
const { user } = render(
<HostActionsDropdown
hostTeamId={null}
onSelect={noop}
hostStatus="online"
hostMdmEnrollmentStatus="On (automatic)"
mdmName="Fleet"
hostPlatform="darwin"
hostMdmDeviceStatus="locked"
/>
);
await user.click(screen.getByText("Actions"));
expect(screen.queryByText("Unlock")).not.toBeInTheDocument();
});
});
describe("Wipe action", () => {
it("renders only when the host is unlocked", async () => {
const render = createCustomRenderer({
context: {
app: {
isPremiumTier: true,
isMacMdmEnabledAndConfigured: true,
isGlobalAdmin: true,
currentUser: createMockUser(),
},
},
});
const { user } = render(
<HostActionsDropdown
hostTeamId={null}
onSelect={noop}
hostStatus="online"
hostMdmEnrollmentStatus="On (automatic)"
mdmName="Fleet"
hostPlatform="darwin"
hostMdmDeviceStatus="unlocked"
/>
);
await user.click(screen.getByText("Actions"));
expect(screen.getByText("Wipe")).toBeInTheDocument();
});
it("does not renders when a windows host but does not have Fleet windows mdm enabled and configured", async () => {
const render = createCustomRenderer({
context: {
app: {
isPremiumTier: true,
isMacMdmEnabledAndConfigured: true,
isWindowsMdmEnabledAndConfigured: false,
isGlobalAdmin: true,
currentUser: createMockUser(),
},
},
});
const { user } = render(
<HostActionsDropdown
hostTeamId={null}
onSelect={noop}
hostStatus="online"
hostMdmEnrollmentStatus="On (automatic)"
mdmName="Fleet"
hostPlatform="windows"
hostMdmDeviceStatus="unlocked"
/>
);
await user.click(screen.getByText("Actions"));
expect(screen.queryByText("Wipe")).not.toBeInTheDocument();
});
it("does not renders when a mac host but does not have Fleet mac mdm enabled and configured", async () => {
const render = createCustomRenderer({
context: {
app: {
isPremiumTier: true,
isMacMdmEnabledAndConfigured: false,
isWindowsMdmEnabledAndConfigured: true,
isGlobalAdmin: true,
currentUser: createMockUser(),
},
},
});
const { user } = render(
<HostActionsDropdown
hostTeamId={null}
onSelect={noop}
hostStatus="online"
hostMdmEnrollmentStatus="On (automatic)"
mdmName="Fleet"
hostPlatform="darwin"
hostMdmDeviceStatus="unlocked"
/>
);
await user.click(screen.getByText("Actions"));
expect(screen.queryByText("Wipe")).not.toBeInTheDocument();
});
});
});

View File

@ -38,7 +38,8 @@ const HostActionsDropdown = ({
isPremiumTier = false,
isGlobalAdmin = false,
isGlobalMaintainer = false,
isMdmEnabledAndConfigured = false,
isMacMdmEnabledAndConfigured = false,
isWindowsMdmEnabledAndConfigured = false,
isSandboxMode = false,
currentUser,
} = useContext(AppContext);
@ -67,7 +68,8 @@ const HostActionsDropdown = ({
hostMdmEnrollmentStatus ?? ""
),
isFleetMdm: mdmName === "Fleet",
isMdmEnabledAndConfigured,
isMacMdmEnabledAndConfigured,
isWindowsMdmEnabledAndConfigured,
doesStoreEncryptionKey: doesStoreEncryptionKey ?? false,
isSandboxMode,
hostMdmDeviceStatus,

View File

@ -44,11 +44,11 @@ const DEFAULT_OPTIONS = [
value: "lock",
disabled: false,
},
// {
// label: "Wipe",
// value: "wipe",
// disabled: false,
// },
{
label: "Wipe",
value: "wipe",
disabled: false,
},
{
label: "Unlock",
value: "unlock",
@ -74,7 +74,8 @@ interface IHostActionConfigOptions {
isHostOnline: boolean;
isEnrolledInMdm: boolean;
isFleetMdm: boolean;
isMdmEnabledAndConfigured: boolean;
isMacMdmEnabledAndConfigured: boolean;
isWindowsMdmEnabledAndConfigured: boolean;
doesStoreEncryptionKey: boolean;
isSandboxMode: boolean;
hostMdmDeviceStatus: HostMdmDeviceStatusUIState;
@ -93,11 +94,11 @@ const canEditMdm = (config: IHostActionConfigOptions) => {
isTeamMaintainer,
isEnrolledInMdm,
isFleetMdm,
isMdmEnabledAndConfigured,
isMacMdmEnabledAndConfigured,
} = config;
return (
config.hostPlatform === "darwin" &&
isMdmEnabledAndConfigured &&
isMacMdmEnabledAndConfigured &&
isEnrolledInMdm &&
isFleetMdm &&
(isGlobalAdmin || isGlobalMaintainer || isTeamAdmin || isTeamMaintainer)
@ -107,7 +108,7 @@ const canEditMdm = (config: IHostActionConfigOptions) => {
const canLockHost = ({
isPremiumTier,
hostPlatform,
isMdmEnabledAndConfigured,
isMacMdmEnabledAndConfigured,
isEnrolledInMdm,
isFleetMdm,
isGlobalAdmin,
@ -120,7 +121,7 @@ const canLockHost = ({
const canLockDarwin =
hostPlatform === "darwin" &&
isFleetMdm &&
isMdmEnabledAndConfigured &&
isMacMdmEnabledAndConfigured &&
isEnrolledInMdm;
return (
@ -143,23 +144,23 @@ const canWipeHost = ({
isTeamObserver,
isFleetMdm,
isEnrolledInMdm,
isMdmEnabledAndConfigured,
isMacMdmEnabledAndConfigured,
isWindowsMdmEnabledAndConfigured,
hostPlatform,
hostMdmDeviceStatus,
}: IHostActionConfigOptions) => {
// TODO: remove when we work on wipe issue.
return false;
const hostMdmEnabled =
(hostPlatform === "darwin" && isMacMdmEnabledAndConfigured) ||
(hostPlatform === "windows" && isWindowsMdmEnabledAndConfigured);
// macOS and Windows hosts have the same conditions and can be wiped if they
// are enrolled in MDM and the MDM is enabled.
const canWipeMacOrWindows =
(hostPlatform === "darwin" || hostPlatform === "windows") &&
isFleetMdm &&
isMdmEnabledAndConfigured &&
isEnrolledInMdm;
const canWipeMacOrWindows = hostMdmEnabled && isFleetMdm && isEnrolledInMdm;
return (
isPremiumTier &&
(hostPlatform === "linux" || canWipeMacOrWindows) &&
hostMdmDeviceStatus === "unlocked" &&
(isLinuxLike(hostPlatform) || canWipeMacOrWindows) &&
(isGlobalAdmin ||
isGlobalMaintainer ||
isGlobalObserver ||
@ -177,14 +178,14 @@ const canUnlock = ({
isTeamMaintainer,
isFleetMdm,
isEnrolledInMdm,
isMdmEnabledAndConfigured,
isMacMdmEnabledAndConfigured,
hostPlatform,
hostMdmDeviceStatus,
}: IHostActionConfigOptions) => {
const canLockDarwin =
const canUnlockDarwin =
hostPlatform === "darwin" &&
isFleetMdm &&
isMdmEnabledAndConfigured &&
isMacMdmEnabledAndConfigured &&
isEnrolledInMdm;
// "unlocking" for a macOS host means that somebody saw the unlock pin, but
@ -198,7 +199,7 @@ const canUnlock = ({
isPremiumTier &&
isValidState &&
(isGlobalAdmin || isGlobalMaintainer || isTeamAdmin || isTeamMaintainer) &&
(canLockDarwin || hostPlatform === "windows" || isLinuxLike(hostPlatform))
(canUnlockDarwin || hostPlatform === "windows" || isLinuxLike(hostPlatform))
);
};
@ -265,9 +266,9 @@ const filterOutOptions = (
options = options.filter((option) => option.value !== "lock");
}
// if (!canWipeHost(config)) {
// options = options.filter((option) => option.value !== "wipe");
// }
if (!canWipeHost(config)) {
options = options.filter((option) => option.value !== "wipe");
}
if (!canUnlock(config)) {
options = options.filter((option) => option.value !== "unlock");
@ -292,7 +293,12 @@ const setOptionsAsDisabled = (
};
let optionsToDisable: IDropdownOption[] = [];
if (!isHostOnline) {
if (
!isHostOnline ||
isDeviceStatusUpdating(hostMdmDeviceStatus) ||
hostMdmDeviceStatus === "locked" ||
hostMdmDeviceStatus === "wiped"
) {
optionsToDisable = optionsToDisable.concat(
options.filter(
(option) => option.value === "query" || option.value === "mdmOff"
@ -304,16 +310,6 @@ const setOptionsAsDisabled = (
options.filter((option) => option.value === "transfer")
);
}
if (
isDeviceStatusUpdating(hostMdmDeviceStatus) ||
hostMdmDeviceStatus === "locked"
) {
optionsToDisable = optionsToDisable.concat(
options.filter(
(option) => option.value === "query" || option.value === "mdmOff"
)
);
}
disableOptions(optionsToDisable);
return options;

View File

@ -14,6 +14,7 @@ import { NotificationContext } from "context/notification";
import activitiesAPI, {
IActivitiesResponse,
IPastActivitiesResponse,
IUpcomingActivitiesResponse,
} from "services/entities/activities";
import hostAPI from "services/entities/hosts";
@ -90,6 +91,7 @@ import {
HostMdmDeviceStatusUIState,
getHostDeviceStatusUIState,
} from "../helpers";
import WipeModal from "./modals/WipeModal";
const baseClass = "host-details";
@ -164,6 +166,7 @@ const HostDetailsPage = ({
);
const [showLockHostModal, setShowLockHostModal] = useState(false);
const [showUnlockHostModal, setShowUnlockHostModal] = useState(false);
const [showWipeModal, setShowWipeModal] = useState(false);
const [scriptDetailsId, setScriptDetailsId] = useState("");
const [selectedPolicy, setSelectedPolicy] = useState<IHostPolicy | null>(
null
@ -366,9 +369,9 @@ const HostDetailsPage = ({
isError: pastActivitiesIsError,
refetch: refetchPastActivities,
} = useQuery<
IActivitiesResponse,
IPastActivitiesResponse,
Error,
IActivitiesResponse,
IPastActivitiesResponse,
Array<{
scope: string;
pageIndex: number;
@ -644,6 +647,9 @@ const HostDetailsPage = ({
case "unlock":
setShowUnlockHostModal(true);
break;
case "wipe":
setShowWipeModal(true);
break;
default: // do nothing
}
};
@ -976,6 +982,14 @@ const HostDetailsPage = ({
onClose={() => setShowUnlockHostModal(false)}
/>
)}
{showWipeModal && (
<WipeModal
id={host.id}
hostName={host.display_name}
onSuccess={() => setHostMdmDeviceState("wiping")}
onClose={() => setShowWipeModal(false)}
/>
)}
</>
</MainContent>
);

View File

@ -0,0 +1,78 @@
import React, { useContext } from "react";
import hostAPI from "services/entities/hosts";
import Modal from "components/Modal";
import Button from "components/buttons/Button";
import Checkbox from "components/forms/fields/Checkbox";
import { NotificationContext } from "context/notification";
import { AxiosError } from "axios";
const baseClass = "wipe-modal";
interface IWipeModalProps {
id: number;
hostName: string;
onSuccess: () => void;
onClose: () => void;
}
const WipeModal = ({ id, hostName, onSuccess, onClose }: IWipeModalProps) => {
const { renderFlash } = useContext(NotificationContext);
const [lockChecked, setLockChecked] = React.useState(false);
const [isWiping, setIsWiping] = React.useState(false);
const onWipe = async () => {
setIsWiping(true);
try {
await hostAPI.wipeHost(id);
onSuccess();
renderFlash("success", "Success! Host is wiping.");
} catch (error) {
const err = error as AxiosError;
renderFlash("error", err.message);
}
onClose();
setIsWiping(false);
};
return (
<Modal className={baseClass} title="Wipe host" onExit={onClose}>
<>
<div className={`${baseClass}__modal-content`}>
<p>All content will be erased on this host.</p>
<div className={`${baseClass}__confirm-message`}>
<span>
<b>Please check to confirm:</b>
</span>
<Checkbox
wrapperClassName={`${baseClass}__wipe-checkbox`}
value={lockChecked}
onChange={(value: boolean) => setLockChecked(value)}
>
I wish to wipe <b>{hostName}</b>
</Checkbox>
</div>
</div>
<div className="modal-cta-wrap">
<Button
type="button"
onClick={onWipe}
variant="alert"
className="delete-loading"
disabled={!lockChecked}
isLoading={isWiping}
>
Wipe
</Button>
<Button onClick={onClose} variant="inverse-alert">
Cancel
</Button>
</div>
</>
</Modal>
);
};
export default WipeModal;

View File

@ -0,0 +1,14 @@
.wipe-modal {
p {
margin: 0;
}
&__modal-content {
display: grid;
gap: $pad-large;
}
&__wipe-checkbox {
margin-top: $pad-small;
}
}

View File

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

View File

@ -2,7 +2,10 @@ import React from "react";
import { Tab, TabList, TabPanel, Tabs } from "react-tabs";
import { IActivityDetails } from "interfaces/activity";
import { IActivitiesResponse } from "services/entities/activities";
import {
IPastActivitiesResponse,
IUpcomingActivitiesResponse,
} from "services/entities/activities";
import Card from "components/Card";
import TabsWrapper from "components/TabsWrapper";
@ -45,7 +48,7 @@ const UpcomingTooltip = () => {
interface IActivityProps {
activeTab: "past" | "upcoming";
activities?: IActivitiesResponse;
activities?: IPastActivitiesResponse | IUpcomingActivitiesResponse;
isLoading?: boolean;
isError?: boolean;
upcomingCount: number;
@ -93,7 +96,7 @@ const Activity = ({
</TabList>
<TabPanel>
<PastActivityFeed
activities={activities}
activities={activities as IPastActivitiesResponse | undefined}
onDetailsClick={onShowDetails}
isError={isError}
onNextPage={onNextPage}
@ -103,7 +106,7 @@ const Activity = ({
<TabPanel>
<UpcomingTooltip />
<UpcomingActivityFeed
activities={activities}
activities={activities as IUpcomingActivitiesResponse | undefined}
onDetailsClick={onShowDetails}
isError={isError}
onNextPage={onNextPage}

View File

@ -0,0 +1,34 @@
import React from "react";
import {
ActivityType,
IHostPastActivityType,
IPastActivity,
} from "interfaces/activity";
import { ShowActivityDetailsHandler } from "./Activity";
import RanScriptActivityItem from "./ActivityItems/RanScriptActivityItem";
import LockedHostActivityItem from "./ActivityItems/LockedHostActivityItem";
import UnlockedHostActivityItem from "./ActivityItems/UnlockedHostActivityItem";
/** The component props that all host activity items must adhere to */
export interface IHostActivityItemComponentProps {
activity: IPastActivity;
}
/** Used for activity items component that need a show details handler */
export interface IHostActivityItemComponentPropsWithShowDetails
extends IHostActivityItemComponentProps {
onShowDetails: ShowActivityDetailsHandler;
}
export const pastActivityComponentMap: Record<
IHostPastActivityType,
| React.FC<IHostActivityItemComponentProps>
| React.FC<IHostActivityItemComponentPropsWithShowDetails>
> = {
[ActivityType.RanScript]: RanScriptActivityItem,
[ActivityType.LockedHost]: LockedHostActivityItem,
[ActivityType.UnlockedHost]: UnlockedHostActivityItem,
};

View File

@ -0,0 +1,18 @@
import React from "react";
import { IHostActivityItemComponentProps } from "../../ActivityConfig";
import HostActivityItem from "../../HostActivityItem";
const baseClass = "locked-host-activity-item";
const LockedHostActivityItem = ({
activity,
}: IHostActivityItemComponentProps) => {
return (
<HostActivityItem className={baseClass} activity={activity}>
<b>{activity.actor_full_name}</b> locked this host.
</HostActivityItem>
);
};
export default LockedHostActivityItem;

View File

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

View File

@ -0,0 +1,28 @@
import React from "react";
import { formatScriptNameForActivityItem } from "utilities/helpers";
import HostActivityItem from "../../HostActivityItem";
import { IHostActivityItemComponentPropsWithShowDetails } from "../../ActivityConfig";
import ShowDetailsButton from "../../ShowDetailsButton";
const baseClass = "ran-script-activity-item";
const RanScriptActivityItem = ({
activity,
onShowDetails,
}: IHostActivityItemComponentPropsWithShowDetails) => {
return (
<HostActivityItem className={baseClass} activity={activity}>
<b>{activity.actor_full_name}</b>
<>
{" "}
ran {formatScriptNameForActivityItem(activity.details?.script_name)} on
this host.{" "}
<ShowDetailsButton activity={activity} onShowDetails={onShowDetails} />
</>
</HostActivityItem>
);
};
export default RanScriptActivityItem;

View File

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

View File

@ -0,0 +1,18 @@
import React from "react";
import { IHostActivityItemComponentProps } from "../../ActivityConfig";
import HostActivityItem from "../../HostActivityItem";
const baseClass = "unlocked-host-activity-item";
const UnlockedHostActivityItem = ({
activity,
}: IHostActivityItemComponentProps) => {
return (
<HostActivityItem className={baseClass} activity={activity}>
<b>{activity.actor_full_name}</b> unlocked this host.
</HostActivityItem>
);
};
export default UnlockedHostActivityItem;

View File

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

View File

@ -0,0 +1,94 @@
import React from "react";
import ReactTooltip from "react-tooltip";
import { formatDistanceToNowStrict } from "date-fns";
import classnames from "classnames";
import { IActivity } from "interfaces/activity";
import {
addGravatarUrlToResource,
internationalTimeFormat,
} from "utilities/helpers";
import { DEFAULT_GRAVATAR_LINK } from "utilities/constants";
import Avatar from "components/Avatar";
import { COLORS } from "styles/var/colors";
const baseClass = "host-activity-item";
interface IHostActivityItemProps {
activity: IActivity;
children: React.ReactNode;
className?: string;
}
/**
* A wrapper that will render all the common elements of a host activity item.
* This includes the avatar, the created at timestamp, and a dash to separate
* the activity items. The `children` will be the specific details of the activity
* implemented in the component that uses this wrapper.
*/
const HostActivityItem = ({
activity,
children,
className,
}: IHostActivityItemProps) => {
const { actor_email } = activity;
const { gravatar_url } = actor_email
? addGravatarUrlToResource({ email: actor_email })
: { gravatar_url: DEFAULT_GRAVATAR_LINK };
// wrapped just in case the date string does not parse correctly
let activityCreatedAt: Date | null = null;
try {
activityCreatedAt = new Date(activity.created_at);
} catch (e) {
activityCreatedAt = null;
}
const classNames = classnames(baseClass, className);
return (
<div className={classNames}>
<Avatar
className={`${baseClass}__avatar-image`}
user={{ gravatar_url }}
size="small"
hasWhiteBackground
/>
<div className={`${baseClass}__details-wrapper`}>
<div className={"activity-details"}>
<span className={`${baseClass}__details-topline`}>
<span>{children}</span>
</span>
<br />
<span
className={`${baseClass}__details-bottomline`}
data-tip
data-for={`activity-${activity.id}`}
>
{activityCreatedAt &&
formatDistanceToNowStrict(activityCreatedAt, {
addSuffix: true,
})}
</span>
{activityCreatedAt && (
<ReactTooltip
className="date-tooltip"
place="top"
type="dark"
effect="solid"
id={`activity-${activity.id}`}
backgroundColor={COLORS["tooltip-bg"]}
>
{internationalTimeFormat(activityCreatedAt)}
</ReactTooltip>
)}
</div>
</div>
<div className={`${baseClass}__dash`} />
</div>
);
};
export default HostActivityItem;

View File

@ -1,4 +1,4 @@
.past-activity {
.host-activity-item {
display: grid; // Grid system is used to create variable dashed line lengths
grid-template-columns: 16px 16px 1fr;
grid-template-rows: 32px max-content;
@ -62,4 +62,5 @@
padding-bottom: $pad-xxlarge;
}
}
}

View File

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

View File

@ -1,141 +0,0 @@
import React from "react";
import ReactTooltip from "react-tooltip";
import { formatDistanceToNowStrict } from "date-fns";
import Avatar from "components/Avatar";
import Icon from "components/Icon";
import Button from "components/buttons/Button";
import { COLORS } from "styles/var/colors";
import { DEFAULT_GRAVATAR_LINK } from "utilities/constants";
import {
addGravatarUrlToResource,
formatScriptNameForActivityItem,
internationalTimeFormat,
} from "utilities/helpers";
import { IActivity } from "interfaces/activity";
import { ShowActivityDetailsHandler } from "../Activity";
const baseClass = "past-activity";
interface IPastActivityProps {
activity: IActivity;
// TODO: To handle clicks for different activity types, this could be refactored as a reducer that
// takes the activity and dispatches the relevant show details action based on the activity type
onDetailsClick: ShowActivityDetailsHandler;
}
const RanScriptActivityDetails = ({
activity,
onDetailsClick,
}: Pick<IPastActivityProps, "activity" | "onDetailsClick">) => (
<span className={`${baseClass}__details-topline`}>
<b>{activity.actor_full_name}</b>
<>
{" "}
ran {formatScriptNameForActivityItem(activity.details?.script_name)} on
this host.{" "}
<Button
className={`${baseClass}__show-query-link`}
variant="text-link"
onClick={() => onDetailsClick?.(activity)}
>
Show details{" "}
<Icon className={`${baseClass}__show-query-icon`} name="eye" />
</Button>
</>
</span>
);
const LockedHostActivityDetails = ({
activity,
}: Pick<IPastActivityProps, "activity">) => (
<span className={`${baseClass}__details-topline`}>
<b>{activity.actor_full_name}</b> locked this host.
</span>
);
const UnlockedHostActivityDetails = ({
activity,
}: Pick<IPastActivityProps, "activity">) => (
<span className={`${baseClass}__details-topline`}>
<b>{activity.actor_full_name}</b>{" "}
{activity.details?.host_platform === "darwin"
? "viewed the six-digit unlock PIN for"
: "unlocked"}{" "}
this host.
</span>
);
const PastActivityTopline = ({
activity,
onDetailsClick,
}: IPastActivityProps) => {
switch (activity.type) {
case "ran_script":
return (
<RanScriptActivityDetails
activity={activity}
onDetailsClick={onDetailsClick}
/>
);
case "locked_host":
return <LockedHostActivityDetails activity={activity} />;
case "unlocked_host":
return <UnlockedHostActivityDetails activity={activity} />;
default:
return null;
}
};
// TODO: Combine this with ./UpcomingActivity/UpcomingActivity.tsx and
// frontend/pages/DashboardPage/cards/ActivityFeed/ActivityItem/ActivityItem.tsx
const PastActivity = ({ activity, onDetailsClick }: IPastActivityProps) => {
const { actor_email } = activity;
const { gravatar_url } = actor_email
? addGravatarUrlToResource({ email: actor_email })
: { gravatar_url: DEFAULT_GRAVATAR_LINK };
const activityCreatedAt = new Date(activity.created_at);
return (
<div className={baseClass}>
<Avatar
className={`${baseClass}__avatar-image`}
user={{ gravatar_url }}
size="small"
hasWhiteBackground
/>
<div className={`${baseClass}__details-wrapper`}>
<div className="activity-details">
<PastActivityTopline
activity={activity}
onDetailsClick={onDetailsClick}
/>
<br />
<span
className={`${baseClass}__details-bottomline`}
data-tip
data-for={`activity-${activity.id}`}
>
{formatDistanceToNowStrict(activityCreatedAt, {
addSuffix: true,
})}
</span>
<ReactTooltip
className="date-tooltip"
place="top"
type="dark"
effect="solid"
id={`activity-${activity.id}`}
backgroundColor={COLORS["tooltip-bg"]}
>
{internationalTimeFormat(activityCreatedAt)}
</ReactTooltip>
</div>
</div>
<div className={`${baseClass}__dash`} />
</div>
);
};
export default PastActivity;

View File

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

View File

@ -1,7 +1,7 @@
import React from "react";
import { IActivity } from "interfaces/activity";
import { IActivitiesResponse } from "services/entities/activities";
import { IPastActivity } from "interfaces/activity";
import { IPastActivitiesResponse } from "services/entities/activities";
// @ts-ignore
import FleetIcon from "components/icons/FleetIcon";
@ -9,13 +9,14 @@ import Button from "components/buttons/Button";
import DataError from "components/DataError";
import EmptyFeed from "../EmptyFeed/EmptyFeed";
import PastActivity from "../PastActivity/PastActivity";
import { ShowActivityDetailsHandler } from "../Activity";
import { pastActivityComponentMap } from "../ActivityConfig";
const baseClass = "past-activity-feed";
interface IPastActivityFeedProps {
activities?: IActivitiesResponse;
activities?: IPastActivitiesResponse;
isError?: boolean;
onDetailsClick: ShowActivityDetailsHandler;
onNextPage: () => void;
@ -52,9 +53,16 @@ const PastActivityFeed = ({
return (
<div className={baseClass}>
<div>
{activitiesList.map((activity: IActivity) => (
<PastActivity activity={activity} onDetailsClick={onDetailsClick} />
))}
{activitiesList.map((activity: IPastActivity) => {
const ActivityItemComponent = pastActivityComponentMap[activity.type];
return (
<ActivityItemComponent
key={activity.id}
activity={activity}
onShowDetails={onDetailsClick}
/>
);
})}
</div>
<div className={`${baseClass}__pagination`}>
<Button

View File

@ -0,0 +1,33 @@
import React from "react";
import { IActivity } from "interfaces/activity";
import Icon from "components/Icon";
import Button from "components/buttons/Button";
import { ShowActivityDetailsHandler } from "../Activity";
const baseClass = "show-details-button";
interface IShowDetailsButtonProps {
activity: IActivity;
onShowDetails: ShowActivityDetailsHandler;
}
const ShowDetailsButton = ({
activity,
onShowDetails,
}: IShowDetailsButtonProps) => {
return (
<Button
className={baseClass}
variant="text-link"
onClick={() => onShowDetails?.(activity)}
>
Show details{" "}
<Icon className={`${baseClass}__show-details-icon`} name="eye" />
</Button>
);
};
export default ShowDetailsButton;

View File

@ -0,0 +1,5 @@
.show-details-button {
&__show-details-icon {
margin-left: $pad-xsmall;
}
}

View File

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

View File

@ -12,7 +12,8 @@
}
&.error {
background-color: $ui-error;
color: $core-white;
background-color: $core-vibrant-red;
}
}

View File

@ -39,6 +39,20 @@ export const DEVICE_STATUS_TAGS: DeviceStatusTagConfig = {
generateTooltip: (platform) =>
"Host will lock when it comes online. If the host is online, it will lock the next time it checks in to Fleet.",
},
wiped: {
title: "WIPED",
tagType: "error",
generateTooltip: (platform) =>
platform === "darwin"
? "Host is wiped. To prevent the host from automatically reenrolling to Fleet, first release the host from Apple Business Manager and then delete the host in Fleet."
: "Host is wiped.",
},
wiping: {
title: "WIPE PENDING",
tagType: "error",
generateTooltip: () =>
"Host will wipe when it comes online. If the host is online, it will wipe the next time it checks in to Fleet.",
},
};
// We exclude "unlocked" as we dont display a tooltip for it.
@ -66,4 +80,14 @@ export const REFETCH_TOOLTIP_MESSAGES: Record<
You can&apos;t fetch data from <br /> a locked host.
</>
),
wiping: (
<>
You can&apos;t fetch data from <br /> a wiping host.
</>
),
wiped: (
<>
You can&apos;t fetch data from <br /> a wiped host.
</>
),
} as const;

View File

@ -1,5 +1,4 @@
/** Helpers used across the host details and my device pages and components. */
import { is } from "date-fns/locale";
import { HostMdmDeviceStatus, HostMdmPendingAction } from "interfaces/host";
import {
IHostMdmProfile,
@ -39,7 +38,9 @@ export type HostMdmDeviceStatusUIState =
| "unlocked"
| "locked"
| "unlocking"
| "locking";
| "locking"
| "wiped"
| "wiping";
// Exclude the empty string from HostPendingAction as that doesn't represent a
// valid device status.
@ -51,9 +52,11 @@ const API_TO_UI_DEVICE_STATUS_MAP: Record<
locked: "locked",
unlock: "unlocking",
lock: "locking",
wiped: "wiped",
wipe: "wiping",
};
const deviceUpdatingStates = ["unlocking", "locking"] as const;
const deviceUpdatingStates = ["unlocking", "locking", "wiping"] as const;
/**
* Gets the current UI state for the host device status. This helps us know what
@ -74,7 +77,7 @@ export const getHostDeviceStatusUIState = (
};
/**
* Helps check if our device status UI state is in an updating state.
* Checks if our device status UI state is in an updating state.
*/
export const isDeviceStatusUpdating = (
deviceStatus: HostMdmDeviceStatusUIState

View File

@ -1,5 +1,5 @@
import endpoints from "utilities/endpoints";
import { IActivity } from "interfaces/activity";
import { IActivity, IPastActivity } from "interfaces/activity";
import sendRequest from "services";
import { buildQueryStringFromParams } from "utilities/url";
@ -16,6 +16,14 @@ export interface IActivitiesResponse {
};
}
export interface IPastActivitiesResponse {
activities: IPastActivity[] | null;
meta: {
has_next_results: boolean;
has_previous_results: boolean;
};
}
export interface IUpcomingActivitiesResponse extends IActivitiesResponse {
count: number;
}
@ -45,7 +53,7 @@ export default {
id: number,
page = DEFAULT_PAGE,
perPage = DEFAULT_PAGE_SIZE
): Promise<IActivitiesResponse> => {
): Promise<IPastActivitiesResponse> => {
const { HOST_PAST_ACTIVITIES } = endpoints;
const queryParams = {

View File

@ -397,8 +397,14 @@ export default {
const { HOST_LOCK } = endpoints;
return sendRequest("POST", HOST_LOCK(id));
},
unlockHost: (id: number): Promise<IUnlockHostResponse> => {
const { HOST_UNLOCK } = endpoints;
return sendRequest("POST", HOST_UNLOCK(id));
},
wipeHost: (id: number) => {
const { HOST_WIPE } = endpoints;
return sendRequest("POST", HOST_WIPE(id));
},
};

View File

@ -43,6 +43,7 @@ export default {
HOSTS_TRANSFER_BY_FILTER: `/${API_VERSION}/fleet/hosts/transfer/filter`,
HOST_LOCK: (id: number) => `/${API_VERSION}/fleet/hosts/${id}/lock`,
HOST_UNLOCK: (id: number) => `/${API_VERSION}/fleet/hosts/${id}/unlock`,
HOST_WIPE: (id: number) => `/${API_VERSION}/fleet/hosts/${id}/wipe`,
INVITES: `/${API_VERSION}/fleet/invites`,
LABELS: `/${API_VERSION}/fleet/labels`,

View File

@ -13,10 +13,14 @@ export const isPremiumTier = (config: IConfig): boolean => {
return config.license.tier === "premium";
};
export const isMdmEnabledAndConfigured = (config: IConfig): boolean => {
export const isMacMdmEnabledAndConfigured = (config: IConfig): boolean => {
return Boolean(config.mdm.enabled_and_configured);
};
export const isWindowsMdmEnabledAndConfigured = (config: IConfig): boolean => {
return Boolean(config.mdm.windows_enabled_and_configured);
};
export const isGlobalAdmin = (user: IUser): boolean => {
return user.global_role === "admin";
};
@ -142,7 +146,8 @@ export default {
isSandboxMode,
isFreeTier,
isPremiumTier,
isMdmEnabledAndConfigured,
isMacMdmEnabledAndConfigured,
isWindowsMdmEnabledAndConfigured,
isGlobalAdmin,
isGlobalMaintainer,
isGlobalObserver,