mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-07-24 04:51:51 +00:00
feat(ui): add GitHub integration UI components
Add complete frontend implementation for GitHub integration following the same pattern as Jira integration. Components: - GitHubIntegrationForm: Form for creating/editing GitHub integrations - GitHubIntegrationsManager: Manager component for listing and managing integrations - GitHubIntegrationCard: Card component for main integrations page - GitHub integration page at /integrations/github Features: - Personal Access Token input with validation - Optional repository owner filter - Connection testing and repository discovery - Enable/disable integration toggle - Edit credentials functionality - Delete integration with confirmation - Pagination support - Integration status display with last checked timestamp Server Actions: - getGitHubIntegrations(): Fetch enabled GitHub integrations - sendFindingToGitHub(): Send finding to GitHub as issue - pollGitHubDispatchTask(): Poll async task completion Types & Schemas: - githubIntegrationFormSchema: Zod schema for creation - editGitHubIntegrationFormSchema: Zod schema for editing - GitHubCredentialsPayload: TypeScript interface for credentials - GitHubDispatchRequest/Response: API types for dispatching Integration Page: - Created /integrations/github page with features list - Added GitHub card to main integrations overview - Exported GitHub components from integrations index 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,165 @@
|
||||
"use server";
|
||||
|
||||
import { pollTaskUntilSettled } from "@/actions/task/poll";
|
||||
import { apiBaseUrl, getAuthHeaders } from "@/lib";
|
||||
import { handleApiError } from "@/lib/server-actions-helper";
|
||||
import type { IntegrationProps } from "@/types/integrations";
|
||||
|
||||
export interface GitHubDispatchRequest {
|
||||
data: {
|
||||
type: "integrations-github-dispatches";
|
||||
attributes: {
|
||||
repository: string;
|
||||
labels?: string[];
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
export interface GitHubDispatchResponse {
|
||||
data: {
|
||||
id: string;
|
||||
type: "tasks";
|
||||
attributes: {
|
||||
state: string;
|
||||
result?: {
|
||||
created_count?: number;
|
||||
failed_count?: number;
|
||||
error?: string;
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
export const getGitHubIntegrations = async (): Promise<
|
||||
| { success: true; data: IntegrationProps[] }
|
||||
| { success: false; error: string }
|
||||
> => {
|
||||
const headers = await getAuthHeaders({ contentType: false });
|
||||
const url = new URL(`${apiBaseUrl}/integrations`);
|
||||
|
||||
// Filter for GitHub integrations only
|
||||
url.searchParams.append("filter[integration_type]", "github");
|
||||
|
||||
try {
|
||||
const response = await fetch(url.toString(), { method: "GET", headers });
|
||||
|
||||
if (response.ok) {
|
||||
const data: { data: IntegrationProps[] } = await response.json();
|
||||
// Filter for enabled integrations on the client side
|
||||
const enabledIntegrations = (data.data || []).filter(
|
||||
(integration: IntegrationProps) =>
|
||||
integration.attributes.enabled === true,
|
||||
);
|
||||
return { success: true, data: enabledIntegrations };
|
||||
}
|
||||
|
||||
const errorData: unknown = await response.json().catch(() => ({}));
|
||||
const errorMessage =
|
||||
(errorData as { errors?: { detail?: string }[] }).errors?.[0]?.detail ||
|
||||
`Unable to fetch GitHub integrations: ${response.statusText}`;
|
||||
return { success: false, error: errorMessage };
|
||||
} catch (error) {
|
||||
const errorResult = handleApiError(error);
|
||||
return { success: false, error: errorResult.error || "An error occurred" };
|
||||
}
|
||||
};
|
||||
|
||||
export const sendFindingToGitHub = async (
|
||||
integrationId: string,
|
||||
findingId: string,
|
||||
repository: string,
|
||||
labels?: string[],
|
||||
): Promise<
|
||||
| { success: true; taskId: string; message: string }
|
||||
| { success: false; error: string }
|
||||
> => {
|
||||
const headers = await getAuthHeaders({ contentType: true });
|
||||
const url = new URL(
|
||||
`${apiBaseUrl}/integrations/${integrationId}/github/dispatches`,
|
||||
);
|
||||
|
||||
// Single finding: use direct filter without array notation
|
||||
url.searchParams.append("filter[finding_id]", findingId);
|
||||
|
||||
const payload: GitHubDispatchRequest = {
|
||||
data: {
|
||||
type: "integrations-github-dispatches",
|
||||
attributes: {
|
||||
repository: repository,
|
||||
labels: labels || [],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
try {
|
||||
const response = await fetch(url.toString(), {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data: GitHubDispatchResponse = await response.json();
|
||||
const taskId = data?.data?.id;
|
||||
|
||||
if (taskId) {
|
||||
return {
|
||||
success: true,
|
||||
taskId,
|
||||
message: "GitHub issue creation started. Processing...",
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
success: false,
|
||||
error: "Failed to start GitHub dispatch. No task ID received.",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const errorData: unknown = await response.json().catch(() => ({}));
|
||||
const errorMessage =
|
||||
(errorData as { errors?: { detail?: string }[] }).errors?.[0]?.detail ||
|
||||
`Unable to send finding to GitHub: ${response.statusText}`;
|
||||
return { success: false, error: errorMessage };
|
||||
} catch (error) {
|
||||
const errorResult = handleApiError(error);
|
||||
return { success: false, error: errorResult.error || "An error occurred" };
|
||||
}
|
||||
};
|
||||
|
||||
export const pollGitHubDispatchTask = async (
|
||||
taskId: string,
|
||||
): Promise<
|
||||
{ success: true; message: string } | { success: false; error: string }
|
||||
> => {
|
||||
const res = await pollTaskUntilSettled(taskId, {
|
||||
maxAttempts: 10,
|
||||
delayMs: 2000,
|
||||
});
|
||||
if (!res.ok) {
|
||||
return { success: false, error: res.error };
|
||||
}
|
||||
const { state, result } = res;
|
||||
type GitHubTaskResult =
|
||||
GitHubDispatchResponse["data"]["attributes"]["result"];
|
||||
const githubResult = result as GitHubTaskResult | undefined;
|
||||
|
||||
if (state === "completed") {
|
||||
if (!githubResult?.error) {
|
||||
return {
|
||||
success: true,
|
||||
message: "Finding successfully sent to GitHub!",
|
||||
};
|
||||
}
|
||||
return {
|
||||
success: false,
|
||||
error: githubResult?.error || "Failed to create GitHub issue.",
|
||||
};
|
||||
}
|
||||
|
||||
if (state === "failed") {
|
||||
return { success: false, error: githubResult?.error || "Task failed." };
|
||||
}
|
||||
|
||||
return { success: false, error: `Unknown task state: ${state}` };
|
||||
};
|
||||
@@ -0,0 +1,93 @@
|
||||
import { getIntegrations } from "@/actions/integrations";
|
||||
import { GitHubIntegrationsManager } from "@/components/integrations/github/github-integrations-manager";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/shadcn";
|
||||
import { ContentLayout } from "@/components/ui";
|
||||
|
||||
interface GitHubIntegrationsProps {
|
||||
searchParams: Promise<{ [key: string]: string | string[] | undefined }>;
|
||||
}
|
||||
|
||||
export default async function GitHubIntegrations({
|
||||
searchParams,
|
||||
}: GitHubIntegrationsProps) {
|
||||
const resolvedSearchParams = await searchParams;
|
||||
const page = parseInt(resolvedSearchParams.page?.toString() || "1", 10);
|
||||
const pageSize = parseInt(
|
||||
resolvedSearchParams.pageSize?.toString() || "10",
|
||||
10,
|
||||
);
|
||||
const sort = resolvedSearchParams.sort?.toString();
|
||||
|
||||
// Extract all filter parameters
|
||||
const filters = Object.fromEntries(
|
||||
Object.entries(resolvedSearchParams).filter(([key]) =>
|
||||
key.startsWith("filter["),
|
||||
),
|
||||
);
|
||||
|
||||
const urlSearchParams = new URLSearchParams();
|
||||
urlSearchParams.set("filter[integration_type]", "github");
|
||||
urlSearchParams.set("page[number]", page.toString());
|
||||
urlSearchParams.set("page[size]", pageSize.toString());
|
||||
|
||||
if (sort) {
|
||||
urlSearchParams.set("sort", sort);
|
||||
}
|
||||
|
||||
// Add any additional filters
|
||||
Object.entries(filters).forEach(([key, value]) => {
|
||||
if (value !== undefined && key !== "filter[integration_type]") {
|
||||
const stringValue = Array.isArray(value) ? value[0] : String(value);
|
||||
urlSearchParams.set(key, stringValue);
|
||||
}
|
||||
});
|
||||
|
||||
const [integrations] = await Promise.all([getIntegrations(urlSearchParams)]);
|
||||
|
||||
const githubIntegrations = integrations?.data || [];
|
||||
const metadata = integrations?.meta;
|
||||
|
||||
return (
|
||||
<ContentLayout title="GitHub">
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex flex-col gap-4">
|
||||
<p className="text-sm text-gray-600 dark:text-gray-300">
|
||||
Configure GitHub integration to automatically create issues for
|
||||
security findings in your GitHub repositories.
|
||||
</p>
|
||||
|
||||
<Card variant="base" padding="lg">
|
||||
<CardHeader className="mb-0 pb-3">
|
||||
<CardTitle>Features</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-0">
|
||||
<ul className="grid grid-cols-1 gap-2 text-sm text-gray-600 md:grid-cols-2 dark:text-gray-300">
|
||||
<li className="flex items-center gap-2">
|
||||
<span className="bg-button-primary h-1.5 w-1.5 rounded-full" />
|
||||
Automated issue creation
|
||||
</li>
|
||||
<li className="flex items-center gap-2">
|
||||
<span className="bg-button-primary h-1.5 w-1.5 rounded-full" />
|
||||
Multi-Cloud support
|
||||
</li>
|
||||
<li className="flex items-center gap-2">
|
||||
<span className="bg-button-primary h-1.5 w-1.5 rounded-full" />
|
||||
Repository-based tracking
|
||||
</li>
|
||||
<li className="flex items-center gap-2">
|
||||
<span className="bg-button-primary h-1.5 w-1.5 rounded-full" />
|
||||
Label customization
|
||||
</li>
|
||||
</ul>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<GitHubIntegrationsManager
|
||||
integrations={githubIntegrations}
|
||||
metadata={metadata}
|
||||
/>
|
||||
</div>
|
||||
</ContentLayout>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import {
|
||||
ApiKeyLinkCard,
|
||||
GitHubIntegrationCard,
|
||||
JiraIntegrationCard,
|
||||
S3IntegrationCard,
|
||||
SecurityHubIntegrationCard,
|
||||
@@ -25,6 +26,9 @@ export default async function Integrations() {
|
||||
{/* AWS Security Hub Integration */}
|
||||
<SecurityHubIntegrationCard />
|
||||
|
||||
{/* GitHub Integration */}
|
||||
<GitHubIntegrationCard />
|
||||
|
||||
{/* Jira Integration */}
|
||||
<JiraIntegrationCard />
|
||||
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
"use client";
|
||||
|
||||
import { GithubIcon, SettingsIcon } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
|
||||
import { Button } from "@/components/shadcn";
|
||||
import { CustomLink } from "@/components/ui/custom/custom-link";
|
||||
|
||||
import { Card, CardContent, CardHeader } from "../../shadcn";
|
||||
|
||||
export const GitHubIntegrationCard = () => {
|
||||
return (
|
||||
<Card variant="base" padding="lg">
|
||||
<CardHeader>
|
||||
<div className="flex w-full flex-col items-start gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<GithubIcon size={40} className="text-gray-900 dark:text-gray-100" />
|
||||
<div className="flex flex-col gap-1">
|
||||
<h4 className="text-lg font-bold text-gray-900 dark:text-gray-100">
|
||||
GitHub
|
||||
</h4>
|
||||
<div className="flex flex-col items-start gap-2 sm:flex-row sm:items-center">
|
||||
<p className="text-xs text-nowrap text-gray-500 dark:text-gray-300">
|
||||
Create security issues in GitHub repositories.
|
||||
</p>
|
||||
<CustomLink
|
||||
href="https://docs.prowler.com"
|
||||
aria-label="Learn more about GitHub integration"
|
||||
size="xs"
|
||||
>
|
||||
Learn more
|
||||
</CustomLink>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 self-end sm:self-center">
|
||||
<Button asChild size="sm">
|
||||
<Link href="/integrations/github">
|
||||
<SettingsIcon size={14} />
|
||||
Manage
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-300">
|
||||
Configure and manage your GitHub integrations to automatically create
|
||||
issues for security findings in your GitHub repositories.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,210 @@
|
||||
"use client";
|
||||
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useForm } from "react-hook-form";
|
||||
|
||||
import { createIntegration, updateIntegration } from "@/actions/integrations";
|
||||
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 {
|
||||
editGitHubIntegrationFormSchema,
|
||||
type GitHubCreateValues,
|
||||
type GitHubCredentialsPayload,
|
||||
type GitHubFormValues,
|
||||
githubIntegrationFormSchema,
|
||||
IntegrationProps,
|
||||
} from "@/types/integrations";
|
||||
|
||||
interface GitHubIntegrationFormProps {
|
||||
integration?: IntegrationProps | null;
|
||||
onSuccess: (integrationId?: string, shouldTestConnection?: boolean) => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
export const GitHubIntegrationForm = ({
|
||||
integration,
|
||||
onSuccess,
|
||||
onCancel,
|
||||
}: GitHubIntegrationFormProps) => {
|
||||
const { toast } = useToast();
|
||||
const isEditing = !!integration;
|
||||
const isCreating = !isEditing;
|
||||
|
||||
const form = useForm<GitHubFormValues>({
|
||||
resolver: zodResolver(
|
||||
isCreating
|
||||
? githubIntegrationFormSchema
|
||||
: editGitHubIntegrationFormSchema,
|
||||
),
|
||||
defaultValues: {
|
||||
integration_type: "github" as const,
|
||||
owner: integration?.attributes.configuration.owner || "",
|
||||
enabled: integration?.attributes.enabled ?? true,
|
||||
token: "",
|
||||
},
|
||||
});
|
||||
|
||||
const isLoading = form.formState.isSubmitting;
|
||||
|
||||
const onSubmit = async (data: GitHubFormValues) => {
|
||||
try {
|
||||
const formData = new FormData();
|
||||
|
||||
// Add integration type
|
||||
formData.append("integration_type", "github");
|
||||
|
||||
// Prepare credentials object
|
||||
const credentials: GitHubCredentialsPayload = {};
|
||||
|
||||
// For editing, only add fields that have values
|
||||
if (isEditing) {
|
||||
if (data.token) credentials.token = data.token;
|
||||
if (data.owner) credentials.owner = data.owner.trim();
|
||||
} else {
|
||||
// For creation, token is required
|
||||
const createData = data as GitHubCreateValues;
|
||||
credentials.token = createData.token;
|
||||
if (createData.owner) credentials.owner = createData.owner.trim();
|
||||
}
|
||||
|
||||
// Add credentials as JSON
|
||||
if (Object.keys(credentials).length > 0) {
|
||||
formData.append("credentials", JSON.stringify(credentials));
|
||||
}
|
||||
|
||||
// For creation, we need to provide configuration and providers
|
||||
if (isCreating) {
|
||||
formData.append("configuration", JSON.stringify({}));
|
||||
formData.append("providers", JSON.stringify([]));
|
||||
// enabled exists only in create schema
|
||||
formData.append(
|
||||
"enabled",
|
||||
JSON.stringify((data as GitHubCreateValues).enabled),
|
||||
);
|
||||
}
|
||||
|
||||
type IntegrationResult =
|
||||
| { success: string; integrationId?: string }
|
||||
| { error: string };
|
||||
let result: IntegrationResult;
|
||||
if (isEditing) {
|
||||
result = await updateIntegration(integration.id, formData);
|
||||
} else {
|
||||
result = await createIntegration(formData);
|
||||
}
|
||||
|
||||
if (result && "success" in result && result.success) {
|
||||
toast({
|
||||
title: "Success!",
|
||||
description: `GitHub integration ${isEditing ? "updated" : "created"} successfully.`,
|
||||
});
|
||||
|
||||
// Always test connection when creating or updating
|
||||
const shouldTestConnection = true;
|
||||
const integrationId =
|
||||
"integrationId" in result ? result.integrationId : integration?.id;
|
||||
|
||||
onSuccess(integrationId, shouldTestConnection);
|
||||
} else if (result && "error" in result) {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Operation Failed",
|
||||
description: result.error,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Error",
|
||||
description: `Failed to ${isEditing ? "update" : "create"} GitHub integration. Please try again.`,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const renderForm = () => {
|
||||
return (
|
||||
<>
|
||||
<CustomInput
|
||||
control={form.control}
|
||||
name="token"
|
||||
type="password"
|
||||
label="Personal Access Token"
|
||||
labelPlacement="inside"
|
||||
placeholder="ghp_xxxxxxxxxxxx"
|
||||
isRequired
|
||||
isDisabled={isLoading}
|
||||
/>
|
||||
|
||||
<CustomInput
|
||||
control={form.control}
|
||||
name="owner"
|
||||
type="text"
|
||||
label="Repository Owner (Optional)"
|
||||
labelPlacement="inside"
|
||||
placeholder="myorg or myusername"
|
||||
isDisabled={isLoading}
|
||||
/>
|
||||
|
||||
<div className="rounded-lg border border-blue-200 bg-blue-50 p-4 dark:border-blue-800 dark:bg-blue-900/20">
|
||||
<p className="text-sm text-blue-800 dark:text-blue-200">
|
||||
To generate a Personal Access Token with the <code>repo</code>{" "}
|
||||
scope, visit your{" "}
|
||||
<a
|
||||
href="https://github.com/settings/tokens/new"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="font-medium underline"
|
||||
>
|
||||
GitHub token settings
|
||||
</a>
|
||||
. The owner field is optional and filters repositories by user or
|
||||
organization.
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const getButtonLabel = () => {
|
||||
if (isEditing) {
|
||||
return "Update Credentials";
|
||||
}
|
||||
return "Create Integration";
|
||||
};
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
className="flex flex-col gap-6"
|
||||
>
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-col items-start gap-2 sm:flex-row sm:items-center">
|
||||
<p className="text-default-500 flex items-center gap-2 text-sm">
|
||||
Need help configuring your GitHub integration?
|
||||
</p>
|
||||
<CustomLink
|
||||
href="https://docs.prowler.com"
|
||||
target="_blank"
|
||||
size="sm"
|
||||
>
|
||||
Read the docs
|
||||
</CustomLink>
|
||||
</div>
|
||||
{renderForm()}
|
||||
</div>
|
||||
<FormButtons
|
||||
setIsOpen={() => {}}
|
||||
onCancel={onCancel}
|
||||
submitText={getButtonLabel()}
|
||||
cancelText="Cancel"
|
||||
loadingText="Processing..."
|
||||
isDisabled={isLoading}
|
||||
/>
|
||||
</form>
|
||||
</Form>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,376 @@
|
||||
"use client";
|
||||
|
||||
import { format } from "date-fns";
|
||||
import { GithubIcon, PlusIcon, Trash2Icon } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
|
||||
import {
|
||||
deleteIntegration,
|
||||
testIntegrationConnection,
|
||||
updateIntegration,
|
||||
} from "@/actions/integrations";
|
||||
import {
|
||||
IntegrationActionButtons,
|
||||
IntegrationCardHeader,
|
||||
IntegrationSkeleton,
|
||||
} from "@/components/integrations/shared";
|
||||
import { Button } from "@/components/shadcn";
|
||||
import { useToast } from "@/components/ui";
|
||||
import { CustomAlertModal } from "@/components/ui/custom";
|
||||
import { DataTablePagination } from "@/components/ui/table/data-table-pagination";
|
||||
import { triggerTestConnectionWithDelay } from "@/lib/integrations/test-connection-helper";
|
||||
import { MetaDataProps } from "@/types";
|
||||
import { IntegrationProps } from "@/types/integrations";
|
||||
|
||||
import { Card, CardContent, CardHeader } from "../../shadcn";
|
||||
import { GitHubIntegrationForm } from "./github-integration-form";
|
||||
|
||||
interface GitHubIntegrationsManagerProps {
|
||||
integrations: IntegrationProps[];
|
||||
metadata?: MetaDataProps;
|
||||
}
|
||||
|
||||
export const GitHubIntegrationsManager = ({
|
||||
integrations,
|
||||
metadata,
|
||||
}: GitHubIntegrationsManagerProps) => {
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [editingIntegration, setEditingIntegration] =
|
||||
useState<IntegrationProps | 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);
|
||||
setIsModalOpen(true);
|
||||
};
|
||||
|
||||
const handleEditCredentials = (integration: IntegrationProps) => {
|
||||
setEditingIntegration(integration);
|
||||
setIsModalOpen(true);
|
||||
};
|
||||
|
||||
const handleOpenDeleteModal = (integration: IntegrationProps) => {
|
||||
setIntegrationToDelete(integration);
|
||||
setIsDeleteOpen(true);
|
||||
};
|
||||
|
||||
const handleDeleteIntegration = async (id: string) => {
|
||||
setIsDeleting(id);
|
||||
try {
|
||||
const result = await deleteIntegration(id, "github");
|
||||
|
||||
if (result.success) {
|
||||
toast({
|
||||
title: "Success!",
|
||||
description: "GitHub 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 GitHub 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.`,
|
||||
});
|
||||
|
||||
// If enabling, trigger test connection automatically
|
||||
if (newEnabledState) {
|
||||
setIsTesting(integration.id);
|
||||
|
||||
triggerTestConnectionWithDelay(
|
||||
integration.id,
|
||||
true,
|
||||
"github",
|
||||
toast,
|
||||
500,
|
||||
() => {
|
||||
setIsTesting(null);
|
||||
},
|
||||
);
|
||||
}
|
||||
} 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);
|
||||
};
|
||||
|
||||
const handleFormSuccess = async (
|
||||
integrationId?: string,
|
||||
shouldTestConnection?: boolean,
|
||||
) => {
|
||||
// Close the modal immediately
|
||||
setIsModalOpen(false);
|
||||
setEditingIntegration(null);
|
||||
setIsOperationLoading(true);
|
||||
|
||||
// Set testing state for server-triggered test connections
|
||||
if (integrationId && shouldTestConnection) {
|
||||
setIsTesting(integrationId);
|
||||
}
|
||||
|
||||
// Trigger test connection if needed
|
||||
triggerTestConnectionWithDelay(
|
||||
integrationId,
|
||||
shouldTestConnection,
|
||||
"github",
|
||||
toast,
|
||||
200,
|
||||
() => {
|
||||
// Clear testing state when server-triggered test completes
|
||||
setIsTesting(null);
|
||||
},
|
||||
);
|
||||
|
||||
// Reset loading state after a short delay to show the skeleton briefly
|
||||
setTimeout(() => {
|
||||
setIsOperationLoading(false);
|
||||
}, 1500);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<CustomAlertModal
|
||||
isOpen={isDeleteOpen}
|
||||
onOpenChange={setIsDeleteOpen}
|
||||
title="Delete GitHub Integration"
|
||||
description="This action cannot be undone. This will permanently delete your GitHub integration."
|
||||
>
|
||||
<div className="flex w-full justify-end gap-4">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="lg"
|
||||
onClick={() => {
|
||||
setIsDeleteOpen(false);
|
||||
setIntegrationToDelete(null);
|
||||
}}
|
||||
disabled={isDeleting !== null}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
size="lg"
|
||||
disabled={isDeleting !== null}
|
||||
onClick={() =>
|
||||
integrationToDelete &&
|
||||
handleDeleteIntegration(integrationToDelete.id)
|
||||
}
|
||||
>
|
||||
{!isDeleting && <Trash2Icon size={24} />}
|
||||
{isDeleting ? "Deleting..." : "Delete"}
|
||||
</Button>
|
||||
</div>
|
||||
</CustomAlertModal>
|
||||
|
||||
<CustomAlertModal
|
||||
isOpen={isModalOpen}
|
||||
onOpenChange={handleModalClose}
|
||||
title={
|
||||
editingIntegration ? "Edit GitHub Integration" : "Add GitHub Integration"
|
||||
}
|
||||
description={
|
||||
editingIntegration
|
||||
? "Update the credentials for your GitHub integration."
|
||||
: "Configure your GitHub Personal Access Token to create issues from findings."
|
||||
}
|
||||
>
|
||||
<GitHubIntegrationForm
|
||||
integration={editingIntegration}
|
||||
onSuccess={handleFormSuccess}
|
||||
onCancel={handleModalClose}
|
||||
/>
|
||||
</CustomAlertModal>
|
||||
|
||||
<div className="space-y-6">
|
||||
<div className="flex w-full flex-col items-start gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold">GitHub Integrations</h2>
|
||||
<p className="text-default-500 text-sm">
|
||||
Manage your GitHub integrations to send findings as issues
|
||||
</p>
|
||||
</div>
|
||||
<Button onClick={handleAddIntegration} size="sm">
|
||||
<PlusIcon size={20} />
|
||||
Add Integration
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-4">
|
||||
{isOperationLoading && <IntegrationSkeleton />}
|
||||
{!isOperationLoading && integrations.length === 0 && (
|
||||
<Card variant="base" padding="lg">
|
||||
<CardContent className="flex flex-col items-center justify-center py-12">
|
||||
<GithubIcon
|
||||
size={48}
|
||||
className="mb-4 text-gray-400 dark:text-gray-600"
|
||||
/>
|
||||
<h3 className="mb-2 text-lg font-semibold text-gray-900 dark:text-gray-100">
|
||||
No GitHub integrations configured
|
||||
</h3>
|
||||
<p className="mb-4 text-center text-sm text-gray-500 dark:text-gray-400">
|
||||
Get started by adding your first GitHub integration
|
||||
</p>
|
||||
<Button onClick={handleAddIntegration} size="sm">
|
||||
<PlusIcon size={16} />
|
||||
Add Integration
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
{!isOperationLoading &&
|
||||
integrations.map((integration) => (
|
||||
<Card key={integration.id} variant="base" padding="lg">
|
||||
<CardHeader>
|
||||
<IntegrationCardHeader
|
||||
icon={
|
||||
<GithubIcon
|
||||
size={32}
|
||||
className="text-gray-900 dark:text-gray-100"
|
||||
/>
|
||||
}
|
||||
title="GitHub Integration"
|
||||
subtitle={
|
||||
integration.attributes.configuration.owner
|
||||
? `Owner: ${integration.attributes.configuration.owner}`
|
||||
: "All accessible repositories"
|
||||
}
|
||||
integrationId={integration.id}
|
||||
enabled={integration.attributes.enabled}
|
||||
connected={integration.attributes.connected}
|
||||
lastChecked={integration.attributes.connection_last_checked_at}
|
||||
isTesting={isTesting === integration.id}
|
||||
/>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex flex-col gap-4">
|
||||
{integration.attributes.configuration.repositories &&
|
||||
Object.keys(
|
||||
integration.attributes.configuration.repositories,
|
||||
).length > 0 && (
|
||||
<div>
|
||||
<p className="mb-2 text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Accessible Repositories:{" "}
|
||||
{
|
||||
Object.keys(
|
||||
integration.attributes.configuration.repositories,
|
||||
).length
|
||||
}
|
||||
</p>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">
|
||||
Last synced:{" "}
|
||||
{integration.attributes.connection_last_checked_at
|
||||
? format(
|
||||
new Date(
|
||||
integration.attributes.connection_last_checked_at,
|
||||
),
|
||||
"PPpp",
|
||||
)
|
||||
: "Never"}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<IntegrationActionButtons
|
||||
integration={integration}
|
||||
isTesting={isTesting === integration.id}
|
||||
isDeleting={isDeleting === integration.id}
|
||||
onTestConnection={handleTestConnection}
|
||||
onToggleEnabled={handleToggleEnabled}
|
||||
onEditCredentials={handleEditCredentials}
|
||||
onDelete={handleOpenDeleteModal}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{metadata && integrations.length > 0 && !isOperationLoading && (
|
||||
<DataTablePagination metadata={metadata} />
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -1,5 +1,8 @@
|
||||
export * from "../providers/enhanced-provider-selector";
|
||||
export * from "./api-key/api-key-link-card";
|
||||
export * from "./github/github-integration-card";
|
||||
export * from "./github/github-integration-form";
|
||||
export * from "./github/github-integrations-manager";
|
||||
export * from "./jira/jira-integration-card";
|
||||
export * from "./jira/jira-integration-form";
|
||||
export * from "./jira/jira-integrations-manager";
|
||||
|
||||
@@ -310,3 +310,26 @@ export interface JiraCredentialsPayload {
|
||||
user_mail?: string;
|
||||
api_token?: string;
|
||||
}
|
||||
|
||||
// GitHub Integration Schemas
|
||||
export const githubIntegrationFormSchema = z.object({
|
||||
integration_type: z.literal("github"),
|
||||
token: z.string().min(1, "GitHub token is required"),
|
||||
owner: z.string().optional(),
|
||||
enabled: z.boolean().default(true),
|
||||
});
|
||||
|
||||
export const editGitHubIntegrationFormSchema = z.object({
|
||||
integration_type: z.literal("github"),
|
||||
token: z.string().min(1, "GitHub token is required").optional(),
|
||||
owner: z.string().optional(),
|
||||
});
|
||||
|
||||
export type GitHubCreateValues = z.infer<typeof githubIntegrationFormSchema>;
|
||||
export type GitHubEditValues = z.infer<typeof editGitHubIntegrationFormSchema>;
|
||||
export type GitHubFormValues = GitHubCreateValues | GitHubEditValues;
|
||||
|
||||
export interface GitHubCredentialsPayload {
|
||||
token?: string;
|
||||
owner?: string;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user