feat(ui): add Cloudflare provider support (#9910)

Co-authored-by: Alan Buscaglia <gentlemanprogramming@gmail.com>
Co-authored-by: Alejandro Bailo <59607668+alejandrobailo@users.noreply.github.com>
Co-authored-by: alejandrobailo <alejandrobailo94@gmail.com>
This commit is contained in:
Hugo Pereira Brito
2026-02-23 09:33:17 +01:00
committed by GitHub
parent 9f6121bc05
commit bb5a4371bd
32 changed files with 1163 additions and 67 deletions

View File

@@ -8,6 +8,7 @@ All notable changes to the **Prowler UI** are documented in this file.
- OpenStack provider support in the UI [(#10046)](https://github.com/prowler-cloud/prowler/pull/10046)
- PDF report available for the CSA CCM compliance framework [(#10088)](https://github.com/prowler-cloud/prowler/pull/10088)
- Cloudflare provider support [(#9910)](https://github.com/prowler-cloud/prowler/pull/9910)
- CSV and PDF download buttons in compliance views [(#10093)](https://github.com/prowler-cloud/prowler/pull/10093)
### 🔄 Changed

View File

@@ -7,6 +7,7 @@ import {
AlibabaCloudProviderBadge,
AWSProviderBadge,
AzureProviderBadge,
CloudflareProviderBadge,
GCPProviderBadge,
GitHubProviderBadge,
IacProviderBadge,
@@ -37,6 +38,7 @@ const PROVIDER_ICON: Record<ProviderType, ReactNode> = {
oraclecloud: <OracleCloudProviderBadge width={18} height={18} />,
mongodbatlas: <MongoDBAtlasProviderBadge width={18} height={18} />,
alibabacloud: <AlibabaCloudProviderBadge width={18} height={18} />,
cloudflare: <CloudflareProviderBadge width={18} height={18} />,
openstack: <OpenStackProviderBadge width={18} height={18} />,
};

View File

@@ -63,6 +63,11 @@ const AlibabaCloudProviderBadge = lazy(() =>
default: m.AlibabaCloudProviderBadge,
})),
);
const CloudflareProviderBadge = lazy(() =>
import("@/components/icons/providers-badge").then((m) => ({
default: m.CloudflareProviderBadge,
})),
);
const OpenStackProviderBadge = lazy(() =>
import("@/components/icons/providers-badge").then((m) => ({
default: m.OpenStackProviderBadge,
@@ -119,6 +124,10 @@ const PROVIDER_DATA: Record<
label: "Alibaba Cloud",
icon: AlibabaCloudProviderBadge,
},
cloudflare: {
label: "Cloudflare",
icon: CloudflareProviderBadge,
},
openstack: {
label: "OpenStack",
icon: OpenStackProviderBadge,

View File

@@ -5,6 +5,7 @@ import {
} from "@/components/providers/workflow/forms";
import { SelectViaAlibabaCloud } from "@/components/providers/workflow/forms/select-credentials-type/alibabacloud";
import { SelectViaAWS } from "@/components/providers/workflow/forms/select-credentials-type/aws";
import { SelectViaCloudflare } from "@/components/providers/workflow/forms/select-credentials-type/cloudflare";
import {
AddViaServiceAccountForm,
SelectViaGCP,
@@ -43,6 +44,8 @@ export default async function AddCredentialsPage({ searchParams }: Props) {
if (providerType === "m365") return <SelectViaM365 initialVia={via} />;
if (providerType === "alibabacloud")
return <SelectViaAlibabaCloud initialVia={via} />;
if (providerType === "cloudflare")
return <SelectViaCloudflare initialVia={via} />;
return null;
case "credentials":

View File

@@ -2,6 +2,7 @@ import {
AlibabaCloudProviderBadge,
AWSProviderBadge,
AzureProviderBadge,
CloudflareProviderBadge,
GCPProviderBadge,
GitHubProviderBadge,
IacProviderBadge,
@@ -102,6 +103,15 @@ export const CustomProviderInputAlibabaCloud = () => {
);
};
export const CustomProviderInputCloudflare = () => {
return (
<div className="flex items-center gap-x-2">
<CloudflareProviderBadge width={25} height={25} />
<p className="text-sm">Cloudflare</p>
</div>
);
};
export const CustomProviderInputOpenStack = () => {
return (
<div className="flex items-center gap-x-2">

View File

@@ -2,6 +2,7 @@ import {
AlibabaCloudProviderBadge,
AWSProviderBadge,
AzureProviderBadge,
CloudflareProviderBadge,
GCPProviderBadge,
GitHubProviderBadge,
IacProviderBadge,
@@ -24,6 +25,7 @@ export const PROVIDER_ICONS = {
oraclecloud: OracleCloudProviderBadge,
mongodbatlas: MongoDBAtlasProviderBadge,
alibabacloud: AlibabaCloudProviderBadge,
cloudflare: CloudflareProviderBadge,
openstack: OpenStackProviderBadge,
} as const;

View File

@@ -0,0 +1,36 @@
import * as React from "react";
import { IconSvgProps } from "@/types";
export const CloudflareProviderBadge: React.FC<IconSvgProps> = ({
size,
width,
height,
...props
}) => (
<svg
xmlns="http://www.w3.org/2000/svg"
aria-hidden="true"
fill="none"
focusable="false"
height={size || height}
role="presentation"
viewBox="0 0 256 256"
width={size || width}
{...props}
>
<g fill="none">
<rect width="256" height="256" fill="#f4f2ed" rx="60" />
<g transform="translate(40, 40) scale(5.5)">
<path
d="M22.012 22.222c.197-.675.122-1.294-.206-1.754-.3-.422-.807-.666-1.416-.694l-11.545-.15c-.075 0-.14-.038-.178-.094s-.047-.13-.028-.206c.038-.113.15-.197.272-.206l11.648-.15c1.38-.066 2.88-1.182 3.404-2.55l.666-1.735a.38.38 0 0 0 .02-.225c-.75-3.395-3.78-5.927-7.4-5.927-3.34 0-6.17 2.157-7.184 5.15-.657-.488-1.5-.75-2.392-.666-1.604.16-2.9 1.444-3.048 3.048a3.58 3.58 0 0 0 .084 1.191A4.84 4.84 0 0 0 0 22.1c0 .234.02.47.047.703.02.113.113.197.225.197H21.58a.29.29 0 0 0 .272-.206l.16-.572z"
fill="#f38020"
/>
<path
d="M25.688 14.803l-.32.01c-.075 0-.14.056-.17.13l-.45 1.566c-.197.675-.122 1.294.206 1.754.3.422.807.666 1.416.694l2.457.15c.075 0 .14.038.178.094s.047.14.028.206c-.038.113-.15.197-.272.206l-2.56.15c-1.388.066-2.88 1.182-3.404 2.55l-.188.478c-.038.094.028.188.13.188h8.797a.23.23 0 0 0 .225-.169A6.41 6.41 0 0 0 32 21.106a6.32 6.32 0 0 0-6.312-6.302"
fill="#faae40"
/>
</g>
</g>
</svg>
);

View File

@@ -5,6 +5,7 @@ import { IconSvgProps } from "@/types";
import { AlibabaCloudProviderBadge } from "./alibabacloud-provider-badge";
import { AWSProviderBadge } from "./aws-provider-badge";
import { AzureProviderBadge } from "./azure-provider-badge";
import { CloudflareProviderBadge } from "./cloudflare-provider-badge";
import { GCPProviderBadge } from "./gcp-provider-badge";
import { GitHubProviderBadge } from "./github-provider-badge";
import { IacProviderBadge } from "./iac-provider-badge";
@@ -18,6 +19,7 @@ export {
AlibabaCloudProviderBadge,
AWSProviderBadge,
AzureProviderBadge,
CloudflareProviderBadge,
GCPProviderBadge,
GitHubProviderBadge,
IacProviderBadge,
@@ -40,5 +42,6 @@ export const PROVIDER_ICONS: Record<string, FC<IconSvgProps>> = {
"Oracle Cloud Infrastructure": OracleCloudProviderBadge,
"MongoDB Atlas": MongoDBAtlasProviderBadge,
"Alibaba Cloud": AlibabaCloudProviderBadge,
Cloudflare: CloudflareProviderBadge,
OpenStack: OpenStackProviderBadge,
};

View File

@@ -2,6 +2,7 @@
import { SelectViaAlibabaCloud } from "@/components/providers/workflow/forms/select-credentials-type/alibabacloud";
import { SelectViaAWS } from "@/components/providers/workflow/forms/select-credentials-type/aws";
import { SelectViaCloudflare } from "@/components/providers/workflow/forms/select-credentials-type/cloudflare";
import { SelectViaGCP } from "@/components/providers/workflow/forms/select-credentials-type/gcp";
import { SelectViaGitHub } from "@/components/providers/workflow/forms/select-credentials-type/github";
import { SelectViaM365 } from "@/components/providers/workflow/forms/select-credentials-type/m365";
@@ -29,6 +30,9 @@ export const CredentialsUpdateInfo = ({
if (providerType === "m365") {
return <SelectViaM365 initialVia={initialVia} />;
}
if (providerType === "cloudflare") {
return <SelectViaCloudflare initialVia={initialVia} />;
}
if (providerType === "alibabacloud") {
return <SelectViaAlibabaCloud initialVia={initialVia} />;
}

View File

@@ -12,6 +12,7 @@ import {
AlibabaCloudProviderBadge,
AWSProviderBadge,
AzureProviderBadge,
CloudflareProviderBadge,
GCPProviderBadge,
GitHubProviderBadge,
IacProviderBadge,
@@ -74,6 +75,11 @@ const PROVIDERS = [
label: "Alibaba Cloud",
badge: AlibabaCloudProviderBadge,
},
{
value: "cloudflare",
label: "Cloudflare",
badge: CloudflareProviderBadge,
},
{
value: "openstack",
label: "OpenStack",

View File

@@ -17,6 +17,8 @@ import {
AWSCredentials,
AWSCredentialsRole,
AzureCredentials,
CloudflareApiKeyCredentials,
CloudflareTokenCredentials,
GCPDefaultCredentials,
GCPServiceAccountKey,
IacCredentials,
@@ -36,6 +38,10 @@ import {
} from "./select-credentials-type/alibabacloud/credentials-type";
import { AWSStaticCredentialsForm } from "./select-credentials-type/aws/credentials-type";
import { AWSRoleCredentialsForm } from "./select-credentials-type/aws/credentials-type/aws-role-credentials-form";
import {
CloudflareApiKeyCredentialsForm,
CloudflareApiTokenCredentialsForm,
} from "./select-credentials-type/cloudflare";
import { GCPDefaultCredentialsForm } from "./select-credentials-type/gcp/credentials-type";
import { GCPServiceAccountKeyForm } from "./select-credentials-type/gcp/credentials-type/gcp-service-account-key-form";
import {
@@ -208,6 +214,22 @@ export const BaseCredentialsForm = ({
}
/>
)}
{providerType === "cloudflare" &&
searchParamsObj.get("via") === "api_token" && (
<CloudflareApiTokenCredentialsForm
control={
form.control as unknown as Control<CloudflareTokenCredentials>
}
/>
)}
{providerType === "cloudflare" &&
searchParamsObj.get("via") === "api_key" && (
<CloudflareApiKeyCredentialsForm
control={
form.control as unknown as Control<CloudflareApiKeyCredentials>
}
/>
)}
{providerType === "openstack" && (
<OpenStackCredentialsForm
control={form.control as unknown as Control<OpenStackCredentials>}

View File

@@ -72,6 +72,11 @@ const getProviderFieldDetails = (providerType?: ProviderType) => {
label: "Account ID",
placeholder: "e.g. 1234567890123456",
};
case "cloudflare":
return {
label: "Account ID",
placeholder: "e.g. a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4",
};
case "openstack":
return {
label: "Project ID",

View File

@@ -0,0 +1,52 @@
"use client";
import { Control } from "react-hook-form";
import { CustomInput } from "@/components/ui/custom";
import { ProviderCredentialFields } from "@/lib/provider-credentials/provider-credential-fields";
import { CloudflareApiKeyCredentials } from "@/types";
export const CloudflareApiKeyCredentialsForm = ({
control,
}: {
control: Control<CloudflareApiKeyCredentials>;
}) => {
return (
<>
<div className="flex flex-col">
<div className="text-md text-default-foreground leading-9 font-bold">
Connect via API Key + Email
</div>
<div className="text-default-500 text-sm">
Provide your Cloudflare Global API Key and the email address
associated with your Cloudflare account.
</div>
</div>
<CustomInput
control={control}
name={ProviderCredentialFields.CLOUDFLARE_API_EMAIL}
type="text"
label="Email"
labelPlacement="inside"
placeholder="Enter your Cloudflare account email"
variant="bordered"
isRequired
/>
<CustomInput
control={control}
name={ProviderCredentialFields.CLOUDFLARE_API_KEY}
type="password"
label="Global API Key"
labelPlacement="inside"
placeholder="Enter your Cloudflare Global API Key"
variant="bordered"
isRequired
/>
<div className="text-default-400 text-xs">
Credentials never leave your browser unencrypted and are stored as
secrets in the backend. You can regenerate your API Key from the
Cloudflare dashboard anytime if needed.
</div>
</>
);
};

View File

@@ -0,0 +1,43 @@
"use client";
import { Control } from "react-hook-form";
import { CustomInput } from "@/components/ui/custom";
import { ProviderCredentialFields } from "@/lib/provider-credentials/provider-credential-fields";
import { CloudflareTokenCredentials } from "@/types";
export const CloudflareApiTokenCredentialsForm = ({
control,
}: {
control: Control<CloudflareTokenCredentials>;
}) => {
return (
<>
<div className="flex flex-col">
<div className="text-md text-default-foreground leading-9 font-bold">
Connect via API Token
</div>
<div className="text-default-500 text-sm">
Provide a Cloudflare API Token with read permissions to the resources
you want Prowler to assess. This is the recommended authentication
method.
</div>
</div>
<CustomInput
control={control}
name={ProviderCredentialFields.CLOUDFLARE_API_TOKEN}
type="password"
label="API Token"
labelPlacement="inside"
placeholder="Enter your Cloudflare API Token"
variant="bordered"
isRequired
/>
<div className="text-default-400 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
anytime if needed.
</div>
</>
);
};

View File

@@ -0,0 +1,2 @@
export { CloudflareApiKeyCredentialsForm } from "./cloudflare-api-key-credentials-form";
export { CloudflareApiTokenCredentialsForm } from "./cloudflare-api-token-credentials-form";

View File

@@ -0,0 +1,5 @@
export {
CloudflareApiKeyCredentialsForm,
CloudflareApiTokenCredentialsForm,
} from "./credentials-type";
export { SelectViaCloudflare } from "./select-via-cloudflare";

View File

@@ -0,0 +1,71 @@
"use client";
import { RadioGroup } from "@heroui/radio";
import { Control, Controller } from "react-hook-form";
import { CustomRadio } from "@/components/ui/custom";
import { FormMessage } from "@/components/ui/form";
type RadioGroupCloudflareViaCredentialsFormProps = {
control: Control<any>;
isInvalid: boolean;
errorMessage?: string;
onChange?: (value: string) => void;
};
export const RadioGroupCloudflareViaCredentialsTypeForm = ({
control,
isInvalid,
errorMessage,
onChange,
}: RadioGroupCloudflareViaCredentialsFormProps) => {
return (
<Controller
name="cloudflareCredentialsType"
control={control}
render={({ field }) => (
<>
<RadioGroup
className="flex flex-wrap"
isInvalid={isInvalid}
{...field}
value={field.value || ""}
onValueChange={(value) => {
field.onChange(value);
if (onChange) {
onChange(value);
}
}}
>
<div className="flex flex-col gap-4">
<span className="text-default-500 text-sm">
Select Authentication Method
</span>
<CustomRadio
description="Connect using a Cloudflare API Token (recommended)"
value="api_token"
>
<div className="flex items-center">
<span className="ml-2">API Token</span>
</div>
</CustomRadio>
<CustomRadio
description="Connect using Global API Key and Email"
value="api_key"
>
<div className="flex items-center">
<span className="ml-2">API Key + Email</span>
</div>
</CustomRadio>
</div>
</RadioGroup>
{errorMessage && (
<FormMessage className="text-text-error">
{errorMessage}
</FormMessage>
)}
</>
)}
/>
);
};

View File

@@ -0,0 +1,40 @@
"use client";
import { useRouter } from "next/navigation";
import { useForm } from "react-hook-form";
import { Form } from "@/components/ui/form";
import { RadioGroupCloudflareViaCredentialsTypeForm } from "./radio-group-cloudflare-via-credentials-type-form";
interface SelectViaCloudflareProps {
initialVia?: string;
}
export const SelectViaCloudflare = ({
initialVia,
}: SelectViaCloudflareProps) => {
const router = useRouter();
const form = useForm({
defaultValues: {
cloudflareCredentialsType: initialVia || "",
},
});
const handleSelectionChange = (value: string) => {
const url = new URL(window.location.href);
url.searchParams.set("via", value);
router.push(url.toString());
};
return (
<Form {...form}>
<RadioGroupCloudflareViaCredentialsTypeForm
control={form.control}
isInvalid={!!form.formState.errors.cloudflareCredentialsType}
errorMessage={form.formState.errors.cloudflareCredentialsType?.message}
onChange={handleSelectionChange}
/>
</Form>
);
};

View File

@@ -2,6 +2,7 @@ import {
AlibabaCloudProviderBadge,
AWSProviderBadge,
AzureProviderBadge,
CloudflareProviderBadge,
GCPProviderBadge,
GitHubProviderBadge,
IacProviderBadge,
@@ -35,6 +36,8 @@ export const getProviderLogo = (provider: ProviderType) => {
return <MongoDBAtlasProviderBadge width={35} height={35} />;
case "alibabacloud":
return <AlibabaCloudProviderBadge width={35} height={35} />;
case "cloudflare":
return <CloudflareProviderBadge width={35} height={35} />;
case "openstack":
return <OpenStackProviderBadge width={35} height={35} />;
default:
@@ -64,6 +67,8 @@ export const getProviderName = (provider: ProviderType): string => {
return "MongoDB Atlas";
case "alibabacloud":
return "Alibaba Cloud";
case "cloudflare":
return "Cloudflare";
case "openstack":
return "OpenStack";
default:

View File

@@ -46,8 +46,12 @@ export const useCredentialsForm = ({
if (providerType === "gcp" && via === "service-account") {
return addCredentialsServiceAccountFormSchema(providerType);
}
// For GitHub and M365, we need to pass the via parameter to determine which fields are required
if (providerType === "github" || providerType === "m365") {
// For GitHub, M365, and Cloudflare, we need to pass the via parameter to determine which fields are required
if (
providerType === "github" ||
providerType === "m365" ||
providerType === "cloudflare"
) {
return addCredentialsFormSchema(providerType, via);
}
return addCredentialsFormSchema(providerType);
@@ -192,6 +196,22 @@ export const useCredentialsForm = ({
[ProviderCredentialFields.ALIBABACLOUD_ACCESS_KEY_ID]: "",
[ProviderCredentialFields.ALIBABACLOUD_ACCESS_KEY_SECRET]: "",
};
case "cloudflare":
// Cloudflare credentials based on via parameter
if (via === "api_token") {
return {
...baseDefaults,
[ProviderCredentialFields.CLOUDFLARE_API_TOKEN]: "",
};
}
if (via === "api_key") {
return {
...baseDefaults,
[ProviderCredentialFields.CLOUDFLARE_API_KEY]: "",
[ProviderCredentialFields.CLOUDFLARE_API_EMAIL]: "",
};
}
return baseDefaults;
case "openstack":
return {
...baseDefaults,

View File

@@ -30,6 +30,12 @@ export const PROVIDER_CREDENTIALS_ERROR_MAPPING: Record<string, string> = {
ProviderCredentialFields.SERVICE_ACCOUNT_KEY,
[ErrorPointers.ATLAS_PUBLIC_KEY]: ProviderCredentialFields.ATLAS_PUBLIC_KEY,
[ErrorPointers.ATLAS_PRIVATE_KEY]: ProviderCredentialFields.ATLAS_PRIVATE_KEY,
[ErrorPointers.CLOUDFLARE_API_TOKEN]:
ProviderCredentialFields.CLOUDFLARE_API_TOKEN,
[ErrorPointers.CLOUDFLARE_API_KEY]:
ProviderCredentialFields.CLOUDFLARE_API_KEY,
[ErrorPointers.CLOUDFLARE_API_EMAIL]:
ProviderCredentialFields.CLOUDFLARE_API_EMAIL,
[ErrorPointers.OPENSTACK_CLOUDS_YAML_CONTENT]:
ProviderCredentialFields.OPENSTACK_CLOUDS_YAML_CONTENT,
[ErrorPointers.OPENSTACK_CLOUDS_YAML_CLOUD]:

View File

@@ -58,6 +58,11 @@ export const getProviderHelpText = (provider: string) => {
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?",

View File

@@ -332,6 +332,61 @@ export const buildOracleCloudSecret = (
return filterEmptyValues(secret);
};
/**
* Clean a Cloudflare API token by removing common copy-paste issues:
* - Leading/trailing whitespace
* - "Bearer " prefix (if user copied the full header)
* - Tabs and other whitespace characters
*/
const cleanCloudflareToken = (token: string | null | undefined): string => {
if (!token) return "";
// Remove leading/trailing whitespace and tabs
let cleaned = token.trim().replace(/\t/g, "");
// Remove "Bearer " prefix if present (case-insensitive)
if (cleaned.toLowerCase().startsWith("bearer ")) {
cleaned = cleaned.slice(7).trim();
}
return cleaned;
};
export const buildCloudflareSecret = (formData: FormData) => {
// Check which authentication method is being used
const hasApiToken =
formData.get(ProviderCredentialFields.CLOUDFLARE_API_TOKEN) !== null &&
formData.get(ProviderCredentialFields.CLOUDFLARE_API_TOKEN) !== "";
const hasApiKey =
formData.get(ProviderCredentialFields.CLOUDFLARE_API_KEY) !== null &&
formData.get(ProviderCredentialFields.CLOUDFLARE_API_KEY) !== "";
if (hasApiToken) {
const apiToken = getFormValue(
formData,
ProviderCredentialFields.CLOUDFLARE_API_TOKEN,
) as string;
return {
[ProviderCredentialFields.CLOUDFLARE_API_TOKEN]:
cleanCloudflareToken(apiToken),
};
}
if (hasApiKey) {
const apiKey = getFormValue(
formData,
ProviderCredentialFields.CLOUDFLARE_API_KEY,
) as string;
const apiEmail = getFormValue(
formData,
ProviderCredentialFields.CLOUDFLARE_API_EMAIL,
) as string;
return filterEmptyValues({
[ProviderCredentialFields.CLOUDFLARE_API_KEY]: apiKey?.trim(),
[ProviderCredentialFields.CLOUDFLARE_API_EMAIL]: apiEmail?.trim(),
});
}
return {};
};
// Main function to build secret configuration
export const buildSecretConfig = (
formData: FormData,
@@ -387,6 +442,10 @@ export const buildSecretConfig = (
secret: buildAlibabaCloudSecret(formData, isRole),
};
},
cloudflare: () => ({
secretType: "static",
secret: buildCloudflareSecret(formData),
}),
openstack: () => ({
secretType: "static",
secret: buildOpenStackSecret(formData),

View File

@@ -68,6 +68,11 @@ export const ProviderCredentialFields = {
ALIBABACLOUD_ROLE_ARN: "role_arn",
ALIBABACLOUD_ROLE_SESSION_NAME: "role_session_name",
// Cloudflare fields
CLOUDFLARE_API_TOKEN: "api_token",
CLOUDFLARE_API_KEY: "api_key",
CLOUDFLARE_API_EMAIL: "api_email",
// OpenStack fields
OPENSTACK_CLOUDS_YAML_CONTENT: "clouds_yaml_content",
OPENSTACK_CLOUDS_YAML_CLOUD: "clouds_yaml_cloud",
@@ -115,6 +120,9 @@ export const ErrorPointers = {
ALIBABACLOUD_ACCESS_KEY_SECRET: "/data/attributes/secret/access_key_secret",
ALIBABACLOUD_ROLE_ARN: "/data/attributes/secret/role_arn",
ALIBABACLOUD_ROLE_SESSION_NAME: "/data/attributes/secret/role_session_name",
CLOUDFLARE_API_TOKEN: "/data/attributes/secret/api_token",
CLOUDFLARE_API_KEY: "/data/attributes/secret/api_key",
CLOUDFLARE_API_EMAIL: "/data/attributes/secret/api_email",
OPENSTACK_CLOUDS_YAML_CONTENT: "/data/attributes/secret/clouds_yaml_content",
OPENSTACK_CLOUDS_YAML_CLOUD: "/data/attributes/secret/clouds_yaml_cloud",
} as const;

View File

@@ -88,6 +88,7 @@ export const getProviderFormType = (
"github",
"m365",
"alibabacloud",
"cloudflare",
].includes(providerType);
// Show selector if no via parameter and provider needs it
@@ -129,6 +130,14 @@ export const getProviderFormType = (
if (via === "credentials") return "credentials";
}
// Cloudflare credential types
if (
providerType === "cloudflare" &&
["api_token", "api_key"].includes(via || "")
) {
return "credentials";
}
// Other providers go directly to credentials form
if (!needsSelector) {
return "credentials";
@@ -150,8 +159,11 @@ export const requiresBackButton = (via?: string | null): boolean => {
"github_app",
"app_client_secret",
"app_certificate",
"api_token",
"api_key",
];
// Note: "role" is already included for AWS, now also used by AlibabaCloud
// "api_token" and "api_key" are used by Cloudflare
return validViaTypes.includes(via);
};

View File

@@ -334,6 +334,21 @@ export type AlibabaCloudCredentialsRole = {
[ProviderCredentialFields.PROVIDER_ID]: string;
};
export type CloudflareTokenCredentials = {
[ProviderCredentialFields.CLOUDFLARE_API_TOKEN]: string;
[ProviderCredentialFields.PROVIDER_ID]: string;
};
export type CloudflareApiKeyCredentials = {
[ProviderCredentialFields.CLOUDFLARE_API_KEY]: string;
[ProviderCredentialFields.CLOUDFLARE_API_EMAIL]: string;
[ProviderCredentialFields.PROVIDER_ID]: string;
};
export type CloudflareCredentials =
| CloudflareTokenCredentials
| CloudflareApiKeyCredentials;
export type OpenStackCredentials = {
[ProviderCredentialFields.OPENSTACK_CLOUDS_YAML_CONTENT]: string;
[ProviderCredentialFields.OPENSTACK_CLOUDS_YAML_CLOUD]: string;
@@ -353,6 +368,7 @@ export type CredentialsFormSchema =
| MongoDBAtlasCredentials
| AlibabaCloudCredentials
| AlibabaCloudCredentialsRole
| CloudflareCredentials
| OpenStackCredentials;
export interface SearchParamsProps {

View File

@@ -130,6 +130,11 @@ export const addProviderFormSchema = z
[ProviderCredentialFields.PROVIDER_ALIAS]: z.string(),
providerUid: z.string(),
}),
z.object({
providerType: z.literal("cloudflare"),
[ProviderCredentialFields.PROVIDER_ALIAS]: z.string(),
providerUid: z.string(),
}),
z.object({
providerType: z.literal("openstack"),
[ProviderCredentialFields.PROVIDER_ALIAS]: z.string(),
@@ -264,16 +269,44 @@ export const addCredentialsFormSchema = (
.string()
.min(1, "Access Key Secret is required"),
}
: providerType === "openstack"
: providerType === "cloudflare"
? {
[ProviderCredentialFields.OPENSTACK_CLOUDS_YAML_CONTENT]:
[ProviderCredentialFields.CLOUDFLARE_API_TOKEN]:
z.string().optional(),
[ProviderCredentialFields.CLOUDFLARE_API_KEY]: z
.string()
.optional(),
[ProviderCredentialFields.CLOUDFLARE_API_EMAIL]:
z
.string()
.min(1, "Clouds YAML content is required"),
[ProviderCredentialFields.OPENSTACK_CLOUDS_YAML_CLOUD]:
z.string().min(1, "Cloud name is required"),
.superRefine((val, ctx) => {
if (val && val.trim() !== "") {
const emailRegex =
/^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(val)) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message:
"Please enter a valid email address",
});
}
}
})
.optional(),
}
: {}),
: providerType === "openstack"
? {
[ProviderCredentialFields.OPENSTACK_CLOUDS_YAML_CONTENT]:
z
.string()
.min(
1,
"Clouds YAML content is required",
),
[ProviderCredentialFields.OPENSTACK_CLOUDS_YAML_CLOUD]:
z.string().min(1, "Cloud name is required"),
}
: {}),
})
.superRefine((data: Record<string, string | undefined>, ctx) => {
if (providerType === "m365") {
@@ -335,6 +368,37 @@ export const addCredentialsFormSchema = (
}
}
}
if (providerType === "cloudflare") {
// For Cloudflare, validation depends on the 'via' parameter
if (via === "api_token") {
const apiToken = data[ProviderCredentialFields.CLOUDFLARE_API_TOKEN];
if (!apiToken || apiToken.trim() === "") {
ctx.addIssue({
code: "custom",
message: "API Token is required",
path: [ProviderCredentialFields.CLOUDFLARE_API_TOKEN],
});
}
} else if (via === "api_key") {
const apiKey = data[ProviderCredentialFields.CLOUDFLARE_API_KEY];
const apiEmail = data[ProviderCredentialFields.CLOUDFLARE_API_EMAIL];
if (!apiKey || apiKey.trim() === "") {
ctx.addIssue({
code: "custom",
message: "API Key is required",
path: [ProviderCredentialFields.CLOUDFLARE_API_KEY],
});
}
if (!apiEmail || apiEmail.trim() === "") {
ctx.addIssue({
code: "custom",
message: "Email is required",
path: [ProviderCredentialFields.CLOUDFLARE_API_EMAIL],
});
}
}
}
});
export const addCredentialsRoleFormSchema = (providerType: string) =>

View File

@@ -9,6 +9,7 @@ export const PROVIDER_TYPES = [
"iac",
"oraclecloud",
"alibabacloud",
"cloudflare",
"openstack",
] as const;
@@ -25,6 +26,7 @@ export const PROVIDER_DISPLAY_NAMES: Record<ProviderType, string> = {
iac: "Infrastructure as Code",
oraclecloud: "Oracle Cloud Infrastructure",
alibabacloud: "Alibaba Cloud",
cloudflare: "Cloudflare",
openstack: "OpenStack",
};