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(
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,