diff --git a/ui/CHANGELOG.md b/ui/CHANGELOG.md index b419cec6c5..83661eb731 100644 --- a/ui/CHANGELOG.md +++ b/ui/CHANGELOG.md @@ -7,6 +7,7 @@ All notable changes to the **Prowler UI** are documented in this file. ### Added - Lighthouse banner [(#8259)](https://github.com/prowler-cloud/prowler/pull/8259) +- Amazon AWS S3 integration [(#8391)](https://github.com/prowler-cloud/prowler/pull/8391) - Github provider support [(#8405)](https://github.com/prowler-cloud/prowler/pull/8405) ### 🔄 Changed @@ -14,7 +15,8 @@ All notable changes to the **Prowler UI** are documented in this file. - Rename `Memberships` to `Organization` in the sidebar [(#8415)](https://github.com/prowler-cloud/prowler/pull/8415) - Removed `Browse all resources` from the sidebar, sidebar now shows a single `Resources` entry [(#8418)](https://github.com/prowler-cloud/prowler/pull/8418) - Removed `Misconfigurations` from the `Top Failed Findings` section in the sidebar [(#8426)](https://github.com/prowler-cloud/prowler/pull/8426) -___ + +--- ## [v1.9.3] (Prowler v5.9.3) diff --git a/ui/actions/integrations/index.ts b/ui/actions/integrations/index.ts index 466fc73cb2..faf38d3ec7 100644 --- a/ui/actions/integrations/index.ts +++ b/ui/actions/integrations/index.ts @@ -1 +1,15 @@ -export * from "./saml"; +export { + createIntegration, + deleteIntegration, + getIntegration, + getIntegrations, + testIntegrationConnection, + updateIntegration, +} from "./integrations"; +export { + createSamlConfig, + deleteSamlConfig, + getSamlConfig, + initiateSamlAuth, + updateSamlConfig, +} from "./saml"; diff --git a/ui/actions/integrations/integrations.ts b/ui/actions/integrations/integrations.ts new file mode 100644 index 0000000000..62c0502c61 --- /dev/null +++ b/ui/actions/integrations/integrations.ts @@ -0,0 +1,316 @@ +"use server"; + +import { revalidatePath } from "next/cache"; + +import { + apiBaseUrl, + getAuthHeaders, + handleApiError, + parseStringify, +} from "@/lib"; + +import { getTask } from "../task"; + +export const getIntegrations = async (searchParams?: URLSearchParams) => { + const headers = await getAuthHeaders({ contentType: false }); + const url = new URL(`${apiBaseUrl}/integrations`); + + if (searchParams) { + searchParams.forEach((value, key) => { + url.searchParams.append(key, value); + }); + } + + try { + const response = await fetch(url.toString(), { method: "GET", headers }); + + if (response.ok) { + const data = await response.json(); + return parseStringify(data); + } + + console.error(`Failed to fetch integrations: ${response.statusText}`); + return { data: [], meta: { pagination: { count: 0 } } }; + } catch (error) { + console.error("Error fetching integrations:", error); + return { data: [], meta: { pagination: { count: 0 } } }; + } +}; + +export const getIntegration = async (id: string) => { + const headers = await getAuthHeaders({ contentType: false }); + const url = new URL(`${apiBaseUrl}/integrations/${id}`); + + try { + const response = await fetch(url.toString(), { method: "GET", headers }); + + if (response.ok) { + const data = await response.json(); + return parseStringify(data); + } + + console.error(`Failed to fetch integration: ${response.statusText}`); + return null; + } catch (error) { + console.error("Error fetching integration:", error); + return null; + } +}; + +export const createIntegration = async ( + formData: FormData, +): Promise<{ success: string; testConnection?: any } | { error: string }> => { + const headers = await getAuthHeaders({ contentType: true }); + const url = new URL(`${apiBaseUrl}/integrations`); + + try { + const integration_type = formData.get("integration_type") as string; + const configuration = JSON.parse(formData.get("configuration") as string); + const credentials = JSON.parse(formData.get("credentials") as string); + const providers = JSON.parse(formData.get("providers") as string); + + const integrationData = { + data: { + type: "integrations", + attributes: { integration_type, configuration, credentials }, + relationships: { + providers: { + data: providers.map((providerId: string) => ({ + id: providerId, + type: "providers", + })), + }, + }, + }, + }; + + const response = await fetch(url.toString(), { + method: "POST", + headers, + body: JSON.stringify(integrationData), + }); + + if (response.ok) { + const responseData = await response.json(); + const integrationId = responseData.data.id; + + const testResult = await testIntegrationConnection(integrationId); + + return { + success: "Integration created successfully!", + testConnection: testResult, + }; + } + + const errorData = await response.json().catch(() => ({})); + const errorMessage = + errorData.errors?.[0]?.detail || + `Unable to create S3 integration: ${response.statusText}`; + return { error: errorMessage }; + } catch (error) { + return handleApiError(error); + } +}; + +export const updateIntegration = async (id: string, formData: FormData) => { + const headers = await getAuthHeaders({ contentType: true }); + const url = new URL(`${apiBaseUrl}/integrations/${id}`); + + try { + const integration_type = formData.get("integration_type") as string; + const configuration = formData.get("configuration") + ? JSON.parse(formData.get("configuration") as string) + : undefined; + const credentials = formData.get("credentials") + ? JSON.parse(formData.get("credentials") as string) + : undefined; + const providers = formData.get("providers") + ? JSON.parse(formData.get("providers") as string) + : undefined; + const enabled = formData.get("enabled") + ? JSON.parse(formData.get("enabled") as string) + : undefined; + + const integrationData: any = { + data: { + type: "integrations", + id, + attributes: { integration_type }, + }, + }; + + if (configuration) { + integrationData.data.attributes.configuration = configuration; + } + + if (credentials) { + integrationData.data.attributes.credentials = credentials; + } + + if (enabled !== undefined) { + integrationData.data.attributes.enabled = enabled; + } + + if (providers) { + integrationData.data.relationships = { + providers: { + data: providers.map((providerId: string) => ({ + id: providerId, + type: "providers", + })), + }, + }; + } + + const response = await fetch(url.toString(), { + method: "PATCH", + headers, + body: JSON.stringify(integrationData), + }); + + if (response.ok) { + revalidatePath("/integrations/s3"); + + // Only test connection if credentials or configuration were updated + if (credentials || configuration) { + const testResult = await testIntegrationConnection(id); + return { + success: "Integration updated successfully!", + testConnection: testResult, + }; + } else { + return { + success: "Integration updated successfully!", + }; + } + } + + const errorData = await response.json().catch(() => ({})); + const errorMessage = + errorData.errors?.[0]?.detail || + `Unable to update S3 integration: ${response.statusText}`; + return { error: errorMessage }; + } catch (error) { + return handleApiError(error); + } +}; + +export const deleteIntegration = async (id: string) => { + const headers = await getAuthHeaders({ contentType: true }); + const url = new URL(`${apiBaseUrl}/integrations/${id}`); + + try { + const response = await fetch(url.toString(), { method: "DELETE", headers }); + + if (response.ok) { + revalidatePath("/integrations/s3"); + return { success: "Integration deleted successfully!" }; + } + + const errorData = await response.json().catch(() => ({})); + const errorMessage = + errorData.errors?.[0]?.detail || + `Unable to delete S3 integration: ${response.statusText}`; + return { error: errorMessage }; + } catch (error) { + return handleApiError(error); + } +}; + +const pollTaskUntilComplete = async (taskId: string): Promise => { + const maxAttempts = 10; + let attempts = 0; + + while (attempts < maxAttempts) { + try { + const taskResponse = await getTask(taskId); + + if (taskResponse.error) { + return { error: taskResponse.error }; + } + + const task = taskResponse.data; + const taskState = task?.attributes?.state; + + // Continue polling while task is executing + if (taskState === "executing") { + await new Promise((resolve) => setTimeout(resolve, 3000)); + attempts++; + continue; + } + + const result = task?.attributes?.result; + const isSuccessful = + taskState === "completed" && + result?.connected === true && + result?.error === null; + + let message; + if (isSuccessful) { + message = "Connection test completed successfully."; + } else { + message = result?.error || "Connection test failed."; + } + + return { + success: isSuccessful, + message, + taskState, + result, + }; + } catch (error) { + return { error: "Failed to monitor connection test." }; + } + } + + return { error: "Connection test timeout. Test took too long to complete." }; +}; + +export const testIntegrationConnection = async (id: string) => { + const headers = await getAuthHeaders({ contentType: true }); + const url = new URL(`${apiBaseUrl}/integrations/${id}/connection`); + + try { + const response = await fetch(url.toString(), { method: "POST", headers }); + + if (response.ok) { + const data = await response.json(); + const taskId = data?.data?.id; + + if (taskId) { + // Poll the task until completion + const pollResult = await pollTaskUntilComplete(taskId); + + revalidatePath("/integrations/s3"); + + if (pollResult.error) { + return { error: pollResult.error }; + } + + if (pollResult.success) { + return { + success: "Connection test completed successfully!", + message: pollResult.message, + data: parseStringify(data), + }; + } else { + return { + error: pollResult.message || "Connection test failed.", + }; + } + } else { + return { + error: "Failed to start connection test. No task ID received.", + }; + } + } + + const errorData = await response.json().catch(() => ({})); + const errorMessage = + errorData.errors?.[0]?.detail || + `Unable to test S3 integration connection: ${response.statusText}`; + return { error: errorMessage }; + } catch (error) { + return handleApiError(error); + } +}; diff --git a/ui/actions/providers/providers.ts b/ui/actions/providers/providers.ts index 7a38b8875f..19bf794de1 100644 --- a/ui/actions/providers/providers.ts +++ b/ui/actions/providers/providers.ts @@ -8,14 +8,12 @@ import { getAuthHeaders, getErrorMessage, getFormValue, + handleApiError, + handleApiResponse, parseStringify, wait, } from "@/lib"; -import { - buildSecretConfig, - handleApiError, - handleApiResponse, -} from "@/lib/provider-credentials/build-crendentials"; +import { buildSecretConfig } from "@/lib/provider-credentials/build-crendentials"; import { ProviderCredentialFields } from "@/lib/provider-credentials/provider-credential-fields"; import { ProvidersApiResponse, ProviderType } from "@/types/providers"; diff --git a/ui/app/(prowler)/integrations/page.tsx b/ui/app/(prowler)/integrations/page.tsx index 3760cae57e..c9f03b6770 100644 --- a/ui/app/(prowler)/integrations/page.tsx +++ b/ui/app/(prowler)/integrations/page.tsx @@ -1,11 +1,28 @@ import React from "react"; +import { getIntegrations } from "@/actions/integrations"; +import { S3IntegrationCard } from "@/components/integrations"; import { ContentLayout } from "@/components/ui"; -export default function Integrations() { +export default async function Integrations() { + const integrations = await getIntegrations(); + return ( - -

