mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-07-23 12:31:54 +00:00
feat(ui): S3 integration (#8391)
This commit is contained in:
+3
-1
@@ -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)
|
||||
|
||||
|
||||
@@ -1 +1,15 @@
|
||||
export * from "./saml";
|
||||
export {
|
||||
createIntegration,
|
||||
deleteIntegration,
|
||||
getIntegration,
|
||||
getIntegrations,
|
||||
testIntegrationConnection,
|
||||
updateIntegration,
|
||||
} from "./integrations";
|
||||
export {
|
||||
createSamlConfig,
|
||||
deleteSamlConfig,
|
||||
getSamlConfig,
|
||||
initiateSamlAuth,
|
||||
updateSamlConfig,
|
||||
} from "./saml";
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
+1
-1
@@ -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" && (
|
||||
|
||||
+111
-76
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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¶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",
|
||||
};
|
||||
|
||||
+29
-8
@@ -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),
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
Reference in New Issue
Block a user