diff --git a/ui/changelog.d/provider-wizard-step-aware-docs-link.changed.md b/ui/changelog.d/provider-wizard-step-aware-docs-link.changed.md new file mode 100644 index 0000000000..23875532ad --- /dev/null +++ b/ui/changelog.d/provider-wizard-step-aware-docs-link.changed.md @@ -0,0 +1 @@ +`Add Provider` wizard documentation link targeting each provider's credentials section and selected authentication method diff --git a/ui/components/providers/wizard/hooks/use-provider-wizard-controller.test.tsx b/ui/components/providers/wizard/hooks/use-provider-wizard-controller.test.tsx index 8fec91a005..ea8892ea5b 100644 --- a/ui/components/providers/wizard/hooks/use-provider-wizard-controller.test.tsx +++ b/ui/components/providers/wizard/hooks/use-provider-wizard-controller.test.tsx @@ -170,8 +170,10 @@ describe("useProviderWizardController", () => { }); expect(result.current.modalTitle).toBe("Update Provider Credentials"); expect(result.current.isProviderFlow).toBe(true); + // Update mode enters at the credentials step, so the docs link scrolls + // the getting-started page to the credentials/authentication section. expect(result.current.docsLink).toBe( - "https://goto.prowler.com/provider-aws", + "https://docs.prowler.com/user-guide/providers/aws/getting-started-aws#step-3-set-up-aws-authentication", ); const state = useProviderWizardStore.getState(); @@ -183,6 +185,36 @@ describe("useProviderWizardController", () => { expect(state.mode).toBe(PROVIDER_WIZARD_MODE.UPDATE); }); + it("updates the credentials docs link when AWS assume role is selected", async () => { + const onOpenChange = vi.fn(); + const { result } = renderHook(() => + useProviderWizardController({ + open: true, + onOpenChange, + initialData: { + providerId: "provider-1", + providerType: "aws", + providerUid: "111111111111", + providerAlias: "production", + secretId: null, + mode: PROVIDER_WIZARD_MODE.ADD, + }, + }), + ); + + await waitFor(() => { + expect(result.current.currentStep).toBe(PROVIDER_WIZARD_STEP.CREDENTIALS); + }); + + act(() => { + useProviderWizardStore.getState().setVia("role"); + }); + + expect(result.current.docsLink).toBe( + "https://docs.prowler.com/user-guide/providers/aws/getting-started-aws#assume-role-recommended", + ); + }); + it("switches into and out of organizations flow", () => { // Given const onOpenChange = vi.fn(); diff --git a/ui/components/providers/wizard/hooks/use-provider-wizard-controller.ts b/ui/components/providers/wizard/hooks/use-provider-wizard-controller.ts index 12906e380b..0c82d3849e 100644 --- a/ui/components/providers/wizard/hooks/use-provider-wizard-controller.ts +++ b/ui/components/providers/wizard/hooks/use-provider-wizard-controller.ts @@ -106,6 +106,7 @@ export function useProviderWizardController({ setMode, mode, providerType, + via, } = useProviderWizardStore(); const { reset: resetOrgWizard, @@ -269,7 +270,11 @@ export function useProviderWizardController({ const isProviderFlow = wizardVariant === WIZARD_VARIANT.PROVIDER; const docsLink = isProviderFlow - ? getProviderHelpText(providerTypeHint ?? providerType ?? "").link + ? getProviderHelpText( + providerTypeHint ?? providerType ?? "", + currentStep, + via, + ).link : ORG_DOCS_URL[organizationType]; const resolvedFooterConfig: WizardFooterConfig = footerConfig; const modalTitle = getProviderWizardModalTitle(mode); diff --git a/ui/components/providers/wizard/provider-wizard-modal.tsx b/ui/components/providers/wizard/provider-wizard-modal.tsx index c2802209f7..50e7b8f343 100644 --- a/ui/components/providers/wizard/provider-wizard-modal.tsx +++ b/ui/components/providers/wizard/provider-wizard-modal.tsx @@ -119,7 +119,7 @@ export function ProviderWizardModal({ diff --git a/ui/components/providers/wizard/provider-wizard-modal.utils.test.ts b/ui/components/providers/wizard/provider-wizard-modal.utils.test.ts index 099dfa73c3..815ffd9271 100644 --- a/ui/components/providers/wizard/provider-wizard-modal.utils.test.ts +++ b/ui/components/providers/wizard/provider-wizard-modal.utils.test.ts @@ -1,7 +1,12 @@ import { describe, expect, it } from "vitest"; +import { getProviderHelpText } from "@/lib/external-urls"; import { ORG_SETUP_PHASE, ORG_WIZARD_STEP } from "@/types/organizations"; -import { PROVIDER_WIZARD_MODE } from "@/types/provider-wizard"; +import { + PROVIDER_WIZARD_MODE, + PROVIDER_WIZARD_STEP, +} from "@/types/provider-wizard"; +import { type KnownProviderType, PROVIDER_TYPES } from "@/types/providers"; import { getOrganizationsStepperOffset, @@ -61,6 +66,111 @@ describe("getProviderWizardDocsDestination", () => { expect(destination).toBe("AWS"); }); + it("returns a compact provider label for deep-linked getting-started URLs", () => { + const destination = getProviderWizardDocsDestination( + "https://docs.prowler.com/user-guide/providers/aws/getting-started-aws", + ); + + expect(destination).toBe("AWS"); + }); + + it("returns a specific label for AWS assume role docs links", () => { + const destination = getProviderWizardDocsDestination( + "https://docs.prowler.com/user-guide/providers/aws/getting-started-aws#assume-role-recommended", + ); + + expect(destination).toBe("AWS Assume Role"); + }); + + it("returns a method-specific label for every subsection deep-link", () => { + // Locks the docsSectionLabelMap keys to the URLs the frontend emits. + // Adding a new (provider, method) subsection URL requires wiring a + // matching label here or the modal header regresses to just the provider + // name. + const cases: Array<[string, string]> = [ + [ + "https://docs.prowler.com/user-guide/providers/aws/getting-started-aws#assume-role-recommended", + "AWS Assume Role", + ], + [ + "https://docs.prowler.com/user-guide/providers/aws/getting-started-aws#credentials-static-access-keys", + "AWS Credentials", + ], + [ + "https://docs.prowler.com/user-guide/providers/microsoft365/getting-started-m365#application-certificate-authentication-recommended", + "M365 Certificate", + ], + [ + "https://docs.prowler.com/user-guide/providers/microsoft365/getting-started-m365#application-client-secret-authentication", + "M365 Client Secret", + ], + [ + "https://docs.prowler.com/user-guide/providers/alibabacloud/getting-started-alibabacloud#ram-role-assumption-recommended", + "Alibaba Cloud RAM Role", + ], + [ + "https://docs.prowler.com/user-guide/providers/alibabacloud/getting-started-alibabacloud#credentials-static-access-keys", + "Alibaba Cloud Credentials", + ], + [ + "https://docs.prowler.com/user-guide/providers/cloudflare/getting-started-cloudflare#user-api-token-authentication-recommended", + "Cloudflare API Token", + ], + [ + "https://docs.prowler.com/user-guide/providers/cloudflare/getting-started-cloudflare#api-key-and-email-authentication-legacy", + "Cloudflare API Key", + ], + ]; + + for (const [url, expected] of cases) { + expect(getProviderWizardDocsDestination(url)).toBe(expected); + } + }); + + it("ignores a #authentication anchor when deriving the label", () => { + // The credentials step falls back to a shortlink + anchor for providers + // without a dedicated auth page (Kubernetes). The label shown in the + // modal header must stay the provider name, not shift with the anchor. + const destination = getProviderWizardDocsDestination( + "https://goto.prowler.com/provider-k8s#authentication", + ); + + expect(destination).toBe("Kubernetes"); + }); + + it("derives the provider label from the dedicated authentication docs URL", () => { + // On the credentials step providers with a standalone authentication.mdx + // (all except Kubernetes) point to `/providers//authentication`. + // The label must come from the `` segment, not from the trailing + // "authentication" page name. + const destination = getProviderWizardDocsDestination( + "https://docs.prowler.com/user-guide/providers/aws/authentication", + ); + + expect(destination).toBe("AWS"); + }); + + it("maps the OCI docs slug to the Oracle Cloud label", () => { + // The provider is called `oraclecloud` in the wizard but the docs path + // uses the `oci` slug, so the parser must know both refer to the same + // provider. + const destination = getProviderWizardDocsDestination( + "https://docs.prowler.com/user-guide/providers/oci/authentication", + ); + + expect(destination).toBe("Oracle Cloud"); + }); + + it("maps the microsoft365 docs slug to the Microsoft 365 label", () => { + // Same shape: the wizard's `m365` maps to the `microsoft365` folder in + // docs, so the parser must recognise the folder slug as the provider. + const destination = getProviderWizardDocsDestination( + "https://docs.prowler.com/user-guide/providers/microsoft365/authentication", + ); + + expect(destination).toBe("Microsoft 365"); + }); + it("returns a compact destination label for long docs links", () => { const destination = getProviderWizardDocsDestination( "https://docs.prowler.com/user-guide/tutorials/prowler-cloud-aws-organizations", @@ -69,3 +179,101 @@ describe("getProviderWizardDocsDestination", () => { expect(destination).toBe("AWS Organizations"); }); }); + +// The URL maps in `ui/lib/external-urls.ts` and the `destinationLabelMap` in +// this file share the same set of providers but use different keys (wizard +// slug vs. docs path slug — e.g. `oraclecloud` vs `oci`, `m365` vs +// `microsoft365`). Nothing structural stops a provider from being added to +// one file and forgotten in the other, which would silently render the +// title-cased URL segment (e.g. "Getting Started Aws") in the modal header +// instead of the intended label. +// +// This suite locks the end-to-end contract: for every provider in +// `PROVIDER_TYPES`, on every wizard step, the URL emitted by +// `getProviderHelpText` must round-trip through the parser into the expected +// label. `Record` also gives compile-time coverage — +// adding a provider to `PROVIDER_TYPES` requires updating this table. +const EXPECTED_MODAL_HEADER_LABEL: Record = { + aws: "AWS", + azure: "Azure", + m365: "Microsoft 365", + gcp: "GCP", + kubernetes: "Kubernetes", + github: "GitHub", + iac: "IaC", + image: "Image", + oraclecloud: "Oracle Cloud", + mongodbatlas: "MongoDB Atlas", + alibabacloud: "Alibaba Cloud", + cloudflare: "Cloudflare", + openstack: "OpenStack", + googleworkspace: "Google Workspace", + vercel: "Vercel", + okta: "Okta", +}; + +// Method-specific labels for the credentials step. Providers with per-method +// docs subsections (AWS, M365, Alibaba Cloud, Cloudflare) must round-trip +// through `getProviderWizardDocsDestination` into a specific label — +// otherwise the URL in `PROVIDER_CREDENTIALS_METHOD_DOCS_URL` and the label +// in `docsSectionLabelMap` have drifted apart. Providers without a per-method +// deep link (GCP, GitHub, and every single-method provider) fall through to +// the generic `EXPECTED_MODAL_HEADER_LABEL` above and are not listed here. +const EXPECTED_METHOD_MODAL_HEADER_LABEL: Array< + [KnownProviderType, string, string] +> = [ + ["aws", "role", "AWS Assume Role"], + ["aws", "credentials", "AWS Credentials"], + ["m365", "app_certificate", "M365 Certificate"], + ["m365", "app_client_secret", "M365 Client Secret"], + ["alibabacloud", "role", "Alibaba Cloud RAM Role"], + ["alibabacloud", "credentials", "Alibaba Cloud Credentials"], + ["cloudflare", "api_token", "Cloudflare API Token"], + ["cloudflare", "api_key", "Cloudflare API Key"], +]; + +describe("provider label parity", () => { + const STEPS = [ + PROVIDER_WIZARD_STEP.CONNECT, + PROVIDER_WIZARD_STEP.CREDENTIALS, + PROVIDER_WIZARD_STEP.TEST, + PROVIDER_WIZARD_STEP.LAUNCH, + ] as const; + + it("resolves the expected modal header label for every provider on every wizard step", () => { + for (const provider of PROVIDER_TYPES) { + for (const step of STEPS) { + const { link } = getProviderHelpText(provider, step); + const label = getProviderWizardDocsDestination(link); + expect( + label, + `Modal header label drift for provider="${provider}" step=${step}: getProviderHelpText returned "${link}" which the parser resolved to "${label}" instead of "${EXPECTED_MODAL_HEADER_LABEL[provider]}". Check destinationLabelMap in provider-wizard-modal.utils.ts.`, + ).toBe(EXPECTED_MODAL_HEADER_LABEL[provider]); + } + } + }); + + it("resolves the expected modal header label for every method-specific credentials deep link", () => { + // Round-trip check: catches drift between the URLs in + // `PROVIDER_CREDENTIALS_METHOD_DOCS_URL` (external-urls.ts) and the + // section labels in `docsSectionLabelMap` (this file). A hash typo on + // either side would otherwise slip past the individual literal-URL tests + // in both files. + for (const [ + provider, + method, + expected, + ] of EXPECTED_METHOD_MODAL_HEADER_LABEL) { + const { link } = getProviderHelpText( + provider, + PROVIDER_WIZARD_STEP.CREDENTIALS, + method, + ); + const label = getProviderWizardDocsDestination(link); + expect( + label, + `Method-specific label drift for provider="${provider}" method="${method}": getProviderHelpText returned "${link}" which the parser resolved to "${label}" instead of "${expected}". Ensure PROVIDER_CREDENTIALS_METHOD_DOCS_URL and docsSectionLabelMap agree on the anchor.`, + ).toBe(expected); + } + }); +}); diff --git a/ui/components/providers/wizard/provider-wizard-modal.utils.ts b/ui/components/providers/wizard/provider-wizard-modal.utils.ts index eb1b2d33c9..b5307120b5 100644 --- a/ui/components/providers/wizard/provider-wizard-modal.utils.ts +++ b/ui/components/providers/wizard/provider-wizard-modal.utils.ts @@ -35,21 +35,55 @@ export function getProviderWizardDocsDestination(docsLink: string) { aws: "AWS", azure: "Azure", m365: "Microsoft 365", + microsoft365: "Microsoft 365", gcp: "GCP", k8s: "Kubernetes", kubernetes: "Kubernetes", github: "GitHub", iac: "IaC", + image: "Image", + oci: "Oracle Cloud", oraclecloud: "Oracle Cloud", mongodbatlas: "MongoDB Atlas", alibabacloud: "Alibaba Cloud", cloudflare: "Cloudflare", openstack: "OpenStack", + googleworkspace: "Google Workspace", + vercel: "Vercel", + okta: "Okta", help: "Provider", + providers: "Provider", }; + const stripUrlShapePrefix = (segment: string) => + segment + .replace(/^getting-started-/, "") + .replace(/^provider-/, "") + .replace(/^prowler-cloud-/, ""); + try { const parsed = new URL(docsLink); + // Labels for method-specific credentials-step deep links. Keyed by the + // docs URL slug (which can differ from the wizard provider key — e.g. + // the docs use `microsoft365` while the wizard uses `m365`). Providers + // whose credentials-step URL is the general step anchor are omitted + // here and fall back to the provider label ("AWS", "Google Workspace", + // etc.) via the `destinationLabelMap` below. + const docsSectionLabelMap: Record = { + "aws#assume-role-recommended": "AWS Assume Role", + "aws#credentials-static-access-keys": "AWS Credentials", + "microsoft365#application-certificate-authentication-recommended": + "M365 Certificate", + "microsoft365#application-client-secret-authentication": + "M365 Client Secret", + "alibabacloud#ram-role-assumption-recommended": "Alibaba Cloud RAM Role", + "alibabacloud#credentials-static-access-keys": + "Alibaba Cloud Credentials", + "cloudflare#user-api-token-authentication-recommended": + "Cloudflare API Token", + "cloudflare#api-key-and-email-authentication-legacy": + "Cloudflare API Key", + }; const pathSegments = parsed.pathname .split("/") .filter((segment) => segment.length > 0); @@ -59,16 +93,32 @@ export function getProviderWizardDocsDestination(docsLink: string) { return parsed.hostname; } - const compactDestination = lastSegment - .replace(/^provider-/, "") - .replace(/^prowler-cloud-/, ""); - const mappedDestination = destinationLabelMap[compactDestination]; + // For docs URLs shaped as `/user-guide/providers//` the + // provider slug is the segment right after `providers`, not the last one + // (which is a page name like `authentication` or `getting-started-`). + // Prefer that when present so pages like + // `/user-guide/providers/aws/authentication` map to "AWS" instead of + // the meaningless title-cased fallback ("Authentication"). + const providersIndex = pathSegments.indexOf("providers"); + const providerSlugFromPath = + providersIndex >= 0 && providersIndex + 1 < pathSegments.length + ? pathSegments[providersIndex + 1] + : undefined; - if (mappedDestination) { - return mappedDestination; + if (providerSlugFromPath && parsed.hash) { + const sectionLabel = + docsSectionLabelMap[`${providerSlugFromPath}${parsed.hash}`]; + if (sectionLabel) return sectionLabel; } - return compactDestination + for (const candidate of [providerSlugFromPath, lastSegment]) { + if (!candidate) continue; + const compact = stripUrlShapePrefix(candidate); + const mapped = destinationLabelMap[compact]; + if (mapped) return mapped; + } + + return stripUrlShapePrefix(lastSegment) .split("-") .map((word) => word.length === 0 ? word : word[0].toUpperCase() + word.slice(1), diff --git a/ui/lib/external-urls.test.ts b/ui/lib/external-urls.test.ts index 0f82a4e0cd..86a9976ccd 100644 --- a/ui/lib/external-urls.test.ts +++ b/ui/lib/external-urls.test.ts @@ -3,9 +3,12 @@ import { join } from "node:path"; import { describe, expect, it } from "vitest"; +import { PROVIDER_WIZARD_STEP } from "@/types/provider-wizard"; + import { getAWSCredentialsTemplateLinks, getAWSOrgDeploymentQuickLink, + getProviderHelpText, PROWLER_CF_TEMPLATE_URL, } from "./external-urls"; @@ -107,6 +110,249 @@ describe("getAWSOrgDeploymentQuickLink", () => { }); }); +describe("getProviderHelpText", () => { + const AWS_SHORTLINK = "https://goto.prowler.com/provider-aws"; + const AWS_CREDENTIALS_STEP_DOCS = + "https://docs.prowler.com/user-guide/providers/aws/getting-started-aws#step-3-set-up-aws-authentication"; + + it("returns the provider shortlink on the connect step", () => { + // Given the user is picking a provider (no deep-link into auth yet) + // When + const { link } = getProviderHelpText("aws", PROVIDER_WIZARD_STEP.CONNECT); + + // Then + expect(link).toBe(AWS_SHORTLINK); + }); + + it("points to the credentials section of the getting-started page on the credentials step", () => { + // No method picked yet — link should scroll the getting-started page to + // the credentials/authentication step so the user reads about the choice + // in the same page they came from. + const { link } = getProviderHelpText( + "aws", + PROVIDER_WIZARD_STEP.CREDENTIALS, + ); + + expect(link).toBe(AWS_CREDENTIALS_STEP_DOCS); + }); + + it("points AWS assume role credentials to the exact setup section", () => { + const { link } = getProviderHelpText( + "aws", + PROVIDER_WIZARD_STEP.CREDENTIALS, + "role", + ); + + expect(link).toBe( + "https://docs.prowler.com/user-guide/providers/aws/getting-started-aws#assume-role-recommended", + ); + }); + + it("points AWS static credentials to the exact setup section", () => { + const { link } = getProviderHelpText( + "aws", + PROVIDER_WIZARD_STEP.CREDENTIALS, + "credentials", + ); + + expect(link).toBe( + "https://docs.prowler.com/user-guide/providers/aws/getting-started-aws#credentials-static-access-keys", + ); + }); + + it("falls back to the credentials step section when the picked method has no dedicated subsection", () => { + // GCP's methods render inside a Mintlify component in the docs + // page, so no per-method anchor exists. Any method-selected variant + // resolves to the general credentials step anchor. + const { link } = getProviderHelpText( + "gcp", + PROVIDER_WIZARD_STEP.CREDENTIALS, + "service-account", + ); + + expect(link).toBe( + "https://docs.prowler.com/user-guide/providers/gcp/getting-started-gcp#step-3-set-up-gcp-authentication", + ); + }); + + it("keeps the shortlink on the test connection step", () => { + // Credentials-step docs are only surfaced while the user is still + // supplying credentials; after that the shortlink landing is the useful + // destination. + const { link } = getProviderHelpText("aws", PROVIDER_WIZARD_STEP.TEST); + + expect(link).toBe(AWS_SHORTLINK); + }); + + it("keeps the shortlink on the launch step", () => { + const { link } = getProviderHelpText("aws", PROVIDER_WIZARD_STEP.LAUNCH); + + expect(link).toBe(AWS_SHORTLINK); + }); + + it("resolves the credentials-step link for every supported provider", () => { + // Guard against silently dropping a provider from + // PROVIDER_CREDENTIALS_STEP_DOCS_URL. When no auth method is selected + // yet, every provider should deep-link to its own getting-started + // credentials section (never to authentication.mdx). + const cases: Array<[string, string]> = [ + [ + "aws", + "https://docs.prowler.com/user-guide/providers/aws/getting-started-aws#step-3-set-up-aws-authentication", + ], + [ + "azure", + "https://docs.prowler.com/user-guide/providers/azure/getting-started-azure#step-3-add-credentials-to-prowler-cloud", + ], + [ + "m365", + "https://docs.prowler.com/user-guide/providers/microsoft365/getting-started-m365#step-3-choose-and-provide-authentication", + ], + [ + "gcp", + "https://docs.prowler.com/user-guide/providers/gcp/getting-started-gcp#step-3-set-up-gcp-authentication", + ], + [ + "kubernetes", + "https://docs.prowler.com/user-guide/providers/kubernetes/getting-started-k8s#step-2-configure-kubernetes-authentication", + ], + [ + "github", + "https://docs.prowler.com/user-guide/providers/github/getting-started-github#step-3-choose-authentication-method", + ], + [ + "iac", + "https://docs.prowler.com/user-guide/providers/iac/getting-started-iac#step-2-enter-authentication-details", + ], + [ + "image", + "https://docs.prowler.com/user-guide/providers/image/getting-started-image#step-2-enter-authentication-and-scan-filters", + ], + [ + "oraclecloud", + "https://docs.prowler.com/user-guide/providers/oci/getting-started-oci#step-3-add-oci-api-key-credentials", + ], + [ + "mongodbatlas", + "https://docs.prowler.com/user-guide/providers/mongodbatlas/getting-started-mongodbatlas#step-2-provide-api-credentials", + ], + [ + "alibabacloud", + "https://docs.prowler.com/user-guide/providers/alibabacloud/getting-started-alibabacloud#step-3-choose-and-provide-authentication", + ], + [ + "cloudflare", + "https://docs.prowler.com/user-guide/providers/cloudflare/getting-started-cloudflare#step-3-choose-and-provide-authentication", + ], + [ + "openstack", + "https://docs.prowler.com/user-guide/providers/openstack/getting-started-openstack#step-2-provide-credentials", + ], + [ + "googleworkspace", + "https://docs.prowler.com/user-guide/providers/googleworkspace/getting-started-googleworkspace#step-3-provide-credentials", + ], + [ + "vercel", + "https://docs.prowler.com/user-guide/providers/vercel/getting-started-vercel#step-2-provide-credentials", + ], + [ + "okta", + "https://docs.prowler.com/user-guide/providers/okta/getting-started-okta#step-2-provide-credentials", + ], + ]; + + for (const [provider, expected] of cases) { + expect( + getProviderHelpText(provider, PROVIDER_WIZARD_STEP.CREDENTIALS).link, + ).toBe(expected); + } + }); + + it("resolves the method-specific credentials link for every provider with a per-method subsection", () => { + // Providers whose docs have a heading per auth method: verify each + // (provider, method) combo maps to the exact subsection anchor. Missing + // an entry in PROVIDER_CREDENTIALS_METHOD_DOCS_URL silently regresses + // the user to the general step section — this test catches that. + const cases: Array<[string, string, string]> = [ + [ + "aws", + "role", + "https://docs.prowler.com/user-guide/providers/aws/getting-started-aws#assume-role-recommended", + ], + [ + "aws", + "credentials", + "https://docs.prowler.com/user-guide/providers/aws/getting-started-aws#credentials-static-access-keys", + ], + [ + "m365", + "app_certificate", + "https://docs.prowler.com/user-guide/providers/microsoft365/getting-started-m365#application-certificate-authentication-recommended", + ], + [ + "m365", + "app_client_secret", + "https://docs.prowler.com/user-guide/providers/microsoft365/getting-started-m365#application-client-secret-authentication", + ], + [ + "alibabacloud", + "role", + "https://docs.prowler.com/user-guide/providers/alibabacloud/getting-started-alibabacloud#ram-role-assumption-recommended", + ], + [ + "alibabacloud", + "credentials", + "https://docs.prowler.com/user-guide/providers/alibabacloud/getting-started-alibabacloud#credentials-static-access-keys", + ], + [ + "cloudflare", + "api_token", + "https://docs.prowler.com/user-guide/providers/cloudflare/getting-started-cloudflare#user-api-token-authentication-recommended", + ], + [ + "cloudflare", + "api_key", + "https://docs.prowler.com/user-guide/providers/cloudflare/getting-started-cloudflare#api-key-and-email-authentication-legacy", + ], + ]; + + for (const [provider, method, expected] of cases) { + expect( + getProviderHelpText(provider, PROVIDER_WIZARD_STEP.CREDENTIALS, method) + .link, + ).toBe(expected); + } + }); + + it("falls back to the generic help shortlink for unknown providers regardless of step", () => { + // Unknown providers have no dedicated docs page, so a step-specific + // anchor would deep-link into nothing. + const { link } = getProviderHelpText( + "not-a-real-provider", + PROVIDER_WIZARD_STEP.CREDENTIALS, + ); + + expect(link).toBe("https://goto.prowler.com/provider-help"); + }); + + it("falls back for unknown providers colliding with Object.prototype", () => { + // Given + const providers = ["constructor", "toString", "__proto__"]; + + for (const provider of providers) { + // When + const { link } = getProviderHelpText( + provider, + PROVIDER_WIZARD_STEP.CREDENTIALS, + ); + + // Then + expect(link).toBe("https://goto.prowler.com/provider-help"); + } + }); +}); + describe("Prowler CloudFormation template", () => { it("should define every parameter used by the UI quick-create links", () => { // Given diff --git a/ui/lib/external-urls.ts b/ui/lib/external-urls.ts index 9814cab9fb..0c5a9585cf 100644 --- a/ui/lib/external-urls.ts +++ b/ui/lib/external-urls.ts @@ -1,4 +1,8 @@ import type { IntegrationType } from "../types/integrations"; +import { + PROVIDER_WIZARD_STEP, + type ProviderWizardStep, +} from "../types/provider-wizard"; // Documentation URLs export const DOCS_URLS = { @@ -60,94 +64,176 @@ const buildCloudFormationQuickCreateLink = ( return `${CF_QUICKCREATE_BASE_URL}?${searchParams.toString()}`; }; -export const getProviderHelpText = (provider: string) => { - switch (provider) { - case "aws": - return { - text: "Need help connecting your AWS account?", - link: "https://goto.prowler.com/provider-aws", - }; - case "azure": - return { - text: "Need help connecting your Azure subscription?", - link: "https://goto.prowler.com/provider-azure", - }; - case "m365": - return { - text: "Need help connecting your Microsoft 365 account?", - link: "https://goto.prowler.com/provider-m365", - }; - case "gcp": - return { - text: "Need help connecting your GCP project?", - link: "https://goto.prowler.com/provider-gcp", - }; - case "kubernetes": - return { - text: "Need help connecting your Kubernetes cluster?", - link: "https://goto.prowler.com/provider-k8s", - }; - case "github": - return { - text: "Need help connecting your GitHub account?", - link: "https://goto.prowler.com/provider-github", - }; - case "iac": - return { - text: "Need help scanning your Infrastructure as Code repository?", - link: "https://goto.prowler.com/provider-iac", - }; - case "image": - return { - text: "Need help scanning your container registry?", - link: "https://goto.prowler.com/provider-image", - }; - case "oraclecloud": - return { - text: "Need help connecting your Oracle Cloud account?", - link: "https://goto.prowler.com/provider-oraclecloud", - }; - case "mongodbatlas": - return { - text: "Need help connecting your MongoDB Atlas organization?", - link: "https://goto.prowler.com/provider-mongodbatlas", - }; - case "alibabacloud": - return { - text: "Need help connecting your Alibaba Cloud account?", - link: "https://goto.prowler.com/provider-alibabacloud", - }; - case "cloudflare": - return { - text: "Need help connecting your Cloudflare account?", - link: "https://goto.prowler.com/provider-cloudflare", - }; - case "openstack": - return { - text: "Need help connecting your OpenStack cloud?", - link: "https://goto.prowler.com/provider-openstack", - }; - case "googleworkspace": - return { - text: "Need help connecting your Google Workspace account?", - link: "https://goto.prowler.com/provider-googleworkspace", - }; - case "vercel": - return { - text: "Need help connecting your Vercel team?", - link: "https://goto.prowler.com/provider-vercel", - }; - case "okta": - return { - text: "Need help connecting your Okta organization?", - link: "https://goto.prowler.com/provider-okta", - }; - default: - return { - text: "How to setup a provider?", - link: "https://goto.prowler.com/provider-help", - }; +// Shortlinks are used for all wizard steps except credentials so link +// ownership stays with the docs/marketing team: they can retarget +// destinations from the shortener panel without a UI PR. +const PROVIDER_DOCS_SHORTLINK: Record = { + aws: "https://goto.prowler.com/provider-aws", + azure: "https://goto.prowler.com/provider-azure", + m365: "https://goto.prowler.com/provider-m365", + gcp: "https://goto.prowler.com/provider-gcp", + kubernetes: "https://goto.prowler.com/provider-k8s", + github: "https://goto.prowler.com/provider-github", + iac: "https://goto.prowler.com/provider-iac", + image: "https://goto.prowler.com/provider-image", + oraclecloud: "https://goto.prowler.com/provider-oraclecloud", + mongodbatlas: "https://goto.prowler.com/provider-mongodbatlas", + alibabacloud: "https://goto.prowler.com/provider-alibabacloud", + cloudflare: "https://goto.prowler.com/provider-cloudflare", + openstack: "https://goto.prowler.com/provider-openstack", + googleworkspace: "https://goto.prowler.com/provider-googleworkspace", + vercel: "https://goto.prowler.com/provider-vercel", + okta: "https://goto.prowler.com/provider-okta", +}; + +// Default target for the credentials step: the section of the provider's +// getting-started page that introduces the credentials flow. The getting- +// started page keeps the user in the same mental model as the wizard, and +// each section already links to `authentication.mdx` for readers who need +// deeper detail. That indirection is intentional — we do NOT deep-link into +// `authentication.mdx` from the wizard, otherwise the user is jumped into +// low-level docs before they have the context to make sense of them. +const PROVIDER_CREDENTIALS_STEP_DOCS_URL: Record = { + aws: "https://docs.prowler.com/user-guide/providers/aws/getting-started-aws#step-3-set-up-aws-authentication", + azure: + "https://docs.prowler.com/user-guide/providers/azure/getting-started-azure#step-3-add-credentials-to-prowler-cloud", + m365: "https://docs.prowler.com/user-guide/providers/microsoft365/getting-started-m365#step-3-choose-and-provide-authentication", + gcp: "https://docs.prowler.com/user-guide/providers/gcp/getting-started-gcp#step-3-set-up-gcp-authentication", + kubernetes: + "https://docs.prowler.com/user-guide/providers/kubernetes/getting-started-k8s#step-2-configure-kubernetes-authentication", + github: + "https://docs.prowler.com/user-guide/providers/github/getting-started-github#step-3-choose-authentication-method", + iac: "https://docs.prowler.com/user-guide/providers/iac/getting-started-iac#step-2-enter-authentication-details", + image: + "https://docs.prowler.com/user-guide/providers/image/getting-started-image#step-2-enter-authentication-and-scan-filters", + oraclecloud: + "https://docs.prowler.com/user-guide/providers/oci/getting-started-oci#step-3-add-oci-api-key-credentials", + mongodbatlas: + "https://docs.prowler.com/user-guide/providers/mongodbatlas/getting-started-mongodbatlas#step-2-provide-api-credentials", + alibabacloud: + "https://docs.prowler.com/user-guide/providers/alibabacloud/getting-started-alibabacloud#step-3-choose-and-provide-authentication", + cloudflare: + "https://docs.prowler.com/user-guide/providers/cloudflare/getting-started-cloudflare#step-3-choose-and-provide-authentication", + openstack: + "https://docs.prowler.com/user-guide/providers/openstack/getting-started-openstack#step-2-provide-credentials", + googleworkspace: + "https://docs.prowler.com/user-guide/providers/googleworkspace/getting-started-googleworkspace#step-3-provide-credentials", + vercel: + "https://docs.prowler.com/user-guide/providers/vercel/getting-started-vercel#step-2-provide-credentials", + okta: "https://docs.prowler.com/user-guide/providers/okta/getting-started-okta#step-2-provide-credentials", +}; + +// When the user has picked a specific auth method inside the credentials +// step, jump directly to that method's subsection in the getting-started +// page. Only providers whose docs have a heading per method are listed — +// GCP and GitHub render their methods inside a Mintlify `` component +// so a per-method anchor isn't available today; they fall back to the +// general step URL above. +const PROVIDER_CREDENTIALS_METHOD_DOCS_URL: Record< + string, + Record +> = { + aws: { + role: "https://docs.prowler.com/user-guide/providers/aws/getting-started-aws#assume-role-recommended", + credentials: + "https://docs.prowler.com/user-guide/providers/aws/getting-started-aws#credentials-static-access-keys", + }, + m365: { + app_certificate: + "https://docs.prowler.com/user-guide/providers/microsoft365/getting-started-m365#application-certificate-authentication-recommended", + app_client_secret: + "https://docs.prowler.com/user-guide/providers/microsoft365/getting-started-m365#application-client-secret-authentication", + }, + alibabacloud: { + role: "https://docs.prowler.com/user-guide/providers/alibabacloud/getting-started-alibabacloud#ram-role-assumption-recommended", + credentials: + "https://docs.prowler.com/user-guide/providers/alibabacloud/getting-started-alibabacloud#credentials-static-access-keys", + }, + cloudflare: { + api_token: + "https://docs.prowler.com/user-guide/providers/cloudflare/getting-started-cloudflare#user-api-token-authentication-recommended", + api_key: + "https://docs.prowler.com/user-guide/providers/cloudflare/getting-started-cloudflare#api-key-and-email-authentication-legacy", + }, +}; + +const PROVIDER_HELP_FALLBACK_URL = "https://goto.prowler.com/provider-help"; + +const getOwnRecordValue = ( + record: Readonly>, + key: string, +): T | undefined => (Object.hasOwn(record, key) ? record[key] : undefined); + +const resolveDocsLink = ( + provider: string, + step?: ProviderWizardStep, + credentialsMethod?: string | null, +) => { + const shortlink = getOwnRecordValue(PROVIDER_DOCS_SHORTLINK, provider); + + if (step === PROVIDER_WIZARD_STEP.CREDENTIALS) { + if (credentialsMethod) { + const methodDocs = getOwnRecordValue( + PROVIDER_CREDENTIALS_METHOD_DOCS_URL, + provider, + ); + const methodUrl = methodDocs + ? getOwnRecordValue(methodDocs, credentialsMethod) + : undefined; + if (methodUrl) return methodUrl; + } + + const stepUrl = getOwnRecordValue( + PROVIDER_CREDENTIALS_STEP_DOCS_URL, + provider, + ); + if (stepUrl) return stepUrl; } + + return shortlink; +}; + +const PROVIDER_HELP_TEXT: Record = { + aws: "Need help connecting your AWS account?", + azure: "Need help connecting your Azure subscription?", + m365: "Need help connecting your Microsoft 365 account?", + gcp: "Need help connecting your GCP project?", + kubernetes: "Need help connecting your Kubernetes cluster?", + github: "Need help connecting your GitHub account?", + iac: "Need help scanning your Infrastructure as Code repository?", + image: "Need help scanning your container registry?", + oraclecloud: "Need help connecting your Oracle Cloud account?", + mongodbatlas: "Need help connecting your MongoDB Atlas organization?", + alibabacloud: "Need help connecting your Alibaba Cloud account?", + cloudflare: "Need help connecting your Cloudflare account?", + openstack: "Need help connecting your OpenStack cloud?", + googleworkspace: "Need help connecting your Google Workspace account?", + vercel: "Need help connecting your Vercel team?", + okta: "Need help connecting your Okta organization?", +}; + +export const getProviderHelpText = ( + provider: string, + step?: ProviderWizardStep, + credentialsMethod?: string | null, +) => { + const link = resolveDocsLink(provider, step, credentialsMethod); + + if (!link) { + // Unknown provider: hand off to the generic help shortlink instead of + // deep-linking into a page that may not exist. + return { + text: "How to setup a provider?", + link: PROVIDER_HELP_FALLBACK_URL, + }; + } + + return { + text: + getOwnRecordValue(PROVIDER_HELP_TEXT, provider) ?? + "Need help connecting your provider?", + link, + }; }; export const getAWSCredentialsTemplateLinks = (