Integrations

+ +
+
+

Available Integrations

+

+ Connect external services to enhance your security workflow and + automatically export your scan results. +

+
+ +
+ {/* Amazon S3 Integration */} + +
+
); } diff --git a/ui/app/(prowler)/integrations/s3/page.tsx b/ui/app/(prowler)/integrations/s3/page.tsx new file mode 100644 index 0000000000..8a5c0c09fb --- /dev/null +++ b/ui/app/(prowler)/integrations/s3/page.tsx @@ -0,0 +1,60 @@ +import React from "react"; + +import { getIntegrations } from "@/actions/integrations"; +import { getProviders } from "@/actions/providers"; +import { S3IntegrationsManager } from "@/components/integrations/s3/s3-integrations-manager"; +import { ContentLayout } from "@/components/ui"; + +export default async function S3Integrations() { + const [integrations, providers] = await Promise.all([ + getIntegrations( + new URLSearchParams({ "filter[integration_type]": "amazon_s3" }), + ), + getProviders({ pageSize: 100 }), + ]); + + const s3Integrations = integrations?.data || []; + const availableProviders = providers?.data || []; + + return ( + +
+
+

+ Configure Amazon S3 integration to automatically export your scan + results to S3 buckets. +

+ +
+

+ Features: +

+
    +
  • + + Automated scan result exports +
  • +
  • + + Multiple bucket support +
  • +
  • + + Configurable export paths +
  • +
  • + + IAM role and static credentials +
  • +
+
+
+ + +
+
+ ); +} diff --git a/ui/app/(prowler)/profile/page.tsx b/ui/app/(prowler)/profile/page.tsx index 74108355f9..6ccb8a7e3b 100644 --- a/ui/app/(prowler)/profile/page.tsx +++ b/ui/app/(prowler)/profile/page.tsx @@ -4,7 +4,7 @@ import { getSamlConfig } from "@/actions/integrations/saml"; import { getAllTenants } from "@/actions/users/tenants"; import { getUserInfo } from "@/actions/users/users"; import { getUserMemberships } from "@/actions/users/users"; -import { SamlIntegrationCard } from "@/components/integrations/saml-integration-card"; +import { SamlIntegrationCard } from "@/components/integrations/saml/saml-integration-card"; import { ContentLayout } from "@/components/ui"; import { UserBasicInfoCard } from "@/components/users/profile"; import { MembershipsCard } from "@/components/users/profile/memberships-card"; diff --git a/ui/components/integrations/forms/index.ts b/ui/components/integrations/forms/index.ts deleted file mode 100644 index fc10a1a721..0000000000 --- a/ui/components/integrations/forms/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "./saml-config-form"; diff --git a/ui/components/integrations/index.ts b/ui/components/integrations/index.ts index fc77086ec6..5abff3e316 100644 --- a/ui/components/integrations/index.ts +++ b/ui/components/integrations/index.ts @@ -1,2 +1,7 @@ -export * from "./forms"; -export * from "./saml-integration-card"; +export * from "../providers/provider-selector"; +export * from "./s3/s3-integration-card"; +export * from "./s3/s3-integration-form"; +export * from "./s3/s3-integrations-manager"; +export * from "./s3/skeleton-s3-integration-card"; +export * from "./saml/saml-config-form"; +export * from "./saml/saml-integration-card"; diff --git a/ui/components/integrations/s3/s3-integration-card.tsx b/ui/components/integrations/s3/s3-integration-card.tsx new file mode 100644 index 0000000000..9e78b79592 --- /dev/null +++ b/ui/components/integrations/s3/s3-integration-card.tsx @@ -0,0 +1,150 @@ +"use client"; + +import { Card, CardBody, CardHeader, Chip } from "@nextui-org/react"; +import { SettingsIcon } from "lucide-react"; +import Link from "next/link"; + +import { AmazonS3Icon } from "@/components/icons/services/IconServices"; +import { CustomButton } from "@/components/ui/custom"; +import { IntegrationProps } from "@/types/integrations"; + +import { S3IntegrationCardSkeleton } from "./skeleton-s3-integration-card"; + +interface S3IntegrationCardProps { + integrations?: IntegrationProps[]; + isLoading?: boolean; +} + +export const S3IntegrationCard = ({ + integrations = [], + isLoading = false, +}: S3IntegrationCardProps) => { + const s3Integrations = integrations.filter( + (integration) => integration.attributes.integration_type === "amazon_s3", + ); + + const isConfigured = s3Integrations.length > 0; + const connectedCount = s3Integrations.filter( + (integration) => integration.attributes.connected, + ).length; + + if (isLoading) { + return ( + + ); + } + + return ( + + +
+
+ +
+

+ Amazon S3 +

+
+

+ Export security findings to Amazon S3 buckets. +

+ {/* Todo: add real DOCS, use CustomLink when available */} + + Learn more + +
+
+
+
+ {isConfigured && ( + 0 ? "success" : "warning"} + variant="flat" + > + {connectedCount} / {s3Integrations.length} connected + + )} + } + asLink="/integrations/s3" + ariaLabel={ + isConfigured + ? "Manage S3 integrations" + : "Configure S3 integration" + } + > + {isConfigured ? "Manage" : "Configure"} + +
+
+
+ +
+ {isConfigured ? ( + <> +
+ {s3Integrations.map((integration) => ( +
+
+ + {integration.attributes.configuration.bucket_name || + "Unknown Bucket"} + + + Output directory:{" "} + {integration.attributes.configuration + .output_directory || + integration.attributes.configuration.path || + "/"} + +
+ + {integration.attributes.connected + ? "Connected" + : "Disconnected"} + +
+ ))} +
+ + ) : ( + <> +
+ Status: + Not configured +
+ +
+

+ Export your security findings to Amazon S3 buckets + automatically. +

+
+ + )} +
+
+
+ ); +}; diff --git a/ui/components/integrations/s3/s3-integration-form.tsx b/ui/components/integrations/s3/s3-integration-form.tsx new file mode 100644 index 0000000000..9abe16fb1d --- /dev/null +++ b/ui/components/integrations/s3/s3-integration-form.tsx @@ -0,0 +1,411 @@ +"use client"; + +import { zodResolver } from "@hookform/resolvers/zod"; +import { Divider } from "@nextui-org/react"; +import { ArrowRightIcon } from "lucide-react"; +import { useSession } from "next-auth/react"; +import { useState } from "react"; +import { Control, useForm } from "react-hook-form"; + +import { createIntegration, updateIntegration } from "@/actions/integrations"; +import { ProviderSelector } from "@/components/providers/provider-selector"; +import { AWSRoleCredentialsForm } from "@/components/providers/workflow/forms/select-credentials-type/aws/credentials-type/aws-role-credentials-form"; +import { useToast } from "@/components/ui"; +import { CustomInput } from "@/components/ui/custom"; +import { CustomLink } from "@/components/ui/custom/custom-link"; +import { Form } from "@/components/ui/form"; +import { FormButtons } from "@/components/ui/form/form-buttons"; +import { getAWSCredentialsTemplateBucketLinks } from "@/lib"; +import { AWSCredentialsRole } from "@/types"; +import { + editS3IntegrationFormSchema, + IntegrationProps, + s3IntegrationFormSchema, +} from "@/types/integrations"; +import { ProviderProps } from "@/types/providers"; + +interface S3IntegrationFormProps { + integration?: IntegrationProps | null; + providers: ProviderProps[]; + onSuccess: () => void; + onCancel: () => void; + editMode?: "configuration" | "credentials" | null; // null means creating new +} + +export const S3IntegrationForm = ({ + integration, + providers, + onSuccess, + onCancel, + editMode = null, +}: S3IntegrationFormProps) => { + const { data: session } = useSession(); + const { toast } = useToast(); + const [currentStep, setCurrentStep] = useState( + editMode === "credentials" ? 1 : 0, + ); + const isEditing = !!integration; + const isCreating = !isEditing; + const isEditingConfig = editMode === "configuration"; + const isEditingCredentials = editMode === "credentials"; + + // Create the form with updated schema and default values + const form = useForm({ + resolver: zodResolver( + // For credentials editing, use creation schema (all fields required) + // For config editing, use edit schema (partial updates allowed) + // For creation, use creation schema + isEditingCredentials || isCreating + ? s3IntegrationFormSchema + : editS3IntegrationFormSchema, + ), + defaultValues: { + integration_type: "amazon_s3" as const, + bucket_name: integration?.attributes.configuration.bucket_name || "", + output_directory: + integration?.attributes.configuration.output_directory || "", + providers: + integration?.relationships?.providers?.data?.map((p) => p.id) || [], + credentials_type: "aws-sdk-default" as const, + aws_access_key_id: "", + aws_secret_access_key: "", + aws_session_token: "", + // For credentials editing, show current values as placeholders but require new input + role_arn: isEditingCredentials + ? "" + : integration?.attributes.configuration.credentials?.role_arn || "", + // External ID always defaults to tenantId, even when editing credentials + external_id: + integration?.attributes.configuration.credentials?.external_id || + session?.tenantId || + "", + role_session_name: "", + session_duration: "", + }, + }); + + const isLoading = form.formState.isSubmitting; + + const handleNext = async (e: React.FormEvent) => { + e.preventDefault(); + + // If we're in single-step edit mode, don't advance + if (isEditingConfig || isEditingCredentials) { + return; + } + + // Validate current step fields for creation flow + const stepFields = + currentStep === 0 + ? (["bucket_name", "output_directory", "providers"] as const) + : // Step 1: No required fields since role_arn and external_id are optional + []; + + const isValid = stepFields.length === 0 || (await form.trigger(stepFields)); + + if (isValid) { + setCurrentStep(1); + } + }; + + const handleBack = () => { + setCurrentStep(0); + }; + + // Helper function to build credentials object + const buildCredentials = (values: any) => { + const credentials: any = {}; + + // Only include role-related fields if role_arn is provided + if (values.role_arn && values.role_arn.trim() !== "") { + credentials.role_arn = values.role_arn; + credentials.external_id = values.external_id; + + // Optional role fields + if (values.role_session_name) + credentials.role_session_name = values.role_session_name; + if (values.session_duration) + credentials.session_duration = + parseInt(values.session_duration, 10) || 3600; + } + + // Add static credentials if using access-secret-key type + if (values.credentials_type === "access-secret-key") { + credentials.aws_access_key_id = values.aws_access_key_id; + credentials.aws_secret_access_key = values.aws_secret_access_key; + if (values.aws_session_token) + credentials.aws_session_token = values.aws_session_token; + } + + return credentials; + }; + + const buildConfiguration = (values: any, isPartial = false) => { + const configuration: any = {}; + + // For creation mode, include all fields + if (!isPartial) { + configuration.bucket_name = values.bucket_name; + configuration.output_directory = values.output_directory; + } else { + // For edit mode, only include fields that have actually changed + const originalBucketName = + integration?.attributes.configuration.bucket_name || ""; + const originalOutputDirectory = + integration?.attributes.configuration.output_directory || ""; + + // Only include bucket_name if it has changed + if (values.bucket_name && values.bucket_name !== originalBucketName) { + configuration.bucket_name = values.bucket_name; + } + + // Only include output_directory if it has changed + if ( + values.output_directory && + values.output_directory !== originalOutputDirectory + ) { + configuration.output_directory = values.output_directory; + } + } + + return configuration; + }; + + // Helper function to build FormData based on edit mode + const buildFormData = (values: any) => { + const formData = new FormData(); + formData.append("integration_type", values.integration_type); + + if (isEditingConfig) { + const configuration = buildConfiguration(values, true); + if (Object.keys(configuration).length > 0) { + formData.append("configuration", JSON.stringify(configuration)); + } + // Always send providers array, even if empty, to update relationships + formData.append("providers", JSON.stringify(values.providers || [])); + } else if (isEditingCredentials) { + const credentials = buildCredentials(values); + formData.append("credentials", JSON.stringify(credentials)); + } else { + // Creation mode - send everything + const configuration = buildConfiguration(values); + const credentials = buildCredentials(values); + + formData.append("configuration", JSON.stringify(configuration)); + formData.append("credentials", JSON.stringify(credentials)); + formData.append("providers", JSON.stringify(values.providers)); + } + + return formData; + }; + + const onSubmit = async (values: any) => { + const formData = buildFormData(values); + + try { + let result; + if (isEditing && integration) { + result = await updateIntegration(integration.id, formData); + } else { + result = await createIntegration(formData); + } + + if ("success" in result) { + toast({ + title: "Success!", + description: `S3 integration ${isEditing ? "updated" : "created"} successfully.`, + }); + + if ("testConnection" in result) { + if (result.testConnection.success) { + toast({ + title: "Connection test started!", + description: + "Connection test started. It may take some time to complete.", + }); + } else if (result.testConnection.error) { + toast({ + variant: "destructive", + title: "Connection test failed", + description: result.testConnection.error, + }); + } + } + + onSuccess(); + } else if ("error" in result) { + const errorMessage = result.error; + + toast({ + variant: "destructive", + title: "S3 Integration Error", + description: errorMessage, + }); + } + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : "An unexpected error occurred"; + + toast({ + variant: "destructive", + title: "Connection Error", + description: `${errorMessage}. Please check your network connection and try again.`, + }); + } + }; + + const renderStepContent = () => { + // If editing credentials, show only credentials form + if (isEditingCredentials || currentStep === 1) { + const bucketName = form.getValues("bucket_name") || ""; + const externalId = + form.getValues("external_id") || session?.tenantId || ""; + const templateLinks = getAWSCredentialsTemplateBucketLinks( + bucketName, + externalId, + ); + + return ( + } + setValue={form.setValue as any} + externalId={externalId} + templateLinks={templateLinks} + type="s3-integration" + /> + ); + } + + // Show configuration step (step 0 or editing configuration) + if (isEditingConfig || currentStep === 0) { + return ( + <> + {/* Provider Selection */} +
+ +
+ + + + {/* S3 Configuration */} +
+ + + +
+ + ); + } + + return null; + }; + + const renderStepButtons = () => { + // Single edit mode (configuration or credentials) + if (isEditingConfig || isEditingCredentials) { + const updateText = isEditingConfig + ? "Update Configuration" + : "Update Credentials"; + const loadingText = isEditingConfig + ? "Updating Configuration..." + : "Updating Credentials..."; + + return ( + {}} + onCancel={onCancel} + submitText={updateText} + cancelText="Cancel" + loadingText={loadingText} + isDisabled={isLoading} + /> + ); + } + + // Creation flow - step 0 + if (currentStep === 0) { + return ( + {}} + onCancel={onCancel} + submitText="Next" + cancelText="Cancel" + loadingText="Processing..." + isDisabled={isLoading} + rightIcon={} + /> + ); + } + + // Creation flow - step 1 (final step) + return ( + {}} + onCancel={handleBack} + submitText="Create Integration" + cancelText="Back" + loadingText="Creating..." + isDisabled={isLoading} + /> + ); + }; + + return ( +
+ +
+

+ Need help connecting your AWS account? + + Read the docs + +

+ {renderStepContent()} +
+ {renderStepButtons()} +
+ + ); +}; diff --git a/ui/components/integrations/s3/s3-integrations-manager.tsx b/ui/components/integrations/s3/s3-integrations-manager.tsx new file mode 100644 index 0000000000..bf0386e40b --- /dev/null +++ b/ui/components/integrations/s3/s3-integrations-manager.tsx @@ -0,0 +1,394 @@ +"use client"; + +import { Card, CardBody, CardHeader, Chip } from "@nextui-org/react"; +import { + PlusIcon, + Power, + SettingsIcon, + TestTube, + Trash2Icon, +} from "lucide-react"; +import { useState } from "react"; + +import { + deleteIntegration, + testIntegrationConnection, + updateIntegration, +} from "@/actions/integrations"; +import { AmazonS3Icon } from "@/components/icons/services/IconServices"; +import { useToast } from "@/components/ui"; +import { CustomAlertModal, CustomButton } from "@/components/ui/custom"; +import { IntegrationProps } from "@/types/integrations"; +import { ProviderProps } from "@/types/providers"; + +import { S3IntegrationForm } from "./s3-integration-form"; +import { S3IntegrationCardSkeleton } from "./skeleton-s3-integration-card"; + +interface S3IntegrationsManagerProps { + integrations: IntegrationProps[]; + providers: ProviderProps[]; +} + +export const S3IntegrationsManager = ({ + integrations, + providers, +}: S3IntegrationsManagerProps) => { + const [isModalOpen, setIsModalOpen] = useState(false); + const [editingIntegration, setEditingIntegration] = + useState(null); + const [editMode, setEditMode] = useState< + "configuration" | "credentials" | null + >(null); + const [isDeleting, setIsDeleting] = useState(null); + const [isTesting, setIsTesting] = useState(null); + const [isOperationLoading, setIsOperationLoading] = useState(false); + const [isDeleteOpen, setIsDeleteOpen] = useState(false); + const [integrationToDelete, setIntegrationToDelete] = + useState(null); + const { toast } = useToast(); + + const handleAddIntegration = () => { + setEditingIntegration(null); + setEditMode(null); // Creation mode + setIsModalOpen(true); + }; + + const handleEditConfiguration = (integration: IntegrationProps) => { + setEditingIntegration(integration); + setEditMode("configuration"); + setIsModalOpen(true); + }; + + const handleEditCredentials = (integration: IntegrationProps) => { + setEditingIntegration(integration); + setEditMode("credentials"); + setIsModalOpen(true); + }; + + const handleOpenDeleteModal = (integration: IntegrationProps) => { + setIntegrationToDelete(integration); + setIsDeleteOpen(true); + }; + + const handleDeleteIntegration = async (id: string) => { + setIsDeleting(id); + try { + const result = await deleteIntegration(id); + + if (result.success) { + toast({ + title: "Success!", + description: "S3 integration deleted successfully.", + }); + } else if (result.error) { + toast({ + variant: "destructive", + title: "Delete Failed", + description: result.error, + }); + } + } catch (error) { + toast({ + variant: "destructive", + title: "Error", + description: "Failed to delete S3 integration. Please try again.", + }); + } finally { + setIsDeleting(null); + setIsDeleteOpen(false); + setIntegrationToDelete(null); + } + }; + + const handleTestConnection = async (id: string) => { + setIsTesting(id); + try { + const result = await testIntegrationConnection(id); + + if (result.success) { + toast({ + title: "Connection test successful!", + description: + result.message || "Connection test completed successfully.", + }); + } else if (result.error) { + toast({ + variant: "destructive", + title: "Connection test failed", + description: result.error, + }); + } + } catch (error) { + toast({ + variant: "destructive", + title: "Error", + description: "Failed to test connection. Please try again.", + }); + } finally { + setIsTesting(null); + } + }; + + const handleToggleEnabled = async (integration: IntegrationProps) => { + try { + const newEnabledState = !integration.attributes.enabled; + const formData = new FormData(); + formData.append( + "integration_type", + integration.attributes.integration_type, + ); + formData.append("enabled", JSON.stringify(newEnabledState)); + + const result = await updateIntegration(integration.id, formData); + + if (result && "success" in result) { + toast({ + title: "Success!", + description: `Integration ${newEnabledState ? "enabled" : "disabled"} successfully.`, + }); + } else if (result && "error" in result) { + toast({ + variant: "destructive", + title: "Toggle Failed", + description: result.error, + }); + } + } catch (error) { + toast({ + variant: "destructive", + title: "Error", + description: "Failed to toggle integration. Please try again.", + }); + } + }; + + const handleModalClose = () => { + setIsModalOpen(false); + setEditingIntegration(null); + setEditMode(null); + }; + + const handleFormSuccess = () => { + setIsModalOpen(false); + setEditingIntegration(null); + setEditMode(null); + setIsOperationLoading(true); + // Reset loading state after a short delay to show the skeleton briefly + setTimeout(() => { + setIsOperationLoading(false); + }, 1500); + }; + + return ( + <> + +
+ { + setIsDeleteOpen(false); + setIntegrationToDelete(null); + }} + isDisabled={isDeleting !== null} + > + Cancel + + + } + onPress={() => + integrationToDelete && + handleDeleteIntegration(integrationToDelete.id) + } + > + {isDeleting ? "Deleting..." : "Delete"} + +
+
+ + + + + +
+ {/* Header with Add Button */} +
+
+

