feat(ui): pre-fill Cloudflare and GitHub token creation URLs (#12349)

This commit is contained in:
lydiavilchez authored and GitHub committed 2026-08-06 16:36:10 +02:00
1 parent d8c8027215
commit e2cae35d38
9 files changed
+519 -7

No files matched your search

@@ -213,6 +213,7 @@ export const BaseCredentialsForm = ({
<GitHubCredentialsForm
control={form.control}
credentialsType={effectiveVia || undefined}
providerUid={providerUid}
/>
)}
{providerType === "iac" && (
@@ -256,6 +257,7 @@ export const BaseCredentialsForm = ({
control={
form.control as unknown as Control<CloudflareTokenCredentials>
}
providerUid={providerUid}
/>
)}
{providerType === "cloudflare" && effectiveVia === "api_key" && (
@@ -0,0 +1,86 @@
import { render, screen } from "@testing-library/react";
import { FormProvider, useForm } from "react-hook-form";
import { describe, expect, it } from "vitest";
import { CloudflareTokenCredentials } from "@/types";
import { CloudflareApiTokenCredentialsForm } from "./cloudflare-api-token-credentials-form";
// Wraps the form in a react-hook-form context so the WizardInputField mounts
// without exploding. We are testing the surrounding links, not the input.
const Harness = ({ providerUid }: { providerUid?: string }) => {
const form = useForm<CloudflareTokenCredentials>();
return (
<FormProvider {...form}>
<CloudflareApiTokenCredentialsForm
control={form.control}
providerUid={providerUid}
/>
</FormProvider>
);
};
const USER_URL =
"https://dash.cloudflare.com/profile/api-tokens?permissionGroupKeys=%5B%7B%22key%22%3A%22account_settings%22%2C%22type%22%3A%22read%22%7D%2C%7B%22key%22%3A%22zone%22%2C%22type%22%3A%22read%22%7D%2C%7B%22key%22%3A%22zone_settings%22%2C%22type%22%3A%22read%22%7D%2C%7B%22key%22%3A%22dns%22%2C%22type%22%3A%22read%22%7D%5D&accountId=%2A&zoneId=all&name=Prowler%20Security%20Scanner";
describe("CloudflareApiTokenCredentialsForm", () => {
it("always renders the User API Token link with the correct href and safe target attributes", () => {
// Given
render(<Harness />);
// When
const link = screen.getByRole("link", {
name: /create a pre-configured user api token/i,
});
// Then
expect(link).toHaveAttribute("href", USER_URL);
expect(link).toHaveAttribute("target", "_blank");
expect(link).toHaveAttribute("rel", "noopener noreferrer");
});
it("does not render the Account-Owned link when providerUid is missing so the user is not offered an ambiguous duplicate", () => {
// Given
render(<Harness />);
// Then
expect(
screen.queryByRole("link", {
name: /create a pre-configured account-owned api token/i,
}),
).not.toBeInTheDocument();
});
it("renders the Account-Owned link routed through Cloudflare's dashboard `to=` param with the account id substituted when providerUid is provided", () => {
// Given
render(<Harness providerUid="a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4" />);
// When
const link = screen.getByRole("link", {
name: /create a pre-configured account-owned api token/i,
});
// Then
expect(link).toHaveAttribute("target", "_blank");
expect(link).toHaveAttribute("rel", "noopener noreferrer");
const parsed = new URL(link.getAttribute("href") ?? "");
expect(parsed.origin).toBe("https://dash.cloudflare.com");
expect(parsed.pathname).toBe("/");
expect(parsed.searchParams.get("to")).toBe(
"/a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4/api-tokens",
);
expect(parsed.searchParams.get("name")).toBe("Prowler Security Scanner");
});
it("ignores whitespace around providerUid so a stray user-typed space does not hide the Account-Owned link", () => {
// Given
render(<Harness providerUid=" " />);
// Then
expect(
screen.queryByRole("link", {
name: /create a pre-configured account-owned api token/i,
}),
).not.toBeInTheDocument();
});
});
@@ -3,14 +3,31 @@
import { Control } from "react-hook-form";
import { WizardInputField } from "@/components/providers/workflow/forms/fields";
import { Button } from "@/components/shadcn";
import {
buildCloudflareAccountOwnedApiTokenUrl,
PRECONFIGURED_CREDENTIAL_URLS,
} from "@/lib/external-urls";
import { ProviderCredentialFields } from "@/lib/provider-credentials/provider-credential-fields";
import { CloudflareTokenCredentials } from "@/types";
interface CloudflareApiTokenCredentialsFormProps {
control: Control<CloudflareTokenCredentials>;
// Cloudflare Account ID captured in the previous wizard step. When present,
// it flows into the account-owned token URL as the account path segment so
// Cloudflare lands the user directly on the correct account's Create Custom
// Token page. When absent, only the user-scoped link is shown to avoid
// relying on Cloudflare's `:account` router placeholder, which can silently
// fall through when the user is signed into more than one account.
providerUid?: string;
}
export const CloudflareApiTokenCredentialsForm = ({
control,
}: {
control: Control<CloudflareTokenCredentials>;
}) => {
providerUid,
}: CloudflareApiTokenCredentialsFormProps) => {
const trimmedProviderUid = providerUid?.trim();
return (
<>
<div className="flex flex-col">
@@ -33,6 +50,28 @@ export const CloudflareApiTokenCredentialsForm = ({
variant="bordered"
isRequired
/>
<div className="flex flex-col items-start gap-1">
<Button variant="link" size="link-sm" asChild>
<a
href={PRECONFIGURED_CREDENTIAL_URLS.CLOUDFLARE_API_TOKEN_USER}
target="_blank"
rel="noopener noreferrer"
>
Create a pre-configured User API Token
</a>
</Button>
{trimmedProviderUid && (
<Button variant="link" size="link-sm" asChild>
<a
href={buildCloudflareAccountOwnedApiTokenUrl(trimmedProviderUid)}
target="_blank"
rel="noopener noreferrer"
>
Create a pre-configured Account-Owned API Token
</a>
</Button>
)}
</div>
<div className="text-text-neutral-tertiary text-xs">
Tokens never leave your browser unencrypted and are stored as secrets in
the backend. You can revoke the token from the Cloudflare dashboard
@@ -0,0 +1,87 @@
import { render, screen } from "@testing-library/react";
import { FormProvider, useForm } from "react-hook-form";
import { describe, expect, it } from "vitest";
import { GitHubPersonalAccessTokenForm } from "./github-personal-access-token-form";
// Wraps the form in a react-hook-form context so the WizardInputField mounts
// without exploding. We are testing the surrounding links, not the input.
const Harness = ({ providerUid }: { providerUid?: string }) => {
const form = useForm();
return (
<FormProvider {...form}>
<GitHubPersonalAccessTokenForm
control={form.control}
providerUid={providerUid}
/>
</FormProvider>
);
};
const USER_URL =
"https://github.com/settings/personal-access-tokens/new?name=Prowler+Security+Scanner&description=Fine-grained+PAT+for+Prowler+security+scanning&expires_in=90&administration=read&contents=read&vulnerability_alerts=read&emails=read";
const expectSafeExternalLink = (href: string) => (name: RegExp) => {
const link = screen.getByRole("link", { name });
expect(link).toHaveAttribute("href", href);
expect(link).toHaveAttribute("target", "_blank");
expect(link).toHaveAttribute("rel", "noopener noreferrer");
};
describe("GitHubPersonalAccessTokenForm", () => {
it("renders the personal-repositories link with the correct href and safe target attributes", () => {
// Given
render(<Harness />);
// Then
expectSafeExternalLink(USER_URL)(
/create a pre-configured token for personal repositories/i,
);
});
it("does not render the organization link when providerUid is missing so the user is not offered an identical-looking duplicate", () => {
// Given
render(<Harness />);
// Then
expect(
screen.queryByRole("link", {
name: /create a pre-configured token for organization/i,
}),
).not.toBeInTheDocument();
});
it("renders the organization link with the identifier pinned as target_name when providerUid is provided", () => {
// Given
render(<Harness providerUid="prowler-cloud" />);
// When
const orgLink = screen.getByRole("link", {
name: /create a pre-configured token for organization prowler-cloud/i,
});
// Then
expect(orgLink).toHaveAttribute("target", "_blank");
expect(orgLink).toHaveAttribute("rel", "noopener noreferrer");
const orgUrl = new URL(orgLink.getAttribute("href") ?? "");
expect(orgUrl.origin + orgUrl.pathname).toBe(
"https://github.com/settings/personal-access-tokens/new",
);
expect(orgUrl.searchParams.get("target_name")).toBe("prowler-cloud");
expect(orgUrl.searchParams.get("organization_administration")).toBe("read");
expect(orgUrl.searchParams.get("members")).toBe("read");
expect(orgUrl.searchParams.get("emails")).toBeNull();
});
it("ignores whitespace around providerUid so a stray user-typed space does not hide the organization link", () => {
// Given
render(<Harness providerUid=" " />);
// Then
expect(
screen.queryByRole("link", {
name: /create a pre-configured token for organization/i,
}),
).not.toBeInTheDocument();
});
});
@@ -3,13 +3,29 @@
import { Control } from "react-hook-form";
import { WizardInputField } from "@/components/providers/workflow/forms/fields";
import { Button } from "@/components/shadcn";
import {
buildGitHubPersonalAccessTokenOrgUrl,
PRECONFIGURED_CREDENTIAL_URLS,
} from "@/lib/external-urls";
import { ProviderCredentialFields } from "@/lib/provider-credentials/provider-credential-fields";
interface GitHubPersonalAccessTokenFormProps {
control: Control<any>;
// GitHub identifier entered in the previous wizard step. When it names an
// organization, it flows into the org-scoped token URL as `target_name` so
// GitHub pre-selects the right Resource Owner and surfaces the org-only
// permissions (`organization_administration`, `members`). When absent, only
// the personal-repositories link is shown.
providerUid?: string;
}
export const GitHubPersonalAccessTokenForm = ({
control,
}: {
control: Control<any>;
}) => {
providerUid,
}: GitHubPersonalAccessTokenFormProps) => {
const trimmedProviderUid = providerUid?.trim();
return (
<>
<div className="flex flex-col">
@@ -30,6 +46,30 @@ export const GitHubPersonalAccessTokenForm = ({
variant="bordered"
isRequired
/>
<div className="flex flex-col items-start gap-1">
<Button variant="link" size="link-sm" asChild>
<a
href={
PRECONFIGURED_CREDENTIAL_URLS.GITHUB_PERSONAL_ACCESS_TOKEN_USER
}
target="_blank"
rel="noopener noreferrer"
>
Create a pre-configured token for personal repositories
</a>
</Button>
{trimmedProviderUid && (
<Button variant="link" size="link-sm" asChild>
<a
href={buildGitHubPersonalAccessTokenOrgUrl(trimmedProviderUid)}
target="_blank"
rel="noopener noreferrer"
>
{`Create a pre-configured token for organization ${trimmedProviderUid}`}
</a>
</Button>
)}
</div>
</>
);
};
@@ -11,15 +11,22 @@ import {
interface GitHubCredentialsFormProps {
control: Control<any>;
credentialsType?: string;
providerUid?: string;
}
export const GitHubCredentialsForm = ({
control,
credentialsType,
providerUid,
}: GitHubCredentialsFormProps) => {
switch (credentialsType) {
case "personal_access_token":
return <GitHubPersonalAccessTokenForm control={control} />;
return (
<GitHubPersonalAccessTokenForm
control={control}
providerUid={providerUid}
/>
);
case "oauth_app":
return <GitHubOAuthAppForm control={control} />;
case "github_app":