From 79039703366ed577f25aa105e7c315c05affffd8 Mon Sep 17 00:00:00 2001 From: RachelElysia <71795832+RachelElysia@users.noreply.github.com> Date: Fri, 5 Jun 2026 08:10:48 -0400 Subject: [PATCH] Command palette: deep-link Manage policy automations to AutomationsModal (#46884) --- .../CommandPalette/CommandPalette.tsx | 13 +- .../CommandPalette/groups/automations.ts | 111 ++++-------------- .../CommandPalette/helpers.tests.ts | 69 +++++++---- .../ManagePoliciesPage/ManagePoliciesPage.tsx | 34 ++++++ 4 files changed, 106 insertions(+), 121 deletions(-) diff --git a/frontend/components/CommandPalette/CommandPalette.tsx b/frontend/components/CommandPalette/CommandPalette.tsx index 7e9b57410c..5a3d98e396 100644 --- a/frontend/components/CommandPalette/CommandPalette.tsx +++ b/frontend/components/CommandPalette/CommandPalette.tsx @@ -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; diff --git a/frontend/components/CommandPalette/groups/automations.ts b/frontend/components/CommandPalette/groups/automations.ts index e06a19b7e1..077f8da141 100644 --- a/frontend/components/CommandPalette/groups/automations.ts +++ b/frontend/components/CommandPalette/groups/automations.ts @@ -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"] : []), ], }, diff --git a/frontend/components/CommandPalette/helpers.tests.ts b/frontend/components/CommandPalette/helpers.tests.ts index e4fb9d041b..f10a9bc5a1 100644 --- a/frontend/components/CommandPalette/helpers.tests.ts +++ b/frontend/components/CommandPalette/helpers.tests.ts @@ -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=
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", () => { diff --git a/frontend/pages/policies/ManagePoliciesPage/ManagePoliciesPage.tsx b/frontend/pages/policies/ManagePoliciesPage/ManagePoliciesPage.tsx index de5fa585ec..fa586eaa46 100644 --- a/frontend/pages/policies/ManagePoliciesPage/ManagePoliciesPage.tsx +++ b/frontend/pages/policies/ManagePoliciesPage/ManagePoliciesPage.tsx @@ -285,6 +285,7 @@ const ManagePolicyPage = ({ const { data: globalPoliciesCount, isFetching: isFetchingGlobalCount, + isError: isErrorGlobalPoliciesCount, refetch: refetchGlobalPoliciesCount, } = useQuery( [ @@ -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