diff --git a/ui/components/lighthouse/context-chip.test.tsx b/ui/components/lighthouse/context-chip.test.tsx index ac18906d4b..d5e7bf2513 100644 --- a/ui/components/lighthouse/context-chip.test.tsx +++ b/ui/components/lighthouse/context-chip.test.tsx @@ -67,6 +67,88 @@ describe("LighthouseCurrentContextBadge", () => { expect(tooltip).not.toHaveTextContent("status-summary"); }); + it("should say when only the page name is shared", async () => { + // Given an unregistered route contributes a bare, filterless page item + const user = userEvent.setup(); + render(); + + // When + await user.hover(screen.getByLabelText("Manage Groups context")); + + // Then + const tooltip = await screen.findByRole("tooltip"); + expect(tooltip).toHaveTextContent("Manage Groups"); + expect(tooltip).toHaveTextContent("Only the current page name is shared."); + }); + + it("should treat empty filter entries as a bare page", async () => { + // Given a stored envelope whose page filters only carry empty values + const user = userEvent.setup(); + const bareContext = barePageContext(); + const [pageItem] = bareContext.items; + if (pageItem.kind !== "page") throw new Error("expected page item"); + render( + , + ); + + // When + await user.hover(screen.getByLabelText("Manage Groups context")); + + // Then no filters line renders and the page-only notice does + const tooltip = await screen.findByRole("tooltip"); + expect(tooltip).not.toHaveTextContent("Filters:"); + expect(tooltip).toHaveTextContent("Only the current page name is shared."); + }); + + it("should not claim a bare page for a single non-page item", async () => { + // Given a historical envelope whose only item is not a page item + const user = userEvent.setup(); + const context: LighthouseContextEnvelope = { + schemaVersion: 1, + transport: "inline", + items: [ + { + kind: "scan", + id: "scan-1", + source: "selection", + scopeKey: "scans:/scans", + label: "Selected scan", + scanId: "scan-1", + }, + ], + }; + render(); + + // When + await user.hover(screen.getByLabelText("Context context")); + + // Then + const tooltip = await screen.findByRole("tooltip"); + expect(tooltip).not.toHaveTextContent( + "Only the current page name is shared.", + ); + }); + + it("should not claim a bare page when filters or items travel too", async () => { + // Given + const user = userEvent.setup(); + render(); + + // When + await user.hover(screen.getByLabelText("Findings context")); + + // Then + const tooltip = await screen.findByRole("tooltip"); + expect(tooltip).not.toHaveTextContent( + "Only the current page name is shared.", + ); + }); + it.each([ ["resource", resourceContext(), "Resource: resource-1 (bucket-1)"], ["scan", scanContext(), "Scan: scan-1"], @@ -98,6 +180,23 @@ describe("LighthouseContextBadge", () => { }); }); +function barePageContext(): LighthouseContextEnvelope { + return { + schemaVersion: 1, + transport: "inline", + items: [ + { + kind: "page", + id: "other", + source: "automatic", + scopeKey: "other:/manage-groups", + label: "Manage Groups", + path: "/manage-groups", + }, + ], + }; +} + function overviewContext(): LighthouseContextEnvelope { return { schemaVersion: 1, diff --git a/ui/components/lighthouse/context-chip.tsx b/ui/components/lighthouse/context-chip.tsx index 94721d05f8..754015e7e2 100644 --- a/ui/components/lighthouse/context-chip.tsx +++ b/ui/components/lighthouse/context-chip.tsx @@ -85,15 +85,26 @@ function LighthouseContextTooltip({ context }: LighthouseContextBadgeProps) { const page = context.items.find( (item) => item.kind === LIGHTHOUSE_CONTEXT_KIND.PAGE, ); - const filters = + // Entries with no values (possible in stored envelopes) carry nothing, so + // they count neither for the filters line nor against the page-only notice. + const filterEntries = page?.kind === LIGHTHOUSE_CONTEXT_KIND.PAGE - ? Object.entries(page.filters ?? {}) - .map(([key, values]) => `${key}: ${values.join(", ")}`) - .join("; ") - : ""; + ? Object.entries(page.filters ?? {}).filter( + ([, values]) => values.length > 0, + ) + : []; + const filters = filterEntries + .map(([key, values]) => `${key}: ${values.join(", ")}`) + .join("; "); const itemDescriptions = context.items .map(getContextItemDescription) .filter((description) => description !== null); + // A single filterless page item means the model only learns which page the + // user is on — say so instead of implying richer context travels with it. + const sharesOnlyPage = + page?.kind === LIGHTHOUSE_CONTEXT_KIND.PAGE && + context.items.length === 1 && + filterEntries.length === 0; return ( @@ -104,6 +115,7 @@ function LighthouseContextTooltip({ context }: LighthouseContextBadgeProps) {

)} {filters &&

Filters: {filters}

} + {sharesOnlyPage &&

Only the current page name is shared.

} {itemDescriptions.map(({ id, text }) => (

{text}

))} diff --git a/ui/lib/lighthouse/context/constants.ts b/ui/lib/lighthouse/context/constants.ts index d90b8b0bd0..b80da63817 100644 --- a/ui/lib/lighthouse/context/constants.ts +++ b/ui/lib/lighthouse/context/constants.ts @@ -57,5 +57,9 @@ export const LIGHTHOUSE_PAGE_ID = { SERVICES: "services", WORKLOADS: "workloads", MUTELIST: "mutelist", + ROLES: "roles", + USERS: "users", + INVITATIONS: "invitations", + INTEGRATIONS: "integrations", OTHER: "other", } as const; diff --git a/ui/lib/lighthouse/context/pages.constants.ts b/ui/lib/lighthouse/context/pages.constants.ts index 170eb888b3..f7181ec2d9 100644 --- a/ui/lib/lighthouse/context/pages.constants.ts +++ b/ui/lib/lighthouse/context/pages.constants.ts @@ -418,8 +418,123 @@ export const LIGHTHOUSE_PAGE_DEFINITION_INPUTS = [ }, ], }, + { + id: LIGHTHOUSE_PAGE_ID.ROLES, + label: "Roles", + match: (pathname) => + pathname === "/roles" || pathname.startsWith("/roles/"), + allowedSearchParams: [...COMMON_LIST_PARAMS, "filter[permission_state]"], + suggestions: [ + { + label: "Review role permissions", + prompt: + "Review the roles on this page. Which ones grant more than their name implies, and what would you trim first?", + }, + { + label: "Find overlapping roles", + prompt: + "Which of these roles overlap enough to be merged, and does any combination of them add up to an escalation risk?", + }, + { + label: "Design least privilege", + prompt: + "Propose a least-privilege role set for my team: which roles should exist, what should each allow, and how do I migrate from the current ones?", + }, + { + label: "Audit unlimited visibility", + prompt: + "Which roles have unlimited visibility across providers, and who actually needs to keep it?", + }, + ], + }, + { + id: LIGHTHOUSE_PAGE_ID.USERS, + label: "Users", + match: (pathname) => + pathname === "/users" || pathname.startsWith("/users/"), + allowedSearchParams: [...COMMON_LIST_PARAMS], + suggestions: [ + { + label: "Audit user access", + prompt: + "Review the users listed here. Who holds admin-level roles, and does anyone look over-privileged for what they do?", + }, + { + label: "Find concentrated ownership", + prompt: + "Is any critical responsibility — provider management, user administration, billing — concentrated in a single person here?", + }, + { + label: "Plan an access review", + prompt: + "Turn this user list into a quarterly access review: who reviews whom, and what should each reviewer verify?", + }, + { + label: "Prepare safe offboarding", + prompt: + "Draft an offboarding checklist for this tenant: what should be revoked or transferred when one of these users leaves?", + }, + ], + }, + { + id: LIGHTHOUSE_PAGE_ID.INVITATIONS, + label: "Invitations", + match: (pathname) => + pathname === "/invitations" || pathname.startsWith("/invitations/"), + allowedSearchParams: [...COMMON_LIST_PARAMS, "filter[state]"], + suggestions: [ + { + label: "Review pending invitations", + prompt: + "Which pending invitations here should be revoked — expired, unfamiliar domains, or broader roles than the invitee needs?", + }, + { + label: "Tighten invitation roles", + prompt: + "Do any open invitations grant admin or unlimited-visibility roles? Suggest a safer default role for new members.", + }, + { + label: "Draft onboarding steps", + prompt: + "Draft the checklist a new member should follow after accepting one of these invitations.", + }, + { + label: "Explain invitation lifecycle", + prompt: + "Explain how invitations work in Prowler: what happens when one expires, and how do I resend or revoke one safely?", + }, + ], + }, + { + id: LIGHTHOUSE_PAGE_ID.INTEGRATIONS, + label: "Integrations", + match: (pathname) => + pathname === "/integrations" || pathname.startsWith("/integrations/"), + // The grid page takes no params and sub-pages pin their integration type + // server-side, so the open integration travels in `path`; only the + // sub-page tables' sort ever reaches the URL. + allowedSearchParams: ["sort"], + suggestions: [ + { + label: "Review integration coverage", + prompt: + "Which integrations am I missing that would get findings to the right team faster — ticketing, chat, or storage?", + }, + { + label: "Check integration health", + prompt: + "Review the integrations configured here. Are any disconnected, misconfigured, or pointing at destinations nobody watches?", + }, + { + label: "Route findings to teams", + prompt: + "Design how findings should flow from Prowler into my existing tools so each team only sees what they own.", + }, + { + label: "Harden integration credentials", + prompt: + "What credentials do these integrations rely on, and how should I scope and rotate them safely?", + }, + ], + }, ] as const satisfies readonly LighthousePageDefinitionInput[]; - -export const LIGHTHOUSE_KNOWN_ROUTE_LABELS = { - integrations: "Integrations", -} as const; diff --git a/ui/lib/lighthouse/context/pages.test.ts b/ui/lib/lighthouse/context/pages.test.ts index bf5cfd5c3a..29a0b8fc37 100644 --- a/ui/lib/lighthouse/context/pages.test.ts +++ b/ui/lib/lighthouse/context/pages.test.ts @@ -23,6 +23,13 @@ describe("resolveLighthousePage", () => { ["/services", "services"], ["/workloads", "workloads"], ["/mutelist", "mutelist"], + ["/roles", "roles"], + ["/roles/new", "roles"], + ["/users", "users"], + ["/invitations", "invitations"], + ["/invitations/new", "invitations"], + ["/integrations", "integrations"], + ["/integrations/jira", "integrations"], ])("should resolve %s as %s", (pathname, expectedPageId) => { // Given / When const page = resolveLighthousePage(pathname); @@ -34,14 +41,33 @@ describe("resolveLighthousePage", () => { it("should create a labeled fallback for other application pages", () => { // Given / When - const page = resolveLighthousePage("/integrations/"); + const page = resolveLighthousePage("/profile"); // Then expect(page.id).toBe("other"); - expect(page.label).toBe("Integrations"); + expect(page.label).toBe("Profile"); expectValidSuggestions(page.suggestions); }); + it("should not match routes that only share a page's prefix", () => { + for (const pathname of [ + "/roles-preview", + "/users-report", + "/invitations-archive", + "/integrations-beta", + ]) { + expect(resolveLighthousePage(pathname).id).toBe("other"); + } + }); + + it("should title-case multi-segment fallback routes", () => { + const page = resolveLighthousePage("/manage-groups"); + + expect(page.id).toBe("other"); + expect(page.label).toBe("Manage Groups"); + expect(page.allowedSearchParams).toEqual([]); + }); + it("should resolve encoded and decoded dynamic paths to the same scope", () => { expect(getLighthouseScopeKey("/compliance/CSA%20CCM")).toBe( getLighthouseScopeKey("/compliance/CSA CCM"), @@ -161,6 +187,48 @@ describe("buildLighthousePageContext", () => { }); }); + it("should preserve the filter names emitted by tenant admin pages", () => { + const roles = buildLighthousePageContext( + "/roles", + new URLSearchParams({ + "filter[search]": "admin", + sort: "name", + "filter[permission_state]": "unlimited", + }), + ); + const users = buildLighthousePageContext( + "/users", + new URLSearchParams({ "filter[search]": "alice" }), + ); + const invitations = buildLighthousePageContext( + "/invitations", + new URLSearchParams({ "filter[state]": "expired" }), + ); + + expect(roles.filters).toEqual({ + permission_state: ["unlimited"], + search: ["admin"], + sort: ["name"], + }); + expect(users.filters).toEqual({ search: ["alice"] }); + expect(invitations.filters).toEqual({ state: ["expired"] }); + }); + + it("should capture only sort on integrations pages", () => { + // The integration sub-pages pin filter[integration_type] server-side; the + // open integration reaches Lighthouse through the page item's path. + const context = buildLighthousePageContext( + "/integrations/jira", + new URLSearchParams({ + sort: "-inserted_at", + "filter[integration_type]": "jira", + }), + ); + + expect(context.path).toBe("/integrations/jira"); + expect(context.filters).toEqual({ sort: ["-inserted_at"] }); + }); + it("should preserve the filter names emitted by list-page controls", () => { const findings = buildLighthousePageContext( "/findings", diff --git a/ui/lib/lighthouse/context/pages.ts b/ui/lib/lighthouse/context/pages.ts index 9c4cd3477f..faeb0cd56a 100644 --- a/ui/lib/lighthouse/context/pages.ts +++ b/ui/lib/lighthouse/context/pages.ts @@ -12,7 +12,6 @@ import { import { LIGHTHOUSE_GLOBAL_SUGGESTIONS, - LIGHTHOUSE_KNOWN_ROUTE_LABELS, LIGHTHOUSE_PAGE_DEFINITION_INPUTS, } from "./pages.constants"; @@ -103,10 +102,7 @@ function buildLighthouseScopeKey( function createFallbackDefinition(pathname: string): LighthousePageDefinition { const segment = pathname.split("/").filter(Boolean)[0] ?? "overview"; - const label = - LIGHTHOUSE_KNOWN_ROUTE_LABELS[ - segment as keyof typeof LIGHTHOUSE_KNOWN_ROUTE_LABELS - ] ?? toTitleCase(segment); + const label = toTitleCase(segment); return createPageDefinition({ id: LIGHTHOUSE_PAGE_ID.OTHER, label,