Compare commits

...

10 Commits

21 changed files with 877 additions and 332 deletions

View File

@@ -824,6 +824,169 @@ npm run start # Start production server
- Network tab for API debugging
- Lighthouse for performance analysis
## Code Spacing and Logical Unit Separation Guidelines
Proper spacing improves code readability and establishes clear boundaries between distinct operations.
### Rule 1: Separate Logical Units with Blank Lines
Add a blank line between distinct logical operations or phases in a function.
**Example:**
```typescript
// ✅ GOOD - Clear separation between units
async selectCredentialsType(type: "role" | "credentials"): Promise<void> {
// Unit 1: Verify preconditions
await expect(this.page).toHaveURL(/\/providers\/add-credentials/)
// Unit 2: Perform action
if (type === "role") {
await this.roleRadio.click({ force: true });
} else {
await this.staticCredentialsRadio.click({ force: true });
}
// Unit 3: Verify result
await this.waitForPageLoad();
}
// ❌ BAD - No separation makes it hard to read
async selectCredentialsType(type: "role" | "credentials"): Promise<void> {
await expect(this.page).toHaveURL(/\/providers\/add-credentials/)
if (type === "role") {
await this.roleRadio.click({ force: true });
} else {
await this.staticCredentialsRadio.click({ force: true });
}
await this.waitForPageLoad();
}
```
### Rule 2: Group Related Statements Together
Keep statements that belong to the same logical unit without blank lines.
**Example:**
```typescript
// ✅ GOOD - Related statements grouped
if (type === "role") {
await this.roleRadio.click({ force: true });
await this.waitForSelection();
} else {
await this.staticCredentialsRadio.click({ force: true });
await this.waitForSelection();
}
// ❌ BAD - Unnecessary spacing within same unit
if (type === "role") {
await this.roleRadio.click({ force: true });
await this.waitForSelection();
}
```
### Rule 3: Separate Sequential Phases in Functions
Use blank lines to distinguish between:
- **Assertion/Verification** phase (checks and expects)
- **Action** phase (clicks, inputs, navigation)
- **Wait/Confirmation** phase (waits and final assertions)
**Example:**
```typescript
async navigateToAddCredentials(provider: string): Promise<void> {
// Verification: Check current state
await expect(this.page).toHaveURL(/\/providers/)
// Action: Navigate to add credentials
await this.addCredentialsButton.click();
await this.selectProvider(provider);
// Confirmation: Verify arrival
await this.waitForPageLoad();
await expect(this.page).toHaveURL(/\/providers\/add-credentials/)
}
```
### Rule 4: One Blank Line Between Independent Operations
When two operations don't directly depend on each other or serve different purposes, separate them with a blank line.
**Example:**
```typescript
// ✅ GOOD - Different concerns separated
await this.fillPasswordField(password);
await this.acceptTerms();
// ❌ BAD - Related operations should stay together
await this.fillEmailField(email);
await this.fillPasswordField(password);
```
### Rule 5: Comments Can Define Logical Boundaries
Use comments to clarify why blank lines exist between units.
**Example:**
```typescript
async fillCredentialsForm(email: string, secret: string): Promise<void> {
// Setup: Initialize form
await this.openForm();
await this.clearExistingData();
// Input: Fill credentials
await this.fillEmailField(email);
await this.fillSecretField(secret);
// Confirm: Submit and verify
await this.submitForm();
await this.waitForSuccess();
}
```
### Rule 6: Avoid Excessive Spacing
Don't add blank lines between every statement. Keep related operations compact.
**Example:**
```typescript
// ✅ GOOD - Appropriate spacing density
async fillLoginForm(credentials: Credentials): Promise<void> {
await this.emailInput.fill(credentials.email);
await this.passwordInput.fill(credentials.password);
await this.loginButton.click();
await this.page.waitForURL(/\/dashboard/);
}
// ❌ BAD - Over-spaced and hard to follow
async fillLoginForm(credentials: Credentials): Promise<void> {
await this.emailInput.fill(credentials.email);
await this.passwordInput.fill(credentials.password);
await this.loginButton.click();
await this.page.waitForURL(/\/dashboard/);
}
```
### Spacing Checklist
- [ ] Use blank lines between distinct logical phases (verify → act → confirm)
- [ ] Keep related statements together without spacing
- [ ] Add comments if boundaries aren't obvious
- [ ] Maximum 2-3 blank lines per function
- [ ] Be consistent across the codebase
## Recent Major Migrations (January 2025)
- ✅ React 18 → 19.1.1 (async components, useActionState, React Compiler)

View File

