diff --git a/.claude/skills/command-palette/SKILL.md b/.claude/skills/command-palette/SKILL.md
index ef1b4b10a1b..80c0dba8b24 100644
--- a/.claude/skills/command-palette/SKILL.md
+++ b/.claude/skills/command-palette/SKILL.md
@@ -19,6 +19,7 @@ This skill exists to make sure that guide gets followed when palette-worthy chan
frontend/components/CommandPalette/groups/
```
3. **Confirm the destination page's own permission check**, then mirror it on the palette item using a flag from `ICommandPaletteContext` (`frontend/components/CommandPalette/helpers.ts`). Add a new flag there only if no existing one models the destination's check. Don't route users to a screen they can't use.
+4. **Premium paywall check:** if the destination page (or the specific tab/section the link lands on) renders `` for `!isPremiumTier`, gate the palette item on `isPremiumTier` so it's hidden on Free. A palette entry that lands on the upsell wall is a bait-and-switch β the palette is for actions, not for marketing the paid tier. Mirror tab-level paywalls too (e.g., `paths.ADMIN_INTEGRATIONS_SSO_END_USERS` lands on a Premium-only tab even though the parent SSO page works on Free). If the destination only paywalls a sub-section (not the whole page or the linked tab), don't gate β the page is still useful on Free.
## After adding an item
diff --git a/frontend/components/CommandPalette/CommandPalette.tsx b/frontend/components/CommandPalette/CommandPalette.tsx
index 0a742d4debc..6ae3761afb0 100644
--- a/frontend/components/CommandPalette/CommandPalette.tsx
+++ b/frontend/components/CommandPalette/CommandPalette.tsx
@@ -30,6 +30,7 @@ import SoftwarePicker from "./components/SoftwarePicker";
import ReportPicker from "./components/ReportPicker";
import PolicyPicker from "./components/PolicyPicker";
import HighlightedLabel from "./components/HighlightedLabel";
+import UprightEmoji from "./components/UprightEmoji";
import { isPreFilteredResult } from "./components/constants";
const baseClass = "command-palette";
@@ -546,7 +547,9 @@ const CommandPalette = (): JSX.Element | null => {
)}
{item.teamName && (
- {item.teamName}
+
+
+
)}
{/* Render sub-items when expanded (browsing) or always when searching */}
@@ -634,7 +637,7 @@ const CommandPalette = (): JSX.Element | null => {
fleets shows "All fleets"). */}
{!sub && item.teamName && (
- {item.teamName}
+
)}
{/* Parent label as a context chip on promoted sub-items
diff --git a/frontend/components/CommandPalette/components/HostPicker.tsx b/frontend/components/CommandPalette/components/HostPicker.tsx
index a09617be003..92e43d53dce 100644
--- a/frontend/components/CommandPalette/components/HostPicker.tsx
+++ b/frontend/components/CommandPalette/components/HostPicker.tsx
@@ -6,6 +6,7 @@ import hostsAPI, { ILoadHostsResponse } from "services/entities/hosts";
import usePickerSearch from "./usePickerSearch";
import { RESULT_PREFIXES } from "./constants";
import HighlightedLabel from "./HighlightedLabel";
+import UprightEmoji from "./UprightEmoji";
const baseClass = "command-palette";
@@ -85,7 +86,7 @@ const HostPicker = ({
{showTeamColumn && (
- {host.team_name || "Unassigned"}
+
)}
diff --git a/frontend/components/CommandPalette/components/UprightEmoji.tests.tsx b/frontend/components/CommandPalette/components/UprightEmoji.tests.tsx
new file mode 100644
index 00000000000..9119f5e0e28
--- /dev/null
+++ b/frontend/components/CommandPalette/components/UprightEmoji.tests.tsx
@@ -0,0 +1,109 @@
+import React from "react";
+import { render } from "@testing-library/react";
+
+import UprightEmoji, { splitEmojiSegments } from "./UprightEmoji";
+
+describe("splitEmojiSegments", () => {
+ it("returns a single non-emoji segment for an emoji-free string", () => {
+ expect(splitEmojiSegments("Workstations")).toEqual([
+ { text: "Workstations", isEmoji: false },
+ ]);
+ });
+
+ it("returns an empty segment for an empty string", () => {
+ expect(splitEmojiSegments("")).toEqual([{ text: "", isEmoji: false }]);
+ });
+
+ it("splits an emoji prefix from the trailing text", () => {
+ expect(splitEmojiSegments("π» Workstations")).toEqual([
+ { text: "π»", isEmoji: true },
+ { text: " Workstations", isEmoji: false },
+ ]);
+ });
+
+ it("coalesces adjacent emoji into one segment", () => {
+ expect(splitEmojiSegments("π±π Personal mobile devices")).toEqual([
+ { text: "π±π", isEmoji: true },
+ { text: " Personal mobile devices", isEmoji: false },
+ ]);
+ });
+
+ it("handles emoji inside the string (not just at the start)", () => {
+ expect(splitEmojiSegments("Team π alpha")).toEqual([
+ { text: "Team ", isEmoji: false },
+ { text: "π", isEmoji: true },
+ { text: " alpha", isEmoji: false },
+ ]);
+ });
+
+ it("handles a ZWJ-joined emoji as a single segment", () => {
+ // Man technologist: π¨ + ZWJ + π» β one grapheme
+ expect(splitEmojiSegments("π¨βπ» Engineering")).toEqual([
+ { text: "π¨βπ»", isEmoji: true },
+ { text: " Engineering", isEmoji: false },
+ ]);
+ });
+
+ it("handles emoji modifiers (skin tone) without splitting the grapheme", () => {
+ expect(splitEmojiSegments("ππ½ Team")).toEqual([
+ { text: "ππ½", isEmoji: true },
+ { text: " Team", isEmoji: false },
+ ]);
+ });
+
+ it("handles modifier + ZWJ sequences as a single segment", () => {
+ // Man technologist with medium skin tone: π¨ + π½ + ZWJ + π»
+ expect(splitEmojiSegments("π¨π½βπ» Engineering")).toEqual([
+ { text: "π¨π½βπ»", isEmoji: true },
+ { text: " Engineering", isEmoji: false },
+ ]);
+ });
+
+ it("handles a regional indicator pair (flag emoji) as one segment", () => {
+ // πΊπΈ = U+1F1FA U+1F1F8
+ expect(splitEmojiSegments("\u{1F1FA}\u{1F1F8} US fleet")).toEqual([
+ { text: "\u{1F1FA}\u{1F1F8}", isEmoji: true },
+ { text: " US fleet", isEmoji: false },
+ ]);
+ });
+
+ it("handles emoji at the very end of the string", () => {
+ expect(splitEmojiSegments("Sales team π")).toEqual([
+ { text: "Sales team ", isEmoji: false },
+ { text: "π", isEmoji: true },
+ ]);
+ });
+});
+
+describe("UprightEmoji", () => {
+ it("renders plain text when there are no emoji", () => {
+ const { container } = render();
+ expect(container.textContent).toBe("Workstations");
+ expect(container.querySelectorAll("span")).toHaveLength(0);
+ });
+
+ it("wraps emoji segments in a span with font-style: normal", () => {
+ const { container } = render();
+ const spans = container.querySelectorAll("span");
+ expect(spans).toHaveLength(1);
+ expect(spans[0].textContent).toBe("π»");
+ expect(spans[0].style.fontStyle).toBe("normal");
+ expect(container.textContent).toBe("π» Workstations");
+ });
+
+ it("coalesces adjacent emoji into a single span", () => {
+ const { container } = render(
+
+ );
+ const spans = container.querySelectorAll("span");
+ expect(spans).toHaveLength(1);
+ expect(spans[0].textContent).toBe("π±π");
+ });
+
+ it("preserves a ZWJ sequence as one upright span", () => {
+ const { container } = render();
+ const spans = container.querySelectorAll("span");
+ expect(spans).toHaveLength(1);
+ expect(spans[0].textContent).toBe("π¨βπ»");
+ });
+});
diff --git a/frontend/components/CommandPalette/components/UprightEmoji.tsx b/frontend/components/CommandPalette/components/UprightEmoji.tsx
new file mode 100644
index 00000000000..6cf9a9fa397
--- /dev/null
+++ b/frontend/components/CommandPalette/components/UprightEmoji.tsx
@@ -0,0 +1,109 @@
+import React from "react";
+
+// Matches an emoji "grapheme" β a single emoji codepoint with optional
+// variation selector / ZWJ continuations, OR a regional indicator pair
+// (used for flag emoji like πΊπΈ). Browsers fake italic by shearing every
+// glyph including emoji, so wrapping emoji in `font-style: normal` opts
+// them out of the synthetic slant while leaving Latin text italic via
+// the parent's `font-style: italic`.
+// \uFE0F = variation selector-16 (force emoji presentation)
+// \u200D = zero-width joiner (combines emoji into one grapheme,
+// e.g. π¨βπ» β "man technologist")
+const EMOJI_RUN_RE = /(?:\p{Extended_Pictographic}(?:\p{Emoji_Modifier})?\uFE0F?(?:\u200D\p{Extended_Pictographic}(?:\p{Emoji_Modifier})?\uFE0F?)*|\p{Regional_Indicator}\p{Regional_Indicator})/gu;
+
+export interface IEmojiSegment {
+ text: string;
+ isEmoji: boolean;
+}
+
+/**
+ * Split `text` into alternating emoji vs non-emoji segments. Pure helper
+ * exposed for testing; production callers use the wrapper.
+ *
+ * Returns a single non-emoji segment when no emoji are present, so callers
+ * can fast-path the bare-string case without diffing array shapes.
+ */
+export const splitEmojiSegments = (text: string): IEmojiSegment[] => {
+ if (!text) return [{ text: "", isEmoji: false }];
+ // Collect match positions via String.replace with a callback. We don't
+ // actually replace anything β replace just exposes (match, offset) for
+ // each hit. Using replace instead of String.matchAll keeps the helper
+ // compatible with pre-ES2020 lib targets and sidesteps mutating the
+ // global regex's lastIndex.
+ const matches: Array<{ start: number; end: number }> = [];
+ text.replace(EMOJI_RUN_RE, (match, offset) => {
+ const start = offset as number;
+ matches.push({ start, end: start + match.length });
+ return match;
+ });
+
+ const segments: IEmojiSegment[] = [];
+ let cursor = 0;
+ // Coalesce adjacent emoji matches (e.g. "π±π") into one segment so the
+ // DOM stays compact and the span attribute count doesn't balloon for
+ // rows that prefix two or three emoji.
+ let pendingEmojiStart = -1;
+ let pendingEmojiEnd = -1;
+ const flushEmoji = () => {
+ if (pendingEmojiStart === -1) return;
+ segments.push({
+ text: text.slice(pendingEmojiStart, pendingEmojiEnd),
+ isEmoji: true,
+ });
+ pendingEmojiStart = -1;
+ pendingEmojiEnd = -1;
+ };
+ matches.forEach(({ start, end }) => {
+ if (start === pendingEmojiEnd) {
+ pendingEmojiEnd = end;
+ cursor = end;
+ return;
+ }
+ flushEmoji();
+ if (start > cursor) {
+ segments.push({ text: text.slice(cursor, start), isEmoji: false });
+ }
+ pendingEmojiStart = start;
+ pendingEmojiEnd = end;
+ cursor = end;
+ });
+ flushEmoji();
+ if (cursor < text.length) {
+ segments.push({ text: text.slice(cursor), isEmoji: false });
+ }
+ if (segments.length === 0) return [{ text, isEmoji: false }];
+ return segments;
+};
+
+interface IUprightEmojiProps {
+ text: string;
+}
+
+/**
+ * Renders `text` so emoji glyphs stay upright even when the surrounding
+ * element has `font-style: italic`. Use anywhere fleet names (or other
+ * user-provided strings that may carry emoji) appear inside italicized
+ * surfaces β synthetic italic shears emoji and looks broken.
+ */
+const UprightEmoji = ({ text }: IUprightEmojiProps): JSX.Element => {
+ const segments = splitEmojiSegments(text);
+ return (
+ <>
+ {segments.map((seg, i) =>
+ seg.isEmoji ? (
+ // Index keys are safe β segments derive synchronously from the
+ // same input each render, so order is stable.
+ // eslint-disable-next-line react/no-array-index-key
+
+ {seg.text}
+
+ ) : (
+ // eslint-disable-next-line react/no-array-index-key
+ {seg.text}
+ )
+ )}
+ >
+ );
+};
+
+export default UprightEmoji;
diff --git a/frontend/components/CommandPalette/groups/controls.ts b/frontend/components/CommandPalette/groups/controls.ts
index af09fa5ab81..f7692ba1e1b 100644
--- a/frontend/components/CommandPalette/groups/controls.ts
+++ b/frontend/components/CommandPalette/groups/controls.ts
@@ -16,22 +16,28 @@ const buildControlsItems = (
if (!canAccessControls || !hasTeamOrUnassigned) return [];
return [
- {
- id: "controls-os-updates",
- label: "OS updates",
- group: "Controls" as const,
- path: withTeamId(paths.CONTROLS_OS_UPDATES),
- keywords: [
- "minimum version",
- "deadline",
- "nudge",
- "macos",
- "windows",
- "ios",
- "ipados",
- "patch",
- ],
- },
+ // OS updates is Premium-only β OSUpdates renders
+ // on Free.
+ ...(isPremiumTier
+ ? [
+ {
+ id: "controls-os-updates",
+ label: "OS updates",
+ group: "Controls" as const,
+ path: withTeamId(paths.CONTROLS_OS_UPDATES),
+ keywords: [
+ "minimum version",
+ "deadline",
+ "nudge",
+ "macos",
+ "windows",
+ "ios",
+ "ipados",
+ "patch",
+ ],
+ },
+ ]
+ : []),
// OS settings sub-routes
{
id: "controls-os-settings",
diff --git a/frontend/components/CommandPalette/groups/derivations.ts b/frontend/components/CommandPalette/groups/derivations.ts
index b4a2f17ec63..0c4cd8e22e8 100644
--- a/frontend/components/CommandPalette/groups/derivations.ts
+++ b/frontend/components/CommandPalette/groups/derivations.ts
@@ -6,7 +6,7 @@ import { ICommandPaletteContext } from "../helpers";
* to each builder so the derivation logic lives in exactly one place.
*/
export interface IDerivedContext {
- /** Apple Business Manager configured at the org level. */
+ /** Apple Business configured at the org level. */
isAbmConfigured: boolean;
/** GitOps mode active β disables Create-fleet etc. */
isGitOpsMode: boolean;
@@ -36,6 +36,7 @@ export const deriveContext = (ctx: ICommandPaletteContext): IDerivedContext => {
availableTeams,
hasTeamSelected,
isPrimoMode,
+ isPremiumTier,
} = ctx;
const isAbmConfigured = config?.mdm?.apple_bm_enabled_and_configured ?? false;
@@ -47,7 +48,12 @@ export const deriveContext = (ctx: ICommandPaletteContext): IDerivedContext => {
);
const isUnassigned = currentTeam?.id === 0;
- const hasTeamOrUnassigned = !!hasTeamSelected || isUnassigned;
+ // On Fleet Free, the team concept doesn't apply β there's a single
+ // implicit fleet, no team picker, and currentTeam stays undefined. Treat
+ // Free as team-or-unassigned so team-gated palette items (Controls, Add
+ // script, etc.) still surface on Free-available destinations.
+ const hasTeamOrUnassigned =
+ !isPremiumTier || !!hasTeamSelected || isUnassigned;
// In Primo Mode the user perceives a single-fleet install, so the
// concept of "switching fleet context" doesn't apply. All destination
diff --git a/frontend/components/CommandPalette/groups/mdm.ts b/frontend/components/CommandPalette/groups/mdm.ts
index 25b3565c42e..f0b5d9f0b13 100644
--- a/frontend/components/CommandPalette/groups/mdm.ts
+++ b/frontend/components/CommandPalette/groups/mdm.ts
@@ -61,14 +61,14 @@ const buildMdmItems = (
"macbook",
],
},
- // ABM and VPP pages are Premium-only.
+ // AB and VPP pages are Premium-only.
...(isPremiumTier
? [
{
id: isAbmConfigured ? "edit-abm" : "add-abm",
label: isAbmConfigured
- ? "Edit Apple Business Manager (ABM)"
- : "Add Apple Business Manager (ABM)",
+ ? "Edit Apple Business (AB)"
+ : "Add Apple Business (AB)",
group: "MDM" as const,
path: paths.ADMIN_INTEGRATIONS_APPLE_BUSINESS_MANAGER,
keywords: [
diff --git a/frontend/components/CommandPalette/groups/pages.ts b/frontend/components/CommandPalette/groups/pages.ts
index efea8077ae5..2f736479d4f 100644
--- a/frontend/components/CommandPalette/groups/pages.ts
+++ b/frontend/components/CommandPalette/groups/pages.ts
@@ -7,7 +7,13 @@ const buildPagesItems = (
ctx: ICommandPaletteContext,
derived: IDerivedContext
): ICommandItem[] => {
- const { search, canAccessControls, canAccessSettings, withTeamId } = ctx;
+ const {
+ search,
+ canAccessControls,
+ canAccessSettings,
+ isPremiumTier,
+ withTeamId,
+ } = ctx;
const {
hasTeamOrUnassigned,
switchesFromUnassigned,
@@ -45,7 +51,11 @@ const buildPagesItems = (
"computers",
],
},
- ...(canAccessControls && hasTeamOrUnassigned
+ // Hidden on Free: /controls redirects to OS updates, which renders
+ // on Free. Free users still reach the
+ // tier-free Controls sub-pages via their own palette entries
+ // (OS settings, Scripts, Variables).
+ ...(canAccessControls && hasTeamOrUnassigned && isPremiumTier
? [
{
id: "controls-page",
diff --git a/frontend/components/CommandPalette/groups/settings.ts b/frontend/components/CommandPalette/groups/settings.ts
index 3d30e02b66f..1c00e1fac2b 100644
--- a/frontend/components/CommandPalette/groups/settings.ts
+++ b/frontend/components/CommandPalette/groups/settings.ts
@@ -143,12 +143,18 @@ const buildSettingsItems = (ctx: ICommandPaletteContext): ICommandItem[] => {
path: paths.ADMIN_INTEGRATIONS_SSO_FLEET_USERS,
keywords: ["saml", "idp", "admin", "login"],
},
- {
- id: "settings-int-sso-end-users",
- label: "Single sign-on (SSO) for end users",
- path: paths.ADMIN_INTEGRATIONS_SSO_END_USERS,
- keywords: ["saml", "idp", "device user", "login"],
- },
+ // End-users SSO tab renders on Free β
+ // hide the entry on Free.
+ ...(isPremiumTier
+ ? [
+ {
+ id: "settings-int-sso-end-users",
+ label: "Single sign-on (SSO) for end users",
+ path: paths.ADMIN_INTEGRATIONS_SSO_END_USERS,
+ keywords: ["saml", "idp", "device user", "login"],
+ },
+ ]
+ : []),
// Certificate authorities pages are Premium-only.
...(isPremiumTier
? [
@@ -185,12 +191,18 @@ const buildSettingsItems = (ctx: ICommandPaletteContext): ICommandItem[] => {
},
]
: []),
- {
- id: "settings-int-identity-provider",
- label: "Identity provider (IdP)",
- path: paths.ADMIN_INTEGRATIONS_IDENTITY_PROVIDER,
- keywords: ["okta", "entra", "azure ad", "directory", "ldap"],
- },
+ // IdentityProviderSection renders on
+ // Free β hide the entry on Free.
+ ...(isPremiumTier
+ ? [
+ {
+ id: "settings-int-identity-provider",
+ label: "Identity provider (IdP)",
+ path: paths.ADMIN_INTEGRATIONS_IDENTITY_PROVIDER,
+ keywords: ["okta", "entra", "azure ad", "directory", "ldap"],
+ },
+ ]
+ : []),
{
id: "settings-int-host-status-webhook",
label: "Host status webhook",
diff --git a/frontend/components/CommandPalette/helpers.tests.ts b/frontend/components/CommandPalette/helpers.tests.ts
index a0c1212f11e..8f81af461ac 100644
--- a/frontend/components/CommandPalette/helpers.tests.ts
+++ b/frontend/components/CommandPalette/helpers.tests.ts
@@ -251,7 +251,7 @@ describe("CommandPalette helpers", () => {
expect(ids).not.toContain("turn-on-apple-mdm");
});
- it("shows 'Add ABM' when Apple MDM on but ABM not configured", () => {
+ it("shows 'Add AB' when Apple MDM on but AB not configured", () => {
const configNoAbm = createMockConfig();
configNoAbm.mdm = {
...configNoAbm.mdm,
@@ -268,7 +268,7 @@ describe("CommandPalette helpers", () => {
expect(abm?.label).toContain("Add");
});
- it("shows 'Edit ABM' when ABM is configured", () => {
+ it("shows 'Edit AB' when AB is configured", () => {
const items = buildPaletteItems(BASE_CONTEXT);
const abm = items.find((i) => i.id === "edit-abm");
@@ -603,12 +603,14 @@ describe("CommandPalette helpers", () => {
});
describe("Fleet Free (isPremiumTier: false)", () => {
+ // Mirror production state on Free: AppContext never sets currentTeam
+ // (no team picker exists), so hasTeamSelected stays false. The
+ // derivation treats Free as team-or-unassigned regardless.
const FREE_CONTEXT = {
...BASE_CONTEXT,
isPremiumTier: false,
- // Free has a single implicit fleet; mirror what AppContext would set.
- hasTeamSelected: true as const,
- currentTeam: { id: 1, name: "Engineering" },
+ hasTeamSelected: false as const,
+ currentTeam: undefined,
};
it("hides all software-add commands", () => {
@@ -638,7 +640,7 @@ describe("CommandPalette helpers", () => {
expect(subIds).toContain("controls-custom-settings");
});
- it("hides MDM ABM and VPP commands", () => {
+ it("hides MDM AB and VPP commands", () => {
const ids = buildPaletteItems(FREE_CONTEXT).map((i) => i.id);
expect(ids).not.toContain("add-abm");
expect(ids).not.toContain("edit-abm");
@@ -664,6 +666,52 @@ describe("CommandPalette helpers", () => {
expect(ids).not.toContain("create-fleet");
expect(ids).not.toContain("view-software-library");
});
+
+ it("hides the Controls page link (lands on OS updates premium wall on Free)", () => {
+ // /controls redirects to the first permitted tab, which is OS
+ // updates β a premium-walled page on Free. Free users still reach
+ // the tier-free Controls sub-pages via their own palette entries
+ // (OS settings, Scripts, Variables).
+ const ids = buildPaletteItems(FREE_CONTEXT).map((i) => i.id);
+ expect(ids).not.toContain("controls-page");
+ });
+
+ it("hides OS updates, SSO end-users, and Identity provider (each renders on Free)", () => {
+ const items = buildPaletteItems(FREE_CONTEXT);
+ const ids = items.map((i) => i.id);
+ expect(ids).not.toContain("controls-os-updates");
+ const integrations = items.find((i) => i.id === "settings-integrations");
+ const subIds = integrations?.subItems?.map((s) => s.id) ?? [];
+ expect(subIds).not.toContain("settings-int-sso-end-users");
+ expect(subIds).not.toContain("settings-int-identity-provider");
+ });
+
+ it("surfaces Free-available Controls items: OS settings, Scripts, Variables", () => {
+ const ids = buildPaletteItems(FREE_CONTEXT).map((i) => i.id);
+ expect(ids).toContain("controls-os-settings");
+ expect(ids).toContain("controls-scripts");
+ expect(ids).toContain("controls-variables");
+ });
+
+ it("surfaces Free-available Controls sub-items: Configuration profiles, Script library, Script batch progress", () => {
+ const items = buildPaletteItems(FREE_CONTEXT);
+ const osSettingsSubIds =
+ items
+ .find((i) => i.id === "controls-os-settings")
+ ?.subItems?.map((s) => s.id) ?? [];
+ expect(osSettingsSubIds).toContain("controls-custom-settings");
+ const scriptsSubIds =
+ items
+ .find((i) => i.id === "controls-scripts")
+ ?.subItems?.map((s) => s.id) ?? [];
+ expect(scriptsSubIds).toContain("controls-scripts-library");
+ expect(scriptsSubIds).toContain("controls-scripts-batch-progress");
+ });
+
+ it("surfaces Add script on Free (script library is Free-available)", () => {
+ const ids = buildPaletteItems(FREE_CONTEXT).map((i) => i.id);
+ expect(ids).toContain("add-script");
+ });
});
describe("Primo Mode (isPrimoMode: true)", () => {