UI – Always allow i(Pad)OS Refetch; update copy and naming for broader "Apple" MDM (#20674)

## 1st part of #20467
- Update types/interfaces
- Update MDM copy from "macOS" to "Apple"
- Update MDM file, variable, classnames from "Mac" to "Apple"
- Enable refetch for iPads and iPhones 

<img width="1341" alt="Screenshot 2024-07-23 at 4 14 46 PM"
src="https://github.com/user-attachments/assets/3c322680-3c7f-40eb-a9da-6a14d0f1a358">



# Checklist for submitter

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

<!-- Note that API documentation changes are now addressed by the
product design team. -->

- [x] Added/updated tests
- [x] Manual QA for all new/changed functionality

---------

Co-authored-by: Jacob Shandling <jacob@fleetdm.com>
This commit is contained in:
jacobshandling
2024-07-25 12:19:23 -07:00
committed by GitHub
co-authored by Jacob Shandling
parent 95fc48c110
commit 40d91588ec
31 changed files with 111 additions and 103 deletions
+1
View File
@@ -32,6 +32,7 @@ const DEFAULT_MDM_APPLE_VPP_APP_MOCK: IVppApp = {
latest_version: "1.0",
app_store_id: 1,
added: false,
platform: "darwin",
};
export const createMockVppApp = (overrides?: Partial<IVppApp>): IVppApp => {
+6 -4
View File
@@ -34,7 +34,7 @@ export interface ISoftware {
name: string; // e.g., "Figma.app"
version: string; // e.g., "2.1.11"
bundle_identifier?: string | null; // e.g., "com.figma.Desktop"
source: string; // e.g., "apps"
source: string; // "apps" | "ipados" | "ios" | "programs" | ?
generated_cpe: string;
vulnerabilities: ISoftwareVulnerability[] | null;
hosts_count?: number;
@@ -90,7 +90,7 @@ export interface ISoftwareTitle {
id: number;
name: string;
versions_count: number;
source: string;
source: string; // "apps" | "ios_apps" | "ipados_apps" | ?
hosts_count: number;
versions: ISoftwareTitleVersion[] | null;
software_package: ISoftwarePackage | null;
@@ -103,7 +103,7 @@ export interface ISoftwareTitleDetails {
name: string;
software_package: ISoftwarePackage | null;
app_store_app: IAppStoreApp | null;
source: string;
source: string; // "apps" | "ios_apps" | "ipados_apps" | ?
hosts_count: number;
versions: ISoftwareTitleVersion[] | null;
bundle_identifier?: string;
@@ -128,7 +128,7 @@ export interface ISoftwareVersion {
name: string; // e.g., "Figma.app"
version: string; // e.g., "2.1.11"
bundle_identifier?: string; // e.g., "com.figma.Desktop"
source: string; // e.g., "apps"
source: string; // "apps" | "ipados_apps" | "ios_apps" | ?
browser: string; // e.g., "chrome"
release: string; // TODO: on software/verions/:id?
vendor: string;
@@ -148,6 +148,8 @@ export const SOURCE_TYPE_CONVERSION: Record<string, string> = {
atom_packages: "Package (Atom)", // Atom packages were removed from software inventory. Mapping is maintained for backwards compatibility. (2023-12-04)
python_packages: "Package (Python)",
apps: "Application (macOS)",
ios: "Application (iOS)",
ipados: "Application (iPadOS)",
chrome_extensions: "Browser plugin", // chrome_extensions can include any chrome-based browser (e.g., edge), so we rely instead on the `browser` field computed by Fleet server and fallback to this value if it is not present.
firefox_addons: "Browser plugin (Firefox)",
safari_extensions: "Browser plugin (Safari)",
@@ -19,12 +19,12 @@ const AppleAutomaticEnrollmentCard = ({
}: IAppleAutomaticEnrollmentCardProps) => {
let icon = "";
let msg =
"To enable automatic enrollment for macOS devices, first turn on macOS MDM.";
"To enable automatic enrollment for macOS, iOS, and iPadOS hosts, first turn on Apple MDM.";
if (!turnOn && !configured) {
msg =
"Automatically enroll newly purchased macOS devices when theyre first unboxed and set up by your end user.";
"Automatically enroll newly purchased Apple hosts when they're first unboxed and set up by your end user.";
} else if (!turnOn && configured) {
msg = "Automatic enrollment for macOS enabled.";
msg = "Automatic enrollment for Apple (macOS, iOS, iPadOS) hosts enabled.";
icon = "success";
}
@@ -34,7 +34,9 @@ const AppleAutomaticEnrollmentCard = ({
color="gray"
>
<div>
{!icon && <h3>Automatic enrollment for macOS hosts</h3>}
{!icon && (
<h3>Automatic enrollment for Apple (macOS, iOS, iPadOS) hosts.</h3>
)}
<p>
{icon ? (
<span>
@@ -19,11 +19,11 @@ import ApplePushCertSetup from "./components/content/ApplePushCertSetup";
import ApplePushCertInfo from "./components/content/ApplePushCertInfo";
import RenewCertModal from "./components/modals/RenewCertModal";
import TurnOffMacOsMdmModal from "./components/modals/TurnOffMacOsMdmModal";
import TurnOffAppleMdmModal from "./components/modals/TurnOffAppleMdmModal";
export const baseClass = "mac-os-mdm-page";
export const baseClass = "apple-mdm-page";
const MacOSMdmPage = ({ router }: { router: InjectedRouter }) => {
const AppleMdmPage = ({ router }: { router: InjectedRouter }) => {
const { config } = useContext(AppContext);
const { renderFlash } = useContext(NotificationContext);
@@ -65,10 +65,10 @@ const MacOSMdmPage = ({ router }: { router: InjectedRouter }) => {
toggleTurnOffMdmModal();
try {
await mdmAppleAPI.deleteApplePushCertificate();
renderFlash("success", "macOS MDM turned off successfully.");
renderFlash("success", "MDM turned off successfully.");
router.push(PATHS.ADMIN_INTEGRATIONS_MDM);
} catch (e) {
renderFlash("error", "Couldnt turn off MDM. Please try again.");
renderFlash("error", "Couldn't turn off MDM. Please try again.");
setIsUpdating(false);
}
}, [renderFlash, router]);
@@ -124,7 +124,7 @@ const MacOSMdmPage = ({ router }: { router: InjectedRouter }) => {
/>
)}
{showTurnOffMdmModal && (
<TurnOffMacOsMdmModal
<TurnOffAppleMdmModal
onCancel={toggleTurnOffMdmModal}
onConfirm={turnOffMdm}
/>
@@ -134,4 +134,4 @@ const MacOSMdmPage = ({ router }: { router: InjectedRouter }) => {
);
};
export default MacOSMdmPage;
export default AppleMdmPage;
@@ -1,4 +1,4 @@
.mac-os-mdm-page {
.apple-mdm-page {
&__back-to-mdm {
margin-bottom: $pad-xlarge;
}
@@ -28,7 +28,7 @@ const ApplePushCertSetup = ({
setIsUploading(true);
try {
await mdmAppleApi.uploadApplePushCertificate(files[0]);
renderFlash("success", "macOS MDM turned on successfully.");
renderFlash("success", "MDM turned on successfully.");
onSetupSuccess();
} catch (e) {
const msg = getErrorReason(e);
@@ -4,17 +4,17 @@ import Button from "components/buttons/Button";
import Modal from "components/Modal";
const baseClass = "modal turn-off-mdm-modal";
const baseClass = "modal turn-off-apple-mdm-modal";
interface ITurnOffMacOsMdmModalProps {
interface ITurnOffAppleMdmModalProps {
onCancel: () => void;
onConfirm: () => void;
}
const TurnOffMacOsMdmModal = ({
const TurnOffAppleMdmModal = ({
onConfirm,
onCancel,
}: ITurnOffMacOsMdmModalProps): JSX.Element => {
}: ITurnOffAppleMdmModalProps): JSX.Element => {
const [isDeleting, setIsDeleting] = useState(false);
const onClickConfirm = useCallback(() => {
@@ -23,10 +23,11 @@ const TurnOffMacOsMdmModal = ({
}, [onConfirm]);
return (
<Modal title="Turn off macOS MDM" onExit={onCancel} className={baseClass}>
<Modal title="Turn off MDM" onExit={onCancel} className={baseClass}>
<div className={baseClass}>
If you want to use MDM features again, youll have to upload a new APNs
certificate and all end users will have to turn MDM off and back on.
If you want to use MDM features again, you&apos;ll have to upload a new
APNs certificate and all end users will have to turn MDM off and back
on.
<div className="modal-cta-wrap">
<Button
type="button"
@@ -50,4 +51,4 @@ const TurnOffMacOsMdmModal = ({
);
};
export default TurnOffMacOsMdmModal;
export default TurnOffAppleMdmModal;
@@ -0,0 +1 @@
export { default } from "./TurnOffAppleMdmModal";
@@ -0,0 +1 @@
export { default } from "./AppleMdmPage";
@@ -1 +0,0 @@
export { default } from "./TurnOffMacOsMdmModal";
@@ -1 +0,0 @@
export { default } from "./MacOSMdmPage";
@@ -15,7 +15,7 @@ import SectionHeader from "components/SectionHeader";
import EndUserMigrationSection from "./components/EndUserMigrationSection/EndUserMigrationSection";
import WindowsMdmCard from "./components/WindowsMdmCard/WindowsMdmCard";
import MacOSMdmCard from "./components/MacOSMdmCard/MacOSMdmCard";
import AppleMdmCard from "./components/AppleMdmCard/AppleMdmCard";
const baseClass = "mdm-settings";
@@ -49,8 +49,8 @@ const MdmSettings = ({ router }: IMdmSettingsProps) => {
}
);
const navigateToMacOSMdm = () => {
router.push(PATHS.ADMIN_INTEGRATIONS_MDM_MAC);
const navigateToAppleMdm = () => {
router.push(PATHS.ADMIN_INTEGRATIONS_MDM_APPLE);
};
const navigateToWindowsMdm = () => {
@@ -65,11 +65,11 @@ const MdmSettings = ({ router }: IMdmSettingsProps) => {
<Spinner />
) : (
<div className={`${baseClass}__section ${baseClass}__mdm-section`}>
<MacOSMdmCard
<AppleMdmCard
appleAPNInfo={appleAPNInfo}
errorData={errorMdmApple}
turnOnMacOSMdm={navigateToMacOSMdm}
viewDetails={navigateToMacOSMdm}
turnOnAppleMdm={navigateToAppleMdm}
viewDetails={navigateToAppleMdm}
/>
<WindowsMdmCard
turnOnWindowsMdm={navigateToWindowsMdm}
@@ -7,17 +7,17 @@ import DataError from "components/DataError";
import { AxiosError } from "axios";
import { IMdmApple } from "interfaces/mdm";
const baseClass = "mac-os-mdm-card";
const baseClass = "apple-mdm-card";
interface ITurnOnMacOSMdmProps {
interface ITurnOnAppleMdmProps {
onClickTurnOn: () => void;
}
const TurnOnMacOSMdm = ({ onClickTurnOn }: ITurnOnMacOSMdmProps) => {
const TurnOnAppleMdm = ({ onClickTurnOn }: ITurnOnAppleMdmProps) => {
return (
<div className={`${baseClass}__turn-on-mac-os`}>
<div className={`${baseClass}__turn-on-apple-mdm`}>
<div>
<h3>Turn on macOS MDM</h3>
<h3>Turn on Apple (macOS, iOS, iPadOS) MDM</h3>
<p>Enforce settings, OS updates, disk encryption, and more.</p>
</div>
<Button variant="brand" onClick={onClickTurnOn}>
@@ -27,16 +27,16 @@ const TurnOnMacOSMdm = ({ onClickTurnOn }: ITurnOnMacOSMdmProps) => {
);
};
interface ITurnOffMacOSMdmProps {
interface ITurnOffAppleMdmProps {
onClickDetails: () => void;
}
const SeeDetailsMacOSMdm = ({ onClickDetails }: ITurnOffMacOSMdmProps) => {
const SeeDetailsAppleMdm = ({ onClickDetails }: ITurnOffAppleMdmProps) => {
return (
<div className={`${baseClass}__turn-off-mac-os`}>
<div>
<Icon name="success" />
<p>macOS MDM turned on</p>
<p>Apple (macOS, iOS, iPadOS) MDM turned on.</p>
</div>
<Button onClick={onClickDetails} variant="text-icon">
<Icon name="pencil" />
@@ -46,25 +46,25 @@ const SeeDetailsMacOSMdm = ({ onClickDetails }: ITurnOffMacOSMdmProps) => {
);
};
interface IMacOSMdmCardProps {
interface IAppleMdmCardProps {
appleAPNInfo: IMdmApple | undefined;
errorData: AxiosError | null;
turnOnMacOSMdm: () => void;
turnOnAppleMdm: () => void;
viewDetails: () => void;
}
/**
* This compoent is responsible for showing the correct UI for the macOS MDM card.
* This component is responsible for showing the correct UI for the Apple MDM card.
* We pass in the appleAPNInfo and errorData from the MdmSettings component because
* we need to make that API call higher up in the component tree to correctly show
* loading states on the page.
*/
const MacOSMdmCard = ({
const AppleMdmCard = ({
appleAPNInfo,
errorData,
turnOnMacOSMdm,
turnOnAppleMdm,
viewDetails,
}: IMacOSMdmCardProps) => {
}: IAppleMdmCardProps) => {
// The API returns an error if MDM is turned off or APNS is not configured yet.
// If there is any other error we will show the DataError component.
const showError =
@@ -77,12 +77,12 @@ const MacOSMdmCard = ({
return (
<Card className={baseClass} color="gray">
{appleAPNInfo !== undefined ? (
<SeeDetailsMacOSMdm onClickDetails={viewDetails} />
<SeeDetailsAppleMdm onClickDetails={viewDetails} />
) : (
<TurnOnMacOSMdm onClickTurnOn={turnOnMacOSMdm} />
<TurnOnAppleMdm onClickTurnOn={turnOnAppleMdm} />
)}
</Card>
);
};
export default MacOSMdmCard;
export default AppleMdmCard;
@@ -5,41 +5,45 @@ import { render, screen } from "@testing-library/react";
import createMockMdmApple from "__mocks__/appleMdm";
import createMockAxiosError from "__mocks__/axiosError";
import MacOSMdmCard from "./MacOSMdmCard";
import AppleMdmCard from "./AppleMdmCard";
describe("MacOSMdmCard", () => {
it("renders the turn on macOs mdm state when there is no appleAPNInfo", () => {
describe("AppleMdmCard", () => {
it("renders the turn on Apple mdm state when there is no appleAPNInfo", () => {
render(
<MacOSMdmCard
<AppleMdmCard
appleAPNInfo={undefined}
errorData={null}
turnOnMacOSMdm={noop}
turnOnAppleMdm={noop}
viewDetails={noop}
/>
);
expect(screen.getByText("Turn on macOS MDM")).toBeInTheDocument();
expect(
screen.getByText("Turn on Apple (macOS, iOS, iPadOS) MDM")
).toBeInTheDocument();
});
it("renders the show details state when there is appleAPNInfo", () => {
render(
<MacOSMdmCard
<AppleMdmCard
appleAPNInfo={createMockMdmApple()}
errorData={null}
turnOnMacOSMdm={noop}
turnOnAppleMdm={noop}
viewDetails={noop}
/>
);
expect(screen.getByText("macOS MDM turned on")).toBeInTheDocument();
expect(
screen.getByText("Apple (macOS, iOS, iPadOS) MDM turned on.")
).toBeInTheDocument();
});
it("renders the error state when there is a non 404 error", () => {
render(
<MacOSMdmCard
<AppleMdmCard
appleAPNInfo={createMockMdmApple()}
errorData={createMockAxiosError({ status: 500 })}
turnOnMacOSMdm={noop}
turnOnAppleMdm={noop}
viewDetails={noop}
/>
);
@@ -47,10 +51,10 @@ describe("MacOSMdmCard", () => {
expect(screen.getByText(/Something's gone wrong/)).toBeInTheDocument();
render(
<MacOSMdmCard
<AppleMdmCard
appleAPNInfo={createMockMdmApple()}
errorData={createMockAxiosError({ status: 404 })}
turnOnMacOSMdm={noop}
turnOnAppleMdm={noop}
viewDetails={noop}
/>
);
@@ -1,11 +1,11 @@
.mac-os-mdm-card {
.apple-mdm-card {
font-size: $x-small;
p {
margin: 0;
}
&__turn-on-mac-os,
&__turn-on-apple-mdm,
&__turn-off-mac-os {
display: flex;
justify-content: space-between;
@@ -22,7 +22,7 @@
}
}
&__turn-on-mac-os {
&__turn-on-apple-mdm {
h3 {
font-size: $x-small;
font-weight: $bold;
@@ -0,0 +1 @@
export { default } from "./AppleMdmCard";
@@ -1 +0,0 @@
export { default } from "./MacOSMdmCard";
@@ -29,7 +29,7 @@ describe("Vpp Section", () => {
render(<Vpp router={createMockRouter()} />);
expect(
await screen.findByRole("button", { name: "Turn on macOS MDM" })
await screen.findByRole("button", { name: "Turn on MDM" })
).toBeInTheDocument();
});
@@ -37,12 +37,12 @@ const VppCard = ({ isAppleMdmOn, isVppOn, router }: IVppCardProps) => {
<div>
<h3>Volume Purchasing Program (VPP)</h3>
<p>
To enable Volume Purchasing Program (VPP) for macOS devices, first
turn on macOS MDM.
To enable Volume Purchasing Program (VPP), first turn on Apple (macOS,
iOS, iPadOS) MDM.
</p>
</div>
<Button onClick={nagivateToMdm} variant="text-link">
Turn on macOS MDM
Turn on MDM
</Button>
</div>
);
@@ -921,7 +921,7 @@ describe("Host Actions Dropdown", () => {
expect(screen.queryByText("Wipe")).not.toBeInTheDocument();
});
it("does not renders when a macOS host but does not have Fleet macOS mdm enabled and configured", async () => {
it("does not render for a macOS host if Fleet Apple MDM is not enabled and configured", async () => {
const render = createCustomRenderer({
context: {
app: {
@@ -140,7 +140,7 @@ describe("Host Summary section", () => {
});
});
describe("iOS and iPadOS data", () => {
it("for iOS, renders Team, Disk space, and Operating system data only and hides the refetch button", async () => {
it("for iOS, renders Team, Disk space, and Operating system data only", async () => {
const render = createCustomRenderer({
context: {
app: {
@@ -181,15 +181,15 @@ describe("Host Summary section", () => {
expect(
screen.getByText("Operating system").nextElementSibling
).toHaveTextContent(osVersion);
expect(screen.queryByText("Refetch")).toBeInTheDocument();
expect(screen.queryByText("Refetch")).not.toBeInTheDocument();
expect(screen.queryByText("Status")).not.toBeInTheDocument();
expect(screen.queryByText("Memory")).not.toBeInTheDocument();
expect(screen.queryByText("Processor type")).not.toBeInTheDocument();
expect(screen.queryByText("Agent")).not.toBeInTheDocument();
expect(screen.queryByText("Osquery")).not.toBeInTheDocument();
});
it("for iPadOS, renders Team, Disk space, and Operating system data only and hides the refetch button", async () => {
it("for iPadOS, renders Team, Disk space, and Operating system data only", async () => {
const render = createCustomRenderer({
context: {
app: {
@@ -230,8 +230,8 @@ describe("Host Summary section", () => {
expect(
screen.getByText("Operating system").nextElementSibling
).toHaveTextContent(osVersion);
expect(screen.queryByText("Refetch")).toBeInTheDocument();
expect(screen.queryByText("Refetch")).not.toBeInTheDocument();
expect(screen.queryByText("Status")).not.toBeInTheDocument();
expect(screen.queryByText("Memory")).not.toBeInTheDocument();
expect(screen.queryByText("Processor type")).not.toBeInTheDocument();
@@ -191,30 +191,29 @@ const HostSummary = ({
const isIosOrIpadosHost = platform === "ios" || platform === "ipados";
const renderRefetch = () => {
if (isIosOrIpadosHost) {
return null;
}
const isOnline = summaryData.status === "online";
let isDisabled = false;
let tooltip: React.ReactNode = <></>;
let tooltip;
// deviceStatus can be `undefined` in the case of the MyDevice Page not sending
// this prop. When this is the case or when it is `unlocked`, we only take
// into account the host being online or offline for correctly render the
// refresh button. If we have a value for deviceStatus, we then need to also
// take it account for rendering the button.
if (
hostMdmDeviceStatus === undefined ||
hostMdmDeviceStatus === "unlocked"
) {
isDisabled = !isOnline;
tooltip = !isOnline ? REFETCH_TOOLTIP_MESSAGES.offline : null;
} else {
isDisabled = true;
tooltip = !isOnline
? REFETCH_TOOLTIP_MESSAGES.offline
: REFETCH_TOOLTIP_MESSAGES[hostMdmDeviceStatus];
// we don't have a concept of "online" for iPads and iPhones, so always enable refetch
if (!isIosOrIpadosHost) {
// deviceStatus can be `undefined` in the case of the MyDevice Page not sending
// this prop. When this is the case or when it is `unlocked`, we only take
// into account the host being online or offline for correctly render the
// refresh button. If we have a value for deviceStatus, we then need to also
// take it account for rendering the button.
if (
hostMdmDeviceStatus === undefined ||
hostMdmDeviceStatus === "unlocked"
) {
isDisabled = !isOnline;
tooltip = !isOnline ? REFETCH_TOOLTIP_MESSAGES.offline : null;
} else {
isDisabled = true;
tooltip = !isOnline
? REFETCH_TOOLTIP_MESSAGES.offline
: REFETCH_TOOLTIP_MESSAGES[hostMdmDeviceStatus];
}
}
return (
+2 -2
View File
@@ -57,7 +57,7 @@ import OSUpdates from "pages/ManageControlsPage/OSUpdates";
import OSSettings from "pages/ManageControlsPage/OSSettings";
import SetupExperience from "pages/ManageControlsPage/SetupExperience/SetupExperience";
import WindowsMdmPage from "pages/admin/IntegrationsPage/cards/MdmSettings/WindowsMdmPage";
import MacOSMdmPage from "pages/admin/IntegrationsPage/cards/MdmSettings/MacOSMdmPage";
import AppleMdmPage from "pages/admin/IntegrationsPage/cards/MdmSettings/AppleMdmPage";
import Scripts from "pages/ManageControlsPage/Scripts/Scripts";
import AppleAutomaticEnrollmentPage from "pages/admin/IntegrationsPage/cards/AutomaticEnrollment/AppleAutomaticEnrollmentPage";
import WindowsAutomaticEnrollmentPage from "pages/admin/IntegrationsPage/cards/AutomaticEnrollment/WindowsAutomaticEnrollmentPage";
@@ -160,7 +160,7 @@ const routes = (
</Route>
</Route>
<Route path="integrations/mdm/windows" component={WindowsMdmPage} />
<Route path="integrations/mdm/apple" component={MacOSMdmPage} />
<Route path="integrations/mdm/apple" component={AppleMdmPage} />
<Route
path="integrations/automatic-enrollment/apple"
component={AppleAutomaticEnrollmentPage}
+1 -1
View File
@@ -32,7 +32,7 @@ export default {
ADMIN_INTEGRATIONS: `${URL_PREFIX}/settings/integrations`,
ADMIN_INTEGRATIONS_TICKET_DESTINATIONS: `${URL_PREFIX}/settings/integrations/ticket-destinations`,
ADMIN_INTEGRATIONS_MDM: `${URL_PREFIX}/settings/integrations/mdm`,
ADMIN_INTEGRATIONS_MDM_MAC: `${URL_PREFIX}/settings/integrations/mdm/apple`,
ADMIN_INTEGRATIONS_MDM_APPLE: `${URL_PREFIX}/settings/integrations/mdm/apple`,
ADMIN_INTEGRATIONS_MDM_WINDOWS: `${URL_PREFIX}/settings/integrations/mdm/windows`,
ADMIN_INTEGRATIONS_AUTOMATIC_ENROLLMENT: `${URL_PREFIX}/settings/integrations/automatic-enrollment`,
ADMIN_INTEGRATIONS_AUTOMATIC_ENROLLMENT_APPLE: `${URL_PREFIX}/settings/integrations/automatic-enrollment/apple`,
+1
View File
@@ -13,6 +13,7 @@ export interface IVppApp {
latest_version: string;
app_store_id: number;
added: boolean;
platform: string; // "darwin" | "ios" | "ipados"
}
interface IGetVppAppsResponse {
-2
View File
@@ -50,11 +50,9 @@ export interface ISoftwareVersionsResponse {
export interface ISoftwareTitleResponse {
software_title: ISoftwareTitleDetails;
}
export interface ISoftwareVersionResponse {
software: ISoftwareVersion;
}
export interface ISoftwareVersionsQueryKey extends ISoftwareApiParams {
scope: "software-versions";
}