Command palette: deep-link Manage policy automations to AutomationsModal (#46884)

This commit is contained in:
RachelElysia
2026-06-05 08:10:48 -04:00
committed by GitHub
parent 2460ff63a2
commit 7903970336
4 changed files with 106 additions and 121 deletions
@@ -157,12 +157,13 @@ const CommandPalette = (): JSX.Element | null => {
window.removeEventListener("fleet-theme-change", onThemeChange);
}, []);
// Policy automations: same as canAddOrDeletePolicies in ManagePoliciesPage
const canManagePolicyAutomations =
isGlobalAdmin ||
isGlobalMaintainer ||
isAnyTeamAdmin ||
isAnyTeamMaintainer;
// Policy automations: mirrors ManagePoliciesPage.canEditAutomationsSettings.
// Maintainers can add/delete policies but the in-page Automations button
// is hidden for them, and the deep-link useEffect re-checks the same
// gate — so the palette item must match, otherwise it's a dead link for
// maintainers. isTeamAdmin is scoped to currentTeam by AppContext, so a
// user who's team admin of A but viewing B correctly won't see this.
const canManagePolicyAutomations = isGlobalAdmin || isTeamAdmin;
// Software automations require global admin (all fleets view)
const canManageSoftwareAutomations = isGlobalAdmin;
@@ -13,11 +13,10 @@ const buildAutomationsItems = (
canManagePolicyAutomations,
canManageReportAutomations,
hasTeamSelected,
isPremiumTier,
isPrimoMode,
withTeamId,
} = ctx;
const { isUnassigned, switchesFromUnassigned, hasTeamOrUnassigned } = derived;
const { isUnassigned, switchesFromUnassigned } = derived;
return [
// Manage automations — software. Normally All-fleets-only, but in
@@ -82,109 +81,41 @@ const buildAutomationsItems = (
]
: []),
// Manage automations — policies (admins and maintainers)
// Manage automations — policies (admins and maintainers). Mirrors
// the reports pattern: ManagePoliciesPage reads ?manage_automations=1
// and opens AutomationsModal. The page re-checks role +
// hasPoliciesToAutomate before opening, then strips the param.
//
// Keywords match the sections AutomationsModal actually renders for
// the current fleet scope (see AutomationsModal.tsx:230, 244, 281):
// - All fleets: Webhooks/tickets only
// - Unassigned: Webhooks/tickets + Conditional access
// - Team: Webhooks/tickets + Calendar + Conditional access
// Hiding the inapplicable keywords keeps the palette from matching
// (e.g.) "calendar" to a fleet where the section isn't rendered.
...(canManagePolicyAutomations
? [
{
id: "manage-policy-automations",
label: "Manage policy automations",
group: "Automations" as const,
path: withTeamId(paths.MANAGE_POLICIES),
path: withTeamId(`${paths.MANAGE_POLICIES}?manage_automations=1`),
keywords: [
"manage automations",
"failing",
"tickets",
"webhook",
"jira",
"zendesk",
],
subItems: [
{
id: "manage-policy-automations-webhooks",
label: "Tickets & webhooks",
path: withTeamId(
`${paths.MANAGE_POLICIES}?manage_automations=webhooks`
),
keywords: [
"manage policy automations",
"manage automations",
"jira",
"zendesk",
"failing",
],
},
// Team-scoped policy automations (Premium-only). The
// policies page allows install_software / run_script /
// conditional_access on No team / Unassigned, so those
// three use `hasTeamOrUnassigned`. Calendar events stay
// on `hasTeamSelected` — the page disables them when
// there's no specific team.
...(isPremiumTier && hasTeamOrUnassigned
...(hasTeamSelected
? [
{
id: "manage-policy-automations-install-software",
label: "Install software",
path: withTeamId(
`${paths.MANAGE_POLICIES}?manage_automations=install_software`
),
keywords: [
"manage policy automations",
"manage automations",
"resolve",
"remediate",
],
},
{
id: "manage-policy-automations-run-script",
label: "Run script",
path: withTeamId(
`${paths.MANAGE_POLICIES}?manage_automations=run_script`
),
keywords: [
"manage policy automations",
"manage automations",
"resolve",
"remediate",
],
},
"calendar",
"google calendar",
"scheduled maintenance windows",
]
: []),
...(isPremiumTier && hasTeamSelected
? [
{
id: "manage-policy-automations-calendar",
label: "Calendar events",
path: withTeamId(
`${paths.MANAGE_POLICIES}?manage_automations=calendar`
),
keywords: [
"manage policy automations",
"manage automations",
"reserve time",
"maintenance window",
"google calendar",
],
},
]
: []),
...(isPremiumTier && hasTeamOrUnassigned
? [
{
id: "manage-policy-automations-conditional-access",
label: "Conditional access",
path: withTeamId(
`${paths.MANAGE_POLICIES}?manage_automations=conditional_access`
),
keywords: [
"manage policy automations",
"manage automations",
"sso",
"okta",
"entra",
"intune",
"zero trust",
],
},
]
...(hasTeamSelected || isUnassigned
? ["conditional access", "sso", "okta", "entra", "intune"]
: []),
],
},
@@ -287,7 +287,17 @@ describe("CommandPalette helpers", () => {
expect(vpp?.label).toContain("Edit");
});
it("shows team-scoped policy automations when premium and team selected", () => {
it("exposes Manage policy automations as a flat entry that deep-links into AutomationsModal", () => {
// Previously the palette listed Tickets & webhooks / Install
// software / Run script / Calendar / Conditional access as
// sub-items, each with its own ?manage_automations=<section> URL.
// ManagePoliciesPage never parsed those params, so all five were
// dead links. AutomationsModal also dropped its Install software
// / Run script sections (those are per-policy now), and the modal
// has no per-section URL trigger. The palette now mirrors the
// reports pattern: a single entry whose path is
// /policies?manage_automations=1, which the page reads to open
// the modal at its single shared body.
const items = buildPaletteItems({
...BASE_CONTEXT,
hasTeamSelected: true,
@@ -297,47 +307,56 @@ describe("CommandPalette helpers", () => {
const policyAutomations = items.find(
(i) => i.id === "manage-policy-automations"
);
expect(policyAutomations?.subItems?.length).toBeGreaterThan(1);
const subIds = policyAutomations?.subItems?.map((s) => s.id) ?? [];
expect(subIds).toContain("manage-policy-automations-install-software");
expect(subIds).toContain("manage-policy-automations-calendar");
expect(policyAutomations).toBeDefined();
expect(policyAutomations?.subItems).toBeUndefined();
expect(policyAutomations?.path).toContain("manage_automations=1");
});
it("excludes team-scoped policy automations when no team selected", () => {
it("hides calendar + conditional-access keywords on All fleets (modal renders only Webhooks/tickets there)", () => {
const items = buildPaletteItems(BASE_CONTEXT);
const policyAutomations = items.find(
(i) => i.id === "manage-policy-automations"
);
// Only webhooks should be present (no team-scoped items)
expect(policyAutomations?.subItems?.length).toBe(1);
expect(policyAutomations?.subItems?.[0].id).toBe(
"manage-policy-automations-webhooks"
);
const keywords = policyAutomations?.keywords ?? [];
expect(keywords).toContain("webhook");
expect(keywords).not.toContain("calendar");
expect(keywords).not.toContain("google calendar");
expect(keywords).not.toContain("conditional access");
expect(keywords).not.toContain("sso");
});
it("on Unassigned, shows install-software / run-script / conditional-access but NOT calendar", () => {
// ManagePoliciesPage allows these three automations on No team
// but disables Calendar events without a specific fleet. The
// palette must match — earlier all four were gated together on
// hasTeamSelected, which dropped them all on Unassigned.
it("hides calendar keywords on Unassigned (Calendar section is disabled there) but keeps conditional access", () => {
const items = buildPaletteItems({
...BASE_CONTEXT,
hasTeamSelected: false,
currentTeam: { id: 0, name: "No team" },
});
const policyAutomations = items.find(
(i) => i.id === "manage-policy-automations"
);
const subIds = policyAutomations?.subItems?.map((s) => s.id) ?? [];
const keywords = policyAutomations?.keywords ?? [];
expect(keywords).toContain("webhook");
expect(keywords).not.toContain("calendar");
expect(keywords).not.toContain("google calendar");
expect(keywords).toContain("conditional access");
expect(keywords).toContain("sso");
});
expect(subIds).toContain("manage-policy-automations-webhooks");
expect(subIds).toContain("manage-policy-automations-install-software");
expect(subIds).toContain("manage-policy-automations-run-script");
expect(subIds).toContain("manage-policy-automations-conditional-access");
expect(subIds).not.toContain("manage-policy-automations-calendar");
it("includes all keywords on a specific team", () => {
const items = buildPaletteItems({
...BASE_CONTEXT,
hasTeamSelected: true,
currentTeam: { id: 1, name: "Engineering" },
});
const policyAutomations = items.find(
(i) => i.id === "manage-policy-automations"
);
const keywords = policyAutomations?.keywords ?? [];
expect(keywords).toContain("webhook");
expect(keywords).toContain("calendar");
expect(keywords).toContain("google calendar");
expect(keywords).toContain("conditional access");
expect(keywords).toContain("sso");
});
it("excludes certificates and passwords for technicians", () => {
@@ -285,6 +285,7 @@ const ManagePolicyPage = ({
const {
data: globalPoliciesCount,
isFetching: isFetchingGlobalCount,
isError: isErrorGlobalPoliciesCount,
refetch: refetchGlobalPoliciesCount,
} = useQuery<IPoliciesCountResponse, Error, number, IPoliciesCountQueryKey[]>(
[
@@ -342,6 +343,7 @@ const ManagePolicyPage = ({
const {
data: teamPoliciesCountResponse,
isFetching: isFetchingTeamCountMergeInherited,
isError: isErrorTeamPoliciesCount,
refetch: refetchTeamPoliciesCountMergeInherited,
} = useQuery<
IPoliciesCountResponse,
@@ -616,6 +618,38 @@ const ManagePolicyPage = ({
const hasPoliciesToDelete =
hasPoliciesToAutomate || (isPrimoMode && (teamPolicies?.length ?? 0) > 0); // in Primo mode, allow deleting inherited policies, which will be included in teamPolicies, from this view
// Open the Manage automations modal via deep-link (e.g. from the
// command palette). Gate on the same predicate the in-page button
// uses — the param alone must not surface a privileged modal to
// non-admins or when there's nothing to automate. Wait for the
// relevant count query to settle (data OR error) so
// `hasPoliciesToAutomate` is meaningful; then always strip the param
// so a refresh doesn't reopen and the URL doesn't get stuck if the
// count call errored.
useEffect(() => {
if (location.query.manage_automations !== "1") return;
const countSettled = isAllTeamsSelected
? globalPoliciesCount !== undefined || isErrorGlobalPoliciesCount
: teamPoliciesCountResponse !== undefined || isErrorTeamPoliciesCount;
if (!countSettled) return;
if (canEditAutomationsSettings && hasPoliciesToAutomate) {
setShowAutomationsModal(true);
}
const { manage_automations, ...rest } = location.query;
router.replace({ pathname: location.pathname, query: rest });
}, [
location.query,
location.pathname,
router,
canEditAutomationsSettings,
hasPoliciesToAutomate,
isAllTeamsSelected,
globalPoliciesCount,
teamPoliciesCountResponse,
isErrorGlobalPoliciesCount,
isErrorTeamPoliciesCount,
]);
const fleetAutomationInfo = getTicketOrWebhookInfo(automationsConfig);
// Inherited (global) policies are listed in team views, but their webhook
// membership lives on the *global* config — not the team's. Union both