@@ -16,6 +16,8 @@ All notable changes to the **Prowler UI** are documented in this file.
- API key management in user profile [(#8308)](https://github.com/prowler-cloud/prowler/pull/8308)
- Refresh access token error handling [(#8864)](https://github.com/prowler-cloud/prowler/pull/8864)
- Support Common Cloud Controls for AWS, Azure and GCP [(#8000)](https://github.com/prowler-cloud/prowler/pull/8000)
- New M365 credentials certificate authentication method [(#8929)](https://github.com/prowler-cloud/prowler/pull/8929)
- Centralized credentials form factory for consistent provider credential handling [(#8941)](https://github.com/prowler-cloud/prowler/pull/8941)
### 🔄 Changed

View File

@@ -4,42 +4,44 @@ import {
AddViaCredentialsForm,
AddViaRoleForm,
} from "@/components/providers/workflow/forms";
import { SelectViaAWS } from "@/components/providers/workflow/forms/select-credentials-type/aws";
import { AddViaServiceAccountForm } from "@/components/providers/workflow/forms/select-credentials-type/gcp";
import {
AddViaServiceAccountForm,
SelectViaGCP,
} from "@/components/providers/workflow/forms/select-credentials-type/gcp";
import { SelectViaGitHub } from "@/components/providers/workflow/forms/select-credentials-type/github";
import { getProviderFormType } from "@/lib/provider-helpers";
getProviderFormType,
getSelectorComponentKey,
PROVIDER_SELECTOR_COMPONENTS,
} from "@/lib/provider-helpers";
import { ProviderType } from "@/types/providers";
interface Props {
searchParams: Promise<{ type: ProviderType; id: string; via?: string }>;
}
// Form type components mapping
const FORM_COMPONENTS = {
credentials: AddViaCredentialsForm,
role: AddViaRoleForm,
"service-account": AddViaServiceAccountForm,
} as const;
type FormType = keyof typeof FORM_COMPONENTS;
export default async function AddCredentialsPage({ searchParams }: Props) {
const resolvedSearchParams = await searchParams;
const { type: providerType, via } = resolvedSearchParams;
const formType = getProviderFormType(providerType, via);
switch (formType) {
case "selector":
if (providerType === "aws") return <SelectViaAWS initialVia={via} />;
if (providerType === "gcp") return <SelectViaGCP initialVia={via} />;
if (providerType === "github")
return <SelectViaGitHub initialVia={via} />;
return null;
// Handle selector form type
if (formType === "selector") {
const componentKey = getSelectorComponentKey(providerType);
if (!componentKey) return null;
case "credentials":
return <AddViaCredentialsForm searchParams={resolvedSearchParams} />;
case "role":
return <AddViaRoleForm searchParams={resolvedSearchParams} />;
case "service-account":
return <AddViaServiceAccountForm searchParams={resolvedSearchParams} />;
default:
return null;
const SelectorComponent = PROVIDER_SELECTOR_COMPONENTS[componentKey];
return <SelectorComponent initialVia={via} />;
}
// Handle other form types
const FormComponent = FORM_COMPONENTS[formType as FormType];
if (!FormComponent) return null;
return <FormComponent searchParams={resolvedSearchParams} />;
}

View File

@@ -1,8 +1,9 @@
"use client";
import { SelectViaAWS } from "@/components/providers/workflow/forms/select-credentials-type/aws";
import { SelectViaGCP } from "@/components/providers/workflow/forms/select-credentials-type/gcp";
import { SelectViaGitHub } from "@/components/providers/workflow/forms/select-credentials-type/github";
import {
getSelectorComponentKey,
PROVIDER_SELECTOR_COMPONENTS,
} from "@/lib/provider-helpers";
import { ProviderType } from "@/types/providers";
interface UpdateCredentialsInfoProps {
@@ -14,18 +15,15 @@ export const CredentialsUpdateInfo = ({
providerType,
initialVia,
}: UpdateCredentialsInfoProps) => {
const renderSelectComponent = () => {
if (providerType === "aws") {
return <SelectViaAWS initialVia={initialVia} />;
}
if (providerType === "gcp") {
return <SelectViaGCP initialVia={initialVia} />;
}
if (providerType === "github") {
return <SelectViaGitHub initialVia={initialVia} />;
}
return null;
};
const componentKey = getSelectorComponentKey(providerType);
return <div className="flex flex-col gap-4">{renderSelectComponent()}</div>;
if (!componentKey) return null;
const SelectorComponent = PROVIDER_SELECTOR_COMPONENTS[componentKey];
return (
<div className="flex flex-col gap-4">
<SelectorComponent initialVia={initialVia} />
</div>
);
};

View File

@@ -8,28 +8,12 @@ import { CustomButton } from "@/components/ui/custom";
import { Form } from "@/components/ui/form";
import { useCredentialsForm } from "@/hooks/use-credentials-form";
import { getAWSCredentialsTemplateLinks } from "@/lib";
import { getCredentialFormComponent } from "@/lib/provider-credential-forms";
import { ProviderCredentialFields } from "@/lib/provider-credentials/provider-credential-fields";
import { requiresBackButton } from "@/lib/provider-helpers";
import {
AWSCredentials,
AWSCredentialsRole,
AzureCredentials,
GCPDefaultCredentials,
GCPServiceAccountKey,
KubernetesCredentials,
M365Credentials,
ProviderType,
} from "@/types";
import { AWSCredentialsRole, ProviderType } from "@/types";
import { ProviderTitleDocs } from "../provider-title-docs";
import { AWSStaticCredentialsForm } from "./select-credentials-type/aws/credentials-type";
import { AWSRoleCredentialsForm } from "./select-credentials-type/aws/credentials-type/aws-role-credentials-form";
import { GCPDefaultCredentialsForm } from "./select-credentials-type/gcp/credentials-type";
import { GCPServiceAccountKeyForm } from "./select-credentials-type/gcp/credentials-type/gcp-service-account-key-form";
import { AzureCredentialsForm } from "./via-credentials/azure-credentials-form";
import { GitHubCredentialsForm } from "./via-credentials/github-credentials-form";
import { KubernetesCredentialsForm } from "./via-credentials/k8s-credentials-form";
import { M365CredentialsForm } from "./via-credentials/m365-credentials-form";
type BaseCredentialsFormProps = {
providerType: ProviderType;
@@ -63,6 +47,12 @@ export const BaseCredentialsForm = ({
});
const templateLinks = getAWSCredentialsTemplateLinks(externalId);
const credentialFormInfo = getCredentialFormComponent(
providerType,
searchParamsObj.get("via"),
);
if (!credentialFormInfo) return null;
return (
<Form {...form}>
@@ -85,54 +75,27 @@ export const BaseCredentialsForm = ({
<Divider />
{providerType === "aws" && searchParamsObj.get("via") === "role" && (
<AWSRoleCredentialsForm
control={form.control as unknown as Control<AWSCredentialsRole>}
setValue={form.setValue as any}
externalId={externalId}
templateLinks={templateLinks}
/>
)}
{providerType === "aws" && searchParamsObj.get("via") !== "role" && (
<AWSStaticCredentialsForm
control={form.control as unknown as Control<AWSCredentials>}
/>
)}
{providerType === "azure" && (
<AzureCredentialsForm
control={form.control as unknown as Control<AzureCredentials>}
/>
)}
{providerType === "m365" && (
<M365CredentialsForm
control={form.control as unknown as Control<M365Credentials>}
/>
)}
{providerType === "gcp" &&
searchParamsObj.get("via") === "service-account" && (
<GCPServiceAccountKeyForm
control={form.control as unknown as Control<GCPServiceAccountKey>}
{credentialFormInfo.requiresExtendedProps &&
credentialFormInfo.passesCredentialsType === false &&
providerType === "aws" && (
<credentialFormInfo.component
control={form.control as unknown as Control<AWSCredentialsRole>}
setValue={form.setValue as any}
externalId={externalId}
templateLinks={templateLinks}
/>
)}
{providerType === "gcp" &&
searchParamsObj.get("via") !== "service-account" && (
<GCPDefaultCredentialsForm
control={
form.control as unknown as Control<GCPDefaultCredentials>
}
{!credentialFormInfo.requiresExtendedProps &&
credentialFormInfo.passesCredentialsType === true && (
<credentialFormInfo.component
control={form.control as unknown as Control}
credentialsType={searchParamsObj.get("via") || undefined}
/>
)}
{providerType === "kubernetes" && (
<KubernetesCredentialsForm
control={form.control as unknown as Control<KubernetesCredentials>}
/>
)}
{providerType === "github" && (
<GitHubCredentialsForm
control={form.control}
credentialsType={searchParamsObj.get("via") || undefined}
/>
)}
{!credentialFormInfo.requiresExtendedProps &&
credentialFormInfo.passesCredentialsType === false && (
<credentialFormInfo.component control={form.control as any} />
)}
<div className="flex w-full justify-end sm:gap-6">
{showBackButton && requiresBackButton(searchParamsObj.get("via")) && (
@@ -159,6 +122,15 @@ export const BaseCredentialsForm = ({
size="lg"
isLoading={isLoading}
endContent={!isLoading && <ChevronRightIcon size={24} />}
onPress={(e) => {
const formElement = e.target as HTMLElement;
const form = formElement.closest("form");
if (form) {
form.dispatchEvent(
new Event("submit", { bubbles: true, cancelable: true }),
);
}
}}
>
{isLoading ? <>Loading</> : <span>{submitButtonText}</span>}
</CustomButton>

View File

@@ -0,0 +1,2 @@
export { M365CertificateCredentialsForm } from "./m365-certificate-credentials-form";
export { M365ClientSecretCredentialsForm } from "./m365-client-secret-credentials-form";

View File

@@ -0,0 +1,72 @@
"use client";
import { Control } from "react-hook-form";
import { CustomInput, CustomTextarea } from "@/components/ui/custom";
import { CustomLink } from "@/components/ui/custom/custom-link";
import { M365CertificateCredentials } from "@/types";
export const M365CertificateCredentialsForm = ({
control,
}: {
control: Control<M365CertificateCredentials>;
}) => {
return (
<>
<div className="flex flex-col">
<div className="text-md text-default-foreground leading-9 font-bold">
App Certificate Credentials
</div>
<div className="text-default-500 text-sm">
Please provide your Microsoft 365 application credentials with
certificate authentication.
</div>
</div>
<CustomInput
control={control}
name="tenant_id"
type="text"
label="Tenant ID"
labelPlacement="inside"
placeholder="Enter the Tenant ID"
variant="bordered"
isRequired
isInvalid={!!control._formState.errors.tenant_id}
/>
<CustomInput
control={control}
name="client_id"
type="text"
label="Client ID"
labelPlacement="inside"
placeholder="Enter the Client ID"
variant="bordered"
isRequired
isInvalid={!!control._formState.errors.client_id}
/>
<CustomTextarea
control={control}
name="certificate_content"
label="Certificate Content"
labelPlacement="inside"
placeholder="Enter the base64 encoded certificate content"
variant="bordered"
isRequired
isInvalid={!!control._formState.errors.certificate_content}
minRows={4}
/>
<p className="text-default-500 text-sm">
The certificate content must be base64 encoded from an unsigned
certificate. For detailed instructions on how to generate and encode
your certificate, please refer to the{" "}
<CustomLink
href="https://docs.prowler.com/user-guide/providers/microsoft365/authentication#generate-the-certificate"
size="sm"
>
certificate generation guide
</CustomLink>
.
</p>
</>
);
};

View File

@@ -0,0 +1,58 @@
"use client";
import { Control } from "react-hook-form";
import { CustomInput } from "@/components/ui/custom";
import { M365ClientSecretCredentials } from "@/types";
export const M365ClientSecretCredentialsForm = ({
control,
}: {
control: Control<M365ClientSecretCredentials>;
}) => {
return (
<>
<div className="flex flex-col">
<div className="text-md text-default-foreground leading-9 font-bold">
App Client Secret Credentials
</div>
<div className="text-default-500 text-sm">
Please provide your Microsoft 365 application credentials.
</div>
</div>
<CustomInput
control={control}
name="tenant_id"
type="text"
label="Tenant ID"
labelPlacement="inside"
placeholder="Enter the Tenant ID"
variant="bordered"
isRequired
isInvalid={!!control._formState.errors.tenant_id}
/>
<CustomInput
control={control}
name="client_id"
type="text"
label="Client ID"
labelPlacement="inside"
placeholder="Enter the Client ID"
variant="bordered"
isRequired
isInvalid={!!control._formState.errors.client_id}
/>
<CustomInput
control={control}
name="client_secret"
type="password"
label="Client Secret"
labelPlacement="inside"
placeholder="Enter the Client Secret"
variant="bordered"
isRequired
isInvalid={!!control._formState.errors.client_secret}
/>
</>
);
};

View File

@@ -0,0 +1,5 @@
export {
M365CertificateCredentialsForm,
M365ClientSecretCredentialsForm,
} from "./credentials-type";
export { SelectViaM365 } from "./select-via-m365";

View File

@@ -0,0 +1,72 @@
"use client";
import { RadioGroup } from "@heroui/radio";
import React from "react";
import { Control, Controller } from "react-hook-form";
import { CustomRadio } from "@/components/ui/custom";
import { FormMessage } from "@/components/ui/form";
type RadioGroupM365ViaCredentialsFormProps = {
control: Control<any>;
isInvalid: boolean;
errorMessage?: string;
onChange?: (value: string) => void;
};
export const RadioGroupM365ViaCredentialsTypeForm = ({
control,
isInvalid,
errorMessage,
onChange,
}: RadioGroupM365ViaCredentialsFormProps) => {
return (
<Controller
name="m365CredentialsType"
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 Application Client Secret"
value="app_client_secret"
>
<div className="flex items-center">
<span className="ml-2">App Client Secret Credentials</span>
</div>
</CustomRadio>
<CustomRadio
description="Connect using Application Certificate"
value="app_certificate"
>
<div className="flex items-center">
<span className="ml-2">App Certificate Credentials</span>
</div>
</CustomRadio>
</div>
</RadioGroup>
{errorMessage && (
<FormMessage className="text-system-error dark:text-system-error">
{errorMessage}
</FormMessage>
)}
</>
)}
/>
);
};

View File

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

View File

@@ -1,4 +1,3 @@
export * from "./azure-credentials-form";
export * from "./github-credentials-form";
export * from "./k8s-credentials-form";
export * from "./m365-credentials-form";

View File

@@ -1,109 +0,0 @@
import { Control } from "react-hook-form";
import { InfoIcon } from "@/components/icons";
import { CustomInput } from "@/components/ui/custom";
import { CustomLink } from "@/components/ui/custom/custom-link";
import { M365Credentials } from "@/types";
export const M365CredentialsForm = ({
control,
}: {
control: Control<M365Credentials>;
}) => {
return (
<>
<div className="flex flex-col">
<div className="text-md text-default-foreground leading-9 font-bold">
Connect via Credentials
</div>
<div className="text-default-500 text-sm">
Please provide the information for your Microsoft 365 credentials.
</div>
</div>
<CustomInput
control={control}
name="client_id"
type="text"
label="Client ID"
labelPlacement="inside"
placeholder="Enter the Client ID"
variant="bordered"
isRequired
isInvalid={!!control._formState.errors.client_id}
/>
<CustomInput
control={control}
name="client_secret"
type="password"
label="Client Secret"
labelPlacement="inside"
placeholder="Enter the Client Secret"
variant="bordered"
isRequired
isInvalid={!!control._formState.errors.client_secret}
/>
<CustomInput
control={control}
name="tenant_id"
type="text"
label="Tenant ID"
labelPlacement="inside"
placeholder="Enter the Tenant ID"
variant="bordered"
isRequired
isInvalid={!!control._formState.errors.tenant_id}
/>
<p className="text-default-500 text-sm">
{" "}
User and password authentication is being deprecated due to
Microsoft&apos;s on-going MFA enforcement across all tenants (see{" "}
<CustomLink
href="https://azure.microsoft.com/en-us/blog/announcing-mandatory-multi-factor-authentication-for-azure-sign-in/"
size="sm"
>
Microsoft docs
</CustomLink>
).
</p>
<div className="border-system-warning bg-system-warning-medium dark:text-default-300 flex items-center rounded-lg border p-2 text-sm">
<InfoIcon className="mr-2 inline h-4 w-4 shrink-0" />
<p className="text-xs font-extrabold">
By October 2025, MFA will be mandatory.
</p>
</div>
<p className="text-default-500 text-sm">
Due to that change, you must only{" "}
<CustomLink
href="https://docs.prowler.com/projects/prowler-open-source/en/latest/tutorials/microsoft365/getting-started-m365/#step-3-configure-your-m365-account"
size="sm"
>
use application authentication
</CustomLink>{" "}
to maintain all Prowler M365 scan capabilities.
</p>
<CustomInput
control={control}
name="user"
type="text"
label="User"
labelPlacement="inside"
placeholder="Enter the User"
variant="bordered"
isRequired={false}
isInvalid={!!control._formState.errors.user}
/>
<CustomInput
control={control}
name="password"
type="password"
label="Password"
labelPlacement="inside"
placeholder="Enter the Password"
variant="bordered"
isRequired={false}
isInvalid={!!control._formState.errors.password}
/>
</>
);
};

View File

@@ -40,8 +40,8 @@ export const useCredentialsForm = ({
if (providerType === "gcp" && via === "service-account") {
return addCredentialsServiceAccountFormSchema(providerType);
}
// For GitHub, we need to pass the via parameter to determine which fields are required
if (providerType === "github") {
// For GitHub and M365, we need to pass the via parameter to determine which fields are required
if (providerType === "github" || providerType === "m365") {
return addCredentialsFormSchema(providerType, via);
}
return addCredentialsFormSchema(providerType);
@@ -99,13 +99,27 @@ export const useCredentialsForm = ({
[ProviderCredentialFields.TENANT_ID]: "",
};
case "m365":
// M365 credentials based on via parameter
if (via === "app_client_secret") {
return {
...baseDefaults,
[ProviderCredentialFields.CLIENT_ID]: "",
[ProviderCredentialFields.CLIENT_SECRET]: "",
[ProviderCredentialFields.TENANT_ID]: "",
};
}
if (via === "app_certificate") {
return {
...baseDefaults,
[ProviderCredentialFields.CLIENT_ID]: "",
[ProviderCredentialFields.CERTIFICATE_CONTENT]: "",
[ProviderCredentialFields.TENANT_ID]: "",
};
}
return {
...baseDefaults,
[ProviderCredentialFields.CLIENT_ID]: "",
[ProviderCredentialFields.CLIENT_SECRET]: "",
[ProviderCredentialFields.TENANT_ID]: "",
[ProviderCredentialFields.USER]: "",
[ProviderCredentialFields.PASSWORD]: "",
};
case "gcp":
return {
@@ -146,9 +160,14 @@ export const useCredentialsForm = ({
}
};
const defaultValues = getDefaultValues();
const form = useForm({
resolver: zodResolver(formSchema),
defaultValues: getDefaultValues(),
defaultValues: defaultValues,
mode: "onSubmit",
reValidateMode: "onChange",
criteriaMode: "all", // Show all errors for each field
});
const { handleServerResponse } = useFormServerErrors(
@@ -169,6 +188,7 @@ export const useCredentialsForm = ({
// Filter out empty values first, then append all remaining values
const filteredValues = filterEmptyValues(values);
Object.entries(filteredValues).forEach(([key, value]) => {
formData.append(key, value);
});
@@ -181,9 +201,12 @@ export const useCredentialsForm = ({
}
};
const { isSubmitting, errors } = form.formState;
return {
form,
isLoading: form.formState.isSubmitting,
isLoading: isSubmitting,
errors,
handleSubmit,
handleBackStep,
searchParamsObj,

View File

@@ -0,0 +1,154 @@
"use client";
import { AWSStaticCredentialsForm } from "@/components/providers/workflow/forms/select-credentials-type/aws/credentials-type";
import { AWSRoleCredentialsForm } from "@/components/providers/workflow/forms/select-credentials-type/aws/credentials-type/aws-role-credentials-form";
import { GCPDefaultCredentialsForm } from "@/components/providers/workflow/forms/select-credentials-type/gcp/credentials-type";
import { GCPServiceAccountKeyForm } from "@/components/providers/workflow/forms/select-credentials-type/gcp/credentials-type/gcp-service-account-key-form";
import {
M365CertificateCredentialsForm,
M365ClientSecretCredentialsForm,
} from "@/components/providers/workflow/forms/select-credentials-type/m365";
import { AzureCredentialsForm } from "@/components/providers/workflow/forms/via-credentials/azure-credentials-form";
import { GitHubCredentialsForm } from "@/components/providers/workflow/forms/via-credentials/github-credentials-form";
import { KubernetesCredentialsForm } from "@/components/providers/workflow/forms/via-credentials/k8s-credentials-form";
import { ProviderType } from "@/types/providers";
// Type definitions for different credential form configurations
export type CredentialFormConfigExtended = {
component: typeof AWSRoleCredentialsForm;
requiresExtendedProps: true;
passesCredentialsType: false;
};
export type CredentialFormConfigPassesCredentialsType = {
component: typeof GitHubCredentialsForm;
requiresExtendedProps: false;
passesCredentialsType: true;
};
export type CredentialFormConfigBasic = {
component:
| typeof AWSStaticCredentialsForm
| typeof GCPServiceAccountKeyForm
| typeof GCPDefaultCredentialsForm
| typeof M365ClientSecretCredentialsForm
| typeof M365CertificateCredentialsForm
| typeof AzureCredentialsForm
| typeof KubernetesCredentialsForm;
requiresExtendedProps: false;
passesCredentialsType: false;
};
export type CredentialFormConfig =
| CredentialFormConfigExtended
| CredentialFormConfigPassesCredentialsType
| CredentialFormConfigBasic;
// Provider credential form components mapping
const PROVIDER_CREDENTIAL_FORMS = {
aws: {
role: AWSRoleCredentialsForm,
credentials: AWSStaticCredentialsForm,
},
gcp: {
"service-account": GCPServiceAccountKeyForm,
credentials: GCPDefaultCredentialsForm,
},
m365: {
app_client_secret: M365ClientSecretCredentialsForm,
app_certificate: M365CertificateCredentialsForm,
},
azure: {
default: AzureCredentialsForm,
},
kubernetes: {
default: KubernetesCredentialsForm,
},
github: {
default: GitHubCredentialsForm,
},
} as const;
// Strategy dictionary: maps (provider + via) to credential form config
type ProviderViaKey =
| "aws:role"
| "aws"
| "gcp:service-account"
| "gcp"
| "m365:app_client_secret"
| "m365:app_certificate"
| "github"
| "azure"
| "kubernetes";
const PROVIDER_VIA_STRATEGIES = {
// AWS strategies
"aws:role": () => ({
component: PROVIDER_CREDENTIAL_FORMS.aws.role,
requiresExtendedProps: true,
passesCredentialsType: false,
}),
aws: () => ({
component: PROVIDER_CREDENTIAL_FORMS.aws.credentials,
requiresExtendedProps: false,
passesCredentialsType: false,
}),
// GCP strategies
"gcp:service-account": () => ({
component: PROVIDER_CREDENTIAL_FORMS.gcp["service-account"],
requiresExtendedProps: false,
passesCredentialsType: false,
}),
gcp: () => ({
component: PROVIDER_CREDENTIAL_FORMS.gcp.credentials,
requiresExtendedProps: false,
passesCredentialsType: false,
}),
// M365 strategies
"m365:app_client_secret": () => ({
component: PROVIDER_CREDENTIAL_FORMS.m365.app_client_secret,
requiresExtendedProps: false,
passesCredentialsType: false,
}),
"m365:app_certificate": () => ({
component: PROVIDER_CREDENTIAL_FORMS.m365.app_certificate,
requiresExtendedProps: false,
passesCredentialsType: false,
}),
// GitHub strategy
github: () => ({
component: PROVIDER_CREDENTIAL_FORMS.github.default,
requiresExtendedProps: false,
passesCredentialsType: true,
}),
// Azure strategy
azure: () => ({
component: PROVIDER_CREDENTIAL_FORMS.azure.default,
requiresExtendedProps: false,
passesCredentialsType: false,
}),
// Kubernetes strategy
kubernetes: () => ({
component: PROVIDER_CREDENTIAL_FORMS.kubernetes.default,
requiresExtendedProps: false,
passesCredentialsType: false,
}),
} satisfies Record<ProviderViaKey, () => CredentialFormConfig>;
// Helper to get credential form component based on provider and via parameter
export const getCredentialFormComponent = (
provider: ProviderType,
via?: string | null,
): CredentialFormConfig | null => {
// Build strategy key: use "provider:via" if via exists, otherwise just "provider"
const strategyKey = via ? `${provider}:${via}` : provider;
const strategy = PROVIDER_VIA_STRATEGIES[strategyKey as ProviderViaKey];
return strategy ? strategy() : null;
};

View File

@@ -80,14 +80,21 @@ export const buildAzureSecret = (formData: FormData) => {
export const buildM365Secret = (formData: FormData) => {
const secret = {
...buildAzureSecret(formData),
[ProviderCredentialFields.USER]: getFormValue(
[ProviderCredentialFields.CLIENT_ID]: getFormValue(
formData,
ProviderCredentialFields.USER,
ProviderCredentialFields.CLIENT_ID,
),
[ProviderCredentialFields.PASSWORD]: getFormValue(
[ProviderCredentialFields.TENANT_ID]: getFormValue(
formData,
ProviderCredentialFields.PASSWORD,
ProviderCredentialFields.TENANT_ID,
),
[ProviderCredentialFields.CLIENT_SECRET]: getFormValue(
formData,
ProviderCredentialFields.CLIENT_SECRET,
),
[ProviderCredentialFields.CERTIFICATE_CONTENT]: getFormValue(
formData,
ProviderCredentialFields.CERTIFICATE_CONTENT,
),
};
return filterEmptyValues(secret);

View File

@@ -29,6 +29,7 @@ export const ProviderCredentialFields = {
TENANT_ID: "tenant_id",
USER: "user",
PASSWORD: "password",
CERTIFICATE_CONTENT: "certificate_content",
// GCP fields
REFRESH_TOKEN: "refresh_token",
@@ -70,6 +71,7 @@ export const ErrorPointers = {
OAUTH_APP_TOKEN: "/data/attributes/secret/oauth_app_token",
GITHUB_APP_ID: "/data/attributes/secret/github_app_id",
GITHUB_APP_KEY: "/data/attributes/secret/github_app_key_content",
CERTIFICATE_CONTENT: "/data/attributes/secret/certificate_content",
} as const;
export type ErrorPointer = (typeof ErrorPointers)[keyof typeof ErrorPointers];

View File

@@ -1,3 +1,7 @@
import { SelectViaAWS } from "@/components/providers/workflow/forms/select-credentials-type/aws";
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";
import {
ProviderEntity,
ProviderProps,
@@ -53,7 +57,7 @@ export const getProviderFormType = (
via?: string,
): ProviderFormType => {
// Providers that need credential type selection
const needsSelector = ["aws", "gcp", "github"].includes(providerType);
const needsSelector = ["aws", "gcp", "github", "m365"].includes(providerType);
// Show selector if no via parameter and provider needs it
if (needsSelector && !via) {
@@ -80,6 +84,14 @@ export const getProviderFormType = (
return "credentials";
}
// M365 credential types
if (
providerType === "m365" &&
["app_client_secret", "app_certificate"].includes(via || "")
) {
return "credentials";
}
// Other providers go directly to credentials form
if (!needsSelector) {
return "credentials";
@@ -99,7 +111,34 @@ export const requiresBackButton = (via?: string | null): boolean => {
"personal_access_token",
"oauth_app",
"github_app",
"app_client_secret",
"app_certificate",
];
return validViaTypes.includes(via);
};
// Provider selector components mapping
export const PROVIDER_SELECTOR_COMPONENTS = {
AWS: SelectViaAWS,
GCP: SelectViaGCP,
GITHUB: SelectViaGitHub,
M365: SelectViaM365,
} as const;
export type SelectorProvider = keyof typeof PROVIDER_SELECTOR_COMPONENTS;
// Helper to map ProviderType to SelectorProvider key
export const getSelectorComponentKey = (
provider: ProviderType,
): SelectorProvider | null => {
const keyMap: Record<ProviderType, SelectorProvider | null> = {
aws: "AWS",
azure: null,
gcp: "GCP",
github: "GITHUB",
kubernetes: null,
m365: "M365",
};
return keyMap[provider] ?? null;
};

View File

@@ -213,15 +213,24 @@ export type AzureCredentials = {
[ProviderCredentialFields.PROVIDER_ID]: string;
};
export type M365Credentials = {
export type M365ClientSecretCredentials = {
[ProviderCredentialFields.CLIENT_ID]: string;
[ProviderCredentialFields.CLIENT_SECRET]: string;
[ProviderCredentialFields.TENANT_ID]: string;
[ProviderCredentialFields.USER]?: string;
[ProviderCredentialFields.PASSWORD]?: string;
[ProviderCredentialFields.PROVIDER_ID]: string;
};
export type M365CertificateCredentials = {
[ProviderCredentialFields.CLIENT_ID]: string;
[ProviderCredentialFields.CERTIFICATE_CONTENT]: string;
[ProviderCredentialFields.TENANT_ID]: string;
[ProviderCredentialFields.PROVIDER_ID]: string;
};
export type M365Credentials =
| M365ClientSecretCredentials
| M365CertificateCredentials;
export type GCPDefaultCredentials = {
client_id: string;
client_secret: string;

View File

@@ -168,12 +168,13 @@ export const addCredentialsFormSchema = (
.min(1, "Client ID is required"),
[ProviderCredentialFields.CLIENT_SECRET]: z
.string()
.min(1, "Client Secret is required"),
.optional(),
[ProviderCredentialFields.CERTIFICATE_CONTENT]: z
.string()
.optional(),
[ProviderCredentialFields.TENANT_ID]: z
.string()
.min(1, "Tenant ID is required"),
[ProviderCredentialFields.USER]: z.string().optional(),
[ProviderCredentialFields.PASSWORD]: z.string().optional(),
}
: providerType === "github"
? {
@@ -194,23 +195,26 @@ export const addCredentialsFormSchema = (
})
.superRefine((data: Record<string, any>, ctx) => {
if (providerType === "m365") {
const hasUser = !!data[ProviderCredentialFields.USER];
const hasPassword = !!data[ProviderCredentialFields.PASSWORD];
if (hasUser && !hasPassword) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "If you provide a user, you must also provide a password",
path: [ProviderCredentialFields.PASSWORD],
});
}
if (hasPassword && !hasUser) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "If you provide a password, you must also provide a user",
path: [ProviderCredentialFields.USER],
});
// Validate based on the via parameter
if (via === "app_client_secret") {
const clientSecret = data[ProviderCredentialFields.CLIENT_SECRET];
if (!clientSecret || clientSecret.trim() === "") {
ctx.addIssue({
code: "custom",
message: "Client Secret is required",
path: [ProviderCredentialFields.CLIENT_SECRET],
});
}
} else if (via === "app_certificate") {
const certificateContent =
data[ProviderCredentialFields.CERTIFICATE_CONTENT];
if (!certificateContent || certificateContent.trim() === "") {
ctx.addIssue({
code: "custom",
message: "Certificate Content is required",
path: [ProviderCredentialFields.CERTIFICATE_CONTENT],
});
}
}
}
@@ -219,7 +223,7 @@ export const addCredentialsFormSchema = (
if (via === "personal_access_token") {
if (!data[ProviderCredentialFields.PERSONAL_ACCESS_TOKEN]) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
code: "custom",
message: "Personal Access Token is required",
path: [ProviderCredentialFields.PERSONAL_ACCESS_TOKEN],
});
@@ -227,7 +231,7 @@ export const addCredentialsFormSchema = (
} else if (via === "oauth_app") {
if (!data[ProviderCredentialFields.OAUTH_APP_TOKEN]) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
code: "custom",
message: "OAuth App Token is required",
path: [ProviderCredentialFields.OAUTH_APP_TOKEN],
});
@@ -235,14 +239,14 @@ export const addCredentialsFormSchema = (
} else if (via === "github_app") {
if (!data[ProviderCredentialFields.GITHUB_APP_ID]) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
code: "custom",
message: "GitHub App ID is required",
path: [ProviderCredentialFields.GITHUB_APP_ID],
});
}
if (!data[ProviderCredentialFields.GITHUB_APP_KEY]) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
code: "custom",
message: "GitHub App Private Key is required",
path: [ProviderCredentialFields.GITHUB_APP_KEY],
});
@@ -390,7 +394,7 @@ export const mutedFindingsConfigFormSchema = z.object({
const yamlValidation = validateYaml(val);
if (!yamlValidation.isValid) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
code: "custom",
message: `Invalid YAML format: ${yamlValidation.error}`,
});
return;
@@ -399,7 +403,7 @@ export const mutedFindingsConfigFormSchema = z.object({
const mutelistValidation = validateMutelistYaml(val);
if (!mutelistValidation.isValid) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
code: "custom",
message: `Invalid mutelist structure: ${mutelistValidation.error}`,
});
}

View File

@@ -9,55 +9,70 @@ export const PROVIDER_TYPES = [
export type ProviderType = (typeof PROVIDER_TYPES)[number];
interface ProviderConnection {
connected: boolean;
last_checked_at: string;
}
interface ScannerArgs {
only_logs: boolean;
excluded_checks: string[];
aws_retries_max_attempts: number;
}
interface CreatedBy {
object: string;
id: string;
}
interface ProviderAttributes {
provider: ProviderType;
uid: string;
alias: string;
status: "completed" | "pending" | "cancelled";
resources: number;
connection: ProviderConnection;
scanner_args: ScannerArgs;
inserted_at: string;
updated_at: string;
created_by: CreatedBy;
}
interface ApiEntity {
type: string;
id: string;
}
interface RelationshipData {
data: ApiEntity | null;
}
interface ProviderGroupMeta {
count: number;
}
interface ProviderGroupRelationship {
meta: ProviderGroupMeta;
data: ApiEntity[];
}
interface ProviderRelationships {
secret: RelationshipData;
provider_groups: ProviderGroupRelationship;
}
export interface ProviderProps {
id: string;
type: "providers";
attributes: {
provider: ProviderType;
uid: string;
alias: string;
status: "completed" | "pending" | "cancelled";
resources: number;
connection: {
connected: boolean;
last_checked_at: string;
};
scanner_args: {
only_logs: boolean;
excluded_checks: string[];
aws_retries_max_attempts: number;
};
inserted_at: string;
updated_at: string;
created_by: {
object: string;
id: string;
};
};
relationships: {
secret: {
data: {
type: string;
id: string;
} | null;
};
provider_groups: {
meta: {
count: number;
};
data: Array<{
type: string;
id: string;
}>;
};
};
attributes: ProviderAttributes;
relationships: ProviderRelationships;
groupNames?: string[];
}
export interface ProviderEntity {
provider: ProviderType;
uid: string;
alias: string | null;
provider: ProviderType;
}
export interface ProviderConnectionStatus {
@@ -65,47 +80,65 @@ export interface ProviderConnectionStatus {
value: string;
}
interface ProviderFinding {
pass: number;
fail: number;
manual: number;
total: number;
}
interface ProviderResources {
total: number;
}
interface ProviderOverviewAttributes {
findings: ProviderFinding;
resources: ProviderResources;
}
interface ProviderOverviewData {
type: "provider-overviews";
id: ProviderType;
attributes: ProviderOverviewAttributes;
}
interface ResponseMeta {
version: string;
}
export interface ProviderOverviewProps {
data: {
type: "provider-overviews";
id: ProviderType;
attributes: {
findings: {
pass: number;
fail: number;
manual: number;
total: number;
};
resources: {
total: number;
};
};
}[];
meta: {
version: string;
};
data: ProviderOverviewData[];
meta: ResponseMeta;
}
interface PaginationMeta {
page: number;
pages: number;
count: number;
}
interface ApiResponseMeta {
pagination: PaginationMeta;
version: string;
}
interface ApiLinks {
first: string;
last: string;
next: string | null;
prev: string | null;
}
interface IncludedEntity {
type: string;
id: string;
attributes?: any;
relationships?: any;
}
export interface ProvidersApiResponse {
links: {
first: string;
last: string;
next: string | null;
prev: string | null;
};
links: ApiLinks;
data: ProviderProps[];
included?: Array<{
type: string;
id: string;
attributes: any;
relationships?: any;
}>;
meta: {
pagination: {
page: number;
pages: number;
count: number;
};
version: string;
};
included?: IncludedEntity[];
meta: ApiResponseMeta;
}