+ Configured S3 Integrations +

+

+ {integrations.length === 0 + ? "Not configured yet" + : `${integrations.length} integration${integrations.length !== 1 ? "s" : ""} configured`} +

+
+ } + onPress={handleAddIntegration} + ariaLabel="Add integration" + > + Add Integration + +
+ + {/* Integrations List */} + {isOperationLoading ? ( + + ) : integrations.length > 0 ? ( +
+ {integrations.map((integration) => ( + + +
+
+ +
+

+ {integration.attributes.configuration.bucket_name || + "Unknown Bucket"} +

+

+ Output directory:{" "} + {integration.attributes.configuration + .output_directory || + integration.attributes.configuration.path || + "/"} +

+
+
+ + {integration.attributes.connected + ? "Connected" + : "Disconnected"} + +
+
+ +
+
+ {integration.attributes.connection_last_checked_at && ( +

+ Last checked:{" "} + {new Date( + integration.attributes.connection_last_checked_at, + ).toLocaleDateString()} +

+ )} +
+
+ } + onPress={() => handleTestConnection(integration.id)} + isLoading={isTesting === integration.id} + isDisabled={!integration.attributes.enabled} + ariaLabel="Test connection" + className="w-full sm:w-auto" + > + Test + + } + onPress={() => handleEditConfiguration(integration)} + ariaLabel="Edit configuration" + className="w-full sm:w-auto" + > + Config + + } + onPress={() => handleEditCredentials(integration)} + ariaLabel="Edit credentials" + className="w-full sm:w-auto" + > + Credentials + + } + onPress={() => handleToggleEnabled(integration)} + ariaLabel={ + integration.attributes.enabled + ? "Disable integration" + : "Enable integration" + } + className="w-full sm:w-auto" + > + {integration.attributes.enabled ? "Disable" : "Enable"} + + } + onPress={() => handleOpenDeleteModal(integration)} + ariaLabel="Delete integration" + className="w-full sm:w-auto" + > + Delete + +
+
+
+
+ ))} +
+ ) : null} +
+ + ); +}; diff --git a/ui/components/integrations/s3/skeleton-s3-integration-card.tsx b/ui/components/integrations/s3/skeleton-s3-integration-card.tsx new file mode 100644 index 0000000000..645b47db78 --- /dev/null +++ b/ui/components/integrations/s3/skeleton-s3-integration-card.tsx @@ -0,0 +1,95 @@ +"use client"; + +import { Card, CardBody, CardHeader, Skeleton } from "@nextui-org/react"; + +import { AmazonS3Icon } from "@/components/icons/services/IconServices"; + +interface S3IntegrationCardSkeletonProps { + variant?: "main" | "manager"; + count?: number; +} + +export const S3IntegrationCardSkeleton = ({ + variant = "main", + count = 1, +}: S3IntegrationCardSkeletonProps) => { + if (variant === "main") { + return ( + + +
+
+ +
+

Amazon S3

+
+

+ Export security findings to Amazon S3 buckets. +

+ +
+
+
+
+ + +
+
+
+ +
+
+ {Array.from({ length: count }).map((_, index) => ( +
+
+ + +
+ +
+ ))} +
+
+
+
+ ); + } + + // Manager variant - for individual cards in S3IntegrationsManager + return ( +
+ {Array.from({ length: count }).map((_, index) => ( + + +
+
+ +
+ + +
+
+ +
+
+ +
+
+ + +
+
+ + + +
+
+
+
+ ))} +
+ ); +}; diff --git a/ui/components/integrations/forms/saml-config-form.tsx b/ui/components/integrations/saml/saml-config-form.tsx similarity index 100% rename from ui/components/integrations/forms/saml-config-form.tsx rename to ui/components/integrations/saml/saml-config-form.tsx diff --git a/ui/components/integrations/saml-integration-card.tsx b/ui/components/integrations/saml/saml-integration-card.tsx similarity index 98% rename from ui/components/integrations/saml-integration-card.tsx rename to ui/components/integrations/saml/saml-integration-card.tsx index 6ad745aa85..f5ef198243 100644 --- a/ui/components/integrations/saml-integration-card.tsx +++ b/ui/components/integrations/saml/saml-integration-card.tsx @@ -9,7 +9,7 @@ import { useToast } from "@/components/ui"; import { CustomAlertModal, CustomButton } from "@/components/ui/custom"; import { CustomLink } from "@/components/ui/custom/custom-link"; -import { SamlConfigForm } from "./forms"; +import { SamlConfigForm } from "./saml-config-form"; export const SamlIntegrationCard = ({ samlConfig }: { samlConfig?: any }) => { const [isSamlModalOpen, setIsSamlModalOpen] = useState(false); diff --git a/ui/components/providers/provider-selector.tsx b/ui/components/providers/provider-selector.tsx new file mode 100644 index 0000000000..a39a924bea --- /dev/null +++ b/ui/components/providers/provider-selector.tsx @@ -0,0 +1,201 @@ +"use client"; + +import { Button, Select, SelectItem } from "@nextui-org/react"; +import { CheckSquare, Square } from "lucide-react"; +import { Control } from "react-hook-form"; + +import { FormControl, FormField, FormMessage } from "@/components/ui/form"; +import { ProviderProps, ProviderType } from "@/types/providers"; + +const providerTypeLabels: Record = { + aws: "Amazon Web Services", + gcp: "Google Cloud Platform", + azure: "Microsoft Azure", + m365: "Microsoft 365", + kubernetes: "Kubernetes", + github: "GitHub", +}; + +interface ProviderSelectorProps { + control: Control; + name: string; + providers: ProviderProps[]; + label?: string; + placeholder?: string; + isInvalid?: boolean; + showFormMessage?: boolean; +} + +export const ProviderSelector = ({ + control, + name, + providers, + label = "Providers", + placeholder = "Select providers", + isInvalid = false, + showFormMessage = true, +}: ProviderSelectorProps) => { + // Sort providers by type and then by name for better organization + const sortedProviders = [...providers].sort((a, b) => { + const typeComparison = a.attributes.provider.localeCompare( + b.attributes.provider, + ); + if (typeComparison !== 0) return typeComparison; + + const nameA = a.attributes.alias || a.attributes.uid; + const nameB = b.attributes.alias || b.attributes.uid; + return nameA.localeCompare(nameB); + }); + + return ( + { + const selectedIds = value || []; + const allProviderIds = sortedProviders.map((p) => p.id); + const isAllSelected = + allProviderIds.length > 0 && + allProviderIds.every((id) => selectedIds.includes(id)); + + const handleSelectAll = () => { + if (isAllSelected) { + onChange([]); + } else { + onChange(allProviderIds); + } + }; + + return ( + <> + +
+
+ + {label} + + {sortedProviders.length > 1 && ( + + )} +
+ +
+
+ {showFormMessage && ( + + )} + + ); + }} + /> + ); +}; diff --git a/ui/components/providers/workflow/credentials-role-helper.tsx b/ui/components/providers/workflow/credentials-role-helper.tsx index 855a3e6fab..209b62e6ee 100644 --- a/ui/components/providers/workflow/credentials-role-helper.tsx +++ b/ui/components/providers/workflow/credentials-role-helper.tsx @@ -1,29 +1,40 @@ "use client"; -import { Snippet } from "@nextui-org/react"; -import { useSession } from "next-auth/react"; - +import { IdIcon } from "@/components/icons"; import { CustomButton } from "@/components/ui/custom"; -import { getAWSCredentialsTemplateLinks } from "@/lib"; +import { SnippetChip } from "@/components/ui/entities"; -export const CredentialsRoleHelper = () => { - const { data: session } = useSession(); +interface CredentialsRoleHelperProps { + externalId: string; + templateLinks: { + cloudformation: string; + cloudformationQuickLink: string; + terraform: string; + }; + type?: "providers" | "s3-integration"; +} +export const CredentialsRoleHelper = ({ + externalId, + templateLinks, + type = "providers", +}: CredentialsRoleHelperProps) => { return (

- A new read-only IAM role must be manually created. + A read-only IAM role must be manually created + {type === "s3-integration" ? " or updated" : ""}.

- Use the following AWS CloudFormation Quick Link to deploy the IAM Role + Use the following AWS CloudFormation Quick Link to create the IAM Role
@@ -33,8 +44,11 @@ export const CredentialsRoleHelper = () => {
+

- Use one of the following templates to create the IAM role: + {type === "providers" + ? "Use one of the following templates to create the IAM role" + : "Refer to the documentation"}

@@ -42,34 +56,28 @@ export const CredentialsRoleHelper = () => { ariaLabel="CloudFormation Template" color="transparent" className="h-auto w-fit min-w-0 p-0 text-blue-500" - asLink={getAWSCredentialsTemplateLinks().cloudformation} + asLink={templateLinks.cloudformation} target="_blank" > - CloudFormation Template + CloudFormation {type === "providers" ? "Template" : ""} - Terraform Code + Terraform {type === "providers" ? "Code" : ""}
-

- The External ID will also be required: -

- -

- {session?.tenantId} -

-
+
+ + External ID: + + } /> +
); diff --git a/ui/components/providers/workflow/forms/base-credentials-form.tsx b/ui/components/providers/workflow/forms/base-credentials-form.tsx index 3762532594..4dd06bbed9 100644 --- a/ui/components/providers/workflow/forms/base-credentials-form.tsx +++ b/ui/components/providers/workflow/forms/base-credentials-form.tsx @@ -7,6 +7,7 @@ import { Control } from "react-hook-form"; import { CustomButton } from "@/components/ui/custom"; import { Form } from "@/components/ui/form"; import { useCredentialsForm } from "@/hooks/use-credentials-form"; +import { getAWSCredentialsTemplateScanLinks } from "@/lib"; import { ProviderCredentialFields } from "@/lib/provider-credentials/provider-credential-fields"; import { requiresBackButton } from "@/lib/provider-helpers"; import { @@ -61,6 +62,8 @@ export const BaseCredentialsForm = ({ successNavigationUrl, }); + const templateLinks = getAWSCredentialsTemplateScanLinks(externalId); + return (
} setValue={form.setValue as any} externalId={externalId} + templateLinks={templateLinks} /> )} {providerType === "aws" && searchParamsObj.get("via") !== "role" && ( diff --git a/ui/components/providers/workflow/forms/select-credentials-type/aws/credentials-type/aws-role-credentials-form.tsx b/ui/components/providers/workflow/forms/select-credentials-type/aws/credentials-type/aws-role-credentials-form.tsx index ebc6c35982..e540476efa 100644 --- a/ui/components/providers/workflow/forms/select-credentials-type/aws/credentials-type/aws-role-credentials-form.tsx +++ b/ui/components/providers/workflow/forms/select-credentials-type/aws/credentials-type/aws-role-credentials-form.tsx @@ -1,4 +1,5 @@ -import { Divider, Select, SelectItem, Spacer } from "@nextui-org/react"; +import { Divider, Select, SelectItem, Switch } from "@nextui-org/react"; +import { useState } from "react"; import { Control, UseFormSetValue, useWatch } from "react-hook-form"; import { CredentialsRoleHelper } from "@/components/providers/workflow"; @@ -10,35 +11,46 @@ export const AWSRoleCredentialsForm = ({ control, setValue, externalId, + templateLinks, + type = "providers", }: { control: Control; setValue: UseFormSetValue; externalId: string; + templateLinks: { + cloudformation: string; + cloudformationQuickLink: string; + terraform: string; + }; + type?: "providers" | "s3-integration"; }) => { + const [showRoleSection, setShowRoleSection] = useState(type === "providers"); + const credentialsType = useWatch({ control, name: ProviderCredentialFields.CREDENTIALS_TYPE, - defaultValue: "aws-sdk-default", + defaultValue: "access-secret-key", }); return ( <>
-
- Connect assuming IAM Role -
-
- Please provide the information for your AWS credentials. -
+ {type === "providers" && ( +
+ Connect assuming IAM Role +
+ )}
- Authentication + + Specify which AWS credentials to use + {credentialsType === "access-secret-key" && ( @@ -101,74 +113,97 @@ export const AWSRoleCredentialsForm = ({ /> )} - - Assume Role - + - + {type === "providers" ? ( + Assume Role + ) : ( +
+ + Optionally add a role + + +
+ )} - - + {showRoleSection && ( + <> + - Optional fields -
- - -
+ + + + + + Optional fields +
+ + +
+ + )} ); }; diff --git a/ui/components/ui/breadcrumbs/breadcrumb-navigation.tsx b/ui/components/ui/breadcrumbs/breadcrumb-navigation.tsx index 6f4daf047b..3f0703afb0 100644 --- a/ui/components/ui/breadcrumbs/breadcrumb-navigation.tsx +++ b/ui/components/ui/breadcrumbs/breadcrumb-navigation.tsx @@ -9,21 +9,17 @@ import { ReactNode } from "react"; export interface CustomBreadcrumbItem { name: string; path?: string; + icon?: string | ReactNode; isLast?: boolean; isClickable?: boolean; onClick?: () => void; } interface BreadcrumbNavigationProps { - // For automatic breadcrumbs (like navbar) mode?: "auto" | "custom" | "hybrid"; title?: string; icon?: string | ReactNode; - - // For custom breadcrumbs (like resource-detail) customItems?: CustomBreadcrumbItem[]; - - // Common options className?: string; paramToPreserve?: string; showTitle?: boolean; @@ -42,6 +38,21 @@ export function BreadcrumbNavigation({ const searchParams = useSearchParams(); const generateAutoBreadcrumbs = (): CustomBreadcrumbItem[] => { + const pathIconMapping: Record = { + "/integrations": "lucide:puzzle", + "/providers": "lucide:cloud", + "/users": "lucide:users", + "/compliance": "lucide:shield-check", + "/findings": "lucide:search", + "/scans": "lucide:activity", + "/roles": "lucide:key", + "/resources": "lucide:database", + "/lighthouse": "lucide:lightbulb", + "/manage-groups": "lucide:users-2", + "/services": "lucide:server", + "/workloads": "lucide:layers", + }; + const pathSegments = pathname .split("/") .filter((segment) => segment !== ""); @@ -66,9 +77,12 @@ export function BreadcrumbNavigation({ .join(" "); } + const segmentIcon = !isLast ? pathIconMapping[currentPath] : undefined; + breadcrumbs.push({ name: displayName, path: currentPath, + icon: segmentIcon, isLast, isClickable: !isLast, }); @@ -129,6 +143,18 @@ export function BreadcrumbNavigation({ href={buildNavigationUrl(breadcrumb.path)} className="flex cursor-pointer items-center space-x-2" > + {breadcrumb.icon && typeof breadcrumb.icon === "string" ? ( + + ) : breadcrumb.icon ? ( +
+ {breadcrumb.icon} +
+ ) : null} {breadcrumb.name} @@ -136,14 +162,40 @@ export function BreadcrumbNavigation({ ) : breadcrumb.isClickable && breadcrumb.onClick ? ( ) : ( - - {breadcrumb.name} - +
+ {breadcrumb.icon && typeof breadcrumb.icon === "string" ? ( + + ) : breadcrumb.icon ? ( +
+ {breadcrumb.icon} +
+ ) : null} + + {breadcrumb.name} + +
)} ))} diff --git a/ui/components/ui/content-layout/content-layout.tsx b/ui/components/ui/content-layout/content-layout.tsx index 181cdea764..6967128ea0 100644 --- a/ui/components/ui/content-layout/content-layout.tsx +++ b/ui/components/ui/content-layout/content-layout.tsx @@ -6,7 +6,7 @@ import { Navbar } from "../nav-bar/navbar"; interface ContentLayoutProps { title: string; - icon: string | ReactNode; + icon?: string | ReactNode; children: React.ReactNode; } diff --git a/ui/components/ui/form/form-buttons.tsx b/ui/components/ui/form/form-buttons.tsx index d5738fa33f..9b939b20c9 100644 --- a/ui/components/ui/form/form-buttons.tsx +++ b/ui/components/ui/form/form-buttons.tsx @@ -9,27 +9,43 @@ import { CustomButton } from "../custom"; interface FormCancelButtonProps { setIsOpen: Dispatch>; + onCancel?: () => void; children?: React.ReactNode; + leftIcon?: React.ReactNode; } interface FormSubmitButtonProps { children?: React.ReactNode; loadingText?: string; isDisabled?: boolean; + rightIcon?: React.ReactNode; } interface FormButtonsProps { setIsOpen: Dispatch>; + onCancel?: () => void; submitText?: string; cancelText?: string; loadingText?: string; isDisabled?: boolean; + rightIcon?: React.ReactNode; + leftIcon?: React.ReactNode; } -export const FormCancelButton = ({ +const FormCancelButton = ({ setIsOpen, + onCancel, children = "Cancel", + leftIcon, }: FormCancelButtonProps) => { + const handleCancel = () => { + if (onCancel) { + onCancel(); + } else { + setIsOpen(false); + } + }; + return ( setIsOpen(false)} + onPress={handleCancel} + startContent={leftIcon} > {children} ); }; -export const FormSubmitButton = ({ +const FormSubmitButton = ({ children = "Save", loadingText = "Loading", isDisabled = false, + rightIcon, }: FormSubmitButtonProps) => { const { pending } = useFormStatus(); @@ -61,7 +79,7 @@ export const FormSubmitButton = ({ size="lg" isLoading={pending} isDisabled={isDisabled} - startContent={!pending && } + startContent={!pending && rightIcon} > {pending ? <>{loadingText} : {children}} @@ -70,16 +88,29 @@ export const FormSubmitButton = ({ export const FormButtons = ({ setIsOpen, + onCancel, submitText = "Save", cancelText = "Cancel", loadingText = "Loading", isDisabled = false, + rightIcon = , + leftIcon, }: FormButtonsProps) => { return (
- {cancelText} + + {cancelText} + - + {submitText}
diff --git a/ui/components/ui/toast/Toast.tsx b/ui/components/ui/toast/Toast.tsx index 01940eb9bc..efba1de1c9 100644 --- a/ui/components/ui/toast/Toast.tsx +++ b/ui/components/ui/toast/Toast.tsx @@ -16,7 +16,7 @@ const ToastViewport = React.forwardRef< { } }; -export const getAWSCredentialsTemplateLinks = () => { +export const getAWSCredentialsTemplateScanLinks = (externalId: string) => { return { cloudformation: "https://github.com/prowler-cloud/prowler/blob/master/permissions/templates/cloudformation/prowler-scan-role.yml", - cloudformationQuickLink: - "https://us-east-1.console.aws.amazon.com/cloudformation/home?region=us-east-1#/stacks/quickcreate?templateURL=https%3A%2F%2Fprowler-cloud-public.s3.eu-west-1.amazonaws.com%2Fpermissions%2Ftemplates%2Faws%2Fcloudformation%2Fprowler-scan-role.yml&stackName=ProwlerScanRole¶m_ExternalId=", + cloudformationQuickLink: `https://us-east-1.console.aws.amazon.com/cloudformation/home?region=us-east-1#/stacks/quickcreate?templateURL=https%3A%2F%2Fprowler-cloud-public.s3.eu-west-1.amazonaws.com%2Fpermissions%2Ftemplates%2Faws%2Fcloudformation%2Fprowler-scan-role.yml&stackName=ProwlerScanRole¶m_ExternalId=${externalId}`, + terraform: + "https://github.com/prowler-cloud/prowler/blob/master/permissions/templates/terraform/main.tf", + }; +}; + +export const getAWSCredentialsTemplateBucketLinks = ( + bucketName: string, + externalId: string, +) => { + return { + cloudformation: + "https://github.com/prowler-cloud/prowler/blob/master/permissions/templates/cloudformation/prowler-scan-role.yml", + cloudformationQuickLink: `https://us-east-1.console.aws.amazon.com/cloudformation/home?region=us-east-1#/stacks/quickcreate?templateURL=https%3A%2F%2Fprowler-cloud-public.s3.eu-west-1.amazonaws.com%2Fpermissions%2Ftemplates%2Faws%2Fcloudformation%2Fprowler-scan-role-with-s3-integration.yml&stackName=ProwlerScanS3Integration¶m_AccountId=232136659152¶m_IAMPrincipal=role%2Fprowler*¶m_ExternalId=${externalId}¶m_S3IntegrationBucketName=${bucketName}`, terraform: "https://github.com/prowler-cloud/prowler/blob/master/permissions/templates/terraform/main.tf", }; diff --git a/ui/lib/helper.ts b/ui/lib/helper.ts index 1ec700e4d3..6e0b89fbbb 100644 --- a/ui/lib/helper.ts +++ b/ui/lib/helper.ts @@ -1,3 +1,5 @@ +import { revalidatePath } from "next/cache"; + import { getComplianceCsv, getExportsZip } from "@/actions/scans"; import { getTask } from "@/actions/task"; import { auth } from "@/auth.config"; @@ -285,19 +287,16 @@ export function decryptKey(passkey: string) { return atob(passkey); } -export const getErrorMessage = async (error: unknown): Promise => { - let message: string; - +export const getErrorMessage = (error: unknown): string => { if (error instanceof Error) { - message = error.message; + return error.message; } else if (error && typeof error === "object" && "message" in error) { - message = String(error.message); + return String(error.message); } else if (typeof error === "string") { - message = error; + return error; } else { - message = "Oops! Something went wrong."; + return "Oops! Something went wrong."; } - return message; }; export const permissionFormFields: PermissionInfo[] = [ @@ -341,3 +340,25 @@ export const permissionFormFields: PermissionInfo[] = [ description: "Provides access to billing settings and invoices", }, ]; + +// Helper function to handle API responses consistently +export const handleApiResponse = async ( + response: Response, + pathToRevalidate?: string, +) => { + const data = await response.json(); + + if (pathToRevalidate) { + revalidatePath(pathToRevalidate); + } + + return parseStringify(data); +}; + +// Helper function to handle API errors consistently +export const handleApiError = (error: unknown) => { + console.error(error); + return { + error: getErrorMessage(error), + }; +}; diff --git a/ui/lib/menu-list.ts b/ui/lib/menu-list.ts index 237540ff2d..41be0020be 100644 --- a/ui/lib/menu-list.ts +++ b/ui/lib/menu-list.ts @@ -7,6 +7,7 @@ import { Group, LayoutGrid, Mail, + Puzzle, Settings, ShieldCheck, SquareChartGantt, @@ -147,6 +148,7 @@ export const getMenuList = (pathname: string): GroupProps[] => { { href: "/providers", label: "Cloud Providers", icon: CloudCog }, { href: "/manage-groups", label: "Provider Groups", icon: Group }, { href: "/scans", label: "Scan Jobs", icon: Timer }, + { href: "/integrations", label: "Integrations", icon: Puzzle }, { href: "/roles", label: "Roles", icon: UserCog }, { href: "/lighthouse/config", label: "Lighthouse AI", icon: Cog }, ], diff --git a/ui/lib/provider-credentials/build-crendentials.ts b/ui/lib/provider-credentials/build-crendentials.ts index 0e2c96f990..475702db0c 100644 --- a/ui/lib/provider-credentials/build-crendentials.ts +++ b/ui/lib/provider-credentials/build-crendentials.ts @@ -1,11 +1,4 @@ -import { revalidatePath } from "next/cache"; - -import { - filterEmptyValues, - getErrorMessage, - getFormValue, - parseStringify, -} from "@/lib"; +import { filterEmptyValues, getFormValue } from "@/lib"; import { ProviderType } from "@/types"; import { ProviderCredentialFields } from "./provider-credential-fields"; @@ -240,25 +233,3 @@ export const buildSecretConfig = ( return builder(); }; - -// Helper function to handle API responses consistently -export const handleApiResponse = async ( - response: Response, - pathToRevalidate?: string, -) => { - const data = await response.json(); - - if (pathToRevalidate) { - revalidatePath(pathToRevalidate); - } - - return parseStringify(data); -}; - -// Helper function to handle API errors consistently -export const handleApiError = (error: unknown) => { - console.error(error); - return { - error: getErrorMessage(error), - }; -}; diff --git a/ui/types/components.ts b/ui/types/components.ts index 82dd41083b..9fc48b9fc0 100644 --- a/ui/types/components.ts +++ b/ui/types/components.ts @@ -190,7 +190,7 @@ export type AWSCredentials = { }; export type AWSCredentialsRole = { - [ProviderCredentialFields.ROLE_ARN]: string; + [ProviderCredentialFields.ROLE_ARN]?: string; [ProviderCredentialFields.AWS_ACCESS_KEY_ID]?: string; [ProviderCredentialFields.AWS_SECRET_ACCESS_KEY]?: string; [ProviderCredentialFields.AWS_SESSION_TOKEN]?: string; diff --git a/ui/types/integrations.ts b/ui/types/integrations.ts new file mode 100644 index 0000000000..6278fc51be --- /dev/null +++ b/ui/types/integrations.ts @@ -0,0 +1,142 @@ +import { z } from "zod"; + +export type IntegrationType = "amazon_s3" | "aws_security_hub" | "jira"; + +export interface IntegrationProps { + type: "integrations"; + id: string; + attributes: { + inserted_at: string; + updated_at: string; + enabled: boolean; + connected: boolean; + connection_last_checked_at: string | null; + integration_type: IntegrationType; + configuration: { + bucket_name?: string; + output_directory?: string; + credentials?: { + aws_access_key_id?: string; + aws_secret_access_key?: string; + aws_session_token?: string; + role_arn?: string; + external_id?: string; + role_session_name?: string; + session_duration?: number; + }; + [key: string]: any; + }; + url?: string; + }; + relationships?: { providers?: { data: { type: "providers"; id: string }[] } }; + links: { self: string }; +} + +const baseS3IntegrationSchema = z.object({ + integration_type: z.literal("amazon_s3"), + bucket_name: z.string().min(1, "Bucket name is required"), + output_directory: z.string().min(1, "Output directory is required"), + providers: z.array(z.string()).optional(), + // AWS Credentials fields compatible with AWSCredentialsRole + credentials_type: z.enum(["aws-sdk-default", "access-secret-key"]), + aws_access_key_id: z.string().optional(), + aws_secret_access_key: z.string().optional(), + aws_session_token: z.string().optional(), + // IAM Role fields + role_arn: z.string().optional(), + external_id: z.string().optional(), + role_session_name: z.string().optional(), + session_duration: z.string().optional(), +}); + +const s3IntegrationValidation = (data: any, ctx: z.RefinementCtx) => { + // If using access-secret-key, require AWS credentials (for create form) + if (data.credentials_type === "access-secret-key") { + if (!data.aws_access_key_id) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: + "AWS Access Key ID is required when using access and secret key", + path: ["aws_access_key_id"], + }); + } + if (!data.aws_secret_access_key) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: + "AWS Secret Access Key is required when using access and secret key", + path: ["aws_secret_access_key"], + }); + } + } + + // If role_arn is provided, external_id is required + if (data.role_arn && data.role_arn.trim() !== "") { + if (!data.external_id || data.external_id.trim() === "") { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "External ID is required when using Role ARN", + path: ["external_id"], + }); + } + } +}; + +const s3IntegrationEditValidation = (data: any, ctx: z.RefinementCtx) => { + // If using access-secret-key, and credentials are provided, require both + if (data.credentials_type === "access-secret-key") { + const hasAccessKey = !!data.aws_access_key_id; + const hasSecretKey = !!data.aws_secret_access_key; + + if (hasAccessKey && !hasSecretKey) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: + "AWS Secret Access Key is required when providing Access Key ID", + path: ["aws_secret_access_key"], + }); + } + + if (hasSecretKey && !hasAccessKey) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: + "AWS Access Key ID is required when providing Secret Access Key", + path: ["aws_access_key_id"], + }); + } + } + + // If role_arn is provided, external_id is required + if (data.role_arn && data.role_arn.trim() !== "") { + if (!data.external_id || data.external_id.trim() === "") { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "External ID is required when using Role ARN", + path: ["external_id"], + }); + } + } +}; + +export const s3IntegrationFormSchema = baseS3IntegrationSchema + .extend({ + credentials_type: z + .enum(["aws-sdk-default", "access-secret-key"]) + .default("aws-sdk-default"), + }) + .superRefine(s3IntegrationValidation); + +export const editS3IntegrationFormSchema = baseS3IntegrationSchema + .extend({ + bucket_name: z.string().min(1, "Bucket name is required").optional(), + output_directory: z + .string() + .min(1, "Output directory is required") + .optional(), + providers: z.array(z.string()).optional(), + credentials_type: z + .enum(["aws-sdk-default", "access-secret-key"]) + .optional(), + }) + .superRefine(s3IntegrationEditValidation);