Skip to content

Commit e2cae35

Browse files
authored
feat(ui): pre-fill Cloudflare and GitHub token creation URLs (#12349)
1 parent d8c8027 commit e2cae35

9 files changed

Lines changed: 519 additions & 7 deletions

File tree

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Surface pre-configured credential creation links in the add-provider wizard. Cloudflare exposes the User API Token template and an Account-Owned template pinned to the Cloudflare Account ID entered in the wizard, GitHub exposes the personal-repositories template and an organization-scanning template pinned to the identifier entered in the wizard

ui/components/providers/workflow/forms/base-credentials-form.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -213,6 +213,7 @@ export const BaseCredentialsForm = ({
213213
<GitHubCredentialsForm
214214
control={form.control}
215215
credentialsType={effectiveVia || undefined}
216+
providerUid={providerUid}
216217
/>
217218
)}
218219
{providerType === "iac" && (
@@ -256,6 +257,7 @@ export const BaseCredentialsForm = ({
256257
control={
257258
form.control as unknown as Control<CloudflareTokenCredentials>
258259
}
260+
providerUid={providerUid}
259261
/>
260262
)}
261263
{providerType === "cloudflare" && effectiveVia === "api_key" && (
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
import { render, screen } from "@testing-library/react";
2+
import { FormProvider, useForm } from "react-hook-form";
3+
import { describe, expect, it } from "vitest";
4+
5+
import { CloudflareTokenCredentials } from "@/types";
6+
7+
import { CloudflareApiTokenCredentialsForm } from "./cloudflare-api-token-credentials-form";
8+
9+
// Wraps the form in a react-hook-form context so the WizardInputField mounts
10+
// without exploding. We are testing the surrounding links, not the input.
11+
const Harness = ({ providerUid }: { providerUid?: string }) => {
12+
const form = useForm<CloudflareTokenCredentials>();
13+
return (
14+
<FormProvider {...form}>
15+
<CloudflareApiTokenCredentialsForm
16+
control={form.control}
17+
providerUid={providerUid}
18+
/>
19+
</FormProvider>
20+
);
21+
};
22+
23+
const USER_URL =
24+
"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";
25+
26+
describe("CloudflareApiTokenCredentialsForm", () => {
27+
it("always renders the User API Token link with the correct href and safe target attributes", () => {
28+
// Given
29+
render(<Harness />);
30+
31+
// When
32+
const link = screen.getByRole("link", {
33+
name: /create a pre-configured user api token/i,
34+
});
35+
36+
// Then
37+
expect(link).toHaveAttribute("href", USER_URL);
38+
expect(link).toHaveAttribute("target", "_blank");
39+
expect(link).toHaveAttribute("rel", "noopener noreferrer");
40+
});
41+
42+
it("does not render the Account-Owned link when providerUid is missing so the user is not offered an ambiguous duplicate", () => {
43+
// Given
44+
render(<Harness />);
45+
46+
// Then
47+
expect(
48+
screen.queryByRole("link", {
49+
name: /create a pre-configured account-owned api token/i,
50+
}),
51+
).not.toBeInTheDocument();
52+
});
53+
54+
it("renders the Account-Owned link routed through Cloudflare's dashboard `to=` param with the account id substituted when providerUid is provided", () => {
55+
// Given
56+
render(<Harness providerUid="a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4" />);
57+
58+
// When
59+
const link = screen.getByRole("link", {
60+
name: /create a pre-configured account-owned api token/i,
61+
});
62+
63+
// Then
64+
expect(link).toHaveAttribute("target", "_blank");
65+
expect(link).toHaveAttribute("rel", "noopener noreferrer");
66+
const parsed = new URL(link.getAttribute("href") ?? "");
67+
expect(parsed.origin).toBe("https://dash.cloudflare.com");
68+
expect(parsed.pathname).toBe("/");
69+
expect(parsed.searchParams.get("to")).toBe(
70+
"/a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4/api-tokens",
71+
);
72+
expect(parsed.searchParams.get("name")).toBe("Prowler Security Scanner");
73+
});
74+
75+
it("ignores whitespace around providerUid so a stray user-typed space does not hide the Account-Owned link", () => {
76+
// Given
77+
render(<Harness providerUid=" " />);
78+
79+
// Then
80+
expect(
81+
screen.queryByRole("link", {
82+
name: /create a pre-configured account-owned api token/i,
83+
}),
84+
).not.toBeInTheDocument();
85+
});
86+
});

ui/components/providers/workflow/forms/select-credentials-type/cloudflare/credentials-type/cloudflare-api-token-credentials-form.tsx

Lines changed: 42 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,14 +3,31 @@
33
import { Control } from "react-hook-form";
44

55
import { WizardInputField } from "@/components/providers/workflow/forms/fields";
6+
import { Button } from "@/components/shadcn";
7+
import {
8+
buildCloudflareAccountOwnedApiTokenUrl,
9+
PRECONFIGURED_CREDENTIAL_URLS,
10+
} from "@/lib/external-urls";
611
import { ProviderCredentialFields } from "@/lib/provider-credentials/provider-credential-fields";
712
import { CloudflareTokenCredentials } from "@/types";
813

14+
interface CloudflareApiTokenCredentialsFormProps {
15+
control: Control<CloudflareTokenCredentials>;
16+
// Cloudflare Account ID captured in the previous wizard step. When present,
17+
// it flows into the account-owned token URL as the account path segment so
18+
// Cloudflare lands the user directly on the correct account's Create Custom
19+
// Token page. When absent, only the user-scoped link is shown to avoid
20+
// relying on Cloudflare's `:account` router placeholder, which can silently
21+
// fall through when the user is signed into more than one account.
22+
providerUid?: string;
23+
}
24+
925
export const CloudflareApiTokenCredentialsForm = ({
1026
control,
11-
}: {
12-
control: Control<CloudflareTokenCredentials>;
13-
}) => {
27+
providerUid,
28+
}: CloudflareApiTokenCredentialsFormProps) => {
29+
const trimmedProviderUid = providerUid?.trim();
30+
1431
return (
1532
<>
1633
<div className="flex flex-col">
@@ -33,6 +50,28 @@ export const CloudflareApiTokenCredentialsForm = ({
3350
variant="bordered"
3451
isRequired
3552
/>
53+
<div className="flex flex-col items-start gap-1">
54+
<Button variant="link" size="link-sm" asChild>
55+
<a
56+
href={PRECONFIGURED_CREDENTIAL_URLS.CLOUDFLARE_API_TOKEN_USER}
57+
target="_blank"
58+
rel="noopener noreferrer"
59+
>
60+
Create a pre-configured User API Token
61+
</a>
62+
</Button>
63+
{trimmedProviderUid && (
64+
<Button variant="link" size="link-sm" asChild>
65+
<a
66+
href={buildCloudflareAccountOwnedApiTokenUrl(trimmedProviderUid)}
67+
target="_blank"
68+
rel="noopener noreferrer"
69+
>
70+
Create a pre-configured Account-Owned API Token
71+
</a>
72+
</Button>
73+
)}
74+
</div>
3675
<div className="text-text-neutral-tertiary text-xs">
3776
Tokens never leave your browser unencrypted and are stored as secrets in
3877
the backend. You can revoke the token from the Cloudflare dashboard
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
import { render, screen } from "@testing-library/react";
2+
import { FormProvider, useForm } from "react-hook-form";
3+
import { describe, expect, it } from "vitest";
4+
5+
import { GitHubPersonalAccessTokenForm } from "./github-personal-access-token-form";
6+
7+
// Wraps the form in a react-hook-form context so the WizardInputField mounts
8+
// without exploding. We are testing the surrounding links, not the input.
9+
const Harness = ({ providerUid }: { providerUid?: string }) => {
10+
const form = useForm();
11+
return (
12+
<FormProvider {...form}>
13+
<GitHubPersonalAccessTokenForm
14+
control={form.control}
15+
providerUid={providerUid}
16+
/>
17+
</FormProvider>
18+
);
19+
};
20+
21+
const USER_URL =
22+
"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";
23+
24+
const expectSafeExternalLink = (href: string) => (name: RegExp) => {
25+
const link = screen.getByRole("link", { name });
26+
expect(link).toHaveAttribute("href", href);
27+
expect(link).toHaveAttribute("target", "_blank");
28+
expect(link).toHaveAttribute("rel", "noopener noreferrer");
29+
};
30+
31+
describe("GitHubPersonalAccessTokenForm", () => {
32+
it("renders the personal-repositories link with the correct href and safe target attributes", () => {
33+
// Given
34+
render(<Harness />);
35+
36+
// Then
37+
expectSafeExternalLink(USER_URL)(
38+
/create a pre-configured token for personal repositories/i,
39+
);
40+
});
41+
42+
it("does not render the organization link when providerUid is missing so the user is not offered an identical-looking duplicate", () => {
43+
// Given
44+
render(<Harness />);
45+
46+
// Then
47+
expect(
48+
screen.queryByRole("link", {
49+
name: /create a pre-configured token for organization/i,
50+
}),
51+
).not.toBeInTheDocument();
52+
});
53+
54+
it("renders the organization link with the identifier pinned as target_name when providerUid is provided", () => {
55+
// Given
56+
render(<Harness providerUid="prowler-cloud" />);
57+
58+
// When
59+
const orgLink = screen.getByRole("link", {
60+
name: /create a pre-configured token for organization prowler-cloud/i,
61+
});
62+
63+
// Then
64+
expect(orgLink).toHaveAttribute("target", "_blank");
65+
expect(orgLink).toHaveAttribute("rel", "noopener noreferrer");
66+
const orgUrl = new URL(orgLink.getAttribute("href") ?? "");
67+
expect(orgUrl.origin + orgUrl.pathname).toBe(
68+
"https://github.com/settings/personal-access-tokens/new",
69+
);
70+
expect(orgUrl.searchParams.get("target_name")).toBe("prowler-cloud");
71+
expect(orgUrl.searchParams.get("organization_administration")).toBe("read");
72+
expect(orgUrl.searchParams.get("members")).toBe("read");
73+
expect(orgUrl.searchParams.get("emails")).toBeNull();
74+
});
75+
76+
it("ignores whitespace around providerUid so a stray user-typed space does not hide the organization link", () => {
77+
// Given
78+
render(<Harness providerUid=" " />);
79+
80+
// Then
81+
expect(
82+
screen.queryByRole("link", {
83+
name: /create a pre-configured token for organization/i,
84+
}),
85+
).not.toBeInTheDocument();
86+
});
87+
});

ui/components/providers/workflow/forms/select-credentials-type/github/credentials-type/github-personal-access-token-form.tsx

Lines changed: 43 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,29 @@
33
import { Control } from "react-hook-form";
44

55
import { WizardInputField } from "@/components/providers/workflow/forms/fields";
6+
import { Button } from "@/components/shadcn";
7+
import {
8+
buildGitHubPersonalAccessTokenOrgUrl,
9+
PRECONFIGURED_CREDENTIAL_URLS,
10+
} from "@/lib/external-urls";
611
import { ProviderCredentialFields } from "@/lib/provider-credentials/provider-credential-fields";
712

13+
interface GitHubPersonalAccessTokenFormProps {
14+
control: Control<any>;
15+
// GitHub identifier entered in the previous wizard step. When it names an
16+
// organization, it flows into the org-scoped token URL as `target_name` so
17+
// GitHub pre-selects the right Resource Owner and surfaces the org-only
18+
// permissions (`organization_administration`, `members`). When absent, only
19+
// the personal-repositories link is shown.
20+
providerUid?: string;
21+
}
22+
823
export const GitHubPersonalAccessTokenForm = ({
924
control,
10-
}: {
11-
control: Control<any>;
12-
}) => {
25+
providerUid,
26+
}: GitHubPersonalAccessTokenFormProps) => {
27+
const trimmedProviderUid = providerUid?.trim();
28+
1329
return (
1430
<>
1531
<div className="flex flex-col">
@@ -30,6 +46,30 @@ export const GitHubPersonalAccessTokenForm = ({
3046
variant="bordered"
3147
isRequired
3248
/>
49+
<div className="flex flex-col items-start gap-1">
50+
<Button variant="link" size="link-sm" asChild>
51+
<a
52+
href={
53+
PRECONFIGURED_CREDENTIAL_URLS.GITHUB_PERSONAL_ACCESS_TOKEN_USER
54+
}
55+
target="_blank"
56+
rel="noopener noreferrer"
57+
>
58+
Create a pre-configured token for personal repositories
59+
</a>
60+
</Button>
61+
{trimmedProviderUid && (
62+
<Button variant="link" size="link-sm" asChild>
63+
<a
64+
href={buildGitHubPersonalAccessTokenOrgUrl(trimmedProviderUid)}
65+
target="_blank"
66+
rel="noopener noreferrer"
67+
>
68+
{`Create a pre-configured token for organization ${trimmedProviderUid}`}
69+
</a>
70+
</Button>
71+
)}
72+
</div>
3373
</>
3474
);
3575
};

ui/components/providers/workflow/forms/via-credentials/github-credentials-form.tsx

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,15 +11,22 @@ import {
1111
interface GitHubCredentialsFormProps {
1212
control: Control<any>;
1313
credentialsType?: string;
14+
providerUid?: string;
1415
}
1516

1617
export const GitHubCredentialsForm = ({
1718
control,
1819
credentialsType,
20+
providerUid,
1921
}: GitHubCredentialsFormProps) => {
2022
switch (credentialsType) {
2123
case "personal_access_token":
22-
return <GitHubPersonalAccessTokenForm control={control} />;
24+
return (
25+
<GitHubPersonalAccessTokenForm
26+
control={control}
27+
providerUid={providerUid}
28+
/>
29+
);
2330
case "oauth_app":
2431
return <GitHubOAuthAppForm control={control} />;
2532
case "github_app":

0 commit comments

Comments
 (0)