feat(ui): S3 integration (#8391)

This commit is contained in:
Alejandro Bailo
2025-08-04 11:43:14 +02:00
committed by GitHub
parent eb1c1791e4
commit 9da5fb67c3
29 changed files with 2117 additions and 177 deletions
+3 -1
View File
@@ -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)
+15 -1
View File
@@ -1 +1,15 @@
export * from "./saml";
export {
createIntegration,
deleteIntegration,
getIntegration,
getIntegrations,
testIntegrationConnection,
updateIntegration,
} from "./integrations";
export {
createSamlConfig,
deleteSamlConfig,
getSamlConfig,
initiateSamlAuth,
updateSamlConfig,
} from "./saml";
+316
View File
@@ -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<any> => {
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);
}
};
+3 -5
View File
@@ -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";
+20 -3
View File
@@ -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 (
<ContentLayout title="Integrations" icon="tabler:puzzle">
<p>Integrations</p>
<ContentLayout title="Integrations" icon="lucide:puzzle">
<div className="space-y-6">
<div className="space-y-4">
<h2 className="text-xl font-semibold">Available Integrations</h2>
<p className="text-sm text-gray-600 dark:text-gray-300">
Connect external services to enhance your security workflow and
automatically export your scan results.
</p>
</div>
<div className="grid gap-6">
{/* Amazon S3 Integration */}
<S3IntegrationCard integrations={integrations?.data || []} />
</div>
</div>
</ContentLayout>
);
}
+60
View File
@@ -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 (
<ContentLayout title="Amazon S3">
<div className="space-y-6">
<div className="space-y-4">
<p className="text-sm text-gray-600 dark:text-gray-300">
Configure Amazon S3 integration to automatically export your scan
results to S3 buckets.
</p>
<div className="rounded-lg border border-gray-200 bg-gray-50 p-4 dark:border-gray-700 dark:bg-gray-800">
<h3 className="mb-3 text-sm font-semibold text-gray-900 dark:text-gray-100">
Features:
</h3>
<ul className="grid grid-cols-1 gap-2 text-sm text-gray-600 dark:text-gray-300 md:grid-cols-2">
<li className="flex items-center gap-2">
<span className="h-1.5 w-1.5 rounded-full bg-green-500" />
Automated scan result exports
</li>
<li className="flex items-center gap-2">
<span className="h-1.5 w-1.5 rounded-full bg-green-500" />
Multiple bucket support
</li>
<li className="flex items-center gap-2">
<span className="h-1.5 w-1.5 rounded-full bg-green-500" />
Configurable export paths
</li>
<li className="flex items-center gap-2">
<span className="h-1.5 w-1.5 rounded-full bg-green-500" />
IAM role and static credentials
</li>
</ul>
</div>
</div>
<S3IntegrationsManager
integrations={s3Integrations}
providers={availableProviders}
/>
</div>
</ContentLayout>
);
}
+1 -1
View File
@@ -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";
@@ -1 +0,0 @@
export * from "./saml-config-form";
+7 -2
View File
@@ -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";
@@ -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 (
<S3IntegrationCardSkeleton
variant="main"
count={s3Integrations.length || 1}
/>
);
}
return (
<Card className="dark:bg-gray-800">
<CardHeader className="gap-2">
<div className="flex w-full items-center justify-between">
<div className="flex items-center gap-3">
<AmazonS3Icon size={40} />
<div className="flex flex-col gap-1">
<h4 className="text-lg font-bold text-gray-900 dark:text-gray-100">
Amazon S3
</h4>
<div className="flex items-center gap-2">
<p className="text-xs text-gray-500 dark:text-gray-300">
Export security findings to Amazon S3 buckets.
</p>
{/* Todo: add real DOCS, use CustomLink when available */}
<Link
href="https://docs.prowler.com/projects/prowler-open-source/en/latest/tutorials/prowler-app/"
target="_blank"
rel="noopener noreferrer"
className="text-xs text-primary"
aria-label="Learn more about S3 integration"
>
Learn more
</Link>
</div>
</div>
</div>
<div className="flex items-center gap-2">
{isConfigured && (
<Chip
size="sm"
color={connectedCount > 0 ? "success" : "warning"}
variant="flat"
>
{connectedCount} / {s3Integrations.length} connected
</Chip>
)}
<CustomButton
size="sm"
variant="bordered"
startContent={<SettingsIcon size={14} />}
asLink="/integrations/s3"
ariaLabel={
isConfigured
? "Manage S3 integrations"
: "Configure S3 integration"
}
>
{isConfigured ? "Manage" : "Configure"}
</CustomButton>
</div>
</div>
</CardHeader>
<CardBody>
<div className="flex flex-col gap-4">
{isConfigured ? (
<>
<div className="space-y-2">
{s3Integrations.map((integration) => (
<div
key={integration.id}
className="flex items-center justify-between rounded-lg border border-gray-200 bg-gray-50 p-3 dark:border-gray-700 dark:bg-gray-800"
>
<div className="flex flex-col">
<span className="text-sm font-medium">
{integration.attributes.configuration.bucket_name ||
"Unknown Bucket"}
</span>
<span className="text-xs text-gray-500 dark:text-gray-300">
Output directory:{" "}
{integration.attributes.configuration
.output_directory ||
integration.attributes.configuration.path ||
"/"}
</span>
</div>
<Chip
size="sm"
color={
integration.attributes.connected ? "success" : "danger"
}
variant="dot"
>
{integration.attributes.connected
? "Connected"
: "Disconnected"}
</Chip>
</div>
))}
</div>
</>
) : (
<>
<div className="text-sm">
<span className="font-medium">Status: </span>
<span className="text-gray-500">Not configured</span>
</div>
<div className="space-y-3">
<p className="text-sm text-gray-600 dark:text-gray-300">
Export your security findings to Amazon S3 buckets
automatically.
</p>
</div>
</>
)}
</div>
</CardBody>
</Card>
);
};
@@ -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 (
<AWSRoleCredentialsForm
control={form.control as unknown as Control<AWSCredentialsRole>}
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 */}
<div className="space-y-4">
<ProviderSelector
control={form.control}
name="providers"
providers={providers}
label="Cloud Providers"
placeholder="Select providers to integrate with"
isInvalid={!!form.formState.errors.providers}
/>
</div>
<Divider />
{/* S3 Configuration */}
<div className="space-y-4">
<CustomInput
control={form.control}
name="bucket_name"
type="text"
label="Bucket name"
labelPlacement="inside"
placeholder="my-security-findings-bucket"
variant="bordered"
isRequired
isInvalid={!!form.formState.errors.bucket_name}
/>
<CustomInput
control={form.control}
name="output_directory"
type="text"
label="Output directory"
labelPlacement="inside"
placeholder="/prowler-findings/"
variant="bordered"
isRequired
isInvalid={!!form.formState.errors.output_directory}
/>
</div>
</>
);
}
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 (
<FormButtons
setIsOpen={() => {}}
onCancel={onCancel}
submitText={updateText}
cancelText="Cancel"
loadingText={loadingText}
isDisabled={isLoading}
/>
);
}
// Creation flow - step 0
if (currentStep === 0) {
return (
<FormButtons
setIsOpen={() => {}}
onCancel={onCancel}
submitText="Next"
cancelText="Cancel"
loadingText="Processing..."
isDisabled={isLoading}
rightIcon={<ArrowRightIcon size={24} />}
/>
);
}
// Creation flow - step 1 (final step)
return (
<FormButtons
setIsOpen={() => {}}
onCancel={handleBack}
submitText="Create Integration"
cancelText="Back"
loadingText="Creating..."
isDisabled={isLoading}
/>
);
};
return (
<Form {...form}>
<form
onSubmit={
// For edit modes, always submit
isEditingConfig || isEditingCredentials
? form.handleSubmit(onSubmit)
: // For creation flow, handle step logic
currentStep === 0
? handleNext
: form.handleSubmit(onSubmit)
}
className="flex flex-col space-y-6"
>
<div className="flex flex-col space-y-4">
<p className="flex items-center gap-2 text-sm text-default-500">
Need help connecting your AWS account?
<CustomLink
href="https://goto.prowler.com/provider-aws"
target="_blank"
size="sm"
>
Read the docs
</CustomLink>
</p>
{renderStepContent()}
</div>
{renderStepButtons()}
</form>
</Form>
);
};
@@ -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<IntegrationProps | null>(null);
const [editMode, setEditMode] = useState<
"configuration" | "credentials" | null
>(null);
const [isDeleting, setIsDeleting] = useState<string | null>(null);
const [isTesting, setIsTesting] = useState<string | null>(null);
const [isOperationLoading, setIsOperationLoading] = useState(false);
const [isDeleteOpen, setIsDeleteOpen] = useState(false);
const [integrationToDelete, setIntegrationToDelete] =
useState<IntegrationProps | null>(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 (
<>
<CustomAlertModal
isOpen={isDeleteOpen}
onOpenChange={setIsDeleteOpen}
title="Delete S3 Integration"
description="This action cannot be undone. This will permanently delete your S3 integration."
>
<div className="flex w-full justify-center space-x-6">
<CustomButton
type="button"
ariaLabel="Cancel"
className="w-full bg-transparent"
variant="faded"
size="lg"
onPress={() => {
setIsDeleteOpen(false);
setIntegrationToDelete(null);
}}
isDisabled={isDeleting !== null}
>
<span>Cancel</span>
</CustomButton>
<CustomButton
type="button"
ariaLabel="Delete"
className="w-full"
variant="solid"
color="danger"
size="lg"
isLoading={isDeleting !== null}
startContent={!isDeleting && <Trash2Icon size={24} />}
onPress={() =>
integrationToDelete &&
handleDeleteIntegration(integrationToDelete.id)
}
>
{isDeleting ? "Deleting..." : "Delete"}
</CustomButton>
</div>
</CustomAlertModal>
<CustomAlertModal
isOpen={isModalOpen}
onOpenChange={setIsModalOpen}
title={
editMode === "configuration"
? "Edit Configuration"
: editMode === "credentials"
? "Edit Credentials"
: editingIntegration
? "Edit S3 Integration"
: "Add S3 Integration"
}
>
<S3IntegrationForm
integration={editingIntegration}
providers={providers}
onSuccess={handleFormSuccess}
onCancel={handleModalClose}
editMode={editMode}
/>
</CustomAlertModal>
<div className="space-y-6">
{/* Header with Add Button */}
<div className="flex items-center justify-between">
<div>
<h3 className="text-lg font-semibold">
Configured S3 Integrations
</h3>
<p className="text-sm text-gray-500 dark:text-gray-300">
{integrations.length === 0
? "Not configured yet"
: `${integrations.length} integration${integrations.length !== 1 ? "s" : ""} configured`}
</p>
</div>
<CustomButton
color="action"
startContent={<PlusIcon size={16} />}
onPress={handleAddIntegration}
ariaLabel="Add integration"
>
Add Integration
</CustomButton>
</div>
{/* Integrations List */}
{isOperationLoading ? (
<S3IntegrationCardSkeleton
variant="manager"
count={integrations.length || 1}
/>
) : integrations.length > 0 ? (
<div className="grid gap-4">
{integrations.map((integration) => (
<Card key={integration.id} className="dark:bg-gray-800">
<CardHeader className="pb-2">
<div className="flex w-full items-center justify-between">
<div className="flex items-center gap-3">
<AmazonS3Icon size={32} />
<div>
<h4 className="text-md font-semibold">
{integration.attributes.configuration.bucket_name ||
"Unknown Bucket"}
</h4>
<p className="text-xs text-gray-500 dark:text-gray-300">
Output directory:{" "}
{integration.attributes.configuration
.output_directory ||
integration.attributes.configuration.path ||
"/"}
</p>
</div>
</div>
<Chip
size="sm"
color={
integration.attributes.connected ? "success" : "danger"
}
variant="flat"
>
{integration.attributes.connected
? "Connected"
: "Disconnected"}
</Chip>
</div>
</CardHeader>
<CardBody className="pt-0">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="text-xs text-gray-500 dark:text-gray-300">
{integration.attributes.connection_last_checked_at && (
<p>
<span className="font-medium">Last checked:</span>{" "}
{new Date(
integration.attributes.connection_last_checked_at,
).toLocaleDateString()}
</p>
)}
</div>
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
<CustomButton
size="sm"
variant="bordered"
startContent={<TestTube size={14} />}
onPress={() => handleTestConnection(integration.id)}
isLoading={isTesting === integration.id}
isDisabled={!integration.attributes.enabled}
ariaLabel="Test connection"
className="w-full sm:w-auto"
>
Test
</CustomButton>
<CustomButton
size="sm"
variant="bordered"
startContent={<SettingsIcon size={14} />}
onPress={() => handleEditConfiguration(integration)}
ariaLabel="Edit configuration"
className="w-full sm:w-auto"
>
Config
</CustomButton>
<CustomButton
size="sm"
variant="bordered"
startContent={<SettingsIcon size={14} />}
onPress={() => handleEditCredentials(integration)}
ariaLabel="Edit credentials"
className="w-full sm:w-auto"
>
Credentials
</CustomButton>
<CustomButton
size="sm"
variant="bordered"
color={
integration.attributes.enabled ? "warning" : "primary"
}
startContent={<Power size={14} />}
onPress={() => handleToggleEnabled(integration)}
ariaLabel={
integration.attributes.enabled
? "Disable integration"
: "Enable integration"
}
className="w-full sm:w-auto"
>
{integration.attributes.enabled ? "Disable" : "Enable"}
</CustomButton>
<CustomButton
size="sm"
color="danger"
variant="bordered"
startContent={<Trash2Icon size={14} />}
onPress={() => handleOpenDeleteModal(integration)}
ariaLabel="Delete integration"
className="w-full sm:w-auto"
>
Delete
</CustomButton>
</div>
</div>
</CardBody>
</Card>
))}
</div>
) : null}
</div>
</>
);
};
@@ -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 (
<Card className="dark:bg-prowler-blue-400">
<CardHeader className="gap-2">
<div className="flex w-full items-center justify-between">
<div className="flex items-center gap-3">
<AmazonS3Icon size={40} />
<div className="flex flex-col gap-1">
<h4 className="text-lg font-bold">Amazon S3</h4>
<div className="flex items-center gap-2">
<p className="text-xs text-gray-500">
Export security findings to Amazon S3 buckets.
</p>
<Skeleton className="h-3 w-16 rounded" />
</div>
</div>
</div>
<div className="flex items-center gap-2">
<Skeleton className="h-6 w-20 rounded-full" />
<Skeleton className="h-8 w-20 rounded-lg" />
</div>
</div>
</CardHeader>
<CardBody>
<div className="flex flex-col gap-4">
<div className="space-y-2">
{Array.from({ length: count }).map((_, index) => (
<div
key={index}
className="flex items-center justify-between rounded-lg border p-3"
>
<div className="flex flex-col gap-1">
<Skeleton className="h-4 w-32 rounded" />
<Skeleton className="h-3 w-48 rounded" />
</div>
<Skeleton className="h-6 w-20 rounded-full" />
</div>
))}
</div>
</div>
</CardBody>
</Card>
);
}
// Manager variant - for individual cards in S3IntegrationsManager
return (
<div className="grid gap-4">
{Array.from({ length: count }).map((_, index) => (
<Card key={index} className="dark:bg-prowler-blue-400">
<CardHeader className="pb-2">
<div className="flex w-full items-center justify-between">
<div className="flex items-center gap-3">
<AmazonS3Icon size={32} />
<div className="flex flex-col gap-1">
<Skeleton className="h-5 w-40 rounded" />
<Skeleton className="h-3 w-32 rounded" />
</div>
</div>
<Skeleton className="h-6 w-20 rounded-full" />
</div>
</CardHeader>
<CardBody className="pt-0">
<div className="flex items-center justify-between">
<div className="space-y-1">
<Skeleton className="h-3 w-48 rounded" />
<Skeleton className="h-3 w-36 rounded" />
</div>
<div className="flex items-center gap-2">
<Skeleton className="h-8 w-16 rounded" />
<Skeleton className="h-8 w-16 rounded" />
<Skeleton className="h-8 w-20 rounded" />
</div>
</div>
</CardBody>
</Card>
))}
</div>
);
};
@@ -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);
@@ -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<ProviderType, string> = {
aws: "Amazon Web Services",
gcp: "Google Cloud Platform",
azure: "Microsoft Azure",
m365: "Microsoft 365",
kubernetes: "Kubernetes",
github: "GitHub",
};
interface ProviderSelectorProps {
control: Control<any>;
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 (
<FormField
control={control}
name={name}
render={({ field: { onChange, value, onBlur } }) => {
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 (
<>
<FormControl>
<div className="space-y-2">
<div className="flex items-center justify-between">
<span className="text-sm font-medium text-default-700">
{label}
</span>
{sortedProviders.length > 1 && (
<Button
size="sm"
variant="light"
onPress={handleSelectAll}
startContent={
isAllSelected ? (
<CheckSquare size={16} />
) : (
<Square size={16} />
)
}
className="h-7 text-xs"
>
{isAllSelected ? "Deselect All" : "Select All"}
</Button>
)}
</div>
<Select
label={label}
placeholder={placeholder}
selectionMode="multiple"
selectedKeys={new Set(value || [])}
onSelectionChange={(keys) => {
const selectedArray = Array.from(keys);
onChange(selectedArray);
}}
onBlur={onBlur}
variant="bordered"
labelPlacement="inside"
isRequired={false}
isInvalid={isInvalid}
classNames={{
trigger: "min-h-12",
popoverContent: "dark:bg-gray-800",
listboxWrapper: "max-h-[300px] dark:bg-gray-800",
listbox: "gap-0",
label:
"tracking-tight font-light !text-default-500 text-xs !z-0",
value: "text-default-500 text-small dark:text-gray-300",
}}
renderValue={(items) => {
if (items.length === 0) {
return (
<span className="text-default-500">{placeholder}</span>
);
}
if (items.length === 1) {
const provider = providers.find(
(p) => p.id === items[0].key,
);
if (provider) {
const displayName =
provider.attributes.alias || provider.attributes.uid;
return (
<div className="flex items-center gap-2">
<span className="truncate">{displayName}</span>
</div>
);
}
}
return (
<span className="text-small">
{items.length} provider{items.length !== 1 ? "s" : ""}{" "}
selected
</span>
);
}}
>
{sortedProviders.map((provider) => {
const providerType = provider.attributes.provider;
const displayName =
provider.attributes.alias || provider.attributes.uid;
const typeLabel = providerTypeLabels[providerType];
return (
<SelectItem
key={provider.id}
textValue={`${displayName} ${typeLabel}`}
className="py-2"
>
<div className="flex w-full items-center justify-between">
<div className="flex min-w-0 flex-1 items-center gap-3">
<div className="min-w-0 flex-1">
<div className="truncate text-small font-medium">
{displayName}
</div>
<div className="truncate text-tiny text-default-500">
{typeLabel}
</div>
</div>
</div>
<div className="ml-2 flex flex-shrink-0 items-center gap-2">
<div
className={`h-2 w-2 rounded-full ${
provider.attributes.connection.connected
? "bg-success"
: "bg-danger"
}`}
title={
provider.attributes.connection.connected
? "Connected"
: "Disconnected"
}
/>
</div>
</div>
</SelectItem>
);
})}
</Select>
</div>
</FormControl>
{showFormMessage && (
<FormMessage className="max-w-full text-xs text-system-error dark:text-system-error" />
)}
</>
);
}}
/>
);
};
@@ -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 (
<div className="flex flex-col gap-2">
<div className="flex flex-col gap-4">
<p className="text-sm text-gray-600 dark:text-gray-400">
A <strong>new read-only IAM role</strong> must be manually created.
A <strong>read-only IAM role</strong> must be manually created
{type === "s3-integration" ? " or updated" : ""}.
</p>
<CustomButton
ariaLabel="Use the following AWS CloudFormation Quick Link to deploy the IAM Role"
color="transparent"
className="h-auto w-fit min-w-0 p-0 text-blue-500"
asLink={`${getAWSCredentialsTemplateLinks().cloudformationQuickLink}${session?.tenantId}`}
asLink={templateLinks.cloudformationQuickLink}
target="_blank"
>
Use the following AWS CloudFormation Quick Link to deploy the IAM Role
Use the following AWS CloudFormation Quick Link to create the IAM Role
</CustomButton>
<div className="flex items-center gap-2">
@@ -33,8 +44,11 @@ export const CredentialsRoleHelper = () => {
</span>
<div className="h-px flex-1 bg-gray-200 dark:bg-gray-700" />
</div>
<p className="text-sm text-gray-600 dark:text-gray-400">
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"}
</p>
<div className="flex w-fit flex-col gap-2">
@@ -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" : ""}
</CustomButton>
<CustomButton
ariaLabel="Terraform Code"
color="transparent"
className="h-auto w-fit min-w-0 p-0 text-blue-500"
asLink={getAWSCredentialsTemplateLinks().terraform}
asLink={templateLinks.terraform}
target="_blank"
>
Terraform Code
Terraform {type === "providers" ? "Code" : ""}
</CustomButton>
</div>
<p className="text-xs font-bold text-gray-600 dark:text-gray-400">
The External ID will also be required:
</p>
<Snippet
className="max-w-full bg-gray-50 py-1 dark:bg-slate-800"
color="warning"
hideSymbol
>
<p className="whitespace-pre-line text-xs font-bold">
{session?.tenantId}
</p>
</Snippet>
<div className="flex items-center gap-2">
<span className="block text-xs font-medium text-default-500">
External ID:
</span>
<SnippetChip value={externalId} icon={<IdIcon size={16} />} />
</div>
</div>
</div>
);
@@ -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 (
<Form {...form}>
<form
@@ -87,6 +90,7 @@ export const BaseCredentialsForm = ({
control={form.control as unknown as Control<AWSCredentialsRole>}
setValue={form.setValue as any}
externalId={externalId}
templateLinks={templateLinks}
/>
)}
{providerType === "aws" && searchParamsObj.get("via") !== "role" && (
@@ -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<AWSCredentialsRole>;
setValue: UseFormSetValue<AWSCredentialsRole>;
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 (
<>
<div className="flex flex-col">
<div className="text-md font-bold leading-9 text-default-foreground">
Connect assuming IAM Role
</div>
<div className="text-sm text-default-500">
Please provide the information for your AWS credentials.
</div>
{type === "providers" && (
<div className="text-md font-bold leading-9 text-default-foreground">
Connect assuming IAM Role
</div>
)}
</div>
<span className="text-xs font-bold text-default-500">Authentication</span>
<span className="text-xs font-bold text-default-500">
Specify which AWS credentials to use
</span>
<Select
name={ProviderCredentialFields.CREDENTIALS_TYPE}
label="Authentication Method"
placeholder="Select credentials type"
defaultSelectedKeys={["aws-sdk-default"]}
defaultSelectedKeys={["access-secret-key"]}
className="mb-4"
variant="bordered"
onSelectionChange={(keys) =>
@@ -49,7 +61,7 @@ export const AWSRoleCredentialsForm = ({
}
>
<SelectItem key="aws-sdk-default">AWS SDK default</SelectItem>
<SelectItem key="access-secret-key">Access & secret key</SelectItem>
<SelectItem key="access-secret-key">Access & Secret Key</SelectItem>
</Select>
{credentialsType === "access-secret-key" && (
@@ -101,74 +113,97 @@ export const AWSRoleCredentialsForm = ({
/>
</>
)}
<Divider />
<span className="text-xs font-bold text-default-500">Assume Role</span>
<CredentialsRoleHelper />
<Divider className="" />
<Spacer y={2} />
{type === "providers" ? (
<span className="text-xs font-bold text-default-500">Assume Role</span>
) : (
<div className="flex items-center justify-between">
<span className="text-xs font-bold text-default-500">
Optionally add a role
</span>
<Switch
size="sm"
isSelected={showRoleSection}
onValueChange={setShowRoleSection}
/>
</div>
)}
<CustomInput
control={control}
name={ProviderCredentialFields.ROLE_ARN}
type="text"
label="Role ARN"
labelPlacement="inside"
placeholder="Enter the Role ARN"
variant="bordered"
isRequired
isInvalid={
!!control._formState.errors[ProviderCredentialFields.ROLE_ARN]
}
/>
<CustomInput
control={control}
name={ProviderCredentialFields.EXTERNAL_ID}
type="text"
label="External ID"
labelPlacement="inside"
placeholder={externalId}
variant="bordered"
defaultValue={externalId}
isDisabled
isRequired
isInvalid={
!!control._formState.errors[ProviderCredentialFields.EXTERNAL_ID]
}
/>
{showRoleSection && (
<>
<CredentialsRoleHelper
externalId={externalId}
templateLinks={templateLinks}
type={type}
/>
<span className="text-xs text-default-500">Optional fields</span>
<div className="mt-4 grid grid-cols-1 gap-4 md:grid-cols-2">
<CustomInput
control={control}
name={ProviderCredentialFields.ROLE_SESSION_NAME}
type="text"
label="Role Session Name"
labelPlacement="inside"
placeholder="Enter the Role Session Name"
variant="bordered"
isRequired={false}
isInvalid={
!!control._formState.errors[
ProviderCredentialFields.ROLE_SESSION_NAME
]
}
/>
<CustomInput
control={control}
name={ProviderCredentialFields.SESSION_DURATION}
type="number"
label="Session Duration (seconds)"
labelPlacement="inside"
placeholder="Enter the session duration (default: 3600)"
variant="bordered"
isRequired={false}
isInvalid={
!!control._formState.errors[
ProviderCredentialFields.SESSION_DURATION
]
}
/>
</div>
<Divider />
<CustomInput
control={control}
name={ProviderCredentialFields.ROLE_ARN}
type="text"
label="Role ARN"
labelPlacement="inside"
placeholder="Enter the Role ARN"
variant="bordered"
isRequired={type === "providers"}
isInvalid={
!!control._formState.errors[ProviderCredentialFields.ROLE_ARN]
}
/>
<CustomInput
control={control}
name={ProviderCredentialFields.EXTERNAL_ID}
type="text"
label="External ID"
labelPlacement="inside"
placeholder={externalId}
variant="bordered"
defaultValue={externalId}
isDisabled
isRequired
isInvalid={
!!control._formState.errors[ProviderCredentialFields.EXTERNAL_ID]
}
/>
<span className="text-xs text-default-500">Optional fields</span>
<div className="mt-4 grid grid-cols-1 gap-4 md:grid-cols-2">
<CustomInput
control={control}
name={ProviderCredentialFields.ROLE_SESSION_NAME}
type="text"
label="Role session name"
labelPlacement="inside"
placeholder="Enter the role session name"
variant="bordered"
isRequired={false}
isInvalid={
!!control._formState.errors[
ProviderCredentialFields.ROLE_SESSION_NAME
]
}
/>
<CustomInput
control={control}
name={ProviderCredentialFields.SESSION_DURATION}
type="number"
label="Session duration (seconds)"
labelPlacement="inside"
placeholder="Enter the session duration (default: 3600 seconds)"
variant="bordered"
isRequired={false}
isInvalid={
!!control._formState.errors[
ProviderCredentialFields.SESSION_DURATION
]
}
/>
</div>
</>
)}
</>
);
};
@@ -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<string, string> = {
"/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" ? (
<Icon
className="text-default-500"
height={24}
icon={breadcrumb.icon}
width={24}
/>
) : breadcrumb.icon ? (
<div className="flex h-6 w-6 items-center justify-center [&>*]:h-full [&>*]:w-full">
{breadcrumb.icon}
</div>
) : null}
<span className="text-wrap text-sm font-bold text-default-700 transition-colors hover:text-primary">
{breadcrumb.name}
</span>
@@ -136,14 +162,40 @@ export function BreadcrumbNavigation({
) : breadcrumb.isClickable && breadcrumb.onClick ? (
<button
onClick={breadcrumb.onClick}
className="cursor-pointer text-wrap text-sm font-medium text-primary transition-colors hover:text-primary-600"
className="flex cursor-pointer items-center space-x-2 text-wrap text-sm font-medium text-primary transition-colors hover:text-primary-600"
>
{breadcrumb.name}
{breadcrumb.icon && typeof breadcrumb.icon === "string" ? (
<Icon
className="text-default-500"
height={24}
icon={breadcrumb.icon}
width={24}
/>
) : breadcrumb.icon ? (
<div className="flex h-6 w-6 items-center justify-center [&>*]:h-full [&>*]:w-full">
{breadcrumb.icon}
</div>
) : null}
<span>{breadcrumb.name}</span>
</button>
) : (
<span className="text-wrap text-sm font-medium text-gray-900 dark:text-gray-100">
{breadcrumb.name}
</span>
<div className="flex items-center space-x-2">
{breadcrumb.icon && typeof breadcrumb.icon === "string" ? (
<Icon
className="text-default-500"
height={24}
icon={breadcrumb.icon}
width={24}
/>
) : breadcrumb.icon ? (
<div className="flex h-6 w-6 items-center justify-center [&>*]:h-full [&>*]:w-full">
{breadcrumb.icon}
</div>
) : null}
<span className="text-wrap text-sm font-medium text-gray-900 dark:text-gray-100">
{breadcrumb.name}
</span>
</div>
)}
</BreadcrumbItem>
))}
@@ -6,7 +6,7 @@ import { Navbar } from "../nav-bar/navbar";
interface ContentLayoutProps {
title: string;
icon: string | ReactNode;
icon?: string | ReactNode;
children: React.ReactNode;
}
+37 -6
View File
@@ -9,27 +9,43 @@ import { CustomButton } from "../custom";
interface FormCancelButtonProps {
setIsOpen: Dispatch<SetStateAction<boolean>>;
onCancel?: () => void;
children?: React.ReactNode;
leftIcon?: React.ReactNode;
}
interface FormSubmitButtonProps {
children?: React.ReactNode;
loadingText?: string;
isDisabled?: boolean;
rightIcon?: React.ReactNode;
}
interface FormButtonsProps {
setIsOpen: Dispatch<SetStateAction<boolean>>;
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 (
<CustomButton
type="button"
@@ -37,17 +53,19 @@ export const FormCancelButton = ({
className="w-full bg-transparent"
variant="faded"
size="lg"
onPress={() => setIsOpen(false)}
onPress={handleCancel}
startContent={leftIcon}
>
<span>{children}</span>
</CustomButton>
);
};
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 && <SaveIcon size={24} />}
startContent={!pending && rightIcon}
>
{pending ? <>{loadingText}</> : <span>{children}</span>}
</CustomButton>
@@ -70,16 +88,29 @@ export const FormSubmitButton = ({
export const FormButtons = ({
setIsOpen,
onCancel,
submitText = "Save",
cancelText = "Cancel",
loadingText = "Loading",
isDisabled = false,
rightIcon = <SaveIcon size={24} />,
leftIcon,
}: FormButtonsProps) => {
return (
<div className="flex w-full justify-center space-x-6">
<FormCancelButton setIsOpen={setIsOpen}>{cancelText}</FormCancelButton>
<FormCancelButton
setIsOpen={setIsOpen}
onCancel={onCancel}
leftIcon={leftIcon}
>
{cancelText}
</FormCancelButton>
<FormSubmitButton loadingText={loadingText} isDisabled={isDisabled}>
<FormSubmitButton
loadingText={loadingText}
isDisabled={isDisabled}
rightIcon={rightIcon}
>
{submitText}
</FormSubmitButton>
</div>
+1 -1
View File
@@ -16,7 +16,7 @@ const ToastViewport = React.forwardRef<
<ToastPrimitives.Viewport
ref={ref}
className={cn(
"fixed top-0 z-[100] flex max-h-screen w-full flex-col-reverse p-4 sm:bottom-0 sm:right-0 sm:top-auto sm:flex-col md:max-w-[420px]",
"fixed top-0 z-[100] flex max-h-screen w-full flex-col-reverse gap-2 p-4 sm:bottom-0 sm:right-0 sm:top-auto sm:flex-col md:max-w-[420px]",
className,
)}
{...props}
+15 -3
View File
@@ -38,12 +38,24 @@ export const getProviderHelpText = (provider: string) => {
}
};
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&param_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&param_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&param_AccountId=232136659152&param_IAMPrincipal=role%2Fprowler*&param_ExternalId=${externalId}&param_S3IntegrationBucketName=${bucketName}`,
terraform:
"https://github.com/prowler-cloud/prowler/blob/master/permissions/templates/terraform/main.tf",
};
+29 -8
View File
@@ -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<string> => {
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),
};
};
+2
View File
@@ -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 },
],
@@ -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),
};
};
+1 -1
View File
@@ -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;
+142
View File
@@ -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);