Resend android cert to host - frontend, update profile API (#42297)

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

- [x] Changes file added for user-visible changes in `changes/`
- [x] Added/updated automated tests
- [x] QA'd all new/changed functionality manually
This commit is contained in:
jacobshandling
2026-03-26 13:06:29 -07:00
committed by GitHub
parent e4b47391fb
commit 9c625ab3d5
14 changed files with 127 additions and 36 deletions
+1
View File
@@ -0,0 +1 @@
- Added ability to re-send Android certificates to a specific host
+4
View File
@@ -85,6 +85,7 @@ export enum ActivityType {
DeletedAndroidProfile = "deleted_android_profile",
EditedAndroidProfile = "edited_android_profile",
EditedAndroidCertificate = "edited_android_certificate",
ResentCertificate = "resent_certificate",
// Note: Both "enabled_disk_encryption" and "enabled_macos_disk_encryption" display the same
// message. The latter is deprecated in the API but it is retained here for backwards compatibility.
EnabledDiskEncryption = "enabled_disk_encryption",
@@ -286,6 +287,8 @@ export interface IActivityDetails {
host_idp_username?: string;
idp_full_name?: string;
tenant_id?: string;
certificate_name?: string;
certificate_template_id?: number;
}
// maps activity types to their corresponding label to use when filtering activites via the dropdown
@@ -443,6 +446,7 @@ export const ACTIVITY_TYPE_TO_FILTER_LABEL: Record<ActivityType, string> = {
"GitOps: edited configuration profiles: Android",
[ActivityType.EditedAndroidCertificate]:
"GitOps: edited certificate templates: Android",
[ActivityType.ResentCertificate]: "Resent certificate",
[ActivityType.AddedConditionalAccessOkta]: "Added conditional access: Okta",
[ActivityType.HostBypassedConditionalAccess]:
"Host bypassed conditional access",
+2
View File
@@ -194,6 +194,8 @@ export interface IHostMdmProfile {
detail: string;
scope: ProfileScope | null;
managed_local_account: string | null;
// identifier when this profile represents an Android certificate template
certificate_template_id?: number;
}
// TODO - move disk encryption related types to dedicated file
@@ -646,6 +646,15 @@ const TAGGED_TEMPLATES = {
</>
);
},
resentCertificate: (activity: IActivity) => {
return (
<>
{" "}
resent {activity.details?.certificate_name} certificate for host{" "}
<b>{activity.details?.host_display_name}</b>.
</>
);
},
addedCertificateAuthority: (name = "") => {
return name ? (
<>
@@ -1902,6 +1911,9 @@ const getDetail = (activity: IActivity, isPremiumTier: boolean) => {
case ActivityType.EditedAndroidCertificate: {
return TAGGED_TEMPLATES.editedAndroidCertificate(activity, isPremiumTier);
}
case ActivityType.ResentCertificate: {
return TAGGED_TEMPLATES.resentCertificate(activity);
}
case ActivityType.AddedNdesScepProxy: {
return TAGGED_TEMPLATES.addedCertificateAuthority("NDES");
}
@@ -740,13 +740,23 @@ const HostDetailsPage = ({
const resendProfile = useCallback(
(profileUUID: string): Promise<void> => {
if (!host?.id) {
return new Promise(() => undefined);
return Promise.resolve();
}
return hostAPI.resendProfile(host.id, profileUUID);
},
[host?.id]
);
const resendCertificate = useCallback(
(certificateTemplateId: number): Promise<void> => {
if (!host?.id) {
return Promise.resolve();
}
return hostAPI.resendCertificate(host.id, certificateTemplateId);
},
[host?.id]
);
const rotateRecoveryLockPassword = useCallback((): Promise<void> => {
if (!host?.id) {
return new Promise(() => undefined);
@@ -1125,7 +1135,7 @@ const HostDetailsPage = ({
const isAppleDeviceHost = isAppleDevice(host.platform);
const canResendProfiles =
(isAppleDeviceHost || isWindowsHost) &&
(isAppleDeviceHost || isWindowsHost || isAndroidHost) &&
(isGlobalAdmin ||
isGlobalMaintainer ||
isGlobalTechnician ||
@@ -1549,6 +1559,7 @@ const HostDetailsPage = ({
hostMDMData={host.mdm}
onClose={toggleOSSettingsModal}
resendRequest={resendProfile}
resendCertificateRequest={resendCertificate}
rotateRecoveryLockPassword={rotateRecoveryLockPassword}
onProfileResent={refetchHostDetails}
/>
@@ -17,6 +17,7 @@ interface IOSSettingsModalProps {
* This behaviour is dynamic based on the page this modal is rendered on
* so we allow the request function to be passed in */
resendRequest: (profileUUID: string) => Promise<void>;
resendCertificateRequest?: (certificateTemplateId: number) => Promise<void>;
rotateRecoveryLockPassword?: () => Promise<void>;
onClose: () => void;
/** handler that fires when a profile was reset. Requires `canResendProfiles` prop
@@ -34,6 +35,7 @@ const OSSettingsModal = ({
canRotateRecoveryLockPassword = false,
onClose,
resendRequest,
resendCertificateRequest,
rotateRecoveryLockPassword,
onProfileResent,
}: IOSSettingsModalProps) => {
@@ -57,6 +59,7 @@ const OSSettingsModal = ({
canRotateRecoveryLockPassword={canRotateRecoveryLockPassword}
tableData={memoizedTableData ?? []}
resendRequest={resendRequest}
resendCertificateRequest={resendCertificateRequest}
rotateRecoveryLockPassword={rotateRecoveryLockPassword}
onProfileResent={onProfileResent}
/>
@@ -6,7 +6,10 @@ import { REC_LOCK_SYNTHETIC_PROFILE_UUID } from "pages/hosts/details/helpers";
import { DEFAULT_EMPTY_CELL_VALUE } from "utilities/constants";
import { NotificationContext } from "context/notification";
import { IHostMdmProfile } from "interfaces/mdm";
import {
FLEET_ANDROID_CERTIFICATE_TEMPLATE_PROFILE_ID,
IHostMdmProfile,
} from "interfaces/mdm";
import { getErrorReason } from "interfaces/errors";
import TooltipTruncatedTextCell from "components/TableContainer/DataTable/TooltipTruncatedTextCell";
@@ -18,23 +21,23 @@ import { IHostMdmProfileWithAddedStatus } from "../OSSettingsTableConfig";
const baseClass = "os-settings-error-cell";
interface IRefetchButtonProps {
isFetching: boolean;
interface IResendButtonProps {
isResending: boolean;
onClick: (evt: React.MouseEvent<HTMLButtonElement, React.MouseEvent>) => void;
}
const RefetchButton = ({ isFetching, onClick }: IRefetchButtonProps) => {
const ResendButton = ({ isResending, onClick }: IResendButtonProps) => {
const classNames = classnames(`${baseClass}__resend-button`, "resend-link", {
[`${baseClass}__resending`]: isFetching,
[`${baseClass}__resending`]: isResending,
});
const buttonText = isFetching ? "Resending..." : "Resend";
const buttonText = isResending ? "Resending..." : "Resend";
// add additional props when we need to display a tooltip for the button
return (
<Button
disabled={isFetching}
disabled={isResending}
onClick={onClick}
variant="inverse"
className={classNames}
@@ -266,6 +269,7 @@ interface IOSSettingsErrorCellProps {
canRotateRecoveryLockPassword?: boolean;
profile: IHostMdmProfileWithAddedStatus;
resendRequest: (profileUUID: string) => Promise<void>;
resendCertificateRequest?: (certificateTemplateId: number) => Promise<void>;
rotateRecoveryLockPassword?: () => Promise<void>;
onProfileResent?: () => void;
}
@@ -275,22 +279,35 @@ const OSSettingsErrorCell = ({
canRotateRecoveryLockPassword = false,
profile,
resendRequest,
resendCertificateRequest,
rotateRecoveryLockPassword,
onProfileResent = noop,
}: IOSSettingsErrorCellProps) => {
const { renderFlash } = useContext(NotificationContext);
const [isLoading, setIsLoading] = useState(false);
const [isResending, setIsResending] = useState(false);
const [isRotating, setIsRotating] = useState(false);
const isAndroidCertificate =
profile.profile_uuid === FLEET_ANDROID_CERTIFICATE_TEMPLATE_PROFILE_ID;
const onResendProfile = async () => {
setIsLoading(true);
setIsResending(true);
try {
await resendRequest(profile.profile_uuid);
onProfileResent();
if (
isAndroidCertificate &&
resendCertificateRequest &&
profile.certificate_template_id !== undefined
) {
await resendCertificateRequest(profile.certificate_template_id);
onProfileResent();
} else if (!isAndroidCertificate) {
await resendRequest(profile.profile_uuid);
onProfileResent();
}
} catch (e) {
renderFlash("error", "Couldn't resend. Please try again.");
}
setIsLoading(false);
setIsResending(false);
};
const onRotatePassword = async () => {
@@ -314,7 +331,7 @@ const OSSettingsErrorCell = ({
const isFailed = profile.status === "failed";
const isVerified = profile.status === "verified";
const showRefetchButton =
const showResendButton =
canResendProfiles &&
(isFailed || isVerified) &&
profile.profile_uuid !== REC_LOCK_SYNTHETIC_PROFILE_UUID;
@@ -331,13 +348,13 @@ const OSSettingsErrorCell = ({
tooltip={tooltip}
value={value}
className={
isFailed || showRefetchButton || showRotateButton
isFailed || showResendButton || showRotateButton
? `${baseClass}__failed-message`
: undefined
}
/>
{showRefetchButton && (
<RefetchButton isFetching={isLoading} onClick={onResendProfile} />
{showResendButton && (
<ResendButton isResending={isResending} onClick={onResendProfile} />
)}
{showRotateButton && (
<RotateButton isRotating={isRotating} onClick={onRotatePassword} />
@@ -12,6 +12,7 @@ interface IOSSettingsTableProps {
canRotateRecoveryLockPassword?: boolean;
tableData: IHostMdmProfileWithAddedStatus[];
resendRequest: (profileUUID: string) => Promise<void>;
resendCertificateRequest?: (certificateTemplateId: number) => Promise<void>;
rotateRecoveryLockPassword?: () => Promise<void>;
onProfileResent: () => void;
}
@@ -21,6 +22,7 @@ const OSSettingsTable = ({
canRotateRecoveryLockPassword = false,
tableData,
resendRequest,
resendCertificateRequest,
rotateRecoveryLockPassword,
onProfileResent,
}: IOSSettingsTableProps) => {
@@ -31,6 +33,7 @@ const OSSettingsTable = ({
canResendProfiles,
resendRequest,
onProfileResent,
resendCertificateRequest,
canRotateRecoveryLockPassword,
rotateRecoveryLockPassword
),
@@ -40,6 +43,7 @@ const OSSettingsTable = ({
onProfileResent,
canRotateRecoveryLockPassword,
rotateRecoveryLockPassword,
resendCertificateRequest,
]
);
@@ -4,6 +4,7 @@ import { Column } from "react-table";
import { IStringCellProps } from "interfaces/datatable_config";
import { HostAndroidCertStatus, IHostMdmData } from "interfaces/host";
import {
FLEET_ANDROID_CERTIFICATE_TEMPLATE_PROFILE_ID,
FLEET_FILEVAULT_PROFILE_DISPLAY_NAME,
IHostMdmProfile,
isLinuxDiskEncryptionStatus,
@@ -47,6 +48,7 @@ const generateTableConfig = (
canResendProfiles: boolean,
resendRequest: (profileUUID: string) => Promise<void>,
onProfileResent: () => void,
resendCertificateRequest?: (certificateTemplateId: number) => Promise<void>,
canRotateRecoveryLockPassword?: boolean,
rotateRecoveryLockPassword?: () => Promise<void>
): ITableColumnConfig[] => {
@@ -97,6 +99,10 @@ const generateTableConfig = (
const isAppleMobileConfigProfile =
isAppleDevice(platform) && !isDDMProfile(cellProps.row.original);
const isWindowsProfile = platform === "windows";
const isAndroidCertificate =
platform === "android" &&
cellProps.row.original.profile_uuid ===
FLEET_ANDROID_CERTIFICATE_TEMPLATE_PROFILE_ID;
const isRecoveryLockRow =
cellProps.row.original.profile_uuid ===
@@ -106,13 +112,16 @@ const generateTableConfig = (
<OSSettingsErrorCell
canResendProfiles={
canResendProfiles &&
(isWindowsProfile || isAppleMobileConfigProfile)
(isWindowsProfile ||
isAppleMobileConfigProfile ||
isAndroidCertificate)
}
canRotateRecoveryLockPassword={
isRecoveryLockRow && canRotateRecoveryLockPassword
}
profile={cellProps.row.original}
resendRequest={resendRequest}
resendCertificateRequest={resendCertificateRequest}
rotateRecoveryLockPassword={rotateRecoveryLockPassword}
onProfileResent={onProfileResent}
/>
+12
View File
@@ -648,6 +648,18 @@ export default {
return sendRequest("POST", HOST_RESEND_PROFILE(hostId, profileUUID));
},
resendCertificate: (
hostId: number,
certificateTemplateId: number
): Promise<void> => {
const { HOST_RESEND_CERTIFICATE } = endpoints;
return sendRequest(
"POST",
HOST_RESEND_CERTIFICATE(hostId, certificateTemplateId)
);
},
getHostSoftware: (
params: IHostSoftwareQueryKey
): Promise<IGetHostSoftwareResponse> => {
+2
View File
@@ -89,6 +89,8 @@ export default {
HOST_WIPE: (id: number) => `/${API_VERSION}/fleet/hosts/${id}/wipe`,
HOST_RESEND_PROFILE: (hostId: number, profileUUID: string) =>
`/${API_VERSION}/fleet/hosts/${hostId}/configuration_profiles/${profileUUID}/resend`,
HOST_RESEND_CERTIFICATE: (hostId: number, certificateTemplateId: number) =>
`/${API_VERSION}/fleet/hosts/${hostId}/certificates/${certificateTemplateId}/resend`,
HOST_SOFTWARE: (id: number) => `/${API_VERSION}/fleet/hosts/${id}/software`,
HOST_SOFTWARE_PACKAGE_INSTALL: (hostId: number, softwareId: number) =>
`/${API_VERSION}/fleet/hosts/${hostId}/software/${softwareId}/install`,
@@ -22,6 +22,7 @@ func TestHostCertificateTemplate(t *testing.T) {
require.Equal(t, "", profile.Name)
require.Equal(t, "", profile.Platform)
require.Nil(t, profile.Status)
require.Nil(t, profile.CertificateTemplateID)
},
},
{
@@ -42,6 +43,16 @@ func TestHostCertificateTemplate(t *testing.T) {
require.Empty(t, profile.Detail)
},
},
{
name: "maps certificate_template_id correctly",
template: &HostCertificateTemplate{
CertificateTemplateID: 42,
},
expectation: func(t *testing.T, profile HostMDMProfile) {
require.NotNil(t, profile.CertificateTemplateID)
require.EqualValues(t, 42, *profile.CertificateTemplateID)
},
},
{
name: "maps detail correctly",
template: &HostCertificateTemplate{
+8 -6
View File
@@ -29,13 +29,15 @@ func (p *HostCertificateTemplate) ToHostMDMProfile() HostMDMProfile {
}
status := string(p.Status)
certTemplateID := p.CertificateTemplateID
profile := HostMDMProfile{
HostUUID: p.HostUUID,
Name: p.Name,
Platform: "android",
Status: &status,
OperationType: p.OperationType,
ProfileUUID: AndroidCertificateTemplateProfileID,
HostUUID: p.HostUUID,
Name: p.Name,
Platform: "android",
Status: &status,
OperationType: p.OperationType,
ProfileUUID: AndroidCertificateTemplateProfileID,
CertificateTemplateID: &certTemplateID,
}
if p.Detail != nil {
profile.Detail = *p.Detail
+12 -11
View File
@@ -466,17 +466,18 @@ type MDMProfilesSummary struct {
// HostMDMProfile is the status of an MDM profile on a host. It can be used to represent either
// a Windows or macOS profile.
type HostMDMProfile struct {
HostUUID string `db:"-" json:"-"`
CommandUUID string `db:"-" json:"-"`
ProfileUUID string `db:"-" json:"profile_uuid"`
Name string `db:"-" json:"name"`
Identifier string `db:"-" json:"-"`
Status *string `db:"-" json:"status"` // MDMDeliveryStatus or CertificateTemplateStatus
OperationType MDMOperationType `db:"-" json:"operation_type"`
Detail string `db:"-" json:"detail"`
Platform string `db:"-" json:"platform"`
Scope *string `db:"-" json:"scope"` // Scope and ManagedLocalAccount will be null on unsupported platforms
ManagedLocalAccount *string `db:"-" json:"managed_local_account"`
HostUUID string `db:"-" json:"-"`
CommandUUID string `db:"-" json:"-"`
ProfileUUID string `db:"-" json:"profile_uuid"`
Name string `db:"-" json:"name"`
Identifier string `db:"-" json:"-"`
Status *string `db:"-" json:"status"` // MDMDeliveryStatus or CertificateTemplateStatus
OperationType MDMOperationType `db:"-" json:"operation_type"`
Detail string `db:"-" json:"detail"`
Platform string `db:"-" json:"platform"`
Scope *string `db:"-" json:"scope"` // Scope and ManagedLocalAccount will be null on unsupported platforms
ManagedLocalAccount *string `db:"-" json:"managed_local_account"`
CertificateTemplateID *uint `db:"-" json:"certificate_template_id,omitempty"`
}
// MDMDeliveryStatus is the status of an MDM command to apply a profile