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