Clear passcode frontend (#43084)

<!-- Add the related story/sub-task/bug number, like Resolves #123, or
remove if NA -->
**Related issue:** Resolves #42369 

# Checklist for submitter

If some of the following don't apply, delete the relevant line.

- [ ] 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. **Done in backend task for whole story**

- [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] QA'd all new/changed functionality manually


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

* **New Features**
* Added "Clear passcode" action for iOS and iPad hosts in the host
actions menu, accessible only to Premium tier users with appropriate
permissions.
  * Added confirmation modal for clearing device passcodes.
* Passcode clearing activity now appears in the activity feed with actor
information.
* Action is conditionally disabled during specific device states (Lost
Mode, pending wipe) with contextual tooltips.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
Magnus Jensen 2026-04-07 16:36:03 -05:00 committed by GitHub
parent 36ad83f611
commit bc32339526
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 458 additions and 3 deletions

View file

@ -164,6 +164,7 @@ export enum ActivityType {
EditedEnrollSecrets = "edited_enroll_secrets",
AddedMicrosoftEntraTenant = "added_microsoft_entra_tenant",
DeletedMicrosoftEntraTenant = "deleted_microsoft_entra_tenant",
ClearedPasscode = "cleared_passcode",
}
/** This is a subset of ActivityType that are shown only for the host past activities */
@ -184,7 +185,8 @@ export type IHostPastActivityType =
| ActivityType.CanceledInstallSoftware
| ActivityType.CanceledUninstallSoftware
| ActivityType.InstalledCertificate
| ActivityType.ResentCertificate;
| ActivityType.ResentCertificate
| ActivityType.ClearedPasscode;
/** This is a subset of ActivityType that are shown only for the host upcoming activities */
export type IHostUpcomingActivityType =
@ -465,4 +467,5 @@ export const ACTIVITY_TYPE_TO_FILTER_LABEL: Record<ActivityType, string> = {
[ActivityType.DeletedCertificate]: "Deleted certificate",
[ActivityType.InstalledCertificate]: "Installed certificate",
[ActivityType.EditedEnrollSecrets]: "Edited enroll secrets",
[ActivityType.ClearedPasscode]: "Cleared passcode",
};

View file

@ -1799,6 +1799,13 @@ const TAGGED_TEMPLATES = {
<> deleted Microsoft Entra tenant ({activity.details?.tenant_id}).</>
);
},
clearedPasscode: (activity: IActivity) => {
return (
<>
cleared the passcode on <b>{activity.details?.host_display_name}</b>.
</>
);
},
};
const getDetail = (activity: IActivity, isPremiumTier: boolean) => {
@ -2204,6 +2211,9 @@ const getDetail = (activity: IActivity, isPremiumTier: boolean) => {
case ActivityType.DeletedMicrosoftEntraTenant: {
return TAGGED_TEMPLATES.deletedMicrosoftEntraTenant(activity);
}
case ActivityType.ClearedPasscode: {
return TAGGED_TEMPLATES.clearedPasscode(activity);
}
default: {
return TAGGED_TEMPLATES.defaultActivityTemplate(activity);
}

View file

@ -7,6 +7,7 @@ import createMockUser from "__mocks__/userMock";
import createMockTeam from "__mocks__/teamMock";
import HostActionsDropdown from "./HostActionsDropdown";
import { HostMdmDeviceStatusUIState } from "../../helpers";
describe("Host Actions Dropdown", () => {
describe("Transfer action", () => {
@ -1404,7 +1405,7 @@ describe("Host Actions Dropdown", () => {
});
describe("Render options only available for iOS and iPadOS", () => {
it("renders only the transfer, wipe, and delete options for iOS", async () => {
it("renders only the transfer, wipe, clear passcode, and delete options for iOS", async () => {
const render = createCustomRenderer({
context: {
app: {
@ -1433,6 +1434,7 @@ describe("Host Actions Dropdown", () => {
expect(screen.queryByText("Transfer")).toBeInTheDocument();
expect(screen.queryByText("Wipe")).toBeInTheDocument();
expect(screen.queryByText("Clear passcode")).toBeInTheDocument();
expect(screen.queryByText("Delete")).toBeInTheDocument();
expect(screen.queryByText("Live report")).not.toBeInTheDocument();
@ -1442,7 +1444,7 @@ describe("Host Actions Dropdown", () => {
).not.toBeInTheDocument();
});
it("renders only the transfer, wipe, and delete options for iPadOS", async () => {
it("renders only the transfer, wipe, clear passcode, and delete options for iPadOS", async () => {
const render = createCustomRenderer({
context: {
app: {
@ -1471,6 +1473,7 @@ describe("Host Actions Dropdown", () => {
expect(screen.queryByText("Transfer")).toBeInTheDocument();
expect(screen.queryByText("Wipe")).toBeInTheDocument();
expect(screen.queryByText("Clear passcode")).toBeInTheDocument();
expect(screen.queryByText("Delete")).toBeInTheDocument();
expect(screen.queryByText("Live report")).not.toBeInTheDocument();
@ -1512,6 +1515,7 @@ describe("Host Actions Dropdown", () => {
expect(screen.getByText("Transfer")).toBeInTheDocument();
expect(screen.getByText("Delete")).toBeInTheDocument();
expect(screen.queryByText("Live report")).not.toBeInTheDocument();
expect(screen.queryByText("Clear passcode")).not.toBeInTheDocument();
expect(screen.queryByText("Run script")).not.toBeInTheDocument();
expect(screen.queryByText("Wipe")).not.toBeInTheDocument();
expect(screen.queryByText("Lock")).not.toBeInTheDocument();
@ -1551,6 +1555,7 @@ describe("Host Actions Dropdown", () => {
expect(screen.getByText("Transfer")).toBeInTheDocument();
expect(screen.getByText("Delete")).toBeInTheDocument();
expect(screen.queryByText("Live report")).not.toBeInTheDocument();
expect(screen.queryByText("Clear passcode")).not.toBeInTheDocument();
expect(screen.queryByText("Run script")).not.toBeInTheDocument();
expect(screen.queryByText("Wipe")).not.toBeInTheDocument();
expect(screen.queryByText("Lock")).not.toBeInTheDocument();
@ -1735,4 +1740,252 @@ describe("Host Actions Dropdown", () => {
});
});
});
describe("Clear passcode action", () => {
it("renders the action when an iOS host is enrolled in MDM", async () => {
const render = createCustomRenderer({
context: {
app: {
isGlobalAdmin: true,
isPremiumTier: true,
isMacMdmEnabledAndConfigured: true,
currentUser: createMockUser(),
},
},
});
const { user } = render(
<HostActionsDropdown
hostTeamId={null}
onSelect={noop}
hostStatus="online"
hostPlatform="ios"
hostMdmEnrollmentStatus="On (company-owned)"
isConnectedToFleetMdm
hostMdmDeviceStatus="unlocked"
hostScriptsEnabled
/>
);
await user.click(screen.getByText("Actions"));
expect(screen.queryByText("Clear passcode")).toBeInTheDocument();
});
it("does not render for below maintainer", async () => {
const render = createCustomRenderer({
context: {
app: {
isGlobalTechnician: true,
isGlobalAdmin: false,
isTeamMaintainer: false,
isTeamAdmin: false,
isPremiumTier: true,
isMacMdmEnabledAndConfigured: true,
currentUser: createMockUser(),
},
},
});
render(
<HostActionsDropdown
hostTeamId={1}
onSelect={noop}
hostStatus="online"
hostPlatform="ios"
hostMdmEnrollmentStatus="On (company-owned)"
isConnectedToFleetMdm
hostMdmDeviceStatus="unlocked"
hostScriptsEnabled
/>
);
// Component returns null when no options are available for this role,
// so neither the Actions button nor Clear passcode are rendered.
expect(screen.queryByText("Actions")).not.toBeInTheDocument();
expect(screen.queryByText("Clear passcode")).not.toBeInTheDocument();
});
it("does not render for non-iOS hosts", async () => {
const render = createCustomRenderer({
context: {
app: {
isGlobalAdmin: true,
isPremiumTier: true,
isMacMdmEnabledAndConfigured: true,
currentUser: createMockUser(),
},
},
});
const { user } = render(
<HostActionsDropdown
hostTeamId={null}
onSelect={noop}
hostStatus="online"
hostPlatform="darwin"
hostMdmEnrollmentStatus="On (company-owned)"
isConnectedToFleetMdm
hostMdmDeviceStatus="unlocked"
hostScriptsEnabled
/>
);
await user.click(screen.getByText("Actions"));
expect(screen.queryByText("Clear passcode")).not.toBeInTheDocument();
});
it("is hidden if Apple MDM is not enabled", async () => {
const render = createCustomRenderer({
context: {
app: {
isGlobalAdmin: true,
isPremiumTier: true,
isMacMdmEnabledAndConfigured: false,
currentUser: createMockUser(),
},
},
});
const { user } = render(
<HostActionsDropdown
hostTeamId={null}
onSelect={noop}
hostStatus="online"
hostPlatform="ios"
hostMdmEnrollmentStatus="On (company-owned)"
isConnectedToFleetMdm
hostMdmDeviceStatus="unlocked"
hostScriptsEnabled
/>
);
await user.click(screen.getByText("Actions"));
expect(screen.queryByText("Clear passcode")).not.toBeInTheDocument();
});
it("is hidden on Fleet free", async () => {
const render = createCustomRenderer({
context: {
app: {
isGlobalAdmin: true,
isPremiumTier: false,
isMacMdmEnabledAndConfigured: true,
currentUser: createMockUser(),
},
},
});
const { user } = render(
<HostActionsDropdown
hostTeamId={null}
onSelect={noop}
hostStatus="online"
hostPlatform="ios"
hostMdmEnrollmentStatus="On (company-owned)"
isConnectedToFleetMdm
hostMdmDeviceStatus="unlocked"
hostScriptsEnabled
/>
);
await user.click(screen.getByText("Actions"));
expect(screen.queryByText("Clear passcode")).not.toBeInTheDocument();
});
it.each<HostMdmDeviceStatusUIState>([
"locked",
"locking",
"unlocking",
"locating",
])("is disabled with tooltip when host status is %s", async (status) => {
const render = createCustomRenderer({
context: {
app: {
isGlobalAdmin: true,
isPremiumTier: true,
isMacMdmEnabledAndConfigured: true,
currentUser: createMockUser(),
},
},
});
const { user } = render(
<HostActionsDropdown
hostTeamId={null}
onSelect={noop}
hostStatus="online"
hostPlatform="ios"
hostMdmEnrollmentStatus="On (company-owned)"
isConnectedToFleetMdm
hostMdmDeviceStatus={status}
hostScriptsEnabled
/>
);
await user.click(screen.getByText("Actions"));
const option = screen.getByText("Clear passcode");
expect(option).toBeInTheDocument();
expect(option).toHaveAttribute("aria-disabled", "true");
await user.hover(option);
await waitFor(() => {
expect(
screen.getByText(
/Clear passcode is unavailable while host is in Lost Mode./i
)
).toBeInTheDocument();
});
});
it.each<HostMdmDeviceStatusUIState>(["wiping", "wiped"])(
"is disabled with tooltip when pending wipe",
async (status) => {
const render = createCustomRenderer({
context: {
app: {
isGlobalAdmin: true,
isPremiumTier: true,
isMacMdmEnabledAndConfigured: true,
currentUser: createMockUser(),
},
},
});
const { user } = render(
<HostActionsDropdown
hostTeamId={null}
onSelect={noop}
hostStatus="online"
hostPlatform="ios"
hostMdmEnrollmentStatus="On (company-owned)"
isConnectedToFleetMdm
hostMdmDeviceStatus={status}
hostScriptsEnabled
/>
);
await user.click(screen.getByText("Actions"));
const option = screen.getByText("Clear passcode");
expect(option).toBeInTheDocument();
expect(option).toHaveAttribute("aria-disabled", "true");
await user.hover(option);
await waitFor(() => {
expect(
screen.getByText(
/Clear passcode is unavailable while host is pending wipe./i
)
).toBeInTheDocument();
});
}
);
});
});

View file

@ -69,6 +69,11 @@ const DEFAULT_OPTIONS = [
value: "unlock",
disabled: false,
},
{
label: "Clear passcode",
value: "clearPasscode",
disabled: false,
},
{
label: "Delete",
disabled: false,
@ -312,6 +317,43 @@ const canShowRecoveryLockPassword = (config: IHostActionConfigOptions) => {
return isRecoveryLockPasswordEnabled;
};
const canClearPasscode = (config: IHostActionConfigOptions) => {
if (!config.isPremiumTier) {
return false;
}
if (!isIPadOrIPhone(config.hostPlatform)) {
return false;
}
if (!config.isEnrolledInMdm) {
return false;
}
if (!config.isConnectedToFleetMdm) {
return false;
}
if (!config.isMacMdmEnabledAndConfigured) {
return false;
}
if (
config.hostMdmEnrollmentStatus !== "On (company-owned)" &&
config.hostMdmEnrollmentStatus !== "On (automatic)" &&
config.hostMdmEnrollmentStatus !== "On (manual)"
) {
return false;
}
return (
config.isGlobalAdmin ||
config.isGlobalMaintainer ||
config.isTeamAdmin ||
config.isTeamMaintainer
);
};
const canRunScript = ({
hostPlatform,
isGlobalAdmin,
@ -356,6 +398,10 @@ const removeUnavailableOptions = (
);
}
if (!canClearPasscode(config)) {
options = options.filter((option) => option.value !== "clearPasscode");
}
if (!canTurnOffMdm(config)) {
options = options.filter((option) => option.value !== "mdmOff");
}
@ -552,6 +598,24 @@ const modifyOptions = (
}
}
const clearPasscodeOption = options.find(
(option) => option.value === "clearPasscode"
);
if (
clearPasscodeOption &&
["locked", "locking", "unlocking", "locating"].includes(hostMdmDeviceStatus)
) {
clearPasscodeOption.disabled = true;
clearPasscodeOption.tooltipContent =
"Clear passcode is unavailable while host is in Lost Mode.";
} else if (
clearPasscodeOption &&
["wiped", "wiping"].includes(hostMdmDeviceStatus)
) {
clearPasscodeOption.disabled = true;
clearPasscodeOption.tooltipContent =
"Clear passcode is unavailable while host is pending wipe.";
}
disableOptions(optionsToDisable);
formatTurnOffOptionLabel(options, hostPlatform);
return options;

View file

@ -139,6 +139,7 @@ import InventoryVersionsModal from "../modals/InventoryVersionsModal";
import UpdateEndUserModal from "../cards/User/components/UpdateEndUserModal";
import LocationModal from "../modals/LocationModal";
import MDMStatusModal from "../modals/MDMStatusModal";
import ClearPasscodeModal from "./modals/ClearPasscodeModal";
const baseClass = "host-details";
@ -237,6 +238,7 @@ const HostDetailsPage = ({
boolean | undefined
>(false);
const [showMDMStatusModal, setShowMDMStatusModal] = useState(false);
const [showClearPasscodeModal, setShowClearPasscodeModal] = useState(false);
// General-use updating state
const [isUpdating, setIsUpdating] = useState(false);
@ -682,6 +684,10 @@ const HostDetailsPage = ({
setShowMDMStatusModal(!showMDMStatusModal);
}, [showMDMStatusModal, setShowMDMStatusModal]);
const toggleClearPasscodeModal = useCallback(() => {
setShowClearPasscodeModal(!showClearPasscodeModal);
}, [showClearPasscodeModal, setShowClearPasscodeModal]);
const onCancelPolicyDetailsModal = useCallback(() => {
setPolicyDetailsModal(!showPolicyDetailsModal);
setSelectedPolicy(null);
@ -960,6 +966,9 @@ const HostDetailsPage = ({
case "wipe":
setShowWipeModal(true);
break;
case "clearPasscode":
setShowClearPasscodeModal(true);
break;
default: // do nothing
}
};
@ -1761,6 +1770,9 @@ const HostDetailsPage = ({
onExit={toggleMDMStatusModal}
/>
)}
{showClearPasscodeModal && (
<ClearPasscodeModal id={host.id} onExit={toggleClearPasscodeModal} />
)}
</>
);
};

View file

@ -0,0 +1,78 @@
import React, { useContext } from "react";
import { NotificationContext } from "context/notification";
import hostAPI from "services/entities/hosts";
import Modal from "components/Modal";
import Button from "components/buttons/Button";
const baseClass = "clear-passcode-modal";
interface IClearPasscodeModalProps {
id: number;
onExit: () => void;
}
const ClearPasscodeModal = ({ id, onExit }: IClearPasscodeModalProps) => {
const { renderFlash } = useContext(NotificationContext);
const [isClearingPasscode, setIsClearingPasscode] = React.useState(false);
const onClearPasscode = async () => {
setIsClearingPasscode(true);
try {
await hostAPI.clearPasscode(id);
renderFlash(
"success",
"Successfully sent request to clear passcode on this host."
);
} catch (e) {
renderFlash(
"error",
"Couldn't send request to clear passcode on this host. Please try again."
);
} finally {
onExit();
setIsClearingPasscode(false);
}
};
const renderModalContent = () => {
return (
<p>
This will remove the current passcode and allow anyone with physical
access to unlock the host.
</p>
);
};
const renderModalButtons = () => {
return (
<>
<Button
type="button"
onClick={onClearPasscode}
className="clear-passcode-loading"
variant="alert"
isLoading={isClearingPasscode}
>
Clear Passcode
</Button>
<Button onClick={onExit} variant="inverse-alert">
Cancel
</Button>
</>
);
};
return (
<Modal className={baseClass} title="Clear passcode" onExit={onExit}>
<div className={`${baseClass}__modal-content`}>
{renderModalContent()}
</div>
<div className="modal-cta-wrap">{renderModalButtons()}</div>
</Modal>
);
};
export default ClearPasscodeModal;

View file

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

View file

@ -24,6 +24,7 @@ import CanceledInstallSoftwareActivityItem from "./ActivityItems/CanceledInstall
import CanceledUninstallSoftwareActivtyItem from "./ActivityItems/CanceledUninstallSoftwareActivtyItem";
import InstalledCertificateActivityItem from "./ActivityItems/InstalledCertificateActivityItem";
import ResentCertificateActivityItem from "./ActivityItems/ResentCertificateActivityItem";
import ClearedPasscodeActivityItem from "./ActivityItems/ClearedPasscodeActivityItem";
/** The component props that all host activity items must adhere to */
export interface IHostActivityItemComponentProps {
@ -68,6 +69,7 @@ export const pastActivityComponentMap: Record<
[ActivityType.CanceledUninstallSoftware]: CanceledUninstallSoftwareActivtyItem,
[ActivityType.InstalledCertificate]: InstalledCertificateActivityItem,
[ActivityType.ResentCertificate]: ResentCertificateActivityItem,
[ActivityType.ClearedPasscode]: ClearedPasscodeActivityItem,
};
export const upcomingActivityComponentMap: Record<

View file

@ -0,0 +1,24 @@
import React from "react";
import ActivityItem from "components/ActivityItem";
import { IHostActivityItemComponentProps } from "../../ActivityConfig";
const baseClass = "cleared-passcode-activity-item";
const ClearedPasscodeActivityItem = ({
activity,
}: IHostActivityItemComponentProps) => {
return (
<ActivityItem
className={baseClass}
activity={activity}
hideCancel
hideShowDetails
>
<b>{activity.actor_full_name}</b> cleared the passcode on this host.
</ActivityItem>
);
};
export default ClearedPasscodeActivityItem;

View file

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

View file

@ -686,6 +686,11 @@ export default {
return sendRequest("POST", HOST_WIPE(id));
},
clearPasscode: (id: number) => {
const { HOST_CLEAR_PASSCODE } = endpoints;
return sendRequest("POST", HOST_CLEAR_PASSCODE(id));
},
resendProfile: (hostId: number, profileUUID: string): Promise<void> => {
const { HOST_RESEND_PROFILE } = endpoints;

View file

@ -87,6 +87,8 @@ export default {
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`,
HOST_CLEAR_PASSCODE: (id: number) =>
`/${API_VERSION}/fleet/hosts/${id}/clear_passcode`,
HOST_RESEND_PROFILE: (hostId: number, profileUUID: string) =>
`/${API_VERSION}/fleet/hosts/${hostId}/configuration_profiles/${profileUUID}/resend`,
HOST_RESEND_CERTIFICATE: (hostId: number, certificateTemplateId: number) =>