diff --git a/changes/issue-10489-ui-for-wiping-host b/changes/issue-10489-ui-for-wiping-host new file mode 100644 index 0000000000..066cc8f06e --- /dev/null +++ b/changes/issue-10489-ui-for-wiping-host @@ -0,0 +1 @@ +- add UI for wiping a host with fleet mdm. diff --git a/frontend/context/app.tsx b/frontend/context/app.tsx index 5784534c4e..4dc0d2137f 100644 --- a/frontend/context/app.tsx +++ b/frontend/context/app.tsx @@ -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, diff --git a/frontend/interfaces/activity.ts b/frontend/interfaces/activity.ts index ed047aa777..143efeaf0a 100644 --- a/frontend/interfaces/activity.ts +++ b/frontend/interfaces/activity.ts @@ -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 & { + type: IHostPastActivityType; +}; + export interface IActivityDetails { pack_id?: number; pack_name?: string; diff --git a/frontend/interfaces/host.ts b/frontend/interfaces/host.ts index 8df2467664..4f424c2ebe 100644 --- a/frontend/interfaces/host.ts +++ b/frontend/interfaces/host.ts @@ -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; diff --git a/frontend/pages/DashboardPage/cards/ActivityFeed/ActivityItem/ActivityItem.tests.tsx b/frontend/pages/DashboardPage/cards/ActivityFeed/ActivityItem/ActivityItem.tests.tsx index a18dd1aae7..e8bff33d98 100644 --- a/frontend/pages/DashboardPage/cards/ActivityFeed/ActivityItem/ActivityItem.tests.tsx +++ b/frontend/pages/DashboardPage/cards/ActivityFeed/ActivityItem/ActivityItem.tests.tsx @@ -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(); + + expect(screen.getByText("wiped", { exact: false })).toBeInTheDocument(); + expect(screen.getByText("Foo Host", { exact: false })).toBeInTheDocument(); + }); }); diff --git a/frontend/pages/DashboardPage/cards/ActivityFeed/ActivityItem/ActivityItem.tsx b/frontend/pages/DashboardPage/cards/ActivityFeed/ActivityItem/ActivityItem.tsx index dadda97c84..f576ad0532 100644 --- a/frontend/pages/DashboardPage/cards/ActivityFeed/ActivityItem/ActivityItem.tsx +++ b/frontend/pages/DashboardPage/cards/ActivityFeed/ActivityItem/ActivityItem.tsx @@ -755,6 +755,14 @@ const TAGGED_TEMPLATES = { ); }, + wipedHost: (activity: IActivity) => { + return ( + <> + {" "} + wiped {activity.details?.host_display_name}. + + ); + }, }; 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); } diff --git a/frontend/pages/admin/IntegrationsPage/IntegrationPage.tests.tsx b/frontend/pages/admin/IntegrationsPage/IntegrationPage.tests.tsx index 94a09ee034..a567506653 100644 --- a/frontend/pages/admin/IntegrationsPage/IntegrationPage.tests.tsx +++ b/frontend/pages/admin/IntegrationsPage/IntegrationPage.tests.tsx @@ -22,7 +22,7 @@ describe("Integrations Page", () => { const render = createCustomRenderer({ withBackendMock: true, context: { - app: { isMdmEnabledAndConfigured: true }, + app: { isMacMdmEnabledAndConfigured: true }, }, }); diff --git a/frontend/pages/hosts/details/HostDetailsPage/HostActionsDropdown/HostActionsDropdown.tests.tsx b/frontend/pages/hosts/details/HostDetailsPage/HostActionsDropdown/HostActionsDropdown.tests.tsx index 2094050495..57426f660c 100644 --- a/frontend/pages/hosts/details/HostDetailsPage/HostActionsDropdown/HostActionsDropdown.tests.tsx +++ b/frontend/pages/hosts/details/HostDetailsPage/HostActionsDropdown/HostActionsDropdown.tests.tsx @@ -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( + + ); + + 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( + + ); + + 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( + + ); + + 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( + + ); + + await user.click(screen.getByText("Actions")); + + expect(screen.queryByText("Wipe")).not.toBeInTheDocument(); + }); }); }); diff --git a/frontend/pages/hosts/details/HostDetailsPage/HostActionsDropdown/HostActionsDropdown.tsx b/frontend/pages/hosts/details/HostDetailsPage/HostActionsDropdown/HostActionsDropdown.tsx index 378b72b9bc..988986012b 100644 --- a/frontend/pages/hosts/details/HostDetailsPage/HostActionsDropdown/HostActionsDropdown.tsx +++ b/frontend/pages/hosts/details/HostDetailsPage/HostActionsDropdown/HostActionsDropdown.tsx @@ -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, diff --git a/frontend/pages/hosts/details/HostDetailsPage/HostActionsDropdown/helpers.tsx b/frontend/pages/hosts/details/HostDetailsPage/HostActionsDropdown/helpers.tsx index 7cee2c5e96..5310ea7ba9 100644 --- a/frontend/pages/hosts/details/HostDetailsPage/HostActionsDropdown/helpers.tsx +++ b/frontend/pages/hosts/details/HostDetailsPage/HostActionsDropdown/helpers.tsx @@ -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; diff --git a/frontend/pages/hosts/details/HostDetailsPage/HostDetailsPage.tsx b/frontend/pages/hosts/details/HostDetailsPage/HostDetailsPage.tsx index 2da5e28db2..99faef93dd 100644 --- a/frontend/pages/hosts/details/HostDetailsPage/HostDetailsPage.tsx +++ b/frontend/pages/hosts/details/HostDetailsPage/HostDetailsPage.tsx @@ -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( 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 && ( + setHostMdmDeviceState("wiping")} + onClose={() => setShowWipeModal(false)} + /> + )} ); diff --git a/frontend/pages/hosts/details/HostDetailsPage/modals/WipeModal/WipeModal.tsx b/frontend/pages/hosts/details/HostDetailsPage/modals/WipeModal/WipeModal.tsx new file mode 100644 index 0000000000..d25ee67ba8 --- /dev/null +++ b/frontend/pages/hosts/details/HostDetailsPage/modals/WipeModal/WipeModal.tsx @@ -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 ( + + <> +
+

All content will be erased on this host.

+
+ + Please check to confirm: + + setLockChecked(value)} + > + I wish to wipe {hostName} + +
+
+ +
+ + +
+ +
+ ); +}; + +export default WipeModal; diff --git a/frontend/pages/hosts/details/HostDetailsPage/modals/WipeModal/_styles.scss b/frontend/pages/hosts/details/HostDetailsPage/modals/WipeModal/_styles.scss new file mode 100644 index 0000000000..e5616edd18 --- /dev/null +++ b/frontend/pages/hosts/details/HostDetailsPage/modals/WipeModal/_styles.scss @@ -0,0 +1,14 @@ +.wipe-modal { + p { + margin: 0; + } + + &__modal-content { + display: grid; + gap: $pad-large; + } + + &__wipe-checkbox { + margin-top: $pad-small; + } +} diff --git a/frontend/pages/hosts/details/HostDetailsPage/modals/WipeModal/index.ts b/frontend/pages/hosts/details/HostDetailsPage/modals/WipeModal/index.ts new file mode 100644 index 0000000000..299b889006 --- /dev/null +++ b/frontend/pages/hosts/details/HostDetailsPage/modals/WipeModal/index.ts @@ -0,0 +1 @@ +export { default } from "./WipeModal"; diff --git a/frontend/pages/hosts/details/cards/Activity/Activity.tsx b/frontend/pages/hosts/details/cards/Activity/Activity.tsx index 2421182e22..628bd71073 100644 --- a/frontend/pages/hosts/details/cards/Activity/Activity.tsx +++ b/frontend/pages/hosts/details/cards/Activity/Activity.tsx @@ -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 = ({ + | React.FC +> = { + [ActivityType.RanScript]: RanScriptActivityItem, + [ActivityType.LockedHost]: LockedHostActivityItem, + [ActivityType.UnlockedHost]: UnlockedHostActivityItem, +}; diff --git a/frontend/pages/hosts/details/cards/Activity/ActivityItems/LockedHostActivityItem/LockedHostActivityItem.tsx b/frontend/pages/hosts/details/cards/Activity/ActivityItems/LockedHostActivityItem/LockedHostActivityItem.tsx new file mode 100644 index 0000000000..09bc6a7c89 --- /dev/null +++ b/frontend/pages/hosts/details/cards/Activity/ActivityItems/LockedHostActivityItem/LockedHostActivityItem.tsx @@ -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 ( + + {activity.actor_full_name} locked this host. + + ); +}; + +export default LockedHostActivityItem; diff --git a/frontend/pages/hosts/details/cards/Activity/ActivityItems/LockedHostActivityItem/index.ts b/frontend/pages/hosts/details/cards/Activity/ActivityItems/LockedHostActivityItem/index.ts new file mode 100644 index 0000000000..ad51e3fabb --- /dev/null +++ b/frontend/pages/hosts/details/cards/Activity/ActivityItems/LockedHostActivityItem/index.ts @@ -0,0 +1 @@ +export { default } from "./LockedHostActivityItem"; diff --git a/frontend/pages/hosts/details/cards/Activity/ActivityItems/RanScriptActivityItem/RanScriptActivityItem.tsx b/frontend/pages/hosts/details/cards/Activity/ActivityItems/RanScriptActivityItem/RanScriptActivityItem.tsx new file mode 100644 index 0000000000..94b1a8dc7c --- /dev/null +++ b/frontend/pages/hosts/details/cards/Activity/ActivityItems/RanScriptActivityItem/RanScriptActivityItem.tsx @@ -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 ( + + {activity.actor_full_name} + <> + {" "} + ran {formatScriptNameForActivityItem(activity.details?.script_name)} on + this host.{" "} + + + + ); +}; + +export default RanScriptActivityItem; diff --git a/frontend/pages/hosts/details/cards/Activity/ActivityItems/RanScriptActivityItem/index.ts b/frontend/pages/hosts/details/cards/Activity/ActivityItems/RanScriptActivityItem/index.ts new file mode 100644 index 0000000000..ea03a04cad --- /dev/null +++ b/frontend/pages/hosts/details/cards/Activity/ActivityItems/RanScriptActivityItem/index.ts @@ -0,0 +1 @@ +export { default } from "./RanScriptActivityItem"; diff --git a/frontend/pages/hosts/details/cards/Activity/ActivityItems/UnlockedHostActivityItem/UnlockedHostActivityItem.tsx b/frontend/pages/hosts/details/cards/Activity/ActivityItems/UnlockedHostActivityItem/UnlockedHostActivityItem.tsx new file mode 100644 index 0000000000..079f4463b7 --- /dev/null +++ b/frontend/pages/hosts/details/cards/Activity/ActivityItems/UnlockedHostActivityItem/UnlockedHostActivityItem.tsx @@ -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 ( + + {activity.actor_full_name} unlocked this host. + + ); +}; + +export default UnlockedHostActivityItem; diff --git a/frontend/pages/hosts/details/cards/Activity/ActivityItems/UnlockedHostActivityItem/index.ts b/frontend/pages/hosts/details/cards/Activity/ActivityItems/UnlockedHostActivityItem/index.ts new file mode 100644 index 0000000000..7e5a20b070 --- /dev/null +++ b/frontend/pages/hosts/details/cards/Activity/ActivityItems/UnlockedHostActivityItem/index.ts @@ -0,0 +1 @@ +export { default } from "./UnlockedHostActivityItem"; diff --git a/frontend/pages/hosts/details/cards/Activity/HostActivityItem/HostActivityItem.tsx b/frontend/pages/hosts/details/cards/Activity/HostActivityItem/HostActivityItem.tsx new file mode 100644 index 0000000000..44cc1267c0 --- /dev/null +++ b/frontend/pages/hosts/details/cards/Activity/HostActivityItem/HostActivityItem.tsx @@ -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 ( +
+ +
+
+ + {children} + +
+ + {activityCreatedAt && + formatDistanceToNowStrict(activityCreatedAt, { + addSuffix: true, + })} + + {activityCreatedAt && ( + + {internationalTimeFormat(activityCreatedAt)} + + )} +
+
+
+
+ ); +}; + +export default HostActivityItem; diff --git a/frontend/pages/hosts/details/cards/Activity/PastActivity/_styles.scss b/frontend/pages/hosts/details/cards/Activity/HostActivityItem/_styles.scss similarity index 98% rename from frontend/pages/hosts/details/cards/Activity/PastActivity/_styles.scss rename to frontend/pages/hosts/details/cards/Activity/HostActivityItem/_styles.scss index 04173fdab9..cfb2fcd282 100644 --- a/frontend/pages/hosts/details/cards/Activity/PastActivity/_styles.scss +++ b/frontend/pages/hosts/details/cards/Activity/HostActivityItem/_styles.scss @@ -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; } } + } diff --git a/frontend/pages/hosts/details/cards/Activity/HostActivityItem/index.ts b/frontend/pages/hosts/details/cards/Activity/HostActivityItem/index.ts new file mode 100644 index 0000000000..b711dde773 --- /dev/null +++ b/frontend/pages/hosts/details/cards/Activity/HostActivityItem/index.ts @@ -0,0 +1 @@ +export { default } from "./HostActivityItem"; diff --git a/frontend/pages/hosts/details/cards/Activity/PastActivity/PastActivity.tsx b/frontend/pages/hosts/details/cards/Activity/PastActivity/PastActivity.tsx deleted file mode 100644 index 8dadc1328c..0000000000 --- a/frontend/pages/hosts/details/cards/Activity/PastActivity/PastActivity.tsx +++ /dev/null @@ -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) => ( - - {activity.actor_full_name} - <> - {" "} - ran {formatScriptNameForActivityItem(activity.details?.script_name)} on - this host.{" "} - - - -); - -const LockedHostActivityDetails = ({ - activity, -}: Pick) => ( - - {activity.actor_full_name} locked this host. - -); - -const UnlockedHostActivityDetails = ({ - activity, -}: Pick) => ( - - {activity.actor_full_name}{" "} - {activity.details?.host_platform === "darwin" - ? "viewed the six-digit unlock PIN for" - : "unlocked"}{" "} - this host. - -); - -const PastActivityTopline = ({ - activity, - onDetailsClick, -}: IPastActivityProps) => { - switch (activity.type) { - case "ran_script": - return ( - - ); - case "locked_host": - return ; - case "unlocked_host": - return ; - 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 ( -
- -
-
- -
- - {formatDistanceToNowStrict(activityCreatedAt, { - addSuffix: true, - })} - - - {internationalTimeFormat(activityCreatedAt)} - -
-
-
-
- ); -}; - -export default PastActivity; diff --git a/frontend/pages/hosts/details/cards/Activity/PastActivity/index.ts b/frontend/pages/hosts/details/cards/Activity/PastActivity/index.ts deleted file mode 100644 index 363a39834c..0000000000 --- a/frontend/pages/hosts/details/cards/Activity/PastActivity/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { default } from "./PastActivity"; diff --git a/frontend/pages/hosts/details/cards/Activity/PastActivityFeed/PastActivityFeed.tsx b/frontend/pages/hosts/details/cards/Activity/PastActivityFeed/PastActivityFeed.tsx index 086f481b6a..05c7a790a8 100644 --- a/frontend/pages/hosts/details/cards/Activity/PastActivityFeed/PastActivityFeed.tsx +++ b/frontend/pages/hosts/details/cards/Activity/PastActivityFeed/PastActivityFeed.tsx @@ -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 (
- {activitiesList.map((activity: IActivity) => ( - - ))} + {activitiesList.map((activity: IPastActivity) => { + const ActivityItemComponent = pastActivityComponentMap[activity.type]; + return ( + + ); + })}
+ ); +}; + +export default ShowDetailsButton; diff --git a/frontend/pages/hosts/details/cards/Activity/ShowDetailsButton/_styles.scss b/frontend/pages/hosts/details/cards/Activity/ShowDetailsButton/_styles.scss new file mode 100644 index 0000000000..c1f3a99a32 --- /dev/null +++ b/frontend/pages/hosts/details/cards/Activity/ShowDetailsButton/_styles.scss @@ -0,0 +1,5 @@ +.show-details-button { + &__show-details-icon { + margin-left: $pad-xsmall; + } +} diff --git a/frontend/pages/hosts/details/cards/Activity/ShowDetailsButton/index.ts b/frontend/pages/hosts/details/cards/Activity/ShowDetailsButton/index.ts new file mode 100644 index 0000000000..533c76eddd --- /dev/null +++ b/frontend/pages/hosts/details/cards/Activity/ShowDetailsButton/index.ts @@ -0,0 +1 @@ +export { default } from "./ShowDetailsButton"; diff --git a/frontend/pages/hosts/details/cards/HostSummary/_styles.scss b/frontend/pages/hosts/details/cards/HostSummary/_styles.scss index b95055e0b6..7b5cca654a 100644 --- a/frontend/pages/hosts/details/cards/HostSummary/_styles.scss +++ b/frontend/pages/hosts/details/cards/HostSummary/_styles.scss @@ -12,7 +12,8 @@ } &.error { - background-color: $ui-error; + color: $core-white; + background-color: $core-vibrant-red; } } diff --git a/frontend/pages/hosts/details/cards/HostSummary/helpers.tsx b/frontend/pages/hosts/details/cards/HostSummary/helpers.tsx index 0282416be7..668feffc6c 100644 --- a/frontend/pages/hosts/details/cards/HostSummary/helpers.tsx +++ b/frontend/pages/hosts/details/cards/HostSummary/helpers.tsx @@ -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't fetch data from
a locked host. ), + wiping: ( + <> + You can't fetch data from
a wiping host. + + ), + wiped: ( + <> + You can't fetch data from
a wiped host. + + ), } as const; diff --git a/frontend/pages/hosts/details/helpers.ts b/frontend/pages/hosts/details/helpers.ts index c0a3a9a316..52a0efd6dd 100644 --- a/frontend/pages/hosts/details/helpers.ts +++ b/frontend/pages/hosts/details/helpers.ts @@ -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 diff --git a/frontend/services/entities/activities.ts b/frontend/services/entities/activities.ts index d63e90fae1..7a45e52b83 100644 --- a/frontend/services/entities/activities.ts +++ b/frontend/services/entities/activities.ts @@ -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 => { + ): Promise => { const { HOST_PAST_ACTIVITIES } = endpoints; const queryParams = { diff --git a/frontend/services/entities/hosts.ts b/frontend/services/entities/hosts.ts index 2104fa2469..16bddc671a 100644 --- a/frontend/services/entities/hosts.ts +++ b/frontend/services/entities/hosts.ts @@ -397,8 +397,14 @@ export default { const { HOST_LOCK } = endpoints; return sendRequest("POST", HOST_LOCK(id)); }, + unlockHost: (id: number): Promise => { const { HOST_UNLOCK } = endpoints; return sendRequest("POST", HOST_UNLOCK(id)); }, + + wipeHost: (id: number) => { + const { HOST_WIPE } = endpoints; + return sendRequest("POST", HOST_WIPE(id)); + }, }; diff --git a/frontend/utilities/endpoints.ts b/frontend/utilities/endpoints.ts index 491c14690b..b54ec4fd97 100644 --- a/frontend/utilities/endpoints.ts +++ b/frontend/utilities/endpoints.ts @@ -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`, diff --git a/frontend/utilities/permissions/permissions.ts b/frontend/utilities/permissions/permissions.ts index 8a394c4732..5ec2c2d35e 100644 --- a/frontend/utilities/permissions/permissions.ts +++ b/frontend/utilities/permissions/permissions.ts @@ -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,