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)", () => {