mirror of
https://github.com/prowler-cloud/prowler.git
synced 2025-12-19 05:17:47 +00:00
refactor(ui): redo the whole app with styles (#9234)
This commit is contained in:
@@ -11,6 +11,7 @@ All notable changes to the **Prowler UI** are documented in this file.
|
||||
- Customer Support menu item [(#9143)](https://github.com/prowler-cloud/prowler/pull/9143)
|
||||
- IaC (Infrastructure as Code) provider support for scanning remote repositories [(#8751)](https://github.com/prowler-cloud/prowler/pull/8751)
|
||||
- External resource link to IaC findings for direct navigation to source code in Git repositories [(#9151)](https://github.com/prowler-cloud/prowler/pull/9151)
|
||||
- New Overview page and new app styles [(#9234)](https://github.com/prowler-cloud/prowler/pull/9234)
|
||||
|
||||
### 🔄 Changed
|
||||
|
||||
|
||||
@@ -123,7 +123,7 @@ export const getThreatScore = async ({
|
||||
} = {}) => {
|
||||
const headers = await getAuthHeaders({ contentType: false });
|
||||
|
||||
const url = new URL(`${apiBaseUrl}/overviews/threat-score`);
|
||||
const url = new URL(`${apiBaseUrl}/overviews/threatscore`);
|
||||
|
||||
// Handle multiple filters
|
||||
Object.entries(filters).forEach(([key, value]) => {
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { Spacer } from "@heroui/spacer";
|
||||
import Image from "next/image";
|
||||
import React, { Suspense } from "react";
|
||||
import { Suspense } from "react";
|
||||
|
||||
import {
|
||||
getComplianceAttributes,
|
||||
@@ -8,16 +7,15 @@ import {
|
||||
getComplianceRequirements,
|
||||
} from "@/actions/compliances";
|
||||
import {
|
||||
BarChart,
|
||||
BarChartSkeleton,
|
||||
ClientAccordionWrapper,
|
||||
ComplianceHeader,
|
||||
ComplianceScanInfo,
|
||||
HeatmapChart,
|
||||
HeatmapChartSkeleton,
|
||||
PieChart,
|
||||
PieChartSkeleton,
|
||||
RequirementsStatusCard,
|
||||
RequirementsStatusCardSkeleton,
|
||||
// SectionsFailureRateCard,
|
||||
// SectionsFailureRateCardSkeleton,
|
||||
SkeletonAccordion,
|
||||
TopFailedSectionsCard,
|
||||
TopFailedSectionsCardSkeleton,
|
||||
} from "@/components/compliance";
|
||||
import { getComplianceIcon } from "@/components/icons/compliance/IconCompliance";
|
||||
import { ContentLayout } from "@/components/ui";
|
||||
@@ -42,38 +40,6 @@ interface ComplianceDetailSearchParams {
|
||||
pageSize?: string;
|
||||
}
|
||||
|
||||
const ComplianceIconSmall = ({
|
||||
logoPath,
|
||||
title,
|
||||
}: {
|
||||
logoPath: string;
|
||||
title: string;
|
||||
}) => {
|
||||
return (
|
||||
<div className="relative h-6 w-6 shrink-0">
|
||||
<Image
|
||||
src={logoPath}
|
||||
alt={`${title} logo`}
|
||||
fill
|
||||
className="h-8 w-8 min-w-8 rounded-md border border-gray-300 bg-white object-contain p-[2px]"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const ChartsWrapper = ({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
logoPath?: string;
|
||||
}) => {
|
||||
return (
|
||||
<div className="mb-8 flex w-full flex-wrap items-center justify-center gap-12 lg:justify-start lg:gap-24">
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default async function ComplianceDetail({
|
||||
params,
|
||||
searchParams,
|
||||
@@ -98,8 +64,8 @@ export default async function ComplianceDetail({
|
||||
|
||||
const formattedTitle = compliancetitle.split("-").join(" ");
|
||||
const pageTitle = version
|
||||
? `Compliance Details: ${formattedTitle} - ${version}`
|
||||
: `Compliance Details: ${formattedTitle}`;
|
||||
? `${formattedTitle} - ${version}`
|
||||
: `${formattedTitle}`;
|
||||
|
||||
let selectedScan: ScanEntity | null = null;
|
||||
|
||||
@@ -122,57 +88,37 @@ export default async function ComplianceDetail({
|
||||
|
||||
// Use compliance_name from attributes if available, otherwise fallback to formatted title
|
||||
const complianceName = attributesData?.data?.[0]?.attributes?.compliance_name;
|
||||
const finalPageTitle = complianceName
|
||||
? `Compliance Details: ${complianceName}`
|
||||
: pageTitle;
|
||||
const finalPageTitle = complianceName ? `${complianceName}` : pageTitle;
|
||||
|
||||
return (
|
||||
<ContentLayout
|
||||
title={finalPageTitle}
|
||||
icon={
|
||||
logoPath ? (
|
||||
<ComplianceIconSmall logoPath={logoPath} title={compliancetitle} />
|
||||
) : (
|
||||
"fluent-mdl2:compliance-audit"
|
||||
)
|
||||
}
|
||||
>
|
||||
{selectedScanId && selectedScan && (
|
||||
<div className="flex max-w-[328px] flex-col items-start">
|
||||
<div className="rounded-lg bg-gray-50 p-2 dark:bg-gray-800">
|
||||
<ComplianceScanInfo scan={selectedScan} />
|
||||
<ContentLayout title={finalPageTitle}>
|
||||
<ComplianceHeader
|
||||
scans={[]}
|
||||
uniqueRegions={uniqueRegions}
|
||||
showSearch={false}
|
||||
framework={compliancetitle}
|
||||
showProviders={false}
|
||||
logoPath={logoPath}
|
||||
complianceTitle={compliancetitle}
|
||||
selectedScan={selectedScan}
|
||||
/>
|
||||
{attributesData?.data?.[0]?.attributes?.framework ===
|
||||
"ProwlerThreatScore" &&
|
||||
selectedScanId && (
|
||||
<div className="flex w-full justify-end">
|
||||
<ThreatScoreDownloadButton scanId={selectedScanId} />
|
||||
</div>
|
||||
<Spacer y={8} />
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="flex-1">
|
||||
<ComplianceHeader
|
||||
scans={[]}
|
||||
uniqueRegions={uniqueRegions}
|
||||
showSearch={false}
|
||||
framework={compliancetitle}
|
||||
showProviders={false}
|
||||
/>
|
||||
</div>
|
||||
{attributesData?.data?.[0]?.attributes?.framework ===
|
||||
"ProwlerThreatScore" &&
|
||||
selectedScanId && (
|
||||
<div className="flex-shrink-0 pt-1">
|
||||
<ThreatScoreDownloadButton scanId={selectedScanId} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Suspense
|
||||
key={searchParamsKey}
|
||||
fallback={
|
||||
<div className="flex flex-col gap-8">
|
||||
<ChartsWrapper logoPath={logoPath}>
|
||||
<PieChartSkeleton />
|
||||
<BarChartSkeleton />
|
||||
<HeatmapChartSkeleton />
|
||||
</ChartsWrapper>
|
||||
<div className="flex flex-col gap-6 md:flex-row md:flex-wrap md:items-stretch">
|
||||
<RequirementsStatusCardSkeleton />
|
||||
<TopFailedSectionsCardSkeleton />
|
||||
{/* <SectionsFailureRateCardSkeleton /> */}
|
||||
</div>
|
||||
<SkeletonAccordion />
|
||||
</div>
|
||||
}
|
||||
@@ -182,7 +128,6 @@ export default async function ComplianceDetail({
|
||||
scanId={selectedScanId || ""}
|
||||
region={regionFilter}
|
||||
filter={cisProfileFilter}
|
||||
logoPath={logoPath}
|
||||
attributesData={attributesData}
|
||||
/>
|
||||
</Suspense>
|
||||
@@ -195,14 +140,12 @@ const SSRComplianceContent = async ({
|
||||
scanId,
|
||||
region,
|
||||
filter,
|
||||
logoPath,
|
||||
attributesData,
|
||||
}: {
|
||||
complianceId: string;
|
||||
scanId: string;
|
||||
region?: string;
|
||||
filter?: string;
|
||||
logoPath?: string;
|
||||
attributesData: AttributesData;
|
||||
}) => {
|
||||
const requirementsData = await getComplianceRequirements({
|
||||
@@ -215,11 +158,11 @@ const SSRComplianceContent = async ({
|
||||
if (!scanId || type === "tasks") {
|
||||
return (
|
||||
<div className="flex flex-col gap-8">
|
||||
<ChartsWrapper logoPath={logoPath}>
|
||||
<PieChart pass={0} fail={0} manual={0} />
|
||||
<BarChart sections={[]} />
|
||||
<HeatmapChart categories={[]} />
|
||||
</ChartsWrapper>
|
||||
<div className="flex flex-col gap-6 md:flex-row md:flex-wrap md:items-stretch">
|
||||
<RequirementsStatusCard pass={0} fail={0} manual={0} />
|
||||
<TopFailedSectionsCard sections={[]} />
|
||||
{/* <SectionsFailureRateCard categories={[]} /> */}
|
||||
</div>
|
||||
<ClientAccordionWrapper items={[]} defaultExpandedKeys={[]} />
|
||||
</div>
|
||||
);
|
||||
@@ -232,7 +175,7 @@ const SSRComplianceContent = async ({
|
||||
requirementsData,
|
||||
filter,
|
||||
);
|
||||
const categoryHeatmapData = mapper.calculateCategoryHeatmapData(data);
|
||||
// const categoryHeatmapData = mapper.calculateCategoryHeatmapData(data);
|
||||
const totalRequirements: RequirementsTotals = data.reduce(
|
||||
(acc: RequirementsTotals, framework: Framework) => ({
|
||||
pass: acc.pass + framework.pass,
|
||||
@@ -246,17 +189,17 @@ const SSRComplianceContent = async ({
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-8">
|
||||
<ChartsWrapper logoPath={logoPath}>
|
||||
<PieChart
|
||||
<div className="flex flex-col gap-6 md:flex-row md:items-stretch">
|
||||
<RequirementsStatusCard
|
||||
pass={totalRequirements.pass}
|
||||
fail={totalRequirements.fail}
|
||||
manual={totalRequirements.manual}
|
||||
/>
|
||||
<BarChart sections={topFailedSections} />
|
||||
<HeatmapChart categories={categoryHeatmapData} />
|
||||
</ChartsWrapper>
|
||||
<TopFailedSectionsCard sections={topFailedSections} />
|
||||
{/* <SectionsFailureRateCard categories={categoryHeatmapData} /> */}
|
||||
</div>
|
||||
|
||||
<Spacer className="h-1 w-full rounded-full bg-gray-200 dark:bg-gray-800" />
|
||||
<Spacer className="bg-border-neutral-primary h-1 w-full rounded-full" />
|
||||
<ClientAccordionWrapper
|
||||
hideExpandButton={complianceId.includes("mitre_attack")}
|
||||
items={accordionItems}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
"use client";
|
||||
|
||||
import { Button } from "@heroui/button";
|
||||
import { DownloadIcon } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
|
||||
import { Button } from "@/components/shadcn/button/button";
|
||||
import { toast } from "@/components/ui";
|
||||
import { downloadThreatScorePdf } from "@/lib/helper";
|
||||
|
||||
@@ -27,18 +27,15 @@ export const ThreatScoreDownloadButton = ({
|
||||
|
||||
return (
|
||||
<Button
|
||||
color="success"
|
||||
variant="solid"
|
||||
startContent={
|
||||
<DownloadIcon
|
||||
className={isDownloading ? "animate-download-icon" : ""}
|
||||
size={16}
|
||||
/>
|
||||
}
|
||||
onPress={handleDownload}
|
||||
isLoading={isDownloading}
|
||||
variant="default"
|
||||
size="sm"
|
||||
onClick={handleDownload}
|
||||
disabled={isDownloading}
|
||||
>
|
||||
<DownloadIcon
|
||||
className={isDownloading ? "animate-download-icon" : ""}
|
||||
size={16}
|
||||
/>
|
||||
PDF ThreatScore Report
|
||||
</Button>
|
||||
);
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
export const dynamic = "force-dynamic";
|
||||
import { Suspense } from "react";
|
||||
|
||||
import {
|
||||
@@ -138,7 +137,7 @@ export default async function Compliance({
|
||||
{selectedScanId ? (
|
||||
<>
|
||||
<div className="mb-6 flex flex-col gap-6">
|
||||
<div className="flex items-start justify-between gap-6">
|
||||
<div className="flex flex-col items-start justify-between lg:flex-row lg:gap-6">
|
||||
<div className="flex-1">
|
||||
<ComplianceHeader
|
||||
scans={expandedScansData}
|
||||
|
||||
@@ -4,8 +4,14 @@ import { Icon } from "@iconify/react";
|
||||
import * as Sentry from "@sentry/nextjs";
|
||||
import { useEffect } from "react";
|
||||
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui";
|
||||
import { CustomButton } from "@/components/ui/custom";
|
||||
import { Button } from "@/components/shadcn";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/shadcn/card/card";
|
||||
import { CustomLink } from "@/components/ui/custom/custom-link";
|
||||
import { SentryErrorSource, SentryErrorType } from "@/sentry";
|
||||
|
||||
@@ -75,37 +81,39 @@ export default function Error({
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center p-4">
|
||||
<Alert className="w-full max-w-lg">
|
||||
<Icon
|
||||
icon={is500Error ? "tabler:server-off" : "tabler:rocket-off"}
|
||||
className="h-5 w-5"
|
||||
/>
|
||||
<AlertTitle className="text-lg">
|
||||
{is500Error
|
||||
? "Server temporarily unavailable"
|
||||
: "An unexpected error occurred"}
|
||||
</AlertTitle>
|
||||
<AlertDescription className="mb-5">
|
||||
{is500Error
|
||||
? "The server is experiencing issues. Our team has been notified and is working on it. Please try again in a few moments."
|
||||
: "We're sorry for the inconvenience. Please try again or contact support if the problem persists."}
|
||||
</AlertDescription>
|
||||
<div className="flex items-center justify-start gap-3">
|
||||
<CustomButton
|
||||
onPress={reset}
|
||||
variant="solid"
|
||||
color="primary"
|
||||
size="sm"
|
||||
startContent={<Icon icon="tabler:refresh" className="h-4 w-4" />}
|
||||
ariaLabel="Try Again"
|
||||
>
|
||||
Try Again
|
||||
</CustomButton>
|
||||
<CustomLink href="/" target="_self" className="font-bold">
|
||||
Go to Overview
|
||||
</CustomLink>
|
||||
</div>
|
||||
</Alert>
|
||||
<Card variant="base" className="w-full max-w-lg">
|
||||
<CardHeader>
|
||||
<div className="flex items-start gap-3">
|
||||
<Icon
|
||||
icon={is500Error ? "tabler:server-off" : "tabler:rocket-off"}
|
||||
className="mt-0.5 h-5 w-5 flex-shrink-0 text-red-500"
|
||||
/>
|
||||
<div className="flex flex-col gap-2">
|
||||
<CardTitle className="text-lg">
|
||||
{is500Error
|
||||
? "Server temporarily unavailable"
|
||||
: "An unexpected error occurred"}
|
||||
</CardTitle>
|
||||
<CardDescription className="text-sm">
|
||||
{is500Error
|
||||
? "The server is experiencing issues. Our team has been notified and is working on it. Please try again in a few moments."
|
||||
: "We're sorry for the inconvenience. Please try again or contact support if the problem persists."}
|
||||
</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex items-center justify-start gap-3">
|
||||
<Button onClick={reset} size="sm" className="gap-2">
|
||||
<Icon icon="tabler:refresh" className="h-4 w-4" />
|
||||
Try Again
|
||||
</Button>
|
||||
<CustomLink href="/" target="_self" className="font-bold">
|
||||
Go to Overview
|
||||
</CustomLink>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import React from "react";
|
||||
import { getIntegrations } from "@/actions/integrations";
|
||||
import { getProviders } from "@/actions/providers";
|
||||
import { S3IntegrationsManager } from "@/components/integrations/s3/s3-integrations-manager";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/shadcn";
|
||||
import { ContentLayout } from "@/components/ui";
|
||||
|
||||
interface S3IntegrationsProps {
|
||||
@@ -62,29 +63,31 @@ export default async function S3Integrations({
|
||||
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 md:grid-cols-2 dark:text-gray-300">
|
||||
<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" />
|
||||
Multi-Cloud 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>
|
||||
<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 scan result exports
|
||||
</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" />
|
||||
Configurable export paths
|
||||
</li>
|
||||
<li className="flex items-center gap-2">
|
||||
<span className="bg-button-primary h-1.5 w-1.5 rounded-full" />
|
||||
IAM role and static credentials
|
||||
</li>
|
||||
</ul>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<S3IntegrationsManager
|
||||
|
||||
@@ -3,6 +3,7 @@ import React from "react";
|
||||
import { getIntegrations } from "@/actions/integrations";
|
||||
import { getProviders } from "@/actions/providers";
|
||||
import { SecurityHubIntegrationsManager } from "@/components/integrations/security-hub/security-hub-integrations-manager";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/shadcn";
|
||||
import { ContentLayout } from "@/components/ui";
|
||||
|
||||
interface SecurityHubIntegrationsProps {
|
||||
@@ -60,29 +61,31 @@ export default async function SecurityHubIntegrations({
|
||||
security findings for centralized monitoring and compliance.
|
||||
</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 md:grid-cols-2 dark:text-gray-300">
|
||||
<li className="flex items-center gap-2">
|
||||
<span className="h-1.5 w-1.5 rounded-full bg-green-500" />
|
||||
Automated findings export
|
||||
</li>
|
||||
<li className="flex items-center gap-2">
|
||||
<span className="h-1.5 w-1.5 rounded-full bg-green-500" />
|
||||
Multi-region support
|
||||
</li>
|
||||
<li className="flex items-center gap-2">
|
||||
<span className="h-1.5 w-1.5 rounded-full bg-green-500" />
|
||||
Send failed findings only
|
||||
</li>
|
||||
<li className="flex items-center gap-2">
|
||||
<span className="h-1.5 w-1.5 rounded-full bg-green-500" />
|
||||
Archive previous findings
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<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 findings export
|
||||
</li>
|
||||
<li className="flex items-center gap-2">
|
||||
<span className="bg-button-primary h-1.5 w-1.5 rounded-full" />
|
||||
Multi-region support
|
||||
</li>
|
||||
<li className="flex items-center gap-2">
|
||||
<span className="bg-button-primary h-1.5 w-1.5 rounded-full" />
|
||||
Send failed findings only
|
||||
</li>
|
||||
<li className="flex items-center gap-2">
|
||||
<span className="bg-button-primary h-1.5 w-1.5 rounded-full" />
|
||||
Archive previous findings
|
||||
</li>
|
||||
</ul>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<SecurityHubIntegrationsManager
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { getIntegrations } from "@/actions/integrations";
|
||||
import { JiraIntegrationsManager } from "@/components/integrations/jira/jira-integrations-manager";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/shadcn";
|
||||
import { ContentLayout } from "@/components/ui";
|
||||
|
||||
interface JiraIntegrationsProps {
|
||||
@@ -55,29 +56,31 @@ export default async function JiraIntegrations({
|
||||
security findings in your Jira projects.
|
||||
</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 md:grid-cols-2 dark:text-gray-300">
|
||||
<li className="flex items-center gap-2">
|
||||
<span className="h-1.5 w-1.5 rounded-full bg-green-500" />
|
||||
Automated issue creation
|
||||
</li>
|
||||
<li className="flex items-center gap-2">
|
||||
<span className="h-1.5 w-1.5 rounded-full bg-green-500" />
|
||||
Multi-Cloud support
|
||||
</li>
|
||||
<li className="flex items-center gap-2">
|
||||
<span className="h-1.5 w-1.5 rounded-full bg-green-500" />
|
||||
Flexible issue tracking
|
||||
</li>
|
||||
<li className="flex items-center gap-2">
|
||||
<span className="h-1.5 w-1.5 rounded-full bg-green-500" />
|
||||
Project-specific configuration
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<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" />
|
||||
Flexible issue tracking
|
||||
</li>
|
||||
<li className="flex items-center gap-2">
|
||||
<span className="bg-button-primary h-1.5 w-1.5 rounded-full" />
|
||||
Project-specific configuration
|
||||
</li>
|
||||
</ul>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<JiraIntegrationsManager
|
||||
|
||||
@@ -1,15 +1,17 @@
|
||||
import { Spacer } from "@heroui/spacer";
|
||||
import Link from "next/link";
|
||||
import React, { Suspense } from "react";
|
||||
|
||||
import { getInvitations } from "@/actions/invitations/invitation";
|
||||
import { getRoles } from "@/actions/roles";
|
||||
import { FilterControls } from "@/components/filters";
|
||||
import { filterInvitations } from "@/components/filters/data-filters";
|
||||
import { SendInvitationButton } from "@/components/invitations";
|
||||
import { AddIcon } from "@/components/icons";
|
||||
import {
|
||||
ColumnsInvitation,
|
||||
SkeletonTableInvitation,
|
||||
} from "@/components/invitations/table";
|
||||
import { Button } from "@/components/shadcn";
|
||||
import { ContentLayout } from "@/components/ui";
|
||||
import { DataTable, DataTableFilterCustom } from "@/components/ui/table";
|
||||
import { InvitationProps, Role, SearchParamsProps } from "@/types";
|
||||
@@ -25,10 +27,17 @@ export default async function Invitations({
|
||||
return (
|
||||
<ContentLayout title="Invitations" icon="lucide:mail">
|
||||
<FilterControls search />
|
||||
<Spacer y={8} />
|
||||
<SendInvitationButton />
|
||||
<Spacer y={4} />
|
||||
<DataTableFilterCustom filters={filterInvitations || []} />
|
||||
|
||||
<div className="flex flex-row items-center justify-between">
|
||||
<DataTableFilterCustom filters={filterInvitations || []} />
|
||||
|
||||
<Button asChild>
|
||||
<Link href="/invitations/new">
|
||||
Send Invitation
|
||||
<AddIcon size={20} />
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
<Spacer y={8} />
|
||||
|
||||
<Suspense key={searchParamsKey} fallback={<SkeletonTableInvitation />}>
|
||||
|
||||
@@ -13,8 +13,9 @@ import {
|
||||
} from "@/actions/lighthouse/lighthouse";
|
||||
import { DeleteLLMProviderForm } from "@/components/lighthouse/forms/delete-llm-provider-form";
|
||||
import { WorkflowConnectLLM } from "@/components/lighthouse/workflow";
|
||||
import { Button } from "@/components/shadcn";
|
||||
import { NavigationHeader } from "@/components/ui";
|
||||
import { CustomAlertModal, CustomButton } from "@/components/ui/custom";
|
||||
import { CustomAlertModal } from "@/components/ui/custom";
|
||||
import type { LighthouseProvider } from "@/types/lighthouse";
|
||||
|
||||
interface ConnectLLMLayoutProps {
|
||||
@@ -89,33 +90,28 @@ export default function ConnectLLMLayout({ children }: ConnectLLMLayoutProps) {
|
||||
<>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{!isDefaultProvider && (
|
||||
<CustomButton
|
||||
ariaLabel="Set as Default Provider"
|
||||
variant="bordered"
|
||||
<Button
|
||||
aria-label="Set as Default Provider"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
startContent={
|
||||
<Icon icon="heroicons:star" className="h-4 w-4" />
|
||||
}
|
||||
onPress={handleSetDefault}
|
||||
onClick={handleSetDefault}
|
||||
className="w-full sm:w-auto"
|
||||
>
|
||||
<Icon icon="heroicons:star" className="h-4 w-4" />
|
||||
Set as Default
|
||||
</CustomButton>
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<CustomButton
|
||||
ariaLabel="Delete Provider"
|
||||
variant="bordered"
|
||||
color="danger"
|
||||
<Button
|
||||
aria-label="Delete Provider"
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
startContent={
|
||||
<Icon icon="heroicons:trash" className="h-4 w-4" />
|
||||
}
|
||||
onPress={() => setIsDeleteOpen(true)}
|
||||
onClick={() => setIsDeleteOpen(true)}
|
||||
className="w-full sm:w-auto"
|
||||
>
|
||||
<Icon icon="heroicons:trash" className="h-4 w-4" />
|
||||
Delete Provider
|
||||
</CustomButton>
|
||||
</Button>
|
||||
</div>
|
||||
<Spacer y={4} />
|
||||
</>
|
||||
|
||||
@@ -14,12 +14,12 @@ import {
|
||||
OracleCloudProviderBadge,
|
||||
} from "@/components/icons/providers-badge";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/shadcn";
|
||||
MultiSelect,
|
||||
MultiSelectContent,
|
||||
MultiSelectItem,
|
||||
MultiSelectTrigger,
|
||||
MultiSelectValue,
|
||||
} from "@/components/shadcn/select/multiselect";
|
||||
import type { ProviderProps, ProviderType } from "@/types/providers";
|
||||
|
||||
const PROVIDER_ICON: Record<ProviderType, ReactNode> = {
|
||||
@@ -91,7 +91,7 @@ export function AccountsSelector({ providers }: AccountsSelectorProps) {
|
||||
};
|
||||
|
||||
const selectedLabel = () => {
|
||||
if (selectedIds.length === 0) return null; // placeholder visible
|
||||
if (selectedIds.length === 0) return null;
|
||||
if (selectedIds.length === 1) {
|
||||
const p = providers.find((pr) => pr.id === selectedIds[0]);
|
||||
const name = p ? p.attributes.alias || p.attributes.uid : selectedIds[0];
|
||||
@@ -117,18 +117,14 @@ export function AccountsSelector({ providers }: AccountsSelectorProps) {
|
||||
Filter by cloud provider account. {filterDescription}. Select one or
|
||||
more accounts to view findings.
|
||||
</label>
|
||||
<Select
|
||||
multiple
|
||||
selectedValues={selectedIds}
|
||||
onMultiValueChange={handleMultiValueChange}
|
||||
ariaLabel="Cloud provider accounts filter"
|
||||
>
|
||||
<SelectTrigger id="accounts-selector" aria-labelledby="accounts-label">
|
||||
<SelectValue placeholder="All accounts">
|
||||
{selectedLabel()}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent align="start">
|
||||
<MultiSelect values={selectedIds} onValuesChange={handleMultiValueChange}>
|
||||
<MultiSelectTrigger
|
||||
id="accounts-selector"
|
||||
aria-labelledby="accounts-label"
|
||||
>
|
||||
{selectedLabel() || <MultiSelectValue placeholder="All accounts" />}
|
||||
</MultiSelectTrigger>
|
||||
<MultiSelectContent search={false}>
|
||||
{visibleProviders.length > 0 ? (
|
||||
visibleProviders.map((p) => {
|
||||
const id = p.id;
|
||||
@@ -136,14 +132,15 @@ export function AccountsSelector({ providers }: AccountsSelectorProps) {
|
||||
const providerType = p.attributes.provider as ProviderType;
|
||||
const icon = PROVIDER_ICON[providerType];
|
||||
return (
|
||||
<SelectItem
|
||||
<MultiSelectItem
|
||||
key={id}
|
||||
value={id}
|
||||
badgeLabel={displayName}
|
||||
aria-label={`${displayName} account (${providerType.toUpperCase()})`}
|
||||
>
|
||||
<span aria-hidden="true">{icon}</span>
|
||||
<span className="truncate">{displayName}</span>
|
||||
</SelectItem>
|
||||
</MultiSelectItem>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
@@ -153,8 +150,8 @@ export function AccountsSelector({ providers }: AccountsSelectorProps) {
|
||||
: "No connected accounts available"}
|
||||
</div>
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</MultiSelectContent>
|
||||
</MultiSelect>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,12 +4,12 @@ import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { lazy, Suspense } from "react";
|
||||
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/shadcn";
|
||||
MultiSelect,
|
||||
MultiSelectContent,
|
||||
MultiSelectItem,
|
||||
MultiSelectTrigger,
|
||||
MultiSelectValue,
|
||||
} from "@/components/shadcn/select/multiselect";
|
||||
import { type ProviderProps, ProviderType } from "@/types/providers";
|
||||
|
||||
const AWSProviderBadge = lazy(() =>
|
||||
@@ -147,7 +147,7 @@ export const ProviderTypeSelector = ({
|
||||
};
|
||||
|
||||
const selectedLabel = () => {
|
||||
if (selectedTypes.length === 0) return null; // placeholder visible
|
||||
if (selectedTypes.length === 0) return null;
|
||||
if (selectedTypes.length === 1) {
|
||||
const providerType = selectedTypes[0] as ProviderType;
|
||||
return (
|
||||
@@ -174,39 +174,36 @@ export const ProviderTypeSelector = ({
|
||||
Filter by cloud provider type. Select one or more providers to view
|
||||
findings.
|
||||
</label>
|
||||
<Select
|
||||
multiple
|
||||
selectedValues={selectedTypes}
|
||||
onMultiValueChange={handleMultiValueChange}
|
||||
ariaLabel="Cloud provider type filter"
|
||||
<MultiSelect
|
||||
values={selectedTypes}
|
||||
onValuesChange={handleMultiValueChange}
|
||||
>
|
||||
<SelectTrigger
|
||||
<MultiSelectTrigger
|
||||
id="provider-type-selector"
|
||||
aria-labelledby="provider-type-label"
|
||||
>
|
||||
<SelectValue placeholder="All providers">
|
||||
{selectedLabel()}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{selectedLabel() || <MultiSelectValue placeholder="All providers" />}
|
||||
</MultiSelectTrigger>
|
||||
<MultiSelectContent search={false}>
|
||||
{availableTypes.length > 0 ? (
|
||||
availableTypes.map((providerType) => (
|
||||
<SelectItem
|
||||
<MultiSelectItem
|
||||
key={providerType}
|
||||
value={providerType}
|
||||
badgeLabel={PROVIDER_DATA[providerType].label}
|
||||
aria-label={`${PROVIDER_DATA[providerType].label} provider`}
|
||||
>
|
||||
<span aria-hidden="true">{renderIcon(providerType)}</span>
|
||||
<span>{PROVIDER_DATA[providerType].label}</span>
|
||||
</SelectItem>
|
||||
</MultiSelectItem>
|
||||
))
|
||||
) : (
|
||||
<div className="px-3 py-2 text-sm text-slate-500 dark:text-slate-400">
|
||||
No connected providers available
|
||||
</div>
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</MultiSelectContent>
|
||||
</MultiSelect>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -22,6 +22,11 @@ export default async function NewOverviewPage({
|
||||
}: {
|
||||
searchParams: Promise<SearchParamsProps>;
|
||||
}) {
|
||||
//if cloud env throw a 500 err
|
||||
if (process.env.NEXT_PUBLIC_IS_CLOUD_ENV === "true") {
|
||||
throw new Error("500");
|
||||
}
|
||||
|
||||
const resolvedSearchParams = await searchParams;
|
||||
const providersData = await getProviders({ page: 1, pageSize: 200 });
|
||||
|
||||
|
||||
@@ -2,22 +2,8 @@ import { Spacer } from "@heroui/spacer";
|
||||
import { Suspense } from "react";
|
||||
|
||||
import { getLatestFindings } from "@/actions/findings/findings";
|
||||
import {
|
||||
getFindingsBySeverity,
|
||||
getFindingsByStatus,
|
||||
getProvidersOverview,
|
||||
} from "@/actions/overview/overview";
|
||||
import { FilterControls } from "@/components/filters";
|
||||
import { LighthouseBanner } from "@/components/lighthouse";
|
||||
import {
|
||||
FindingsBySeverityChart,
|
||||
FindingsByStatusChart,
|
||||
LinkToFindings,
|
||||
ProvidersOverview,
|
||||
SkeletonFindingsBySeverityChart,
|
||||
SkeletonFindingsByStatusChart,
|
||||
SkeletonProvidersOverview,
|
||||
} from "@/components/overview";
|
||||
import { getProviders } from "@/actions/providers";
|
||||
import { LinkToFindings } from "@/components/overview";
|
||||
import { ColumnNewFindingsToDate } from "@/components/overview/new-findings-table/table/column-new-findings-to-date";
|
||||
import { SkeletonTableNewFindings } from "@/components/overview/new-findings-table/table/skeleton-table-new-findings";
|
||||
import { ContentLayout } from "@/components/ui";
|
||||
@@ -25,6 +11,20 @@ import { DataTable } from "@/components/ui/table";
|
||||
import { createDict } from "@/lib/helper";
|
||||
import { FindingProps, SearchParamsProps } from "@/types";
|
||||
|
||||
import { LighthouseBanner } from "../../components/lighthouse/banner";
|
||||
import { AccountsSelector } from "./new-overview/components/accounts-selector";
|
||||
import { CheckFindingsSSR } from "./new-overview/components/check-findings";
|
||||
import { ProviderTypeSelector } from "./new-overview/components/provider-type-selector";
|
||||
import {
|
||||
RiskSeverityChartSkeleton,
|
||||
RiskSeverityChartSSR,
|
||||
} from "./new-overview/components/risk-severity-chart";
|
||||
import { StatusChartSkeleton } from "./new-overview/components/status-chart";
|
||||
import {
|
||||
ThreatScoreSkeleton,
|
||||
ThreatScoreSSR,
|
||||
} from "./new-overview/components/threat-score";
|
||||
|
||||
const FILTER_PREFIX = "filter[";
|
||||
|
||||
// Extract only query params that start with "filter[" for API calls
|
||||
@@ -44,98 +44,39 @@ export default async function Home({
|
||||
}) {
|
||||
const resolvedSearchParams = await searchParams;
|
||||
const searchParamsKey = JSON.stringify(resolvedSearchParams || {});
|
||||
const providersData = await getProviders({ page: 1, pageSize: 200 });
|
||||
|
||||
return (
|
||||
<ContentLayout title="Overview" icon="lucide:square-chart-gantt">
|
||||
<FilterControls providers mutedFindings showClearButton={false} />
|
||||
<div className="xxl:grid-cols-4 mb-6 grid grid-cols-1 gap-6 sm:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4">
|
||||
<ProviderTypeSelector providers={providersData?.data ?? []} />
|
||||
<AccountsSelector providers={providersData?.data ?? []} />
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-12 gap-12 lg:gap-6">
|
||||
<div className="col-span-12 lg:col-span-4">
|
||||
<Suspense fallback={<SkeletonProvidersOverview />}>
|
||||
<SSRProvidersOverview />
|
||||
</Suspense>
|
||||
</div>
|
||||
<div className="flex flex-col gap-6 md:flex-row md:flex-wrap md:items-stretch">
|
||||
<Suspense fallback={<ThreatScoreSkeleton />}>
|
||||
<ThreatScoreSSR searchParams={resolvedSearchParams} />
|
||||
</Suspense>
|
||||
|
||||
<div className="col-span-12 lg:col-span-4">
|
||||
<Suspense fallback={<SkeletonFindingsBySeverityChart />}>
|
||||
<SSRFindingsBySeverity searchParams={resolvedSearchParams} />
|
||||
</Suspense>
|
||||
</div>
|
||||
<Suspense fallback={<StatusChartSkeleton />}>
|
||||
<CheckFindingsSSR searchParams={resolvedSearchParams} />
|
||||
</Suspense>
|
||||
|
||||
<div className="col-span-12 lg:col-span-4">
|
||||
<Suspense fallback={<SkeletonFindingsByStatusChart />}>
|
||||
<SSRFindingsByStatus searchParams={resolvedSearchParams} />
|
||||
</Suspense>
|
||||
</div>
|
||||
<Suspense fallback={<RiskSeverityChartSkeleton />}>
|
||||
<RiskSeverityChartSSR searchParams={resolvedSearchParams} />
|
||||
</Suspense>
|
||||
</div>
|
||||
|
||||
<div className="col-span-12">
|
||||
<Spacer y={16} />
|
||||
<Suspense
|
||||
key={searchParamsKey}
|
||||
fallback={<SkeletonTableNewFindings />}
|
||||
>
|
||||
<SSRDataNewFindingsTable searchParams={resolvedSearchParams} />
|
||||
</Suspense>
|
||||
</div>
|
||||
<div className="mt-6">
|
||||
<Spacer y={16} />
|
||||
<Suspense key={searchParamsKey} fallback={<SkeletonTableNewFindings />}>
|
||||
<SSRDataNewFindingsTable searchParams={resolvedSearchParams} />
|
||||
</Suspense>
|
||||
</div>
|
||||
</ContentLayout>
|
||||
);
|
||||
}
|
||||
|
||||
const SSRProvidersOverview = async () => {
|
||||
const providersOverview = await getProvidersOverview({});
|
||||
|
||||
return (
|
||||
<>
|
||||
<h3 className="mb-4 text-sm font-bold uppercase">Providers Overview</h3>
|
||||
<ProvidersOverview providersOverview={providersOverview} />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const SSRFindingsByStatus = async ({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: SearchParamsProps | undefined | null;
|
||||
}) => {
|
||||
const filters = pickFilterParams(searchParams);
|
||||
|
||||
const findingsByStatus = await getFindingsByStatus({ filters });
|
||||
|
||||
return (
|
||||
<>
|
||||
<h3 className="mb-4 text-sm font-bold uppercase">Findings by Status</h3>
|
||||
<FindingsByStatusChart findingsByStatus={findingsByStatus} />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const SSRFindingsBySeverity = async ({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: SearchParamsProps | undefined | null;
|
||||
}) => {
|
||||
const defaultFilters = {
|
||||
"filter[status]": "FAIL",
|
||||
} as const;
|
||||
|
||||
const filters = pickFilterParams(searchParams);
|
||||
|
||||
const combinedFilters = { ...defaultFilters, ...filters };
|
||||
|
||||
const findingsBySeverity = await getFindingsBySeverity({
|
||||
filters: combinedFilters,
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<h3 className="mb-4 text-sm font-bold uppercase">
|
||||
Failed Findings by Severity
|
||||
</h3>
|
||||
<FindingsBySeverityChart findingsBySeverity={findingsBySeverity} />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const SSRDataNewFindingsTable = async ({
|
||||
searchParams,
|
||||
}: {
|
||||
@@ -188,6 +129,7 @@ const SSRDataNewFindingsTable = async ({
|
||||
|
||||
return (
|
||||
<>
|
||||
<LighthouseBanner />
|
||||
<div className="relative flex w-full">
|
||||
<div className="flex w-full items-center gap-2">
|
||||
<h3 className="text-sm font-bold uppercase">
|
||||
@@ -203,8 +145,6 @@ const SSRDataNewFindingsTable = async ({
|
||||
</div>
|
||||
<Spacer y={4} />
|
||||
|
||||
<LighthouseBanner />
|
||||
|
||||
<DataTable
|
||||
key={`dashboard-${Date.now()}`}
|
||||
columns={ColumnNewFindingsToDate}
|
||||
|
||||
@@ -69,7 +69,6 @@ export default async function Resources({
|
||||
values: uniqueServices,
|
||||
},
|
||||
]}
|
||||
defaultOpen={true}
|
||||
/>
|
||||
<Spacer y={8} />
|
||||
<Suspense key={searchParamsKey} fallback={<SkeletonTableResources />}>
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import { Spacer } from "@heroui/spacer";
|
||||
import Link from "next/link";
|
||||
import { Suspense } from "react";
|
||||
|
||||
import { getRoles } from "@/actions/roles";
|
||||
import { FilterControls } from "@/components/filters";
|
||||
import { filterRoles } from "@/components/filters/data-filters";
|
||||
import { AddRoleButton } from "@/components/roles";
|
||||
import { AddIcon } from "@/components/icons";
|
||||
import { ColumnsRoles } from "@/components/roles/table";
|
||||
import { SkeletonTableRoles } from "@/components/roles/table";
|
||||
import { Button } from "@/components/shadcn";
|
||||
import { ContentLayout } from "@/components/ui";
|
||||
import { DataTable, DataTableFilterCustom } from "@/components/ui/table";
|
||||
import { SearchParamsProps } from "@/types";
|
||||
@@ -22,10 +24,17 @@ export default async function Roles({
|
||||
return (
|
||||
<ContentLayout title="Roles" icon="lucide:user-cog">
|
||||
<FilterControls search />
|
||||
<Spacer y={8} />
|
||||
<AddRoleButton />
|
||||
<Spacer y={4} />
|
||||
<DataTableFilterCustom filters={filterRoles || []} />
|
||||
|
||||
<div className="flex flex-row items-center justify-between">
|
||||
<DataTableFilterCustom filters={filterRoles || []} />
|
||||
|
||||
<Button asChild>
|
||||
<Link href="/roles/new">
|
||||
Add Role
|
||||
<AddIcon size={20} />
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
<Spacer y={8} />
|
||||
|
||||
<Suspense key={searchParamsKey} fallback={<SkeletonTableRoles />}>
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
import { Spacer } from "@heroui/spacer";
|
||||
import Link from "next/link";
|
||||
import { Suspense } from "react";
|
||||
|
||||
import { getRoles } from "@/actions/roles/roles";
|
||||
import { getUsers } from "@/actions/users/users";
|
||||
import { FilterControls } from "@/components/filters";
|
||||
import { filterUsers } from "@/components/filters/data-filters";
|
||||
import { AddIcon } from "@/components/icons";
|
||||
import { Button } from "@/components/shadcn";
|
||||
import { ContentLayout } from "@/components/ui";
|
||||
import { DataTable, DataTableFilterCustom } from "@/components/ui/table";
|
||||
import { AddUserButton } from "@/components/users";
|
||||
import { ColumnsUser, SkeletonTableUser } from "@/components/users/table";
|
||||
import { Role, SearchParamsProps, UserProps } from "@/types";
|
||||
|
||||
@@ -22,10 +24,17 @@ export default async function Users({
|
||||
return (
|
||||
<ContentLayout title="Users" icon="lucide:user">
|
||||
<FilterControls search />
|
||||
<Spacer y={8} />
|
||||
<AddUserButton />
|
||||
<Spacer y={4} />
|
||||
<DataTableFilterCustom filters={filterUsers || []} />
|
||||
|
||||
<div className="flex flex-row items-center justify-between">
|
||||
<DataTableFilterCustom filters={filterUsers || []} />
|
||||
|
||||
<Button asChild>
|
||||
<Link href="/invitations/new">
|
||||
Invite User
|
||||
<AddIcon size={20} />
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
<Spacer y={8} />
|
||||
|
||||
<Suspense key={searchParamsKey} fallback={<SkeletonTableUser />}>
|
||||
|
||||
@@ -9,6 +9,12 @@ import { useTheme } from "next-themes";
|
||||
import { FC } from "react";
|
||||
import React from "react";
|
||||
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/shadcn/tooltip";
|
||||
|
||||
import { MoonFilledIcon, SunFilledIcon } from "./icons";
|
||||
|
||||
export interface ThemeSwitchProps {
|
||||
@@ -41,43 +47,50 @@ export const ThemeSwitch: FC<ThemeSwitchProps> = ({
|
||||
});
|
||||
|
||||
return (
|
||||
<Component
|
||||
{...getBaseProps({
|
||||
className: clsx(
|
||||
"px-px transition-opacity hover:opacity-80 cursor-pointer",
|
||||
className,
|
||||
classNames?.base,
|
||||
),
|
||||
})}
|
||||
>
|
||||
<VisuallyHidden>
|
||||
<input {...getInputProps()} />
|
||||
</VisuallyHidden>
|
||||
<div
|
||||
{...getWrapperProps()}
|
||||
className={slots.wrapper({
|
||||
class: clsx(
|
||||
[
|
||||
"h-auto w-auto",
|
||||
"bg-transparent",
|
||||
"rounded-lg",
|
||||
"flex items-center justify-center",
|
||||
"group-data-[selected=true]:bg-transparent",
|
||||
"!text-default-500",
|
||||
"pt-px",
|
||||
"px-0",
|
||||
"mx-0",
|
||||
],
|
||||
classNames?.wrapper,
|
||||
),
|
||||
})}
|
||||
>
|
||||
{!isSelected || isSSR ? (
|
||||
<SunFilledIcon size={22} />
|
||||
) : (
|
||||
<MoonFilledIcon size={22} />
|
||||
)}
|
||||
</div>
|
||||
</Component>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Component
|
||||
{...getBaseProps({
|
||||
className: clsx(
|
||||
"px-px transition-opacity hover:opacity-80 cursor-pointer",
|
||||
className,
|
||||
classNames?.base,
|
||||
),
|
||||
})}
|
||||
>
|
||||
<VisuallyHidden>
|
||||
<input {...getInputProps()} />
|
||||
</VisuallyHidden>
|
||||
<div
|
||||
{...getWrapperProps()}
|
||||
className={slots.wrapper({
|
||||
class: clsx(
|
||||
[
|
||||
"h-auto w-auto",
|
||||
"bg-transparent",
|
||||
"rounded-lg",
|
||||
"flex items-center justify-center",
|
||||
"group-data-[selected=true]:bg-transparent",
|
||||
"!text-default-500",
|
||||
"pt-px",
|
||||
"px-0",
|
||||
"mx-0",
|
||||
],
|
||||
classNames?.wrapper,
|
||||
),
|
||||
})}
|
||||
>
|
||||
{!isSelected || isSSR ? (
|
||||
<SunFilledIcon size={22} />
|
||||
) : (
|
||||
<MoonFilledIcon size={22} />
|
||||
)}
|
||||
</div>
|
||||
</Component>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{isSelected || isSSR ? "Switch to Dark Mode" : "Switch to Light Mode"}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -14,7 +14,7 @@ export const AuthFooterLink = ({
|
||||
return (
|
||||
<p className="text-small text-center">
|
||||
{text}
|
||||
<CustomLink size="base" href={href} target="_self">
|
||||
<CustomLink size="md" href={href} target="_self">
|
||||
{linkText}
|
||||
</CustomLink>
|
||||
</p>
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
"use client";
|
||||
|
||||
import { Button } from "@heroui/button";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { Icon } from "@iconify/react";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
@@ -13,8 +12,9 @@ import { AuthDivider } from "@/components/auth/oss/auth-divider";
|
||||
import { AuthFooterLink } from "@/components/auth/oss/auth-footer-link";
|
||||
import { AuthLayout } from "@/components/auth/oss/auth-layout";
|
||||
import { SocialButtons } from "@/components/auth/oss/social-buttons";
|
||||
import { Button } from "@/components/shadcn";
|
||||
import { useToast } from "@/components/ui";
|
||||
import { CustomButton, CustomInput } from "@/components/ui/custom";
|
||||
import { CustomInput } from "@/components/ui/custom";
|
||||
import { Form } from "@/components/ui/form";
|
||||
import { SignInFormData, signInSchema } from "@/types";
|
||||
|
||||
@@ -156,32 +156,21 @@ export const SignInForm = ({
|
||||
type="email"
|
||||
label="Email"
|
||||
placeholder="Enter your email"
|
||||
isInvalid={!!form.formState.errors.email}
|
||||
showFormMessage
|
||||
/>
|
||||
{!isSamlMode && (
|
||||
<CustomInput
|
||||
control={form.control}
|
||||
name="password"
|
||||
password
|
||||
isInvalid={!!form.formState.errors.password}
|
||||
/>
|
||||
<CustomInput control={form.control} name="password" password />
|
||||
)}
|
||||
|
||||
<CustomButton
|
||||
<Button
|
||||
type="submit"
|
||||
ariaLabel="Log in"
|
||||
ariaDisabled={isLoading}
|
||||
aria-label="Log in"
|
||||
aria-disabled={isLoading}
|
||||
className="w-full"
|
||||
variant="solid"
|
||||
color="action"
|
||||
size="md"
|
||||
radius="md"
|
||||
isLoading={isLoading}
|
||||
isDisabled={isLoading}
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading ? <span>Loading</span> : <span>Log in</span>}
|
||||
</CustomButton>
|
||||
{isLoading ? "Loading..." : "Log in"}
|
||||
</Button>
|
||||
</form>
|
||||
</Form>
|
||||
|
||||
@@ -197,21 +186,19 @@ export const SignInForm = ({
|
||||
/>
|
||||
)}
|
||||
<Button
|
||||
startContent={
|
||||
!isSamlMode && (
|
||||
<Icon
|
||||
className="text-default-500"
|
||||
icon="mdi:shield-key"
|
||||
width={24}
|
||||
/>
|
||||
)
|
||||
}
|
||||
variant="bordered"
|
||||
className="w-full"
|
||||
variant="outline"
|
||||
className="w-full gap-2"
|
||||
onClick={() => {
|
||||
form.setValue("isSamlMode", !isSamlMode);
|
||||
}}
|
||||
>
|
||||
{!isSamlMode && (
|
||||
<Icon
|
||||
className="text-default-500"
|
||||
icon="mdi:shield-key"
|
||||
width={24}
|
||||
/>
|
||||
)}
|
||||
{isSamlMode ? "Back" : "Continue with SAML SSO"}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -11,8 +11,9 @@ import { AuthFooterLink } from "@/components/auth/oss/auth-footer-link";
|
||||
import { AuthLayout } from "@/components/auth/oss/auth-layout";
|
||||
import { PasswordRequirementsMessage } from "@/components/auth/oss/password-validator";
|
||||
import { SocialButtons } from "@/components/auth/oss/social-buttons";
|
||||
import { Button } from "@/components/shadcn";
|
||||
import { useToast } from "@/components/ui";
|
||||
import { CustomButton, CustomInput } from "@/components/ui/custom";
|
||||
import { CustomInput } from "@/components/ui/custom";
|
||||
import { CustomLink } from "@/components/ui/custom/custom-link";
|
||||
import {
|
||||
Form,
|
||||
@@ -133,7 +134,6 @@ export const SignUpForm = ({
|
||||
type="text"
|
||||
label="Name"
|
||||
placeholder="Enter your name"
|
||||
isInvalid={!!form.formState.errors.name}
|
||||
/>
|
||||
<CustomInput
|
||||
control={form.control}
|
||||
@@ -142,7 +142,6 @@ export const SignUpForm = ({
|
||||
label="Company name"
|
||||
placeholder="Enter your company name"
|
||||
isRequired={false}
|
||||
isInvalid={!!form.formState.errors.company}
|
||||
/>
|
||||
<CustomInput
|
||||
control={form.control}
|
||||
@@ -150,15 +149,9 @@ export const SignUpForm = ({
|
||||
type="email"
|
||||
label="Email"
|
||||
placeholder="Enter your email"
|
||||
isInvalid={!!form.formState.errors.email}
|
||||
showFormMessage
|
||||
/>
|
||||
<CustomInput
|
||||
control={form.control}
|
||||
name="password"
|
||||
password
|
||||
isInvalid={!!form.formState.errors.password}
|
||||
/>
|
||||
<CustomInput control={form.control} name="password" password />
|
||||
<PasswordRequirementsMessage
|
||||
password={form.watch("password") || ""}
|
||||
/>
|
||||
@@ -176,7 +169,6 @@ export const SignUpForm = ({
|
||||
placeholder={invitationToken}
|
||||
defaultValue={invitationToken}
|
||||
isRequired={false}
|
||||
isInvalid={!!form.formState.errors.invitationToken}
|
||||
isDisabled={invitationToken !== null && true}
|
||||
/>
|
||||
)}
|
||||
@@ -194,6 +186,7 @@ export const SignUpForm = ({
|
||||
size="sm"
|
||||
checked={field.value}
|
||||
onChange={(e) => field.onChange(e.target.checked)}
|
||||
color="default"
|
||||
>
|
||||
I agree with the
|
||||
<CustomLink
|
||||
@@ -205,26 +198,21 @@ export const SignUpForm = ({
|
||||
of Prowler
|
||||
</Checkbox>
|
||||
</FormControl>
|
||||
<FormMessage className="text-system-error dark:text-system-error" />
|
||||
<FormMessage className="text-text-error" />
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<CustomButton
|
||||
<Button
|
||||
type="submit"
|
||||
ariaLabel="Sign up"
|
||||
ariaDisabled={isLoading}
|
||||
aria-label="Sign up"
|
||||
aria-disabled={isLoading}
|
||||
className="w-full"
|
||||
variant="solid"
|
||||
color="action"
|
||||
size="md"
|
||||
radius="md"
|
||||
isLoading={isLoading}
|
||||
isDisabled={isLoading}
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading ? <span>Loading</span> : <span>Sign up</span>}
|
||||
</CustomButton>
|
||||
{isLoading ? "Loading..." : "Sign up"}
|
||||
</Button>
|
||||
</form>
|
||||
</Form>
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Button } from "@heroui/button";
|
||||
import { Tooltip } from "@heroui/tooltip";
|
||||
import { Icon } from "@iconify/react";
|
||||
|
||||
import { Button } from "@/components/shadcn";
|
||||
import { CustomLink } from "@/components/ui/custom/custom-link";
|
||||
|
||||
export const SocialButtons = ({
|
||||
@@ -32,14 +32,22 @@ export const SocialButtons = ({
|
||||
>
|
||||
<span>
|
||||
<Button
|
||||
startContent={<Icon icon="flat-color-icons:google" width={24} />}
|
||||
variant="bordered"
|
||||
variant="outline"
|
||||
className="w-full"
|
||||
as="a"
|
||||
href={googleAuthUrl}
|
||||
isDisabled={!isGoogleOAuthEnabled}
|
||||
asChild={isGoogleOAuthEnabled}
|
||||
disabled={!isGoogleOAuthEnabled}
|
||||
>
|
||||
Continue with Google
|
||||
<a href={googleAuthUrl} className="flex items-center gap-2">
|
||||
<Icon
|
||||
icon={
|
||||
isGoogleOAuthEnabled
|
||||
? "flat-color-icons:google"
|
||||
: "simple-icons:google"
|
||||
}
|
||||
width={24}
|
||||
/>
|
||||
Continue with Google
|
||||
</a>
|
||||
</Button>
|
||||
</span>
|
||||
</Tooltip>
|
||||
@@ -59,16 +67,15 @@ export const SocialButtons = ({
|
||||
>
|
||||
<span>
|
||||
<Button
|
||||
startContent={
|
||||
<Icon className="text-default-500" icon="fe:github" width={24} />
|
||||
}
|
||||
variant="bordered"
|
||||
variant="outline"
|
||||
className="w-full"
|
||||
as="a"
|
||||
href={githubAuthUrl}
|
||||
isDisabled={!isGithubOAuthEnabled}
|
||||
asChild={isGithubOAuthEnabled}
|
||||
disabled={!isGithubOAuthEnabled}
|
||||
>
|
||||
Continue with Github
|
||||
<a href={githubAuthUrl} className="flex items-center gap-2">
|
||||
<Icon icon="simple-icons:github" width={24} />
|
||||
Continue with Github
|
||||
</a>
|
||||
</Button>
|
||||
</span>
|
||||
</Tooltip>
|
||||
|
||||
@@ -58,7 +58,7 @@ export const ClientAccordionWrapper = ({
|
||||
return (
|
||||
<div>
|
||||
{!hideExpandButton && (
|
||||
<div className="mt-[-16px] flex justify-end text-xs font-medium text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300">
|
||||
<div className="text-text-neutral-tertiary hover:text-text-neutral-primary mt-[-16px] flex justify-end text-xs font-medium transition-colors">
|
||||
<button
|
||||
onClick={handleToggleExpand}
|
||||
aria-label={isExpanded ? "Collapse all" : "Expand all"}
|
||||
|
||||
@@ -24,7 +24,7 @@ export const ComplianceAccordionTitle = ({
|
||||
<div className="flex flex-col items-start justify-between gap-1 md:flex-row md:items-center md:gap-2">
|
||||
<div className="overflow-hidden md:min-w-0 md:flex-1">
|
||||
<span
|
||||
className="block max-w-[600px] truncate overflow-hidden text-sm text-ellipsis"
|
||||
className="block max-w-[200px] truncate text-sm text-ellipsis sm:max-w-[300px] md:max-w-[400px] lg:max-w-[600px]"
|
||||
title={label}
|
||||
>
|
||||
{label.charAt(0).toUpperCase() + label.slice(1)}
|
||||
@@ -57,8 +57,8 @@ export const ComplianceAccordionTitle = ({
|
||||
delay={0}
|
||||
closeDelay={0}
|
||||
>
|
||||
<div
|
||||
className="h-full bg-[#3CEC6D] transition-all duration-200 hover:brightness-110"
|
||||
<span
|
||||
className="inline-block h-full bg-[#3CEC6D] transition-all duration-200 hover:brightness-110"
|
||||
style={{
|
||||
width: `${passPercentage}%`,
|
||||
marginRight: pass > 0 ? "2px" : "0",
|
||||
@@ -81,8 +81,8 @@ export const ComplianceAccordionTitle = ({
|
||||
delay={0}
|
||||
closeDelay={0}
|
||||
>
|
||||
<div
|
||||
className="h-full bg-[#FB718F] transition-all duration-200 hover:brightness-110"
|
||||
<span
|
||||
className="inline-block h-full bg-[#FB718F] transition-all duration-200 hover:brightness-110"
|
||||
style={{
|
||||
width: `${failPercentage}%`,
|
||||
marginRight: manual > 0 ? "2px" : "0",
|
||||
@@ -105,8 +105,8 @@ export const ComplianceAccordionTitle = ({
|
||||
delay={0}
|
||||
closeDelay={0}
|
||||
>
|
||||
<div
|
||||
className="h-full bg-[#868994] transition-all duration-200 hover:brightness-110"
|
||||
<span
|
||||
className="inline-block h-full bg-[#868994] transition-all duration-200 hover:brightness-110"
|
||||
style={{ width: `${manualPercentage}%` }}
|
||||
/>
|
||||
</Tooltip>
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
"use client";
|
||||
|
||||
import { Card, CardBody } from "@heroui/card";
|
||||
import { Progress } from "@heroui/progress";
|
||||
import Image from "next/image";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import React, { useState } from "react";
|
||||
import { useState } from "react";
|
||||
|
||||
import { Card, CardContent } from "@/components/shadcn/card/card";
|
||||
import { DownloadIconButton, toast } from "@/components/ui";
|
||||
import { downloadComplianceCsv } from "@/lib/helper";
|
||||
import { ScanEntity } from "@/types/scans";
|
||||
@@ -48,23 +48,6 @@ export const ComplianceCard: React.FC<ComplianceCardProps> = ({
|
||||
(passingRequirements / totalRequirements) * 100,
|
||||
);
|
||||
|
||||
// Calculates the percentage change in passing requirements compared to the previous scan.
|
||||
//
|
||||
// const prevRatingPercentage = Math.floor(
|
||||
// (prevPassingRequirements / prevTotalRequirements) * 100,
|
||||
// );
|
||||
|
||||
// const getScanChange = () => {
|
||||
// const scanDifference = ratingPercentage - prevRatingPercentage;
|
||||
// if (scanDifference < 0 && scanDifference <= -1) {
|
||||
// return `${scanDifference}% from last scan`;
|
||||
// }
|
||||
// if (scanDifference > 0 && scanDifference >= 1) {
|
||||
// return `+${scanDifference}% from last scan`;
|
||||
// }
|
||||
// return "No changes from last scan";
|
||||
// };
|
||||
|
||||
const getRatingColor = (ratingPercentage: number) => {
|
||||
if (ratingPercentage <= 10) {
|
||||
return "danger";
|
||||
@@ -112,11 +95,13 @@ export const ComplianceCard: React.FC<ComplianceCardProps> = ({
|
||||
};
|
||||
|
||||
return (
|
||||
<Card fullWidth isHoverable shadow="sm">
|
||||
<CardBody
|
||||
className="dark:bg-prowler-blue-800 flex cursor-pointer flex-row items-center justify-between gap-4"
|
||||
onClick={navigateToDetail}
|
||||
>
|
||||
<Card
|
||||
variant="base"
|
||||
padding="md"
|
||||
className="cursor-pointer transition-shadow hover:shadow-md"
|
||||
onClick={navigateToDetail}
|
||||
>
|
||||
<CardContent className="p-0">
|
||||
<div className="flex w-full items-center gap-4">
|
||||
{getComplianceIcon(title) && (
|
||||
<Image
|
||||
@@ -169,11 +154,10 @@ export const ComplianceCard: React.FC<ComplianceCardProps> = ({
|
||||
isDownloading={isDownloading}
|
||||
/>
|
||||
</div>
|
||||
{/* <small>{getScanChange()}</small> */}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardBody>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,214 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useTheme } from "next-themes";
|
||||
import {
|
||||
Bar,
|
||||
BarChart as RechartsBarChart,
|
||||
Legend,
|
||||
ResponsiveContainer,
|
||||
Tooltip,
|
||||
XAxis,
|
||||
YAxis,
|
||||
} from "recharts";
|
||||
|
||||
import { translateType } from "@/lib/compliance/ens";
|
||||
import { FailedSection } from "@/types/compliance";
|
||||
|
||||
const CustomYAxisTick = (props: any) => {
|
||||
const { x, y, payload, theme } = props;
|
||||
const text = payload.value;
|
||||
const maxLength = 50;
|
||||
|
||||
const truncatedText =
|
||||
text.length > maxLength ? `${text.slice(0, maxLength)}...` : text;
|
||||
|
||||
return (
|
||||
<g transform={`translate(${x},${y})`}>
|
||||
<text
|
||||
x={10}
|
||||
y={-24}
|
||||
fill={theme === "dark" ? "#94a3b8" : "#374151"}
|
||||
fontSize={12}
|
||||
textAnchor="start"
|
||||
dominantBaseline="middle"
|
||||
>
|
||||
{truncatedText}
|
||||
</text>
|
||||
</g>
|
||||
);
|
||||
};
|
||||
|
||||
interface FailedSectionsListProps {
|
||||
sections: FailedSection[];
|
||||
}
|
||||
|
||||
const title = (
|
||||
<h3 className="mb-2 text-xs font-semibold tracking-wide whitespace-nowrap uppercase">
|
||||
Top Failed Sections
|
||||
</h3>
|
||||
);
|
||||
|
||||
export const BarChart = ({ sections }: FailedSectionsListProps) => {
|
||||
const { theme } = useTheme();
|
||||
|
||||
const getTypeColor = (type: string) => {
|
||||
switch (type.toLowerCase()) {
|
||||
case "requisito":
|
||||
return "#FB718F";
|
||||
case "recomendacion":
|
||||
return "#FDC53A"; // Increased contrast from #FDDD8A
|
||||
case "refuerzo":
|
||||
return "#7FB5FF"; // Increased contrast from #B5D7FF
|
||||
default:
|
||||
return "#FB718F";
|
||||
}
|
||||
};
|
||||
|
||||
const chartData = [...sections]
|
||||
.sort((a, b) => b.total - a.total)
|
||||
.slice(0, 5)
|
||||
.map((section) => ({
|
||||
name: section.name.charAt(0).toUpperCase() + section.name.slice(1),
|
||||
...section.types,
|
||||
}));
|
||||
|
||||
const allTypes = Array.from(
|
||||
new Set(sections.flatMap((section) => Object.keys(section.types || {}))),
|
||||
);
|
||||
|
||||
// Add empty bars to complete up to 5 bars for better distribution
|
||||
while (chartData.length < 5) {
|
||||
const emptyBar: any = { name: "" };
|
||||
allTypes.forEach((type) => {
|
||||
emptyBar[type] = 0;
|
||||
});
|
||||
chartData.push(emptyBar);
|
||||
}
|
||||
|
||||
// Calculate the maximum value to ensure proper scaling
|
||||
const maxValue = Math.max(
|
||||
...chartData.map((item) =>
|
||||
allTypes.reduce((sum, type) => sum + ((item as any)[type] || 0), 0),
|
||||
),
|
||||
);
|
||||
|
||||
// Set minimum domain to ensure bars are always visible
|
||||
const domainMax = Math.max(maxValue, 1);
|
||||
|
||||
// Check if there are no failed sections
|
||||
if (!sections || sections.length === 0) {
|
||||
return (
|
||||
<div className="flex w-[400px] flex-col items-center justify-between">
|
||||
{title}
|
||||
<div className="flex h-[320px] w-full items-center justify-center">
|
||||
<p className="text-sm text-gray-500">There are no failed sections</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-[320px] w-[400px] flex-col items-center justify-between">
|
||||
{title}
|
||||
|
||||
<div className="h-full w-full">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<RechartsBarChart
|
||||
data={chartData}
|
||||
layout="vertical"
|
||||
margin={{ top: 12, bottom: 0, right: 0, left: -56 }}
|
||||
maxBarSize={30}
|
||||
>
|
||||
<XAxis
|
||||
type="number"
|
||||
fontSize={12}
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
allowDecimals={false}
|
||||
hide={true}
|
||||
domain={[0, domainMax]}
|
||||
tick={{
|
||||
fontSize: 12,
|
||||
fill: theme === "dark" ? "#94a3b8" : "#374151",
|
||||
}}
|
||||
/>
|
||||
<YAxis
|
||||
type="category"
|
||||
dataKey="name"
|
||||
tick={(props) => <CustomYAxisTick {...props} theme={theme} />}
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
/>
|
||||
<Tooltip
|
||||
content={(props) => {
|
||||
if (!props.active || !props.payload || !props.payload.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const data = props.payload[0].payload;
|
||||
if (!data.name || data.name === "") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const hasValues = allTypes.some((type) => data[type] > 0);
|
||||
if (!hasValues) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
backgroundColor: theme === "dark" ? "#1e293b" : "white",
|
||||
border: `1px solid ${theme === "dark" ? "#475569" : "rgba(0, 0, 0, 0.1)"}`,
|
||||
borderRadius: "6px",
|
||||
boxShadow: "0px 4px 12px rgba(0, 0, 0, 0.15)",
|
||||
fontSize: "12px",
|
||||
padding: "8px 12px",
|
||||
color: theme === "dark" ? "white" : "black",
|
||||
}}
|
||||
>
|
||||
{props.payload.map((entry: any, index: number) => (
|
||||
<div key={index} className="max-w-[200px]">
|
||||
<p>{data.name}</p>
|
||||
<p>
|
||||
<span style={{ color: entry.color }}>
|
||||
{translateType(entry.dataKey)}: {entry.value}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
cursor={false}
|
||||
/>
|
||||
{allTypes.map((type, i) => (
|
||||
<Bar
|
||||
key={type}
|
||||
dataKey={type}
|
||||
stackId="a"
|
||||
fill={getTypeColor(type)}
|
||||
radius={i === allTypes.length - 1 ? [0, 4, 4, 0] : [0, 0, 0, 0]}
|
||||
/>
|
||||
))}
|
||||
<Legend
|
||||
formatter={(value) => translateType(value)}
|
||||
wrapperStyle={{
|
||||
fontSize: "10px",
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
width: "100%",
|
||||
paddingTop: "16px",
|
||||
marginBottom: "16px",
|
||||
marginLeft: "56px",
|
||||
}}
|
||||
iconType="circle"
|
||||
layout="horizontal"
|
||||
verticalAlign="bottom"
|
||||
/>
|
||||
</RechartsBarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,62 @@
|
||||
import { Card, CardContent, CardHeader, Skeleton } from "@/components/shadcn";
|
||||
|
||||
export function RequirementsStatusCardSkeleton() {
|
||||
return (
|
||||
<Card
|
||||
variant="base"
|
||||
className="flex min-h-[372px] min-w-[328px] flex-col justify-between md:max-w-[312px]"
|
||||
>
|
||||
<CardHeader>
|
||||
<Skeleton className="h-7 w-[260px] rounded-xl" />
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-1 flex-col justify-between space-y-4">
|
||||
{/* Circular skeleton for donut chart */}
|
||||
<div className="mx-auto h-[172px] w-[172px]">
|
||||
<Skeleton className="size-[172px] rounded-full" />
|
||||
</div>
|
||||
|
||||
{/* Bottom info box skeleton - inner card with horizontal items */}
|
||||
<Skeleton className="h-[97px] w-full shrink-0 rounded-xl" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export function TopFailedSectionsCardSkeleton() {
|
||||
return (
|
||||
<Card
|
||||
variant="base"
|
||||
className="flex min-h-[372px] min-w-[328px] flex-1 flex-col"
|
||||
>
|
||||
<CardHeader>
|
||||
<Skeleton className="h-6 w-48" />
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-1 flex-col justify-center gap-6">
|
||||
{[...Array(5)].map((_, i) => (
|
||||
<div key={i} className="flex items-center gap-4">
|
||||
<Skeleton className="h-4 w-20" />
|
||||
<Skeleton className="h-[22px] flex-1" />
|
||||
<Skeleton className="h-4 w-24" />
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export function SectionsFailureRateCardSkeleton() {
|
||||
return (
|
||||
<Card variant="base" className="flex min-h-[372px] min-w-[328px] flex-col">
|
||||
<CardHeader>
|
||||
<Skeleton className="h-6 w-48" />
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-1 items-center justify-center p-6">
|
||||
<div className="grid h-full w-full grid-cols-3 gap-2">
|
||||
{[...Array(9)].map((_, i) => (
|
||||
<Skeleton key={i} className="h-full w-full rounded" />
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -29,12 +29,6 @@ const capitalizeFirstLetter = (text: string): string => {
|
||||
);
|
||||
};
|
||||
|
||||
const title = (
|
||||
<h3 className="mb-2 text-xs font-semibold tracking-wide whitespace-nowrap uppercase">
|
||||
Sections Failure Rate
|
||||
</h3>
|
||||
);
|
||||
|
||||
export const HeatmapChart = ({ categories = [] }: HeatmapChartProps) => {
|
||||
const { theme } = useTheme();
|
||||
const [hoveredItem, setHoveredItem] = useState<CategoryData | null>(null);
|
||||
@@ -49,10 +43,9 @@ export const HeatmapChart = ({ categories = [] }: HeatmapChartProps) => {
|
||||
// Check if there are no items with data
|
||||
if (!categories.length || heatmapData.length === 0) {
|
||||
return (
|
||||
<div className="flex w-[400px] flex-col items-center justify-between lg:w-[400px]">
|
||||
{title}
|
||||
<div className="flex h-[320px] w-full items-center justify-center">
|
||||
<p className="text-sm text-gray-500">No category data available</p>
|
||||
<div className="flex w-full flex-col items-center justify-center">
|
||||
<div className="flex h-[250px] w-full items-center justify-center">
|
||||
<p className="text-sm text-slate-400">No category data available</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -72,9 +65,7 @@ export const HeatmapChart = ({ categories = [] }: HeatmapChartProps) => {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-[320px] w-[400px] flex-col items-center justify-between lg:w-[400px]">
|
||||
{title}
|
||||
|
||||
<div className="flex h-full w-full flex-col items-center justify-center">
|
||||
<div className="h-full w-full p-2">
|
||||
<div
|
||||
className={cn(
|
||||
|
||||
@@ -1,192 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useTheme } from "next-themes";
|
||||
import {
|
||||
Cell,
|
||||
Label,
|
||||
Pie,
|
||||
PieChart as RechartsPieChart,
|
||||
Tooltip,
|
||||
} from "recharts";
|
||||
|
||||
import { ChartConfig, ChartContainer } from "@/components/ui/chart/Chart";
|
||||
|
||||
interface PieChartProps {
|
||||
pass: number;
|
||||
fail: number;
|
||||
manual: number;
|
||||
}
|
||||
|
||||
const chartConfig = {
|
||||
number: {
|
||||
label: "Requirements",
|
||||
},
|
||||
pass: {
|
||||
label: "Pass",
|
||||
color: "var(--color-bg-pass)",
|
||||
},
|
||||
fail: {
|
||||
label: "Fail",
|
||||
color: "var(--color-bg-fail)",
|
||||
},
|
||||
manual: {
|
||||
label: "Manual",
|
||||
color: "var(--color-bg-warning)",
|
||||
},
|
||||
} satisfies ChartConfig;
|
||||
|
||||
export const PieChart = ({ pass, fail, manual }: PieChartProps) => {
|
||||
const { theme } = useTheme();
|
||||
|
||||
const chartData = [
|
||||
{
|
||||
name: "Pass",
|
||||
value: pass,
|
||||
fill: "#3CEC6D",
|
||||
},
|
||||
{
|
||||
name: "Fail",
|
||||
value: fail,
|
||||
fill: "#FB718F",
|
||||
},
|
||||
{
|
||||
name: "Manual",
|
||||
value: manual,
|
||||
fill: "#868994",
|
||||
},
|
||||
];
|
||||
|
||||
const totalRequirements = pass + fail + manual;
|
||||
|
||||
const emptyChartData = [
|
||||
{
|
||||
name: "Empty",
|
||||
value: 1,
|
||||
fill: "#64748b",
|
||||
},
|
||||
];
|
||||
|
||||
interface CustomTooltipProps {
|
||||
active: boolean;
|
||||
payload: {
|
||||
payload: {
|
||||
name: string;
|
||||
value: number;
|
||||
fill: string;
|
||||
};
|
||||
}[];
|
||||
}
|
||||
|
||||
const CustomTooltip = ({ active, payload }: CustomTooltipProps) => {
|
||||
if (active && payload && payload.length) {
|
||||
const data = payload[0];
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
backgroundColor: theme === "dark" ? "#1e293b" : "white",
|
||||
border: `1px solid ${theme === "dark" ? "#475569" : "rgba(0, 0, 0, 0.1)"}`,
|
||||
borderRadius: "6px",
|
||||
boxShadow: "0px 4px 12px rgba(0, 0, 0, 0.15)",
|
||||
fontSize: "12px",
|
||||
padding: "8px 12px",
|
||||
color: theme === "dark" ? "white" : "black",
|
||||
}}
|
||||
>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: "8px" }}>
|
||||
<div
|
||||
style={{
|
||||
width: "8px",
|
||||
height: "8px",
|
||||
borderRadius: "50%",
|
||||
backgroundColor: data.payload.fill,
|
||||
}}
|
||||
/>
|
||||
<span>
|
||||
{data.payload.name}: {data.payload.value}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-[320px] flex-col items-center justify-between">
|
||||
<h3 className="text-xs font-semibold tracking-wide whitespace-nowrap uppercase">
|
||||
Requirements Status
|
||||
</h3>
|
||||
|
||||
<ChartContainer
|
||||
config={chartConfig}
|
||||
className="aspect-square w-[200px] min-w-[200px]"
|
||||
>
|
||||
<RechartsPieChart>
|
||||
<Tooltip
|
||||
cursor={false}
|
||||
content={<CustomTooltip active={false} payload={[]} />}
|
||||
/>
|
||||
<Pie
|
||||
data={totalRequirements > 0 ? chartData : emptyChartData}
|
||||
dataKey="value"
|
||||
nameKey="name"
|
||||
innerRadius={70}
|
||||
outerRadius={100}
|
||||
paddingAngle={2}
|
||||
cornerRadius={4}
|
||||
>
|
||||
{(totalRequirements > 0 ? chartData : emptyChartData).map(
|
||||
(entry, index) => (
|
||||
<Cell key={`cell-${index}`} fill={entry.fill} />
|
||||
),
|
||||
)}
|
||||
<Label
|
||||
content={({ viewBox }) => {
|
||||
if (viewBox && "cx" in viewBox && "cy" in viewBox) {
|
||||
return (
|
||||
<text
|
||||
x={viewBox.cx}
|
||||
y={viewBox.cy}
|
||||
textAnchor="middle"
|
||||
dominantBaseline="middle"
|
||||
>
|
||||
<tspan
|
||||
x={viewBox.cx}
|
||||
y={viewBox.cy}
|
||||
className="fill-foreground text-xl font-bold"
|
||||
>
|
||||
{totalRequirements}
|
||||
</tspan>
|
||||
<tspan
|
||||
x={viewBox.cx}
|
||||
y={(viewBox.cy || 0) + 20}
|
||||
className="fill-foreground text-xs"
|
||||
>
|
||||
Total
|
||||
</tspan>
|
||||
</text>
|
||||
);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Pie>
|
||||
</RechartsPieChart>
|
||||
</ChartContainer>
|
||||
|
||||
<div className="mt-2 grid grid-cols-3 gap-4">
|
||||
<div className="flex flex-col items-center">
|
||||
<div className="text-muted-foreground text-sm">Pass</div>
|
||||
<div className="text-system-success-medium font-semibold">{pass}</div>
|
||||
</div>
|
||||
<div className="flex flex-col items-center">
|
||||
<div className="text-muted-foreground text-sm">Fail</div>
|
||||
<div className="text-system-error-medium font-semibold">{fail}</div>
|
||||
</div>
|
||||
<div className="flex flex-col items-center">
|
||||
<div className="text-muted-foreground text-sm">Manual</div>
|
||||
<div className="text-prowler-grey-light font-semibold">{manual}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,126 @@
|
||||
"use client";
|
||||
|
||||
import { ShieldCheck, TriangleAlert, User } from "lucide-react";
|
||||
|
||||
import { DonutChart } from "@/components/graphs/donut-chart";
|
||||
import { DonutDataPoint } from "@/components/graphs/types";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
CardVariant,
|
||||
ResourceStatsCard,
|
||||
} from "@/components/shadcn";
|
||||
import { calculatePercentage } from "@/lib/utils";
|
||||
|
||||
interface RequirementsStatusCardProps {
|
||||
pass: number;
|
||||
fail: number;
|
||||
manual: number;
|
||||
}
|
||||
|
||||
export function RequirementsStatusCard({
|
||||
pass,
|
||||
fail,
|
||||
manual,
|
||||
}: RequirementsStatusCardProps) {
|
||||
const total = pass + fail + manual;
|
||||
|
||||
const passPercentage = calculatePercentage(pass, total);
|
||||
const failPercentage = calculatePercentage(fail, total);
|
||||
const manualPercentage = calculatePercentage(manual, total);
|
||||
|
||||
const donutData: DonutDataPoint[] = [
|
||||
{
|
||||
name: "Pass",
|
||||
value: pass,
|
||||
color: "var(--bg-pass-primary)",
|
||||
percentage: Number(passPercentage),
|
||||
},
|
||||
{
|
||||
name: "Fail",
|
||||
value: fail,
|
||||
color: "var(--bg-fail-primary)",
|
||||
percentage: Number(failPercentage),
|
||||
},
|
||||
{
|
||||
name: "Manual",
|
||||
value: manual,
|
||||
color: "var(--color-bg-data-muted)",
|
||||
percentage: Number(manualPercentage),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<Card
|
||||
variant="base"
|
||||
className="flex min-h-[372px] flex-col justify-between"
|
||||
>
|
||||
<CardHeader>
|
||||
<CardTitle>Requirements Status</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-1 flex-col justify-between space-y-4">
|
||||
<div className="mx-auto h-[172px] w-[172px]">
|
||||
<DonutChart
|
||||
data={donutData}
|
||||
showLegend={false}
|
||||
innerRadius={66}
|
||||
outerRadius={86}
|
||||
centerLabel={{
|
||||
value: total.toLocaleString(),
|
||||
label: "Total",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Card
|
||||
variant="inner"
|
||||
className="flex w-full flex-col items-center justify-around md:flex-row"
|
||||
>
|
||||
<ResourceStatsCard
|
||||
containerless
|
||||
badge={{
|
||||
icon: ShieldCheck,
|
||||
count: pass,
|
||||
variant: CardVariant.pass,
|
||||
}}
|
||||
label="Pass"
|
||||
emptyState={
|
||||
pass === 0 ? { message: "No passed requirements" } : undefined
|
||||
}
|
||||
className="w-full"
|
||||
/>
|
||||
|
||||
<ResourceStatsCard
|
||||
containerless
|
||||
badge={{
|
||||
icon: TriangleAlert,
|
||||
count: fail,
|
||||
variant: CardVariant.fail,
|
||||
}}
|
||||
label="Fail"
|
||||
emptyState={
|
||||
fail === 0 ? { message: "No failed requirements" } : undefined
|
||||
}
|
||||
className="w-full"
|
||||
/>
|
||||
|
||||
<ResourceStatsCard
|
||||
containerless
|
||||
badge={{
|
||||
icon: User,
|
||||
count: manual,
|
||||
variant: CardVariant.default,
|
||||
}}
|
||||
label="Manual"
|
||||
emptyState={
|
||||
manual === 0 ? { message: "No manual requirements" } : undefined
|
||||
}
|
||||
className="w-full"
|
||||
/>
|
||||
</Card>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
"use client";
|
||||
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/shadcn";
|
||||
import { CategoryData } from "@/types/compliance";
|
||||
|
||||
import { HeatmapChart } from "./heatmap-chart";
|
||||
|
||||
interface SectionsFailureRateCardProps {
|
||||
categories: CategoryData[];
|
||||
}
|
||||
|
||||
export function SectionsFailureRateCard({
|
||||
categories,
|
||||
}: SectionsFailureRateCardProps) {
|
||||
return (
|
||||
<Card variant="base" className="flex min-h-[372px] min-w-[328px] flex-col">
|
||||
<CardHeader>
|
||||
<CardTitle>Sections Failure Rate</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-1 items-center justify-start">
|
||||
<HeatmapChart categories={categories} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
"use client";
|
||||
|
||||
import { HorizontalBarChart } from "@/components/graphs/horizontal-bar-chart";
|
||||
import { BarDataPoint } from "@/components/graphs/types";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/shadcn";
|
||||
import { FailedSection } from "@/types/compliance";
|
||||
|
||||
interface TopFailedSectionsCardProps {
|
||||
sections: FailedSection[];
|
||||
}
|
||||
|
||||
export function TopFailedSectionsCard({
|
||||
sections,
|
||||
}: TopFailedSectionsCardProps) {
|
||||
// Transform FailedSection[] to BarDataPoint[]
|
||||
const total = sections.reduce((sum, section) => sum + section.total, 0);
|
||||
|
||||
const barData: BarDataPoint[] = sections.map((section) => ({
|
||||
name: section.name,
|
||||
value: section.total,
|
||||
percentage: total > 0 ? Math.round((section.total / total) * 100) : 0,
|
||||
color: "var(--bg-fail-primary)",
|
||||
}));
|
||||
|
||||
return (
|
||||
<Card
|
||||
variant="base"
|
||||
className="flex min-h-[372px] w-full flex-col sm:min-w-[500px]"
|
||||
>
|
||||
<CardHeader>
|
||||
<CardTitle>Top Failed Sections</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-1 items-center justify-start">
|
||||
<HorizontalBarChart data={barData} labelWidth="w-60" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -1,10 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import { Spacer } from "@heroui/spacer";
|
||||
import Image from "next/image";
|
||||
|
||||
import { FilterControls } from "@/components/filters";
|
||||
import { DataTableFilterCustom } from "@/components/ui/table/data-table-filter-custom";
|
||||
import { ScanEntity } from "@/types/scans";
|
||||
|
||||
import { ComplianceScanInfo } from "./compliance-scan-info";
|
||||
import { DataCompliance } from "./data-compliance";
|
||||
import { SelectScanComplianceDataProps } from "./scan-selector";
|
||||
|
||||
@@ -15,6 +17,10 @@ interface ComplianceHeaderProps {
|
||||
showRegionFilter?: boolean;
|
||||
framework?: string; // Framework name to show specific filters
|
||||
showProviders?: boolean;
|
||||
hideFilters?: boolean;
|
||||
logoPath?: string;
|
||||
complianceTitle?: string;
|
||||
selectedScan?: ScanEntity | null;
|
||||
}
|
||||
|
||||
export const ComplianceHeader = ({
|
||||
@@ -24,6 +30,10 @@ export const ComplianceHeader = ({
|
||||
showRegionFilter = true,
|
||||
framework,
|
||||
showProviders = true,
|
||||
hideFilters = false,
|
||||
logoPath,
|
||||
complianceTitle,
|
||||
selectedScan,
|
||||
}: ComplianceHeaderProps) => {
|
||||
const frameworkFilters = [];
|
||||
|
||||
@@ -54,18 +64,39 @@ export const ComplianceHeader = ({
|
||||
|
||||
const allFilters = [...frameworkFilters, ...regionFilters];
|
||||
|
||||
const hasContent =
|
||||
showProviders ||
|
||||
showSearch ||
|
||||
(!hideFilters && allFilters.length > 0) ||
|
||||
selectedScan;
|
||||
|
||||
return (
|
||||
<>
|
||||
{(showProviders || showSearch) && (
|
||||
<>
|
||||
<div className="flex items-start justify-start gap-4">
|
||||
{hasContent && (
|
||||
<div className="flex w-full items-start justify-between gap-6">
|
||||
<div className="flex flex-1 flex-col justify-end gap-4">
|
||||
{selectedScan && <ComplianceScanInfo scan={selectedScan} />}
|
||||
|
||||
{showProviders && <DataCompliance scans={scans} />}
|
||||
{showSearch && <FilterControls search />}
|
||||
{!hideFilters && allFilters.length > 0 && (
|
||||
<DataTableFilterCustom filters={allFilters} />
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
{logoPath && complianceTitle && (
|
||||
<div className="hidden shrink-0 sm:block">
|
||||
<div className="relative h-24 w-24">
|
||||
<Image
|
||||
src={logoPath}
|
||||
alt={`${complianceTitle} logo`}
|
||||
fill
|
||||
className="rounded-lg border border-gray-300 bg-white object-contain p-0"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{allFilters.length > 0 && <DataTableFilterCustom filters={allFilters} />}
|
||||
<Spacer y={8} />
|
||||
{hasContent && <Spacer y={8} />}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
import { Select, SelectItem } from "@heroui/select";
|
||||
"use client";
|
||||
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/shadcn/select/select";
|
||||
import { ProviderType, ScanProps } from "@/types";
|
||||
|
||||
import { ComplianceScanInfo } from "./compliance-scan-info";
|
||||
@@ -21,37 +28,33 @@ export const ScanSelector = ({
|
||||
selectedScanId,
|
||||
onSelectionChange,
|
||||
}: SelectScanComplianceDataProps) => {
|
||||
const selectedScan = scans.find((item) => item.id === selectedScanId);
|
||||
|
||||
return (
|
||||
<Select
|
||||
aria-label="Select a Scan"
|
||||
placeholder="Select a scan"
|
||||
classNames={{
|
||||
trigger: "w-full min-w-[365px] rounded-lg",
|
||||
popoverContent: "rounded-lg",
|
||||
}}
|
||||
size="lg"
|
||||
labelPlacement="outside"
|
||||
selectedKeys={new Set([selectedScanId])}
|
||||
onSelectionChange={(keys) => {
|
||||
const newSelectedId = Array.from(keys)[0] as string;
|
||||
if (newSelectedId && newSelectedId !== selectedScanId) {
|
||||
onSelectionChange(newSelectedId);
|
||||
value={selectedScanId}
|
||||
onValueChange={(value) => {
|
||||
if (value && value !== selectedScanId) {
|
||||
onSelectionChange(value);
|
||||
}
|
||||
}}
|
||||
renderValue={() => {
|
||||
const selectedItem = scans.find((item) => item.id === selectedScanId);
|
||||
return selectedItem ? (
|
||||
<ComplianceScanInfo scan={selectedItem} />
|
||||
) : (
|
||||
"Select a scan"
|
||||
);
|
||||
}}
|
||||
>
|
||||
{scans.map((scan) => (
|
||||
<SelectItem key={scan.id} textValue={scan.attributes.name || "- -"}>
|
||||
<ComplianceScanInfo scan={scan} />
|
||||
</SelectItem>
|
||||
))}
|
||||
<SelectTrigger className="w-full min-w-[365px]">
|
||||
<SelectValue placeholder="Select a scan">
|
||||
{selectedScan ? (
|
||||
<ComplianceScanInfo scan={selectedScan} />
|
||||
) : (
|
||||
"Select a scan"
|
||||
)}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{scans.map((scan) => (
|
||||
<SelectItem key={scan.id} value={scan.id}>
|
||||
<ComplianceScanInfo scan={scan} />
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -3,9 +3,11 @@ export * from "./compliance-accordion/client-accordion-wrapper";
|
||||
export * from "./compliance-accordion/compliance-accordion-requeriment-title";
|
||||
export * from "./compliance-accordion/compliance-accordion-title";
|
||||
export * from "./compliance-card";
|
||||
export * from "./compliance-charts/bar-chart";
|
||||
export * from "./compliance-charts/chart-skeletons";
|
||||
export * from "./compliance-charts/heatmap-chart";
|
||||
export * from "./compliance-charts/pie-chart";
|
||||
export * from "./compliance-charts/requirements-status-card";
|
||||
export * from "./compliance-charts/sections-failure-rate-card";
|
||||
export * from "./compliance-charts/top-failed-sections-card";
|
||||
export * from "./compliance-custom-details/cis-details";
|
||||
export * from "./compliance-custom-details/ens-details";
|
||||
export * from "./compliance-custom-details/iso-details";
|
||||
|
||||
@@ -1,40 +1,45 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import React from "react";
|
||||
|
||||
import { Button } from "@/components/shadcn/button/button";
|
||||
import { Card, CardContent } from "@/components/shadcn/card/card";
|
||||
|
||||
import { InfoIcon } from "../icons/Icons";
|
||||
import { CustomButton } from "../ui/custom";
|
||||
|
||||
export const NoScansAvailable = () => {
|
||||
return (
|
||||
<div className="flex h-full min-h-[calc(100vh-56px)] items-center justify-center">
|
||||
<div className="mx-auto w-full max-w-2xl">
|
||||
<div className="dark:bg-prowler-blue-400 flex items-center justify-start rounded-lg border border-gray-200 bg-white p-6 dark:border-gray-700">
|
||||
<div className="flex w-full items-center justify-between gap-6">
|
||||
<div className="flex items-start gap-4">
|
||||
<InfoIcon className="mt-1 h-5 w-5 text-gray-400 dark:text-gray-300" />
|
||||
<div>
|
||||
<h2 className="mb-1 text-base font-medium text-gray-900 dark:text-white">
|
||||
No Scans available
|
||||
</h2>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-300">
|
||||
A scan must be completed before generating a compliance
|
||||
report.
|
||||
</p>
|
||||
<Card variant="base" padding="lg">
|
||||
<CardContent>
|
||||
<div className="flex w-full items-center justify-between gap-6">
|
||||
<div className="flex items-start gap-4">
|
||||
<InfoIcon className="mt-1 h-5 w-5 text-gray-400 dark:text-gray-300" />
|
||||
<div>
|
||||
<h2 className="mb-1 text-base font-medium text-gray-900 dark:text-white">
|
||||
No Scans available
|
||||
</h2>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-300">
|
||||
A scan must be completed before generating a compliance
|
||||
report.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
asChild
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
className="shrink-0"
|
||||
>
|
||||
<Link href="/scans" aria-label="Go to Scans page">
|
||||
Go to Scans
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
<CustomButton
|
||||
asLink="/scans"
|
||||
className="shrink-0"
|
||||
ariaLabel="Go to Scans page"
|
||||
variant="solid"
|
||||
color="action"
|
||||
size="sm"
|
||||
>
|
||||
Go to Scans
|
||||
</CustomButton>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
"use client";
|
||||
|
||||
import { Button } from "@heroui/button";
|
||||
import { Card, CardBody } from "@heroui/card";
|
||||
import { Progress } from "@heroui/progress";
|
||||
import { DownloadIcon, FileTextIcon } from "lucide-react";
|
||||
@@ -8,6 +7,7 @@ import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
|
||||
import { ThreatScoreLogo } from "@/components/compliance/threatscore-logo";
|
||||
import { Button } from "@/components/shadcn/button/button";
|
||||
import { toast } from "@/components/ui";
|
||||
import { downloadComplianceCsv, downloadThreatScorePdf } from "@/lib/helper";
|
||||
import type { ScanEntity } from "@/types/scans";
|
||||
@@ -41,7 +41,7 @@ export const ThreatScoreBadge = ({
|
||||
const getTextColor = () => {
|
||||
if (score >= 80) return "text-success";
|
||||
if (score >= 40) return "text-warning";
|
||||
return "text-danger";
|
||||
return "text-text-error";
|
||||
};
|
||||
|
||||
const handleCardClick = () => {
|
||||
@@ -121,24 +121,25 @@ export const ThreatScoreBadge = ({
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="text-default-500 hover:text-primary flex-1"
|
||||
startContent={<DownloadIcon size={14} className="text-primary" />}
|
||||
onPress={handleDownloadPdf}
|
||||
isLoading={isDownloadingPdf}
|
||||
isDisabled={isDownloadingCsv}
|
||||
variant="outline"
|
||||
className="flex-1"
|
||||
onClick={handleDownloadPdf}
|
||||
disabled={isDownloadingPdf || isDownloadingCsv}
|
||||
>
|
||||
<DownloadIcon
|
||||
size={14}
|
||||
className={isDownloadingPdf ? "animate-download-icon" : ""}
|
||||
/>
|
||||
PDF
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="text-default-500 hover:text-primary flex-1"
|
||||
startContent={<FileTextIcon size={14} className="text-primary" />}
|
||||
onPress={handleDownloadCsv}
|
||||
isLoading={isDownloadingCsv}
|
||||
isDisabled={isDownloadingPdf}
|
||||
variant="outline"
|
||||
className="flex-1"
|
||||
onClick={handleDownloadCsv}
|
||||
disabled={isDownloadingCsv || isDownloadingPdf}
|
||||
>
|
||||
<FileTextIcon size={14} />
|
||||
CSV
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -14,6 +14,11 @@ import {
|
||||
DropdownMenuTrigger,
|
||||
Separator,
|
||||
} from "@/components/shadcn";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/shadcn/tooltip";
|
||||
import { hasNewFeeds, markFeedsAsSeen } from "@/lib/feeds-storage";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
@@ -49,30 +54,39 @@ export function FeedsClient({ feedData, error }: FeedsClientProps) {
|
||||
|
||||
return (
|
||||
<DropdownMenu onOpenChange={handleOpenChange}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="relative h-8 w-8 rounded-full bg-transparent p-2"
|
||||
aria-label={
|
||||
hasUnseenFeeds
|
||||
? "New updates available - Click to view"
|
||||
: "Check for updates"
|
||||
}
|
||||
>
|
||||
<BellRing
|
||||
size={18}
|
||||
className={cn(
|
||||
hasFeeds && hasUnseenFeeds && "text-prowler-green animate-pulse",
|
||||
)}
|
||||
/>
|
||||
{hasFeeds && hasUnseenFeeds && (
|
||||
<span className="absolute top-0 right-0 flex h-2 w-2">
|
||||
<span className="bg-prowler-green absolute inline-flex h-full w-full animate-ping rounded-full opacity-75"></span>
|
||||
<span className="bg-prowler-green relative inline-flex h-2 w-2 rounded-full"></span>
|
||||
</span>
|
||||
)}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="border-border-input-primary-fill relative h-8 w-8 rounded-full bg-transparent p-2"
|
||||
aria-label={
|
||||
hasUnseenFeeds
|
||||
? "New updates available - Click to view"
|
||||
: "Check for updates"
|
||||
}
|
||||
>
|
||||
<BellRing
|
||||
size={18}
|
||||
className={cn(
|
||||
hasFeeds &&
|
||||
hasUnseenFeeds &&
|
||||
"text-button-primary animate-pulse",
|
||||
)}
|
||||
/>
|
||||
{hasFeeds && hasUnseenFeeds && (
|
||||
<span className="absolute top-0 right-0 flex h-2 w-2">
|
||||
<span className="bg-button-primary absolute inline-flex h-full w-full animate-ping rounded-full opacity-75"></span>
|
||||
<span className="bg-button-primary relative inline-flex h-2 w-2 rounded-full"></span>
|
||||
</span>
|
||||
)}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{hasUnseenFeeds ? "New updates available" : "Latest Updates"}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<DropdownMenuContent
|
||||
align="end"
|
||||
@@ -143,7 +157,7 @@ function FeedTimelineItem({ item, isLast }: FeedTimelineItemProps) {
|
||||
<div className="group relative flex gap-3 px-3 py-2">
|
||||
{/* Timeline dot */}
|
||||
<div className="relative flex flex-col items-center">
|
||||
<div className="border-prowler-green bg-prowler-green z-10 h-2 w-2 rounded-full border-2" />
|
||||
<div className="border-button-primary bg-button-primary z-10 h-2 w-2 rounded-full border-2" />
|
||||
{!isLast && (
|
||||
<div className="h-full w-px bg-slate-200 dark:bg-slate-700" />
|
||||
)}
|
||||
@@ -158,13 +172,13 @@ function FeedTimelineItem({ item, isLast }: FeedTimelineItemProps) {
|
||||
className="backdrop-blur-0 block space-y-1 rounded-[12px] border border-transparent p-2 transition-all hover:border-slate-300 hover:bg-[#F8FAFC80] hover:backdrop-blur-[46px] dark:hover:border-[rgba(38,38,38,0.70)] dark:hover:bg-[rgba(23,23,23,0.50)]"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<h4 className="group-hover:text-prowler-green dark:group-hover:text-prowler-green min-w-0 flex-1 text-sm leading-tight font-semibold break-words text-slate-900 dark:text-white">
|
||||
<h4 className="group-hover:text-button-primary dark:group-hover:text-button-primary min-w-0 flex-1 text-sm leading-tight font-semibold break-words text-slate-900 dark:text-white">
|
||||
{item.title}
|
||||
</h4>
|
||||
{version && (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="border-prowler-green bg-prowler-green/10 text-prowler-green dark:bg-prowler-green/20 shrink-0 text-[10px] font-semibold"
|
||||
className="border-button-primary bg-button-primary/10 text-button-primary dark:bg-button-primary/20 shrink-0 text-[10px] font-semibold"
|
||||
>
|
||||
v{version}
|
||||
</Badge>
|
||||
@@ -182,7 +196,7 @@ function FeedTimelineItem({ item, isLast }: FeedTimelineItemProps) {
|
||||
{relativeTime}
|
||||
</time>
|
||||
|
||||
<div className="text-prowler-green flex items-center gap-1 opacity-0 transition-opacity group-hover:opacity-100">
|
||||
<div className="text-button-primary flex items-center gap-1 opacity-0 transition-opacity group-hover:opacity-100">
|
||||
<span className="text-[11px] font-medium">Read more</span>
|
||||
<ExternalLink size={10} />
|
||||
</div>
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import { CrossIcon } from "@/components/icons";
|
||||
import { XCircle } from "lucide-react";
|
||||
|
||||
import { useUrlFilters } from "@/hooks/use-url-filters";
|
||||
|
||||
import { CustomButton } from "../ui/custom/custom-button";
|
||||
import { Button } from "../shadcn";
|
||||
|
||||
export interface ClearFiltersButtonProps {
|
||||
className?: string;
|
||||
@@ -12,7 +13,6 @@ export interface ClearFiltersButtonProps {
|
||||
}
|
||||
|
||||
export const ClearFiltersButton = ({
|
||||
className = "w-full md:w-fit",
|
||||
text = "Clear all filters",
|
||||
ariaLabel = "Reset",
|
||||
}: ClearFiltersButtonProps) => {
|
||||
@@ -23,16 +23,9 @@ export const ClearFiltersButton = ({
|
||||
}
|
||||
|
||||
return (
|
||||
<CustomButton
|
||||
ariaLabel={ariaLabel}
|
||||
className={className}
|
||||
onPress={clearAllFilters}
|
||||
variant="dashed"
|
||||
size="md"
|
||||
endContent={<CrossIcon size={24} />}
|
||||
radius="sm"
|
||||
>
|
||||
<Button aria-label={ariaLabel} onClick={clearAllFilters} variant="link">
|
||||
<XCircle className="mr-0.5 size-4" />
|
||||
{text}
|
||||
</CustomButton>
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -25,7 +25,7 @@ export const CustomCheckboxMutedFindings = () => {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-full">
|
||||
<div className="flex h-full text-nowrap">
|
||||
<Checkbox
|
||||
classNames={{
|
||||
label: "text-small",
|
||||
|
||||
@@ -61,13 +61,30 @@ export const CustomDatePicker = () => {
|
||||
return (
|
||||
<div className="flex w-full flex-col md:gap-2">
|
||||
<DatePicker
|
||||
style={{
|
||||
borderRadius: "0.5rem",
|
||||
}}
|
||||
aria-label="Select a Date"
|
||||
label="Date"
|
||||
labelPlacement="inside"
|
||||
classNames={{
|
||||
base: "w-full [&]:!rounded-lg [&>*]:!rounded-lg",
|
||||
selectorButton: "text-bg-button-secondary shrink-0",
|
||||
input:
|
||||
"text-bg-button-secondary placeholder:text-bg-button-secondary text-sm",
|
||||
innerWrapper: "[&]:!rounded-lg",
|
||||
inputWrapper:
|
||||
"!border-border-input-primary !bg-bg-input-primary dark:!bg-input/30 dark:hover:!bg-input/50 hover:!bg-bg-neutral-secondary !border [&]:!rounded-lg !shadow-xs !transition-[color,box-shadow] focus-within:!border-border-input-primary-press focus-within:!ring-1 focus-within:!ring-border-input-primary-press focus-within:!ring-offset-1 !h-10 !px-4 !py-3 !outline-none",
|
||||
segment: "text-bg-button-secondary",
|
||||
}}
|
||||
popoverProps={{
|
||||
classNames: {
|
||||
content:
|
||||
"border-border-input-primary bg-bg-input-primary border rounded-lg",
|
||||
},
|
||||
}}
|
||||
CalendarTopContent={
|
||||
<ButtonGroup
|
||||
fullWidth
|
||||
className="bg-content1 dark:bg-prowler-blue-400 [&>button]:border-default-200/60 [&>button]:text-default-500 px-3 pt-3 pb-2"
|
||||
className="bg-bg-neutral-secondary [&>button]:border-border-neutral-secondary [&>button]:text-bg-button-secondary px-3 pt-3 pb-2"
|
||||
radius="full"
|
||||
size="sm"
|
||||
variant="flat"
|
||||
@@ -93,8 +110,6 @@ export const CustomDatePicker = () => {
|
||||
}}
|
||||
value={value}
|
||||
onChange={handleDateChange}
|
||||
size="sm"
|
||||
variant="flat"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -54,16 +54,23 @@ export const CustomSearchInput: React.FC = () => {
|
||||
|
||||
return (
|
||||
<Input
|
||||
variant="flat"
|
||||
style={{
|
||||
borderRadius: "0.5rem",
|
||||
}}
|
||||
classNames={{
|
||||
label: "tracking-tight font-light !text-default-600 text-sm z-0! pb-1",
|
||||
base: "w-full [&]:!rounded-lg [&>*]:!rounded-lg",
|
||||
input:
|
||||
"text-bg-button-secondary placeholder:text-bg-button-secondary text-sm",
|
||||
inputWrapper:
|
||||
"!border-border-input-primary !bg-bg-input-primary dark:!bg-input/30 dark:hover:!bg-input/50 hover:!bg-bg-neutral-secondary !border [&]:!rounded-lg !shadow-xs !transition-[color,box-shadow] focus-within:!border-border-input-primary-press focus-within:!ring-1 focus-within:!ring-border-input-primary-press focus-within:!ring-offset-1 !h-10 !px-4 !py-3 !outline-none",
|
||||
clearButton: "text-bg-button-secondary",
|
||||
}}
|
||||
aria-label="Search"
|
||||
label="Search"
|
||||
placeholder="Search..."
|
||||
labelPlacement="inside"
|
||||
value={searchQuery}
|
||||
startContent={<SearchIcon className="text-default-400" width={16} />}
|
||||
startContent={
|
||||
<SearchIcon className="text-bg-button-secondary shrink-0" width={16} />
|
||||
}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value;
|
||||
setSearchQuery(value);
|
||||
@@ -71,13 +78,14 @@ export const CustomSearchInput: React.FC = () => {
|
||||
}}
|
||||
endContent={
|
||||
searchQuery && (
|
||||
<button onClick={clearIconSearch} className="focus:outline-none">
|
||||
<XCircle className="text-default-400 h-4 w-4" />
|
||||
<button
|
||||
onClick={clearIconSearch}
|
||||
className="text-bg-button-secondary shrink-0 focus:outline-none"
|
||||
>
|
||||
<XCircle className="text-bg-button-secondary h-4 w-4" />
|
||||
</button>
|
||||
)
|
||||
}
|
||||
radius="sm"
|
||||
size="sm"
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,13 +1,11 @@
|
||||
"use client";
|
||||
|
||||
import { Spacer } from "@heroui/spacer";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import React from "react";
|
||||
|
||||
import { FilterOption } from "@/types";
|
||||
|
||||
import { DataTableFilterCustom } from "../ui/table";
|
||||
import { ClearFiltersButton } from "./clear-filters-button";
|
||||
import { CustomAccountSelection } from "./custom-account-selection";
|
||||
import { CustomCheckboxMutedFindings } from "./custom-checkbox-muted-findings";
|
||||
import { CustomDatePicker } from "./custom-date-picker";
|
||||
@@ -23,7 +21,6 @@ export interface FilterControlsProps {
|
||||
accounts?: boolean;
|
||||
mutedFindings?: boolean;
|
||||
customFilters?: FilterOption[];
|
||||
showClearButton?: boolean;
|
||||
}
|
||||
|
||||
export const FilterControls: React.FC<FilterControlsProps> = ({
|
||||
@@ -33,40 +30,22 @@ export const FilterControls: React.FC<FilterControlsProps> = ({
|
||||
regions = false,
|
||||
accounts = false,
|
||||
mutedFindings = false,
|
||||
showClearButton = true,
|
||||
customFilters,
|
||||
}) => {
|
||||
const searchParams = useSearchParams();
|
||||
const [hasFilters, setHasFilters] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const hasFilters = Array.from(searchParams.keys()).some(
|
||||
(key) => key.startsWith("filter[") || key === "sort",
|
||||
);
|
||||
setHasFilters(hasFilters);
|
||||
}, [searchParams]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col">
|
||||
<div className="grid grid-cols-1 items-center gap-x-4 gap-y-4 md:grid-cols-2 xl:grid-cols-4">
|
||||
{search && <CustomSearchInput />}
|
||||
{providers && <CustomSelectProvider />}
|
||||
{date && <CustomDatePicker />}
|
||||
{regions && <CustomRegionSelection />}
|
||||
{accounts && <CustomAccountSelection />}
|
||||
{mutedFindings && <CustomCheckboxMutedFindings />}
|
||||
{!customFilters && hasFilters && showClearButton && (
|
||||
<ClearFiltersButton />
|
||||
)}
|
||||
<div className="flex flex-col items-start gap-4 md:flex-row md:items-center">
|
||||
<div className="grid w-full flex-1 grid-cols-1 items-center gap-x-4 gap-y-4 md:grid-cols-2 xl:grid-cols-4">
|
||||
{search && <CustomSearchInput />}
|
||||
{providers && <CustomSelectProvider />}
|
||||
{date && <CustomDatePicker />}
|
||||
{regions && <CustomRegionSelection />}
|
||||
{accounts && <CustomAccountSelection />}
|
||||
{mutedFindings && <CustomCheckboxMutedFindings />}
|
||||
</div>
|
||||
</div>
|
||||
<Spacer y={8} />
|
||||
{customFilters && (
|
||||
<DataTableFilterCustom
|
||||
filters={customFilters}
|
||||
showClearButton={showClearButton}
|
||||
defaultOpen
|
||||
/>
|
||||
)}
|
||||
{customFilters && <DataTableFilterCustom filters={customFilters} />}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -282,7 +282,7 @@ export const SendToJiraModal = ({
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
<FormMessage className="text-system-error text-xs" />
|
||||
<FormMessage className="text-text-error text-xs" />
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
@@ -366,105 +366,38 @@ export const SendToJiraModal = ({
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
<FormMessage className="text-system-error text-xs" />
|
||||
<FormMessage className="text-text-error text-xs" />
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Issue Type Selection - Enhanced Style */}
|
||||
{/* {selectedProject && issueTypes.length > 0 && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="issueType"
|
||||
render={({ field }) => (
|
||||
<>
|
||||
<FormControl>
|
||||
<Select
|
||||
label="Issue Type"
|
||||
placeholder="Select an issue type"
|
||||
selectedKeys={
|
||||
field.value ? new Set([field.value]) : new Set()
|
||||
}
|
||||
onSelectionChange={(keys: Selection) => {
|
||||
const value = getSelectedValue(keys);
|
||||
field.onChange(value);
|
||||
}}
|
||||
variant="bordered"
|
||||
labelPlacement="inside"
|
||||
isInvalid={!!form.formState.errors.issueType}
|
||||
classNames={{
|
||||
trigger: "min-h-12",
|
||||
popoverContent: "dark:bg-gray-800",
|
||||
listboxWrapper: "max-h-[300px] dark:bg-gray-800",
|
||||
label:
|
||||
"tracking-tight font-light !text-default-500 text-xs z-0!",
|
||||
value: "text-default-500 text-small dark:text-gray-300",
|
||||
}}
|
||||
listboxProps={{
|
||||
topContent:
|
||||
filteredIssueTypes.length > 5 ? (
|
||||
<div className="sticky top-0 z-10 bg-content1 py-2 dark:bg-gray-800">
|
||||
<Input
|
||||
isClearable
|
||||
placeholder="Search issue types..."
|
||||
size="sm"
|
||||
variant="bordered"
|
||||
startContent={<Search size={16} />}
|
||||
value={searchIssueTypeValue}
|
||||
onValueChange={setSearchIssueTypeValue}
|
||||
onClear={() => setSearchIssueTypeValue("")}
|
||||
classNames={{
|
||||
inputWrapper:
|
||||
"border-default-200 bg-transparent hover:bg-default-100/50",
|
||||
input: "text-small",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
) : null,
|
||||
}}
|
||||
>
|
||||
{filteredIssueTypes.map((type) => (
|
||||
<SelectItem key={type} textValue={type}>
|
||||
<div className="flex items-center py-1">
|
||||
<span className="text-small">{type}</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
<FormMessage className="text-xs text-system-error" />
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
)} */}
|
||||
|
||||
{/* No integrations or none connected message */}
|
||||
{!isFetchingIntegrations &&
|
||||
(integrations.length === 0 || !hasConnectedIntegration) && (
|
||||
<CustomBanner
|
||||
title="Jira integration is not available"
|
||||
message="Please add or connect an integration first"
|
||||
buttonLabel="Configure"
|
||||
buttonLink="/integrations/jira"
|
||||
/>
|
||||
)}
|
||||
|
||||
<FormButtons
|
||||
setIsOpen={setOpenForFormButtons}
|
||||
onCancel={() => onOpenChange(false)}
|
||||
submitText="Send to Jira"
|
||||
cancelText="Cancel"
|
||||
loadingText="Sending..."
|
||||
isDisabled={
|
||||
!form.formState.isValid ||
|
||||
form.formState.isSubmitting ||
|
||||
isFetchingIntegrations ||
|
||||
integrations.length === 0 ||
|
||||
!hasConnectedIntegration
|
||||
}
|
||||
rightIcon={<Send size={20} />}
|
||||
/>
|
||||
(integrations.length === 0 || !hasConnectedIntegration) ? (
|
||||
<CustomBanner
|
||||
title="Jira integration is not available"
|
||||
message="Please add or connect an integration first"
|
||||
buttonLabel="Configure"
|
||||
buttonLink="/integrations/jira"
|
||||
/>
|
||||
) : (
|
||||
<FormButtons
|
||||
setIsOpen={setOpenForFormButtons}
|
||||
onCancel={() => onOpenChange(false)}
|
||||
submitText="Send to Jira"
|
||||
cancelText="Cancel"
|
||||
loadingText="Sending..."
|
||||
isDisabled={
|
||||
!form.formState.isValid ||
|
||||
form.formState.isSubmitting ||
|
||||
isFetchingIntegrations ||
|
||||
integrations.length === 0 ||
|
||||
!hasConnectedIntegration
|
||||
}
|
||||
rightIcon={<Send size={20} />}
|
||||
/>
|
||||
)}
|
||||
</form>
|
||||
</Form>
|
||||
</CustomAlertModal>
|
||||
|
||||
@@ -71,7 +71,9 @@ const FindingDetailsCell = ({ row }: { row: any }) => {
|
||||
return (
|
||||
<div className="flex max-w-10 justify-center">
|
||||
<TriggerSheet
|
||||
triggerComponent={<InfoIcon className="text-primary" size={16} />}
|
||||
triggerComponent={
|
||||
<InfoIcon className="text-button-primary" size={16} />
|
||||
}
|
||||
title="Finding Details"
|
||||
description="View the finding details"
|
||||
defaultOpen={isOpen}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
"use client";
|
||||
|
||||
import { Button } from "@heroui/button";
|
||||
import {
|
||||
Dropdown,
|
||||
DropdownItem,
|
||||
@@ -14,6 +13,7 @@ import { useState } from "react";
|
||||
import { SendToJiraModal } from "@/components/findings/send-to-jira-modal";
|
||||
import { VerticalDotsIcon } from "@/components/icons";
|
||||
import { JiraIcon } from "@/components/icons/services/IconServices";
|
||||
import { Button } from "@/components/shadcn";
|
||||
import type { FindingProps } from "@/types/components";
|
||||
|
||||
interface DataTableRowActionsProps {
|
||||
@@ -38,12 +38,12 @@ export function DataTableRowActions({ row }: DataTableRowActionsProps) {
|
||||
|
||||
<div className="relative flex items-center justify-end gap-2">
|
||||
<Dropdown
|
||||
className="dark:bg-prowler-blue-800 shadow-xl"
|
||||
className="border-border-neutral-secondary bg-bg-neutral-secondary border shadow-xl"
|
||||
placement="bottom"
|
||||
>
|
||||
<DropdownTrigger>
|
||||
<Button isIconOnly radius="full" size="sm" variant="light">
|
||||
<VerticalDotsIcon className="text-default-400" />
|
||||
<Button variant="ghost" size="icon-sm" className="rounded-full">
|
||||
<VerticalDotsIcon className="text-slate-400" />
|
||||
</Button>
|
||||
</DropdownTrigger>
|
||||
<DropdownMenu
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Tooltip } from "@heroui/tooltip";
|
||||
|
||||
import { CustomButton } from "@/components/ui/custom/custom-button";
|
||||
import { Button } from "@/components/shadcn";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface DeltaIndicatorProps {
|
||||
@@ -18,16 +18,21 @@ export const DeltaIndicator = ({ delta }: DeltaIndicatorProps) => {
|
||||
? "New finding."
|
||||
: "Status changed since the previous scan."}
|
||||
</span>
|
||||
<CustomButton
|
||||
ariaLabel="Learn more about findings"
|
||||
color="transparent"
|
||||
size="sm"
|
||||
className="text-primary h-auto min-w-0 p-0"
|
||||
asLink="https://docs.prowler.com/projects/prowler-open-source/en/latest/tutorials/prowler-app/#step-8-analyze-the-findings"
|
||||
target="_blank"
|
||||
<Button
|
||||
aria-label="Learn more about findings"
|
||||
variant="link"
|
||||
size="default"
|
||||
className="text-button-primary h-auto min-w-0 p-0 text-xs"
|
||||
asChild
|
||||
>
|
||||
Learn more
|
||||
</CustomButton>
|
||||
<a
|
||||
href="https://docs.prowler.com/user-guide/tutorials/prowler-app#step-8:-analyze-the-findings"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Learn more
|
||||
</a>
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
|
||||
@@ -5,8 +5,14 @@ import { Tooltip } from "@heroui/tooltip";
|
||||
import { ExternalLink, Link } from "lucide-react";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
|
||||
import {
|
||||
Card,
|
||||
CardAction,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/shadcn";
|
||||
import { CodeSnippet } from "@/components/ui/code-snippet/code-snippet";
|
||||
import { CustomSection } from "@/components/ui/custom";
|
||||
import { CustomLink } from "@/components/ui/custom/custom-link";
|
||||
import { EntityInfoShort, InfoField } from "@/components/ui/entities";
|
||||
import { DateWithTime } from "@/components/ui/entities/date-with-time";
|
||||
@@ -92,7 +98,13 @@ export const FindingDetail = ({
|
||||
isMuted={attributes.muted}
|
||||
mutedReason={attributes.muted_reason || ""}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Check Metadata */}
|
||||
<Card variant="base" padding="lg">
|
||||
<CardHeader className="flex items-center justify-between">
|
||||
<CardTitle>Finding Details</CardTitle>
|
||||
<div
|
||||
className={`rounded-lg px-3 py-1 text-sm font-semibold ${
|
||||
attributes.status === "PASS"
|
||||
@@ -104,258 +116,265 @@ export const FindingDetail = ({
|
||||
>
|
||||
{renderValue(attributes.status)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Check Metadata */}
|
||||
<CustomSection title="Finding Details">
|
||||
<div className="flex flex-wrap gap-4">
|
||||
<EntityInfoShort
|
||||
cloudProvider={providerDetails.provider as ProviderType}
|
||||
entityAlias={providerDetails.alias}
|
||||
entityId={providerDetails.uid}
|
||||
showConnectionStatus={providerDetails.connection.connected}
|
||||
/>
|
||||
<InfoField label="Service">
|
||||
{attributes.check_metadata.servicename}
|
||||
</InfoField>
|
||||
<InfoField label="Region">{resource.region}</InfoField>
|
||||
<InfoField label="First Seen">
|
||||
<DateWithTime inline dateTime={attributes.first_seen_at || "-"} />
|
||||
</InfoField>
|
||||
{attributes.delta && (
|
||||
<InfoField
|
||||
label="Delta"
|
||||
tooltipContent="Indicates whether the finding is new (NEW), has changed status (CHANGED), or remains unchanged (NONE) compared to previous scans."
|
||||
className="capitalize"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<DeltaIndicator delta={attributes.delta} />
|
||||
{attributes.delta}
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-4">
|
||||
<div className="flex flex-wrap gap-4">
|
||||
<EntityInfoShort
|
||||
cloudProvider={providerDetails.provider as ProviderType}
|
||||
entityAlias={providerDetails.alias}
|
||||
entityId={providerDetails.uid}
|
||||
showConnectionStatus={providerDetails.connection.connected}
|
||||
/>
|
||||
<InfoField label="Service">
|
||||
{attributes.check_metadata.servicename}
|
||||
</InfoField>
|
||||
)}
|
||||
<InfoField label="Severity" variant="simple">
|
||||
<SeverityBadge severity={attributes.severity || "-"} />
|
||||
</InfoField>
|
||||
</div>
|
||||
<InfoField label="Finding ID" variant="simple">
|
||||
<CodeSnippet value={findingDetails.id} />
|
||||
</InfoField>
|
||||
<InfoField label="Check ID" variant="simple">
|
||||
<CodeSnippet value={attributes.check_id} />
|
||||
</InfoField>
|
||||
<InfoField label="Finding UID" variant="simple">
|
||||
<CodeSnippet value={attributes.uid} />
|
||||
</InfoField>
|
||||
<InfoField label="Resource ID" variant="simple">
|
||||
<CodeSnippet value={resource.uid} />
|
||||
</InfoField>
|
||||
|
||||
{attributes.status === "FAIL" && (
|
||||
<InfoField label="Risk" variant="simple">
|
||||
<Snippet
|
||||
className="max-w-full py-2"
|
||||
color="danger"
|
||||
hideCopyButton
|
||||
hideSymbol
|
||||
>
|
||||
<MarkdownContainer>
|
||||
{attributes.check_metadata.risk}
|
||||
</MarkdownContainer>
|
||||
</Snippet>
|
||||
</InfoField>
|
||||
)}
|
||||
|
||||
<InfoField label="Description">
|
||||
<MarkdownContainer>
|
||||
{attributes.check_metadata.description}
|
||||
</MarkdownContainer>
|
||||
</InfoField>
|
||||
|
||||
<InfoField label="Status Extended">
|
||||
{renderValue(attributes.status_extended)}
|
||||
</InfoField>
|
||||
|
||||
{attributes.check_metadata.remediation && (
|
||||
<div className="flex flex-col gap-4">
|
||||
<h4 className="dark:text-prowler-theme-pale/90 text-sm font-bold text-gray-700">
|
||||
Remediation Details
|
||||
</h4>
|
||||
|
||||
{/* Recommendation section */}
|
||||
{attributes.check_metadata.remediation.recommendation.text && (
|
||||
<InfoField label="Recommendation">
|
||||
<div className="flex flex-col gap-2">
|
||||
<MarkdownContainer>
|
||||
{attributes.check_metadata.remediation.recommendation.text}
|
||||
</MarkdownContainer>
|
||||
|
||||
{attributes.check_metadata.remediation.recommendation.url && (
|
||||
<CustomLink
|
||||
href={
|
||||
attributes.check_metadata.remediation.recommendation.url
|
||||
}
|
||||
size="sm"
|
||||
>
|
||||
Learn more
|
||||
</CustomLink>
|
||||
)}
|
||||
<InfoField label="Region">{resource.region}</InfoField>
|
||||
<InfoField label="First Seen">
|
||||
<DateWithTime inline dateTime={attributes.first_seen_at || "-"} />
|
||||
</InfoField>
|
||||
{attributes.delta && (
|
||||
<InfoField
|
||||
label="Delta"
|
||||
tooltipContent="Indicates whether the finding is new (NEW), has changed status (CHANGED), or remains unchanged (NONE) compared to previous scans."
|
||||
className="capitalize"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<DeltaIndicator delta={attributes.delta} />
|
||||
{attributes.delta}
|
||||
</div>
|
||||
</InfoField>
|
||||
)}
|
||||
<InfoField label="Severity" variant="simple">
|
||||
<SeverityBadge severity={attributes.severity || "-"} />
|
||||
</InfoField>
|
||||
</div>
|
||||
<InfoField label="Finding ID" variant="simple">
|
||||
<CodeSnippet value={findingDetails.id} />
|
||||
</InfoField>
|
||||
<InfoField label="Check ID" variant="simple">
|
||||
<CodeSnippet value={attributes.check_id} />
|
||||
</InfoField>
|
||||
<InfoField label="Finding UID" variant="simple">
|
||||
<CodeSnippet value={attributes.uid} />
|
||||
</InfoField>
|
||||
<InfoField label="Resource ID" variant="simple">
|
||||
<CodeSnippet value={resource.uid} />
|
||||
</InfoField>
|
||||
|
||||
{/* CLI Command section */}
|
||||
{attributes.check_metadata.remediation.code.cli && (
|
||||
<InfoField label="CLI Command" variant="simple">
|
||||
<Snippet className="bg-gray-50 py-1 dark:bg-slate-800">
|
||||
<span className="text-xs whitespace-pre-line">
|
||||
{attributes.check_metadata.remediation.code.cli}
|
||||
</span>
|
||||
</Snippet>
|
||||
</InfoField>
|
||||
)}
|
||||
|
||||
{/* Remediation Steps section */}
|
||||
{attributes.check_metadata.remediation.code.other && (
|
||||
<InfoField label="Remediation Steps">
|
||||
{attributes.status === "FAIL" && (
|
||||
<InfoField label="Risk" variant="simple">
|
||||
<Snippet
|
||||
className="max-w-full py-2"
|
||||
color="danger"
|
||||
hideCopyButton
|
||||
hideSymbol
|
||||
>
|
||||
<MarkdownContainer>
|
||||
{attributes.check_metadata.remediation.code.other}
|
||||
{attributes.check_metadata.risk}
|
||||
</MarkdownContainer>
|
||||
</InfoField>
|
||||
)}
|
||||
|
||||
{/* Additional URLs section */}
|
||||
{attributes.check_metadata.additionalurls &&
|
||||
attributes.check_metadata.additionalurls.length > 0 && (
|
||||
<InfoField label="References">
|
||||
<ul className="list-inside list-disc space-y-1">
|
||||
{attributes.check_metadata.additionalurls.map(
|
||||
(link, idx) => (
|
||||
<li key={idx}>
|
||||
<CustomLink
|
||||
href={link}
|
||||
size="sm"
|
||||
className="break-all whitespace-normal!"
|
||||
>
|
||||
{link}
|
||||
</CustomLink>
|
||||
</li>
|
||||
),
|
||||
)}
|
||||
</ul>
|
||||
</InfoField>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<InfoField label="Categories">
|
||||
{attributes.check_metadata.categories?.join(", ") || "-"}
|
||||
</InfoField>
|
||||
</CustomSection>
|
||||
|
||||
{/* Resource Details */}
|
||||
<CustomSection
|
||||
title={
|
||||
providerDetails.provider === "iac" ? (
|
||||
<span className="flex items-center gap-2">
|
||||
Resource Details
|
||||
{gitUrl && (
|
||||
<Tooltip content="Go to Resource in the Repository" size="sm">
|
||||
<a
|
||||
href={gitUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-bg-data-info inline-flex cursor-pointer"
|
||||
aria-label="Open resource in repository"
|
||||
>
|
||||
<ExternalLink size={16} className="inline" />
|
||||
</a>
|
||||
</Tooltip>
|
||||
)}
|
||||
</span>
|
||||
) : (
|
||||
"Resource Details"
|
||||
)
|
||||
}
|
||||
>
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<InfoField label="Resource Name">
|
||||
{renderValue(resource.name)}
|
||||
</InfoField>
|
||||
<InfoField label="Resource Type">
|
||||
{renderValue(resource.type)}
|
||||
</InfoField>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<InfoField label="Service">{renderValue(resource.service)}</InfoField>
|
||||
<InfoField label="Region">{renderValue(resource.region)}</InfoField>
|
||||
</div>
|
||||
|
||||
{resource.tags && Object.entries(resource.tags).length > 0 && (
|
||||
<div className="flex flex-col gap-4">
|
||||
<h4 className="text-sm font-bold text-gray-500 dark:text-gray-400">
|
||||
Tags
|
||||
</h4>
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
{Object.entries(resource.tags).map(([key, value]) => (
|
||||
<InfoField key={key} label={key}>
|
||||
{renderValue(value)}
|
||||
</InfoField>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<InfoField label="Created At">
|
||||
<DateWithTime inline dateTime={resource.inserted_at || "-"} />
|
||||
</InfoField>
|
||||
<InfoField label="Last Updated">
|
||||
<DateWithTime inline dateTime={resource.updated_at || "-"} />
|
||||
</InfoField>
|
||||
</div>
|
||||
</CustomSection>
|
||||
|
||||
{/* Add new Scan Details section */}
|
||||
<CustomSection title="Scan Details">
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-3">
|
||||
<InfoField label="Scan Name">{scan.name || "N/A"}</InfoField>
|
||||
<InfoField label="Resources Scanned">
|
||||
{scan.unique_resource_count}
|
||||
</InfoField>
|
||||
<InfoField label="Progress">{scan.progress}%</InfoField>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-3">
|
||||
<InfoField label="Trigger">{scan.trigger}</InfoField>
|
||||
<InfoField label="State">{scan.state}</InfoField>
|
||||
<InfoField label="Duration">
|
||||
{formatDuration(scan.duration)}
|
||||
</InfoField>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<InfoField label="Started At">
|
||||
<DateWithTime inline dateTime={scan.started_at || "-"} />
|
||||
</InfoField>
|
||||
<InfoField label="Completed At">
|
||||
<DateWithTime inline dateTime={scan.completed_at || "-"} />
|
||||
</InfoField>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<InfoField label="Launched At">
|
||||
<DateWithTime inline dateTime={scan.inserted_at || "-"} />
|
||||
</InfoField>
|
||||
{scan.scheduled_at && (
|
||||
<InfoField label="Scheduled At">
|
||||
<DateWithTime inline dateTime={scan.scheduled_at} />
|
||||
</Snippet>
|
||||
</InfoField>
|
||||
)}
|
||||
</div>
|
||||
</CustomSection>
|
||||
|
||||
<InfoField label="Description">
|
||||
<MarkdownContainer>
|
||||
{attributes.check_metadata.description}
|
||||
</MarkdownContainer>
|
||||
</InfoField>
|
||||
|
||||
<InfoField label="Status Extended">
|
||||
{renderValue(attributes.status_extended)}
|
||||
</InfoField>
|
||||
|
||||
{attributes.check_metadata.remediation && (
|
||||
<div className="flex flex-col gap-4">
|
||||
<h4 className="dark:text-prowler-theme-pale/90 text-sm font-bold text-gray-700">
|
||||
Remediation Details
|
||||
</h4>
|
||||
|
||||
{/* Recommendation section */}
|
||||
{attributes.check_metadata.remediation.recommendation.text && (
|
||||
<InfoField label="Recommendation">
|
||||
<div className="flex flex-col gap-2">
|
||||
<MarkdownContainer>
|
||||
{
|
||||
attributes.check_metadata.remediation.recommendation
|
||||
.text
|
||||
}
|
||||
</MarkdownContainer>
|
||||
|
||||
{attributes.check_metadata.remediation.recommendation
|
||||
.url && (
|
||||
<CustomLink
|
||||
href={
|
||||
attributes.check_metadata.remediation.recommendation
|
||||
.url
|
||||
}
|
||||
size="sm"
|
||||
>
|
||||
Learn more
|
||||
</CustomLink>
|
||||
)}
|
||||
</div>
|
||||
</InfoField>
|
||||
)}
|
||||
|
||||
{/* CLI Command section */}
|
||||
{attributes.check_metadata.remediation.code.cli && (
|
||||
<InfoField label="CLI Command" variant="simple">
|
||||
<Snippet className="bg-gray-50 py-1 dark:bg-slate-800">
|
||||
<span className="text-xs whitespace-pre-line">
|
||||
{attributes.check_metadata.remediation.code.cli}
|
||||
</span>
|
||||
</Snippet>
|
||||
</InfoField>
|
||||
)}
|
||||
|
||||
{/* Remediation Steps section */}
|
||||
{attributes.check_metadata.remediation.code.other && (
|
||||
<InfoField label="Remediation Steps">
|
||||
<MarkdownContainer>
|
||||
{attributes.check_metadata.remediation.code.other}
|
||||
</MarkdownContainer>
|
||||
</InfoField>
|
||||
)}
|
||||
|
||||
{/* Additional URLs section */}
|
||||
{attributes.check_metadata.additionalurls &&
|
||||
attributes.check_metadata.additionalurls.length > 0 && (
|
||||
<InfoField label="References">
|
||||
<ul className="list-inside list-disc space-y-1">
|
||||
{attributes.check_metadata.additionalurls.map(
|
||||
(link, idx) => (
|
||||
<li key={idx}>
|
||||
<CustomLink
|
||||
href={link}
|
||||
size="sm"
|
||||
className="break-all whitespace-normal!"
|
||||
>
|
||||
{link}
|
||||
</CustomLink>
|
||||
</li>
|
||||
),
|
||||
)}
|
||||
</ul>
|
||||
</InfoField>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<InfoField label="Categories">
|
||||
{attributes.check_metadata.categories?.join(", ") || "-"}
|
||||
</InfoField>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Resource Details */}
|
||||
<Card variant="base" padding="lg">
|
||||
<CardHeader>
|
||||
<CardTitle>Resource Details</CardTitle>
|
||||
{providerDetails.provider === "iac" && gitUrl && (
|
||||
<CardAction>
|
||||
<Tooltip content="Go to Resource in the Repository" size="sm">
|
||||
<a
|
||||
href={gitUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-bg-data-info inline-flex cursor-pointer"
|
||||
aria-label="Open resource in repository"
|
||||
>
|
||||
<ExternalLink size={16} className="inline" />
|
||||
</a>
|
||||
</Tooltip>
|
||||
</CardAction>
|
||||
)}
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-4">
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<InfoField label="Resource Name">
|
||||
{renderValue(resource.name)}
|
||||
</InfoField>
|
||||
<InfoField label="Resource Type">
|
||||
{renderValue(resource.type)}
|
||||
</InfoField>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<InfoField label="Service">
|
||||
{renderValue(resource.service)}
|
||||
</InfoField>
|
||||
<InfoField label="Region">{renderValue(resource.region)}</InfoField>
|
||||
</div>
|
||||
|
||||
{resource.tags && Object.entries(resource.tags).length > 0 && (
|
||||
<div className="flex flex-col gap-4">
|
||||
<h4 className="text-sm font-bold text-gray-500 dark:text-gray-400">
|
||||
Tags
|
||||
</h4>
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
{Object.entries(resource.tags).map(([key, value]) => (
|
||||
<InfoField key={key} label={key}>
|
||||
{renderValue(value)}
|
||||
</InfoField>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<InfoField label="Created At">
|
||||
<DateWithTime inline dateTime={resource.inserted_at || "-"} />
|
||||
</InfoField>
|
||||
<InfoField label="Last Updated">
|
||||
<DateWithTime inline dateTime={resource.updated_at || "-"} />
|
||||
</InfoField>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Add new Scan Details section */}
|
||||
<Card variant="base" padding="lg">
|
||||
<CardHeader>
|
||||
<CardTitle>Scan Details</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-4">
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-3">
|
||||
<InfoField label="Scan Name">{scan.name || "N/A"}</InfoField>
|
||||
<InfoField label="Resources Scanned">
|
||||
{scan.unique_resource_count}
|
||||
</InfoField>
|
||||
<InfoField label="Progress">{scan.progress}%</InfoField>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-3">
|
||||
<InfoField label="Trigger">{scan.trigger}</InfoField>
|
||||
<InfoField label="State">{scan.state}</InfoField>
|
||||
<InfoField label="Duration">
|
||||
{formatDuration(scan.duration)}
|
||||
</InfoField>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<InfoField label="Started At">
|
||||
<DateWithTime inline dateTime={scan.started_at || "-"} />
|
||||
</InfoField>
|
||||
<InfoField label="Completed At">
|
||||
<DateWithTime inline dateTime={scan.completed_at || "-"} />
|
||||
</InfoField>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<InfoField label="Launched At">
|
||||
<DateWithTime inline dateTime={scan.inserted_at || "-"} />
|
||||
</InfoField>
|
||||
{scan.scheduled_at && (
|
||||
<InfoField label="Scheduled At">
|
||||
<DateWithTime inline dateTime={scan.scheduled_at} />
|
||||
</InfoField>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,11 +1,39 @@
|
||||
import React from "react";
|
||||
|
||||
import { SkeletonTable } from "../../ui/skeleton/skeleton";
|
||||
import { Card } from "@/components/shadcn/card/card";
|
||||
import { Skeleton } from "@/components/shadcn/skeleton/skeleton";
|
||||
|
||||
export const SkeletonTableFindings = () => {
|
||||
const columns = 7;
|
||||
const rows = 4;
|
||||
|
||||
return (
|
||||
<div className="bg-card rounded-xl border p-4 shadow-sm">
|
||||
<SkeletonTable rows={4} columns={7} />
|
||||
</div>
|
||||
<Card variant="base" padding="md" className="flex flex-col gap-4">
|
||||
{/* Table headers */}
|
||||
<div className="flex gap-4">
|
||||
{Array.from({ length: columns }).map((_, index) => (
|
||||
<Skeleton
|
||||
key={`header-${index}`}
|
||||
className="h-8"
|
||||
style={{ width: `${100 / columns}%` }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Table body */}
|
||||
<div className="flex flex-col gap-3">
|
||||
{Array.from({ length: rows }).map((_, rowIndex) => (
|
||||
<div key={`row-${rowIndex}`} className="flex gap-4">
|
||||
{Array.from({ length: columns }).map((_, colIndex) => (
|
||||
<Skeleton
|
||||
key={`cell-${rowIndex}-${colIndex}`}
|
||||
className="h-12"
|
||||
style={{ width: `${100 / columns}%` }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -11,9 +11,14 @@ interface HorizontalBarChartProps {
|
||||
data: BarDataPoint[];
|
||||
height?: number;
|
||||
title?: string;
|
||||
labelWidth?: string;
|
||||
}
|
||||
|
||||
export function HorizontalBarChart({ data, title }: HorizontalBarChartProps) {
|
||||
export function HorizontalBarChart({
|
||||
data,
|
||||
title,
|
||||
labelWidth = "w-20",
|
||||
}: HorizontalBarChartProps) {
|
||||
const [hoveredIndex, setHoveredIndex] = useState<number | null>(null);
|
||||
|
||||
const total = data.reduce((sum, d) => sum + (Number(d.value) || 0), 0);
|
||||
@@ -61,13 +66,14 @@ export function HorizontalBarChart({ data, title }: HorizontalBarChartProps) {
|
||||
onMouseLeave={() => !isEmpty && setHoveredIndex(null)}
|
||||
>
|
||||
{/* Label */}
|
||||
<div className="w-20 shrink-0">
|
||||
<div className={`w-20 md:${labelWidth} shrink-0`}>
|
||||
<span
|
||||
className="text-text-neutral-secondary text-sm font-medium"
|
||||
className="text-text-neutral-secondary block truncate text-sm font-medium"
|
||||
style={{
|
||||
opacity: isFaded ? 0.5 : 1,
|
||||
transition: "opacity 0.2s",
|
||||
}}
|
||||
title={item.name}
|
||||
>
|
||||
{item.name === "Informational" ? "Info" : item.name}
|
||||
</span>
|
||||
@@ -134,17 +140,17 @@ export function HorizontalBarChart({ data, title }: HorizontalBarChartProps) {
|
||||
|
||||
{/* Percentage and Count */}
|
||||
<div
|
||||
className="text-text-neutral-secondary ml-6 flex w-[90px] shrink-0 items-center gap-2 text-sm"
|
||||
className="text-text-neutral-secondary ml-6 flex min-w-[90px] shrink-0 items-center gap-2 text-sm"
|
||||
style={{
|
||||
opacity: isFaded ? 0.5 : 1,
|
||||
transition: "opacity 0.2s",
|
||||
}}
|
||||
>
|
||||
<span className="w-[26px] text-right font-medium">
|
||||
<span className="min-w-[26px] text-right font-medium">
|
||||
{isEmpty ? "0" : item.percentage}%
|
||||
</span>
|
||||
<span className="font-medium">•</span>
|
||||
<span className="font-bold">
|
||||
<span className="shrink-0 font-medium">•</span>
|
||||
<span className="font-bold whitespace-nowrap">
|
||||
{isEmpty ? "0" : item.value.toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -32,11 +32,11 @@ export function AlertPill({
|
||||
>
|
||||
<AlertTriangle
|
||||
size={iconSize}
|
||||
style={{ color: "var(--color-text-error)" }}
|
||||
style={{ color: "var(--color-text-text-error)" }}
|
||||
/>
|
||||
<span
|
||||
className={cn(textSizeClass, "font-semibold")}
|
||||
style={{ color: "var(--color-text-error)" }}
|
||||
style={{ color: "var(--color-text-text-error)" }}
|
||||
>
|
||||
{value}
|
||||
</span>
|
||||
|
||||
@@ -70,10 +70,10 @@ function getMapColors(): MapColorsConfig {
|
||||
landStroke:
|
||||
getVar("--border-neutral-tertiary") || DEFAULT_MAP_COLORS.landStroke,
|
||||
pointDefault:
|
||||
getVar("--text-error-primary") || DEFAULT_MAP_COLORS.pointDefault,
|
||||
getVar("--text-text-error") || DEFAULT_MAP_COLORS.pointDefault,
|
||||
pointSelected:
|
||||
getVar("--bg-button-primary") || DEFAULT_MAP_COLORS.pointSelected,
|
||||
pointHover: getVar("--text-error-primary") || DEFAULT_MAP_COLORS.pointHover,
|
||||
pointHover: getVar("--text-text-error") || DEFAULT_MAP_COLORS.pointHover,
|
||||
};
|
||||
|
||||
return colors;
|
||||
|
||||
@@ -1131,7 +1131,7 @@ export const LighthouseIcon: React.FC<IconSvgProps> = ({
|
||||
width={size || width}
|
||||
height={size || height}
|
||||
fill="none"
|
||||
stroke="#8ce112"
|
||||
stroke="var(--bg-button-primary)"
|
||||
strokeWidth="12"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
@@ -1188,3 +1188,69 @@ export const BellIcon: React.FC<IconSvgProps> = ({
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
|
||||
export const SidebarExpandIcon: React.FC<IconSvgProps> = ({
|
||||
size = 24,
|
||||
width,
|
||||
height,
|
||||
...props
|
||||
}) => {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width={size || width}
|
||||
height={size || height}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
{...props}
|
||||
>
|
||||
<path
|
||||
d="M19 21H5C4.46957 21 3.96086 20.7893 3.58579 20.4142C3.21071 20.0391 3 19.5304 3 19V5C3 4.46957 3.21071 3.96086 3.58579 3.58579C3.96086 3.21071 4.46957 3 5 3H19C19.5304 3 20.0391 3.21071 20.4142 3.58579C20.7893 3.96086 21 4.46957 21 5V19C21 19.5304 20.7893 20.0391 20.4142 20.4142C20.0391 20.7893 19.5304 21 19 21Z"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M7.25 10L5.5 12L7.25 14M9.5 21V3"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
|
||||
export const SidebarCollapseIcon: React.FC<IconSvgProps> = ({
|
||||
size = 24,
|
||||
width,
|
||||
height,
|
||||
...props
|
||||
}) => {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width={size || width}
|
||||
height={size || height}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
{...props}
|
||||
>
|
||||
<path
|
||||
d="M19 21H5C4.46957 21 3.96086 20.7893 3.58579 20.4142C3.21071 20.0391 3 19.5304 3 19V5C3 4.46957 3.21071 3.96086 3.58579 3.58579C3.96086 3.21071 4.46957 3 5 3H19C19.5304 3 20.0391 3.21071 20.4142 3.58579C20.7893 3.96086 21 4.46957 21 5V19C21 19.5304 20.7893 20.0391 20.4142 20.4142C20.0391 20.7893 19.5304 21 19 21Z"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M16.75 10L18.5 12L16.75 14M14.5 21V3"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,16 +1,18 @@
|
||||
"use client";
|
||||
|
||||
import { Card, CardBody, CardHeader } from "@heroui/card";
|
||||
import { SettingsIcon } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
|
||||
import { JiraIcon } from "@/components/icons/services/IconServices";
|
||||
import { CustomButton } from "@/components/ui/custom";
|
||||
import { Button } from "@/components/shadcn";
|
||||
import { CustomLink } from "@/components/ui/custom/custom-link";
|
||||
|
||||
import { Card, CardContent, CardHeader } from "../../shadcn";
|
||||
|
||||
export const JiraIntegrationCard = () => {
|
||||
return (
|
||||
<Card className="dark:bg-gray-800">
|
||||
<CardHeader className="gap-2">
|
||||
<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">
|
||||
<JiraIcon size={40} />
|
||||
@@ -33,26 +35,21 @@ export const JiraIntegrationCard = () => {
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 self-end sm:self-center">
|
||||
<CustomButton
|
||||
size="sm"
|
||||
variant="bordered"
|
||||
startContent={<SettingsIcon size={14} />}
|
||||
asLink="/integrations/jira"
|
||||
ariaLabel="Manage Jira integrations"
|
||||
>
|
||||
Manage
|
||||
</CustomButton>
|
||||
<Button asChild size="sm">
|
||||
<Link href="/integrations/jira">
|
||||
<SettingsIcon size={14} />
|
||||
Manage
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardBody>
|
||||
<div className="flex flex-col gap-4">
|
||||
<p className="text-sm text-gray-600 dark:text-gray-300">
|
||||
Configure and manage your Jira integrations to automatically create
|
||||
issues for security findings in your Jira projects.
|
||||
</p>
|
||||
</div>
|
||||
</CardBody>
|
||||
<CardContent>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-300">
|
||||
Configure and manage your Jira integrations to automatically create
|
||||
issues for security findings in your Jira projects.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -152,7 +152,6 @@ export const JiraIntegrationForm = ({
|
||||
placeholder="your-domain.atlassian.net"
|
||||
isRequired
|
||||
isDisabled={isLoading}
|
||||
isInvalid={!!form.formState.errors.domain}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -165,7 +164,6 @@ export const JiraIntegrationForm = ({
|
||||
labelPlacement="inside"
|
||||
placeholder="your-domain.atlassian.net"
|
||||
isDisabled={isLoading}
|
||||
isInvalid={!!form.formState.errors.domain}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -178,7 +176,6 @@ export const JiraIntegrationForm = ({
|
||||
placeholder="user@example.com"
|
||||
isRequired
|
||||
isDisabled={isLoading}
|
||||
isInvalid={!!form.formState.errors.user_mail}
|
||||
/>
|
||||
|
||||
<CustomInput
|
||||
@@ -190,7 +187,6 @@ export const JiraIntegrationForm = ({
|
||||
placeholder="Enter your Jira API token"
|
||||
isRequired
|
||||
isDisabled={isLoading}
|
||||
isInvalid={!!form.formState.errors.api_token}
|
||||
/>
|
||||
|
||||
<div className="rounded-lg border border-blue-200 bg-blue-50 p-4 dark:border-blue-800 dark:bg-blue-900/20">
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
"use client";
|
||||
|
||||
import { Card, CardBody, CardHeader } from "@heroui/card";
|
||||
import { format } from "date-fns";
|
||||
import { PlusIcon, Trash2Icon } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
@@ -16,13 +15,15 @@ import {
|
||||
IntegrationCardHeader,
|
||||
IntegrationSkeleton,
|
||||
} from "@/components/integrations/shared";
|
||||
import { Button } from "@/components/shadcn";
|
||||
import { useToast } from "@/components/ui";
|
||||
import { CustomAlertModal, CustomButton } from "@/components/ui/custom";
|
||||
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 { JiraIntegrationForm } from "./jira-integration-form";
|
||||
|
||||
interface JiraIntegrationsManagerProps {
|
||||
@@ -214,38 +215,33 @@ export const JiraIntegrationsManager = ({
|
||||
title="Delete Jira Integration"
|
||||
description="This action cannot be undone. This will permanently delete your Jira integration."
|
||||
>
|
||||
<div className="flex w-full justify-center gap-6">
|
||||
<CustomButton
|
||||
<div className="flex w-full justify-end gap-4">
|
||||
<Button
|
||||
type="button"
|
||||
ariaLabel="Cancel"
|
||||
className="w-full bg-transparent"
|
||||
variant="faded"
|
||||
variant="ghost"
|
||||
size="lg"
|
||||
onPress={() => {
|
||||
onClick={() => {
|
||||
setIsDeleteOpen(false);
|
||||
setIntegrationToDelete(null);
|
||||
}}
|
||||
isDisabled={isDeleting !== null}
|
||||
disabled={isDeleting !== null}
|
||||
>
|
||||
<span>Cancel</span>
|
||||
</CustomButton>
|
||||
Cancel
|
||||
</Button>
|
||||
|
||||
<CustomButton
|
||||
<Button
|
||||
type="button"
|
||||
ariaLabel="Delete"
|
||||
className="w-full"
|
||||
variant="solid"
|
||||
color="danger"
|
||||
variant="destructive"
|
||||
size="lg"
|
||||
isLoading={isDeleting !== null}
|
||||
startContent={!isDeleting && <Trash2Icon size={24} />}
|
||||
onPress={() =>
|
||||
disabled={isDeleting !== null}
|
||||
onClick={() =>
|
||||
integrationToDelete &&
|
||||
handleDeleteIntegration(integrationToDelete.id)
|
||||
}
|
||||
>
|
||||
{!isDeleting && <Trash2Icon size={24} />}
|
||||
{isDeleting ? "Deleting..." : "Delete"}
|
||||
</CustomButton>
|
||||
</Button>
|
||||
</div>
|
||||
</CustomAlertModal>
|
||||
|
||||
@@ -278,14 +274,10 @@ export const JiraIntegrationsManager = ({
|
||||
: `${integrations.length} integration${integrations.length !== 1 ? "s" : ""} configured`}
|
||||
</p>
|
||||
</div>
|
||||
<CustomButton
|
||||
color="action"
|
||||
startContent={<PlusIcon size={16} />}
|
||||
onPress={handleAddIntegration}
|
||||
ariaLabel="Add integration"
|
||||
>
|
||||
<Button onClick={handleAddIntegration}>
|
||||
<PlusIcon size={16} />
|
||||
Add Integration
|
||||
</CustomButton>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Integrations List */}
|
||||
@@ -300,8 +292,8 @@ export const JiraIntegrationsManager = ({
|
||||
) : integrations.length > 0 ? (
|
||||
<div className="grid gap-4">
|
||||
{integrations.map((integration) => (
|
||||
<Card key={integration.id} className="dark:bg-gray-800">
|
||||
<CardHeader className="pb-2">
|
||||
<Card key={integration.id} variant="base">
|
||||
<CardHeader>
|
||||
<IntegrationCardHeader
|
||||
icon={<JiraIcon size={32} />}
|
||||
title={`${integration.attributes.configuration.domain}`}
|
||||
@@ -311,7 +303,7 @@ export const JiraIntegrationsManager = ({
|
||||
/>
|
||||
</CardHeader>
|
||||
|
||||
<CardBody className="pt-0">
|
||||
<CardContent 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 && (
|
||||
@@ -335,7 +327,7 @@ export const JiraIntegrationsManager = ({
|
||||
isTesting={isTesting === integration.id}
|
||||
/>
|
||||
</div>
|
||||
</CardBody>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -1,16 +1,18 @@
|
||||
"use client";
|
||||
|
||||
import { Card, CardBody, CardHeader } from "@heroui/card";
|
||||
import { SettingsIcon } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
|
||||
import { AmazonS3Icon } from "@/components/icons/services/IconServices";
|
||||
import { CustomButton } from "@/components/ui/custom";
|
||||
import { Button } from "@/components/shadcn";
|
||||
import { CustomLink } from "@/components/ui/custom/custom-link";
|
||||
|
||||
import { Card, CardContent, CardHeader } from "../../shadcn";
|
||||
|
||||
export const S3IntegrationCard = () => {
|
||||
return (
|
||||
<Card className="dark:bg-gray-800">
|
||||
<CardHeader className="gap-2">
|
||||
<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">
|
||||
<AmazonS3Icon size={40} />
|
||||
@@ -33,26 +35,21 @@ export const S3IntegrationCard = () => {
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 self-end sm:self-center">
|
||||
<CustomButton
|
||||
size="sm"
|
||||
variant="bordered"
|
||||
startContent={<SettingsIcon size={14} />}
|
||||
asLink="/integrations/amazon-s3"
|
||||
ariaLabel="Manage S3 integrations"
|
||||
>
|
||||
Manage
|
||||
</CustomButton>
|
||||
<Button asChild size="sm">
|
||||
<Link href="/integrations/amazon-s3">
|
||||
<SettingsIcon size={14} />
|
||||
Manage
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardBody>
|
||||
<div className="flex flex-col gap-4">
|
||||
<p className="text-sm text-gray-600 dark:text-gray-300">
|
||||
Configure and manage your Amazon S3 integrations to automatically
|
||||
export security findings to your S3 buckets.
|
||||
</p>
|
||||
</div>
|
||||
</CardBody>
|
||||
<CardContent>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-300">
|
||||
Configure and manage your Amazon S3 integrations to automatically
|
||||
export security findings to your S3 buckets.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -282,7 +282,6 @@ export const S3IntegrationForm = ({
|
||||
providers={providers}
|
||||
label="Cloud Providers"
|
||||
placeholder="Select providers to integrate with"
|
||||
isInvalid={!!form.formState.errors.providers}
|
||||
selectionMode="multiple"
|
||||
enableSearch={true}
|
||||
/>
|
||||
@@ -301,7 +300,6 @@ export const S3IntegrationForm = ({
|
||||
placeholder="my-security-findings-bucket"
|
||||
variant="bordered"
|
||||
isRequired
|
||||
isInvalid={!!form.formState.errors.bucket_name}
|
||||
/>
|
||||
|
||||
<CustomInput
|
||||
@@ -313,7 +311,6 @@ export const S3IntegrationForm = ({
|
||||
placeholder="output"
|
||||
variant="bordered"
|
||||
isRequired
|
||||
isInvalid={!!form.formState.errors.output_directory}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
"use client";
|
||||
|
||||
import { Card, CardBody, CardHeader } from "@heroui/card";
|
||||
import { format } from "date-fns";
|
||||
import { PlusIcon, Trash2Icon } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
@@ -16,14 +15,16 @@ import {
|
||||
IntegrationCardHeader,
|
||||
IntegrationSkeleton,
|
||||
} from "@/components/integrations/shared";
|
||||
import { Button } from "@/components/shadcn";
|
||||
import { useToast } from "@/components/ui";
|
||||
import { CustomAlertModal, CustomButton } from "@/components/ui/custom";
|
||||
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 { ProviderProps } from "@/types/providers";
|
||||
|
||||
import { Card, CardContent, CardHeader } from "../../shadcn";
|
||||
import { S3IntegrationForm } from "./s3-integration-form";
|
||||
|
||||
interface S3IntegrationsManagerProps {
|
||||
@@ -214,38 +215,33 @@ export const S3IntegrationsManager = ({
|
||||
title="Delete S3 Integration"
|
||||
description="This action cannot be undone. This will permanently delete your S3 integration."
|
||||
>
|
||||
<div className="flex w-full justify-center gap-6">
|
||||
<CustomButton
|
||||
<div className="flex w-full justify-end gap-4">
|
||||
<Button
|
||||
type="button"
|
||||
ariaLabel="Cancel"
|
||||
className="w-full bg-transparent"
|
||||
variant="faded"
|
||||
variant="ghost"
|
||||
size="lg"
|
||||
onPress={() => {
|
||||
onClick={() => {
|
||||
setIsDeleteOpen(false);
|
||||
setIntegrationToDelete(null);
|
||||
}}
|
||||
isDisabled={isDeleting !== null}
|
||||
disabled={isDeleting !== null}
|
||||
>
|
||||
<span>Cancel</span>
|
||||
</CustomButton>
|
||||
Cancel
|
||||
</Button>
|
||||
|
||||
<CustomButton
|
||||
<Button
|
||||
type="button"
|
||||
ariaLabel="Delete"
|
||||
className="w-full"
|
||||
variant="solid"
|
||||
color="danger"
|
||||
variant="destructive"
|
||||
size="lg"
|
||||
isLoading={isDeleting !== null}
|
||||
startContent={!isDeleting && <Trash2Icon size={24} />}
|
||||
onPress={() =>
|
||||
disabled={isDeleting !== null}
|
||||
onClick={() =>
|
||||
integrationToDelete &&
|
||||
handleDeleteIntegration(integrationToDelete.id)
|
||||
}
|
||||
>
|
||||
{!isDeleting && <Trash2Icon size={24} />}
|
||||
{isDeleting ? "Deleting..." : "Delete"}
|
||||
</CustomButton>
|
||||
</Button>
|
||||
</div>
|
||||
</CustomAlertModal>
|
||||
|
||||
@@ -284,14 +280,10 @@ export const S3IntegrationsManager = ({
|
||||
: `${integrations.length} integration${integrations.length !== 1 ? "s" : ""} configured`}
|
||||
</p>
|
||||
</div>
|
||||
<CustomButton
|
||||
color="action"
|
||||
startContent={<PlusIcon size={16} />}
|
||||
onPress={handleAddIntegration}
|
||||
ariaLabel="Add integration"
|
||||
>
|
||||
<Button onClick={handleAddIntegration}>
|
||||
<PlusIcon size={16} />
|
||||
Add Integration
|
||||
</CustomButton>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Integrations List */}
|
||||
@@ -306,8 +298,8 @@ export const S3IntegrationsManager = ({
|
||||
) : integrations.length > 0 ? (
|
||||
<div className="grid gap-4">
|
||||
{integrations.map((integration) => (
|
||||
<Card key={integration.id} className="dark:bg-gray-800">
|
||||
<CardHeader className="pb-2">
|
||||
<Card key={integration.id} variant="base">
|
||||
<CardHeader>
|
||||
<IntegrationCardHeader
|
||||
icon={<AmazonS3Icon size={32} />}
|
||||
title={
|
||||
@@ -326,7 +318,7 @@ export const S3IntegrationsManager = ({
|
||||
/>
|
||||
</CardHeader>
|
||||
|
||||
<CardBody className="pt-0">
|
||||
<CardContent 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 && (
|
||||
@@ -351,7 +343,7 @@ export const S3IntegrationsManager = ({
|
||||
isTesting={isTesting === integration.id}
|
||||
/>
|
||||
</div>
|
||||
</CardBody>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -12,8 +12,9 @@ import { z } from "zod";
|
||||
|
||||
import { createSamlConfig, updateSamlConfig } from "@/actions/integrations";
|
||||
import { AddIcon } from "@/components/icons";
|
||||
import { Button, Card, CardContent, CardHeader } from "@/components/shadcn";
|
||||
import { useToast } from "@/components/ui";
|
||||
import { CustomButton, CustomServerInput } from "@/components/ui/custom";
|
||||
import { CustomServerInput } from "@/components/ui/custom";
|
||||
import { CustomLink } from "@/components/ui/custom/custom-link";
|
||||
import { SnippetChip } from "@/components/ui/entities";
|
||||
import { FormButtons } from "@/components/ui/form";
|
||||
@@ -293,75 +294,76 @@ export const SamlConfigForm = ({
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className="flex flex-col gap-4 rounded-lg bg-gray-50 p-4 dark:bg-gray-800">
|
||||
<h3 className="text-lg font-semibold">
|
||||
<Card variant="inner">
|
||||
<CardHeader className="mb-2">
|
||||
Identity Provider Configuration
|
||||
</h3>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex flex-col gap-4">
|
||||
<div>
|
||||
<span className="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
ACS URL:
|
||||
</span>
|
||||
<SnippetChip
|
||||
value={acsUrl}
|
||||
ariaLabel="Copy ACS URL to clipboard"
|
||||
className="h-10 w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-4">
|
||||
<div>
|
||||
<span className="text-default-500 mb-2 block text-sm font-medium">
|
||||
ACS URL:
|
||||
</span>
|
||||
<SnippetChip
|
||||
value={acsUrl}
|
||||
ariaLabel="Copy ACS URL to clipboard"
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<span className="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Audience:
|
||||
</span>
|
||||
<SnippetChip
|
||||
value="urn:prowler.com:sp"
|
||||
ariaLabel="Copy Audience to clipboard"
|
||||
className="h-10 w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<span className="text-default-500 mb-2 block text-sm font-medium">
|
||||
Audience:
|
||||
</span>
|
||||
<SnippetChip
|
||||
value="urn:prowler.com:sp"
|
||||
ariaLabel="Copy Audience to clipboard"
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<span className="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Name ID Format:
|
||||
</span>
|
||||
<span className="w-full text-sm text-gray-600 dark:text-gray-400">
|
||||
urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<span className="text-default-500 mb-2 block text-sm font-medium">
|
||||
Name ID Format:
|
||||
</span>
|
||||
<span className="text-default-600 w-full text-sm">
|
||||
urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress
|
||||
</span>
|
||||
<div>
|
||||
<span className="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Supported Assertion Attributes:
|
||||
</span>
|
||||
<ul className="ml-4 flex flex-col gap-1 text-sm text-gray-600 dark:text-gray-400">
|
||||
<li>• firstName</li>
|
||||
<li>• lastName</li>
|
||||
<li>• userType</li>
|
||||
<li>• organization</li>
|
||||
</ul>
|
||||
<p className="mt-2 text-xs text-gray-600 dark:text-gray-400">
|
||||
<strong>Note:</strong> The userType attribute will be used to
|
||||
assign the user's role. If the role does not exist, one
|
||||
will be created with minimal permissions. You can assign
|
||||
permissions to roles on the{" "}
|
||||
<CustomLink href="/roles" target="_self">
|
||||
<span>Roles</span>
|
||||
</CustomLink>{" "}
|
||||
page.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<span className="text-default-500 mb-2 block text-sm font-medium">
|
||||
Supported Assertion Attributes:
|
||||
</span>
|
||||
<ul className="text-default-600 ml-4 flex flex-col gap-1 text-sm">
|
||||
<li>• firstName</li>
|
||||
<li>• lastName</li>
|
||||
<li>• userType</li>
|
||||
<li>• organization</li>
|
||||
</ul>
|
||||
<p className="mt-2 text-xs text-gray-600 dark:text-gray-400">
|
||||
<strong>Note:</strong> The userType attribute will be used to
|
||||
assign the user's role. If the role does not exist, one will
|
||||
be created with minimal permissions. You can assign permissions to
|
||||
roles on the{" "}
|
||||
<CustomLink href="/roles" target="_self">
|
||||
<span>Roles</span>
|
||||
</CustomLink>{" "}
|
||||
page.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<div className="flex flex-col items-start gap-2">
|
||||
<span className="text-default-500 text-xs">
|
||||
<span className="text-xs text-gray-700 dark:text-gray-300">
|
||||
Metadata XML File <span className="text-red-500">*</span>
|
||||
</span>
|
||||
<CustomButton
|
||||
<Button
|
||||
type="button"
|
||||
ariaLabel="Select Metadata XML File"
|
||||
isDisabled={isPending}
|
||||
onPress={() => {
|
||||
variant="outline"
|
||||
disabled={isPending}
|
||||
onClick={() => {
|
||||
const fileInput = document.getElementById(
|
||||
"metadata_xml_file",
|
||||
) as HTMLInputElement;
|
||||
@@ -369,8 +371,7 @@ export const SamlConfigForm = ({
|
||||
fileInput.click();
|
||||
}
|
||||
}}
|
||||
startContent={<AddIcon size={20} />}
|
||||
className={`rounded-medium text-default-500 h-10 justify-start border-2 ${
|
||||
className={`justify-start gap-2 ${
|
||||
(
|
||||
clientErrors.metadata_xml === null
|
||||
? undefined
|
||||
@@ -379,10 +380,11 @@ export const SamlConfigForm = ({
|
||||
? "border-red-500"
|
||||
: uploadedFile
|
||||
? "border-green-500 bg-green-50 dark:bg-green-900/20"
|
||||
: "border-default-200"
|
||||
: ""
|
||||
}`}
|
||||
>
|
||||
<span className="text-small">
|
||||
<AddIcon size={20} />
|
||||
<span className="text-sm">
|
||||
{uploadedFile ? (
|
||||
<span className="flex items-center gap-2">
|
||||
<span className="max-w-36 truncate">{uploadedFile.name}</span>
|
||||
@@ -391,7 +393,7 @@ export const SamlConfigForm = ({
|
||||
"Choose File"
|
||||
)}
|
||||
</span>
|
||||
</CustomButton>
|
||||
</Button>
|
||||
|
||||
<input
|
||||
type="file"
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
"use client";
|
||||
|
||||
import { Card, CardBody, CardHeader } from "@heroui/card";
|
||||
import { CheckIcon, Trash2Icon } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
|
||||
import { deleteSamlConfig } from "@/actions/integrations";
|
||||
import { Button } from "@/components/shadcn";
|
||||
import { useToast } from "@/components/ui";
|
||||
import { CustomAlertModal, CustomButton } from "@/components/ui/custom";
|
||||
import { CustomAlertModal } from "@/components/ui/custom";
|
||||
import { CustomLink } from "@/components/ui/custom/custom-link";
|
||||
|
||||
import { Card, CardContent, CardHeader } from "../../shadcn";
|
||||
import { SamlConfigForm } from "./saml-config-form";
|
||||
|
||||
export const SamlIntegrationCard = ({ samlConfig }: { samlConfig?: any }) => {
|
||||
@@ -60,12 +61,12 @@ export const SamlIntegrationCard = ({ samlConfig }: { samlConfig?: any }) => {
|
||||
/>
|
||||
</CustomAlertModal>
|
||||
|
||||
<Card className="dark:bg-prowler-blue-400">
|
||||
<CardHeader className="gap-2">
|
||||
<Card variant="base" padding="lg">
|
||||
<CardHeader>
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<h4 className="text-lg font-bold">SAML SSO Integration</h4>
|
||||
{id && <CheckIcon className="text-prowler-green" size={20} />}
|
||||
{id && <CheckIcon className="text-button-primary" size={20} />}
|
||||
</div>
|
||||
<p className="text-xs text-gray-500">
|
||||
{id ? (
|
||||
@@ -81,39 +82,32 @@ export const SamlIntegrationCard = ({ samlConfig }: { samlConfig?: any }) => {
|
||||
</p>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardBody>
|
||||
<CardContent>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="text-sm">
|
||||
<span className="font-medium">Status: </span>
|
||||
<span className={id ? "text-prowler-green" : "text-gray-500"}>
|
||||
<span className={id ? "text-button-primary" : "text-gray-500"}>
|
||||
{id ? "Enabled" : "Disabled"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<CustomButton
|
||||
size="sm"
|
||||
ariaLabel="Configure SAML SSO"
|
||||
color="action"
|
||||
onPress={() => setIsSamlModalOpen(true)}
|
||||
>
|
||||
<Button size="sm" onClick={() => setIsSamlModalOpen(true)}>
|
||||
{id ? "Update" : "Enable"}
|
||||
</CustomButton>
|
||||
</Button>
|
||||
{id && (
|
||||
<CustomButton
|
||||
<Button
|
||||
size="sm"
|
||||
ariaLabel="Remove SAML SSO"
|
||||
color="danger"
|
||||
variant="bordered"
|
||||
isLoading={isDeleting}
|
||||
startContent={!isDeleting ? <Trash2Icon size={16} /> : null}
|
||||
onPress={handleRemoveSaml}
|
||||
variant="destructive"
|
||||
disabled={isDeleting}
|
||||
onClick={handleRemoveSaml}
|
||||
>
|
||||
Remove
|
||||
</CustomButton>
|
||||
{!isDeleting && <Trash2Icon size={16} />}
|
||||
{isDeleting ? "Removing..." : "Remove"}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardBody>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -1,16 +1,18 @@
|
||||
"use client";
|
||||
|
||||
import { Card, CardBody, CardHeader } from "@heroui/card";
|
||||
import { SettingsIcon } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
|
||||
import { AWSSecurityHubIcon } from "@/components/icons/services/IconServices";
|
||||
import { CustomButton } from "@/components/ui/custom";
|
||||
import { Button } from "@/components/shadcn";
|
||||
import { CustomLink } from "@/components/ui/custom/custom-link";
|
||||
|
||||
import { Card, CardContent, CardHeader } from "../../shadcn";
|
||||
|
||||
export const SecurityHubIntegrationCard = () => {
|
||||
return (
|
||||
<Card className="dark:bg-gray-800">
|
||||
<CardHeader className="gap-2">
|
||||
<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">
|
||||
<AWSSecurityHubIcon size={40} />
|
||||
@@ -33,26 +35,21 @@ export const SecurityHubIntegrationCard = () => {
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 self-end sm:self-center">
|
||||
<CustomButton
|
||||
size="sm"
|
||||
variant="bordered"
|
||||
startContent={<SettingsIcon size={14} />}
|
||||
asLink="/integrations/aws-security-hub"
|
||||
ariaLabel="Manage Security Hub integrations"
|
||||
>
|
||||
Manage
|
||||
</CustomButton>
|
||||
<Button asChild size="sm">
|
||||
<Link href="/integrations/aws-security-hub">
|
||||
<SettingsIcon size={14} />
|
||||
Manage
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardBody>
|
||||
<div className="flex flex-col gap-4">
|
||||
<p className="text-sm text-gray-600 dark:text-gray-300">
|
||||
Configure and manage your AWS Security Hub integrations to
|
||||
automatically send security findings for centralized monitoring.
|
||||
</p>
|
||||
</div>
|
||||
</CardBody>
|
||||
<CardContent>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-300">
|
||||
Configure and manage your AWS Security Hub integrations to
|
||||
automatically send security findings for centralized monitoring.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -352,6 +352,7 @@ export const SecurityHubIntegrationForm = ({
|
||||
isSelected={Boolean(field.value)}
|
||||
onValueChange={field.onChange}
|
||||
size="sm"
|
||||
color="default"
|
||||
>
|
||||
<span className="text-sm">
|
||||
Send only findings with status FAIL
|
||||
@@ -370,6 +371,7 @@ export const SecurityHubIntegrationForm = ({
|
||||
isSelected={Boolean(field.value)}
|
||||
onValueChange={field.onChange}
|
||||
size="sm"
|
||||
color="default"
|
||||
>
|
||||
<span className="text-sm">Archive previous findings</span>
|
||||
</Checkbox>
|
||||
@@ -387,6 +389,7 @@ export const SecurityHubIntegrationForm = ({
|
||||
isSelected={field.value}
|
||||
onValueChange={field.onChange}
|
||||
size="sm"
|
||||
color="default"
|
||||
>
|
||||
<span className="text-sm">
|
||||
Use custom credentials (By default, AWS account ones
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
"use client";
|
||||
|
||||
import { Card, CardBody, CardHeader } from "@heroui/card";
|
||||
import { Chip } from "@heroui/chip";
|
||||
import { format } from "date-fns";
|
||||
import { PlusIcon, Trash2Icon } from "lucide-react";
|
||||
@@ -17,14 +16,16 @@ import {
|
||||
IntegrationCardHeader,
|
||||
IntegrationSkeleton,
|
||||
} from "@/components/integrations/shared";
|
||||
import { Button } from "@/components/shadcn";
|
||||
import { useToast } from "@/components/ui";
|
||||
import { CustomAlertModal, CustomButton } from "@/components/ui/custom";
|
||||
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 { ProviderProps } from "@/types/providers";
|
||||
|
||||
import { Card, CardContent, CardHeader } from "../../shadcn";
|
||||
import { SecurityHubIntegrationForm } from "./security-hub-integration-form";
|
||||
|
||||
interface SecurityHubIntegrationsManagerProps {
|
||||
@@ -264,38 +265,33 @@ export const SecurityHubIntegrationsManager = ({
|
||||
title="Delete Security Hub Integration"
|
||||
description="This action cannot be undone. This will permanently delete your Security Hub integration."
|
||||
>
|
||||
<div className="flex w-full justify-center gap-6">
|
||||
<CustomButton
|
||||
<div className="flex w-full justify-end gap-4">
|
||||
<Button
|
||||
type="button"
|
||||
ariaLabel="Cancel"
|
||||
className="w-full bg-transparent"
|
||||
variant="faded"
|
||||
variant="ghost"
|
||||
size="lg"
|
||||
onPress={() => {
|
||||
onClick={() => {
|
||||
setIsDeleteOpen(false);
|
||||
setIntegrationToDelete(null);
|
||||
}}
|
||||
isDisabled={isDeleting !== null}
|
||||
disabled={isDeleting !== null}
|
||||
>
|
||||
<span>Cancel</span>
|
||||
</CustomButton>
|
||||
Cancel
|
||||
</Button>
|
||||
|
||||
<CustomButton
|
||||
<Button
|
||||
type="button"
|
||||
ariaLabel="Delete"
|
||||
className="w-full"
|
||||
variant="solid"
|
||||
color="danger"
|
||||
variant="destructive"
|
||||
size="lg"
|
||||
isLoading={isDeleting !== null}
|
||||
startContent={!isDeleting && <Trash2Icon size={24} />}
|
||||
onPress={() =>
|
||||
disabled={isDeleting !== null}
|
||||
onClick={() =>
|
||||
integrationToDelete &&
|
||||
handleDeleteIntegration(integrationToDelete.id)
|
||||
}
|
||||
>
|
||||
{!isDeleting && <Trash2Icon size={24} />}
|
||||
{isDeleting ? "Deleting..." : "Delete"}
|
||||
</CustomButton>
|
||||
</Button>
|
||||
</div>
|
||||
</CustomAlertModal>
|
||||
|
||||
@@ -334,14 +330,10 @@ export const SecurityHubIntegrationsManager = ({
|
||||
: `${integrations.length} integration${integrations.length !== 1 ? "s" : ""} configured`}
|
||||
</p>
|
||||
</div>
|
||||
<CustomButton
|
||||
color="action"
|
||||
startContent={<PlusIcon size={16} />}
|
||||
onPress={handleAddIntegration}
|
||||
ariaLabel="Add integration"
|
||||
>
|
||||
<Button onClick={handleAddIntegration}>
|
||||
<PlusIcon size={16} />
|
||||
Add Integration
|
||||
</CustomButton>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{isOperationLoading ? (
|
||||
@@ -359,8 +351,8 @@ export const SecurityHubIntegrationsManager = ({
|
||||
const providerDetails = getProviderDetails(integration);
|
||||
|
||||
return (
|
||||
<Card key={integration.id} className="dark:bg-gray-800">
|
||||
<CardHeader className="pb-2">
|
||||
<Card key={integration.id} variant="base">
|
||||
<CardHeader>
|
||||
<IntegrationCardHeader
|
||||
icon={<AWSSecurityHubIcon size={32} />}
|
||||
title={providerDetails.displayName}
|
||||
@@ -388,7 +380,7 @@ export const SecurityHubIntegrationsManager = ({
|
||||
}}
|
||||
/>
|
||||
</CardHeader>
|
||||
<CardBody className="pt-0">
|
||||
<CardContent className="pt-0">
|
||||
<div className="flex flex-col gap-3">
|
||||
{enabledRegions.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
@@ -397,7 +389,7 @@ export const SecurityHubIntegrationsManager = ({
|
||||
key={region}
|
||||
size="sm"
|
||||
variant="flat"
|
||||
className="bg-default-100"
|
||||
className="bg-bg-neutral-secondary"
|
||||
>
|
||||
{region}
|
||||
</Chip>
|
||||
@@ -428,7 +420,7 @@ export const SecurityHubIntegrationsManager = ({
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</CardBody>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
Trash2Icon,
|
||||
} from "lucide-react";
|
||||
|
||||
import { CustomButton } from "@/components/ui/custom";
|
||||
import { Button } from "@/components/shadcn";
|
||||
import { IntegrationProps } from "@/types/integrations";
|
||||
|
||||
interface IntegrationActionButtonsProps {
|
||||
@@ -34,69 +34,57 @@ export const IntegrationActionButtons = ({
|
||||
}: IntegrationActionButtonsProps) => {
|
||||
return (
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
|
||||
<CustomButton
|
||||
<Button
|
||||
size="sm"
|
||||
variant="bordered"
|
||||
startContent={<TestTube size={14} />}
|
||||
onPress={() => onTestConnection(integration.id)}
|
||||
isLoading={isTesting}
|
||||
isDisabled={!integration.attributes.enabled || isTesting}
|
||||
ariaLabel="Test connection"
|
||||
variant="outline"
|
||||
onClick={() => onTestConnection(integration.id)}
|
||||
disabled={!integration.attributes.enabled || isTesting}
|
||||
className="w-full sm:w-auto"
|
||||
>
|
||||
Test
|
||||
</CustomButton>
|
||||
<TestTube size={14} />
|
||||
{isTesting ? "Testing..." : "Test"}
|
||||
</Button>
|
||||
{onEditConfiguration && (
|
||||
<CustomButton
|
||||
<Button
|
||||
size="sm"
|
||||
variant="bordered"
|
||||
startContent={<SettingsIcon size={14} />}
|
||||
onPress={() => onEditConfiguration(integration)}
|
||||
ariaLabel="Edit configuration"
|
||||
variant="outline"
|
||||
onClick={() => onEditConfiguration(integration)}
|
||||
className="w-full sm:w-auto"
|
||||
>
|
||||
<SettingsIcon size={14} />
|
||||
Config
|
||||
</CustomButton>
|
||||
</Button>
|
||||
)}
|
||||
{showCredentialsButton && (
|
||||
<CustomButton
|
||||
<Button
|
||||
size="sm"
|
||||
variant="bordered"
|
||||
startContent={<LockIcon size={14} />}
|
||||
onPress={() => onEditCredentials(integration)}
|
||||
ariaLabel="Edit credentials"
|
||||
variant="outline"
|
||||
onClick={() => onEditCredentials(integration)}
|
||||
className="w-full sm:w-auto"
|
||||
>
|
||||
<LockIcon size={14} />
|
||||
Credentials
|
||||
</CustomButton>
|
||||
</Button>
|
||||
)}
|
||||
<CustomButton
|
||||
<Button
|
||||
size="sm"
|
||||
variant="bordered"
|
||||
color={integration.attributes.enabled ? "warning" : "primary"}
|
||||
startContent={<Power size={14} />}
|
||||
onPress={() => onToggleEnabled(integration)}
|
||||
isDisabled={isTesting}
|
||||
ariaLabel={
|
||||
integration.attributes.enabled
|
||||
? "Disable integration"
|
||||
: "Enable integration"
|
||||
}
|
||||
variant="outline"
|
||||
onClick={() => onToggleEnabled(integration)}
|
||||
disabled={isTesting}
|
||||
className="w-full sm:w-auto"
|
||||
>
|
||||
<Power size={14} />
|
||||
{integration.attributes.enabled ? "Disable" : "Enable"}
|
||||
</CustomButton>
|
||||
<CustomButton
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
color="danger"
|
||||
variant="bordered"
|
||||
startContent={<Trash2Icon size={14} />}
|
||||
onPress={() => onDelete(integration)}
|
||||
ariaLabel="Delete integration"
|
||||
className="w-full sm:w-auto"
|
||||
variant="outline"
|
||||
onClick={() => onDelete(integration)}
|
||||
className="w-full text-red-600 hover:bg-red-50 hover:text-red-700 sm:w-auto dark:text-red-400 dark:hover:bg-red-950 dark:hover:text-red-300"
|
||||
>
|
||||
<Trash2Icon size={14} />
|
||||
Delete
|
||||
</CustomButton>
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -7,8 +7,8 @@ import * as z from "zod";
|
||||
|
||||
import { revokeInvite } from "@/actions/invitations/invitation";
|
||||
import { DeleteIcon } from "@/components/icons";
|
||||
import { Button } from "@/components/shadcn";
|
||||
import { useToast } from "@/components/ui";
|
||||
import { CustomButton } from "@/components/ui/custom";
|
||||
import { Form } from "@/components/ui/form";
|
||||
|
||||
const formSchema = z.object({
|
||||
@@ -62,33 +62,26 @@ export const DeleteForm = ({
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmitClient)}>
|
||||
<input type="hidden" name="id" value={invitationId} />
|
||||
<div className="flex w-full justify-center sm:gap-6">
|
||||
<CustomButton
|
||||
<div className="flex w-full justify-end gap-4">
|
||||
<Button
|
||||
type="button"
|
||||
ariaLabel="Cancel"
|
||||
className="w-full bg-transparent"
|
||||
variant="faded"
|
||||
variant="ghost"
|
||||
size="lg"
|
||||
radius="lg"
|
||||
onPress={() => setIsOpen(false)}
|
||||
isDisabled={isLoading}
|
||||
onClick={() => setIsOpen(false)}
|
||||
disabled={isLoading}
|
||||
>
|
||||
<span>Cancel</span>
|
||||
</CustomButton>
|
||||
Cancel
|
||||
</Button>
|
||||
|
||||
<CustomButton
|
||||
<Button
|
||||
type="submit"
|
||||
ariaLabel="Revoke"
|
||||
className="w-full"
|
||||
variant="solid"
|
||||
color="danger"
|
||||
variant="destructive"
|
||||
size="lg"
|
||||
radius="lg"
|
||||
isLoading={isLoading}
|
||||
startContent={!isLoading && <DeleteIcon size={24} />}
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading ? <>Loading</> : <span>Revoke</span>}
|
||||
</CustomButton>
|
||||
{!isLoading && <DeleteIcon size={24} />}
|
||||
{isLoading ? "Loading" : "Revoke"}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
|
||||
@@ -6,12 +6,13 @@ import { Controller, useForm } from "react-hook-form";
|
||||
import * as z from "zod";
|
||||
|
||||
import { updateInvite } from "@/actions/invitations/invitation";
|
||||
import { SaveIcon } from "@/components/icons";
|
||||
import { useToast } from "@/components/ui";
|
||||
import { CustomButton, CustomInput } from "@/components/ui/custom";
|
||||
import { Form } from "@/components/ui/form";
|
||||
import { CustomInput } from "@/components/ui/custom";
|
||||
import { Form, FormButtons } from "@/components/ui/form";
|
||||
import { editInviteFormSchema } from "@/types";
|
||||
|
||||
import { Card, CardContent } from "../../shadcn";
|
||||
|
||||
export const EditForm = ({
|
||||
invitationId,
|
||||
invitationEmail,
|
||||
@@ -94,22 +95,24 @@ export const EditForm = ({
|
||||
onSubmit={form.handleSubmit(onSubmitClient)}
|
||||
className="flex flex-col gap-4"
|
||||
>
|
||||
<div className="flex flex-row justify-center gap-4 rounded-lg bg-gray-50 p-3">
|
||||
<div className="text-small flex items-center text-gray-600">
|
||||
<MailIcon className="mr-2 h-4 w-4" />
|
||||
<span className="text-gray-500">Email:</span>
|
||||
<span className="ml-2 font-semibold text-gray-900">
|
||||
{invitationEmail}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-small flex items-center text-gray-600">
|
||||
<ShieldIcon className="mr-2 h-4 w-4" />
|
||||
<span className="text-gray-500">Role:</span>
|
||||
<span className="ml-2 font-semibold text-gray-900">
|
||||
{currentRole}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<Card variant="inner">
|
||||
<CardContent className="flex flex-row justify-center gap-4">
|
||||
<div className="text-small text-text-neutral-secondary flex items-center">
|
||||
<MailIcon className="text-text-neutral-secondary mr-2 h-4 w-4" />
|
||||
<span className="text-text-neutral-secondary">Email:</span>
|
||||
<span className="text-text-neutral-secondary ml-2 font-semibold">
|
||||
{invitationEmail}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-small flex items-center text-gray-600">
|
||||
<ShieldIcon className="text-text-neutral-secondary mr-2 h-4 w-4" />
|
||||
<span className="text-text-neutral-secondary">Role:</span>
|
||||
<span className="text-text-neutral-secondary ml-2 font-semibold">
|
||||
{currentRole}
|
||||
</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div>
|
||||
<CustomInput
|
||||
@@ -121,7 +124,6 @@ export const EditForm = ({
|
||||
placeholder={invitationEmail}
|
||||
variant="flat"
|
||||
isRequired={false}
|
||||
isInvalid={!!form.formState.errors.invitationEmail}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
@@ -157,33 +159,7 @@ export const EditForm = ({
|
||||
</div>
|
||||
<input type="hidden" name="invitationId" value={invitationId} />
|
||||
|
||||
<div className="flex w-full justify-center sm:gap-6">
|
||||
<CustomButton
|
||||
type="button"
|
||||
ariaLabel="Cancel"
|
||||
className="w-full bg-transparent"
|
||||
variant="faded"
|
||||
size="lg"
|
||||
radius="lg"
|
||||
onPress={() => setIsOpen(false)}
|
||||
isDisabled={isLoading}
|
||||
>
|
||||
<span>Cancel</span>
|
||||
</CustomButton>
|
||||
|
||||
<CustomButton
|
||||
type="submit"
|
||||
ariaLabel="Save"
|
||||
className="w-full"
|
||||
variant="solid"
|
||||
color="action"
|
||||
size="lg"
|
||||
isLoading={isLoading}
|
||||
startContent={!isLoading && <SaveIcon size={24} />}
|
||||
>
|
||||
{isLoading ? <>Loading</> : <span>Save</span>}
|
||||
</CustomButton>
|
||||
</div>
|
||||
<FormButtons setIsOpen={setIsOpen} isDisabled={isLoading} />
|
||||
</form>
|
||||
</Form>
|
||||
);
|
||||
|
||||
@@ -1,2 +1 @@
|
||||
export * from "./invitation-details";
|
||||
export * from "./send-invitation-button";
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
"use client";
|
||||
|
||||
import { Card, CardBody } from "@heroui/card";
|
||||
import { Divider } from "@heroui/divider";
|
||||
import { Snippet } from "@heroui/snippet";
|
||||
import Link from "next/link";
|
||||
|
||||
import { AddIcon } from "../icons";
|
||||
import { CustomButton } from "../ui/custom";
|
||||
import { Button, Card, CardContent, CardHeader } from "../shadcn";
|
||||
import { Separator } from "../shadcn/separator/separator";
|
||||
import { DateWithTime } from "../ui/entities";
|
||||
|
||||
interface InvitationDetailsProps {
|
||||
@@ -35,9 +35,11 @@ const InfoField = ({
|
||||
children: React.ReactNode;
|
||||
}) => (
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-xs font-bold text-gray-500">{label}</span>
|
||||
<div className="flex items-center rounded-lg bg-gray-50 p-3">
|
||||
<span className="text-small text-gray-900">{children}</span>
|
||||
<span className="text-text-neutral-secondary text-xs font-bold">
|
||||
{label}
|
||||
</span>
|
||||
<div className="border-border-input-primary bg-bg-input-primary flex items-center rounded-lg border p-3">
|
||||
<span className="text-small text-text-neutral-primary">{children}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -53,17 +55,9 @@ export const InvitationDetails = ({ attributes }: InvitationDetailsProps) => {
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-x-4 gap-y-8">
|
||||
<Card
|
||||
isBlurred
|
||||
className="bg-background/60 dark:bg-prowler-blue-800 border-none"
|
||||
shadow="sm"
|
||||
>
|
||||
<CardBody>
|
||||
<h2 className="text-md text-foreground/90 font-bold">
|
||||
Invitation details
|
||||
</h2>
|
||||
<Divider className="my-4" />
|
||||
|
||||
<Card variant="base" padding="lg">
|
||||
<CardHeader>Invitation details</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex flex-col gap-3">
|
||||
<InfoField label="Email">{attributes.email}</InfoField>
|
||||
|
||||
@@ -88,8 +82,8 @@ export const InvitationDetails = ({ attributes }: InvitationDetailsProps) => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Divider className="my-4" />
|
||||
<h3 className="text-small text-foreground/90 pb-2 font-bold">
|
||||
<Separator className="my-4" />
|
||||
<h3 className="text-text-neutral-primary pb-2 text-sm font-bold">
|
||||
Share this link with the user:
|
||||
</h3>
|
||||
|
||||
@@ -100,26 +94,22 @@ export const InvitationDetails = ({ attributes }: InvitationDetailsProps) => {
|
||||
}}
|
||||
hideSymbol
|
||||
variant="bordered"
|
||||
className="overflow-hidden bg-gray-50 py-1 text-ellipsis whitespace-nowrap dark:bg-slate-800"
|
||||
className="bg-bg-neutral-secondary overflow-hidden py-1 text-ellipsis whitespace-nowrap"
|
||||
>
|
||||
<p className="no-scrollbar text-small w-fit overflow-hidden overflow-x-scroll text-ellipsis whitespace-nowrap">
|
||||
<p className="no-scrollbar w-fit overflow-hidden overflow-x-scroll text-sm text-ellipsis whitespace-nowrap">
|
||||
{invitationLink}
|
||||
</p>
|
||||
</Snippet>
|
||||
</div>
|
||||
</CardBody>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<div className="flex w-full items-center justify-end">
|
||||
<CustomButton
|
||||
asLink="/invitations/"
|
||||
ariaLabel="Send Invitation"
|
||||
variant="solid"
|
||||
color="action"
|
||||
size="md"
|
||||
endContent={<AddIcon size={20} />}
|
||||
>
|
||||
Back to Invitations
|
||||
</CustomButton>
|
||||
<Button asChild size="default" className="gap-2">
|
||||
<Link href="/invitations/">
|
||||
Back to Invitations
|
||||
<AddIcon size={20} />
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { AddIcon } from "../icons";
|
||||
import { CustomButton } from "../ui/custom";
|
||||
|
||||
export const SendInvitationButton = () => {
|
||||
return (
|
||||
<div className="flex w-full items-center justify-end">
|
||||
<CustomButton
|
||||
asLink="/invitations/new"
|
||||
ariaLabel="Send Invitation"
|
||||
variant="solid"
|
||||
color="action"
|
||||
size="md"
|
||||
endContent={<AddIcon size={20} />}
|
||||
>
|
||||
Send Invitation
|
||||
</CustomButton>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,6 +1,5 @@
|
||||
"use client";
|
||||
|
||||
import { Button } from "@heroui/button";
|
||||
import {
|
||||
Dropdown,
|
||||
DropdownItem,
|
||||
@@ -19,6 +18,7 @@ import { useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
|
||||
import { VerticalDotsIcon } from "@/components/icons";
|
||||
import { Button } from "@/components/shadcn";
|
||||
import { CustomAlertModal } from "@/components/ui/custom";
|
||||
|
||||
import { DeleteForm, EditForm } from "../forms";
|
||||
@@ -68,12 +68,12 @@ export function DataTableRowActions<InvitationProps>({
|
||||
|
||||
<div className="relative flex items-center justify-end gap-2">
|
||||
<Dropdown
|
||||
className="dark:bg-prowler-blue-800 shadow-xl"
|
||||
className="border-border-neutral-secondary bg-bg-neutral-secondary border shadow-xl"
|
||||
placement="bottom"
|
||||
>
|
||||
<DropdownTrigger>
|
||||
<Button isIconOnly radius="full" size="sm" variant="light">
|
||||
<VerticalDotsIcon className="text-default-400" />
|
||||
<Button variant="ghost" size="icon-sm" className="rounded-full">
|
||||
<VerticalDotsIcon className="text-slate-400" />
|
||||
</Button>
|
||||
</DropdownTrigger>
|
||||
<DropdownMenu
|
||||
@@ -109,13 +109,13 @@ export function DataTableRowActions<InvitationProps>({
|
||||
<DropdownSection title="Danger zone">
|
||||
<DropdownItem
|
||||
key="delete"
|
||||
className="text-danger"
|
||||
className="text-text-error"
|
||||
color="danger"
|
||||
description="Delete the invitation permanently"
|
||||
textValue="Delete Invitation"
|
||||
startContent={
|
||||
<DeleteDocumentBulkIcon
|
||||
className={clsx(iconClasses, "!text-danger")}
|
||||
className={clsx(iconClasses, "!text-text-error")}
|
||||
/>
|
||||
}
|
||||
onPress={() => setIsDeleteOpen(true)}
|
||||
|
||||
@@ -1,30 +1,19 @@
|
||||
import { Card } from "@heroui/card";
|
||||
import { Skeleton } from "@heroui/skeleton";
|
||||
import React from "react";
|
||||
|
||||
import { Card } from "@/components/shadcn/card/card";
|
||||
import { Skeleton } from "@/components/shadcn/skeleton/skeleton";
|
||||
|
||||
export const SkeletonTableInvitation = () => {
|
||||
return (
|
||||
<Card className="flex h-full w-full flex-col gap-5 p-4" radius="sm">
|
||||
<Card variant="base" padding="md" className="flex flex-col gap-4">
|
||||
{/* Table headers */}
|
||||
<div className="hidden justify-between md:flex">
|
||||
<Skeleton className="w-2/12 rounded-lg">
|
||||
<div className="bg-default-200 h-8"></div>
|
||||
</Skeleton>
|
||||
<Skeleton className="w-2/12 rounded-lg">
|
||||
<div className="bg-default-200 h-8"></div>
|
||||
</Skeleton>
|
||||
<Skeleton className="w-2/12 rounded-lg">
|
||||
<div className="bg-default-200 h-8"></div>
|
||||
</Skeleton>
|
||||
<Skeleton className="w-2/12 rounded-lg">
|
||||
<div className="bg-default-200 h-8"></div>
|
||||
</Skeleton>
|
||||
<Skeleton className="w-2/12 rounded-lg">
|
||||
<div className="bg-default-200 h-8"></div>
|
||||
</Skeleton>
|
||||
<Skeleton className="w-1/12 rounded-lg">
|
||||
<div className="bg-default-200 h-8"></div>
|
||||
</Skeleton>
|
||||
<div className="hidden gap-4 md:flex">
|
||||
<Skeleton className="h-8 w-2/12" />
|
||||
<Skeleton className="h-8 w-2/12" />
|
||||
<Skeleton className="h-8 w-2/12" />
|
||||
<Skeleton className="h-8 w-2/12" />
|
||||
<Skeleton className="h-8 w-2/12" />
|
||||
<Skeleton className="h-8 w-1/12" />
|
||||
</div>
|
||||
|
||||
{/* Table body */}
|
||||
@@ -32,26 +21,14 @@ export const SkeletonTableInvitation = () => {
|
||||
{[...Array(10)].map((_, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="flex flex-col items-center justify-between md:flex-row md:gap-4"
|
||||
className="flex flex-col gap-4 md:flex-row md:items-center"
|
||||
>
|
||||
<Skeleton className="mb-2 w-full rounded-lg md:mb-0 md:w-2/12">
|
||||
<div className="bg-default-300 h-12"></div>
|
||||
</Skeleton>
|
||||
<Skeleton className="mb-2 w-full rounded-lg md:mb-0 md:w-2/12">
|
||||
<div className="bg-default-300 h-12"></div>
|
||||
</Skeleton>
|
||||
<Skeleton className="mb-2 hidden rounded-lg sm:flex md:mb-0 md:w-2/12">
|
||||
<div className="bg-default-300 h-12"></div>
|
||||
</Skeleton>
|
||||
<Skeleton className="mb-2 hidden rounded-lg sm:flex md:mb-0 md:w-2/12">
|
||||
<div className="bg-default-300 h-12"></div>
|
||||
</Skeleton>
|
||||
<Skeleton className="mb-2 hidden rounded-lg sm:flex md:mb-0 md:w-2/12">
|
||||
<div className="bg-default-300 h-12"></div>
|
||||
</Skeleton>
|
||||
<Skeleton className="mb-2 hidden rounded-lg sm:flex md:mb-0 md:w-1/12">
|
||||
<div className="bg-default-300 h-12"></div>
|
||||
</Skeleton>
|
||||
<Skeleton className="h-12 w-full md:w-2/12" />
|
||||
<Skeleton className="h-12 w-full md:w-2/12" />
|
||||
<Skeleton className="hidden h-12 md:block md:w-2/12" />
|
||||
<Skeleton className="hidden h-12 md:block md:w-2/12" />
|
||||
<Skeleton className="hidden h-12 md:block md:w-2/12" />
|
||||
<Skeleton className="hidden h-12 md:block md:w-1/12" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -8,8 +8,9 @@ import { Controller, useForm } from "react-hook-form";
|
||||
import * as z from "zod";
|
||||
|
||||
import { sendInvite } from "@/actions/invitations/invitation";
|
||||
import { Button } from "@/components/shadcn";
|
||||
import { useToast } from "@/components/ui";
|
||||
import { CustomButton, CustomInput } from "@/components/ui/custom";
|
||||
import { CustomInput } from "@/components/ui/custom";
|
||||
import { Form } from "@/components/ui/form";
|
||||
import { ApiError } from "@/types";
|
||||
|
||||
@@ -104,7 +105,6 @@ export const SendInvitationForm = ({
|
||||
placeholder="Enter the email address"
|
||||
variant="flat"
|
||||
isRequired
|
||||
isInvalid={!!form.formState.errors.email}
|
||||
/>
|
||||
|
||||
<Controller
|
||||
@@ -135,7 +135,7 @@ export const SendInvitationForm = ({
|
||||
)}
|
||||
</Select>
|
||||
{form.formState.errors.roleId && (
|
||||
<p className="mt-2 text-sm text-red-600">
|
||||
<p className="text-text-error mt-2 text-sm">
|
||||
{form.formState.errors.roleId.message}
|
||||
</p>
|
||||
)}
|
||||
@@ -145,18 +145,22 @@ export const SendInvitationForm = ({
|
||||
|
||||
{/* Submit Button */}
|
||||
<div className="flex w-full justify-end sm:gap-6">
|
||||
<CustomButton
|
||||
<Button
|
||||
type="submit"
|
||||
ariaLabel="Send Invitation"
|
||||
className="w-1/2"
|
||||
variant="solid"
|
||||
color="action"
|
||||
variant="default"
|
||||
size="lg"
|
||||
isLoading={isLoading}
|
||||
startContent={!isLoading && <SaveIcon size={24} />}
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading ? <>Loading</> : <span>Send Invitation</span>}
|
||||
</CustomButton>
|
||||
{isLoading ? (
|
||||
<>Loading</>
|
||||
) : (
|
||||
<>
|
||||
<SaveIcon size={20} />
|
||||
<span>Send Invitation</span>
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
"use client";
|
||||
|
||||
import type { ButtonProps } from "@heroui/button";
|
||||
import { cn } from "@heroui/theme";
|
||||
import { useControlledState } from "@react-stately/utils";
|
||||
import { domAnimation, LazyMotion, m } from "framer-motion";
|
||||
@@ -26,7 +25,13 @@ export interface VerticalStepsProps
|
||||
*
|
||||
* @default "primary"
|
||||
*/
|
||||
color?: ButtonProps["color"];
|
||||
color?:
|
||||
| "primary"
|
||||
| "secondary"
|
||||
| "success"
|
||||
| "warning"
|
||||
| "danger"
|
||||
| "default";
|
||||
/**
|
||||
* The current step index.
|
||||
*/
|
||||
@@ -123,7 +128,7 @@ export const VerticalSteps = React.forwardRef<
|
||||
|
||||
switch (color) {
|
||||
case "primary":
|
||||
userColor = "[--step-color:hsl(var(--heroui-primary))]";
|
||||
userColor = "[--step-color:var(--bg-button-primary)]";
|
||||
fgColor = "[--step-fg-color:hsl(var(--heroui-primary-foreground))]";
|
||||
break;
|
||||
case "secondary":
|
||||
|
||||
@@ -44,13 +44,14 @@ export const WorkflowSendInvite = () => {
|
||||
base: "px-0.5 mb-3 sm:mb-5",
|
||||
label: "text-xs sm:text-small",
|
||||
value: "text-xs sm:text-small text-default-400",
|
||||
indicator: "bg-button-primary",
|
||||
}}
|
||||
label="Steps"
|
||||
maxValue={steps.length - 1}
|
||||
maxValue={steps.length}
|
||||
minValue={0}
|
||||
showValueLabel={true}
|
||||
size="sm"
|
||||
value={currentStep}
|
||||
value={currentStep + 1}
|
||||
valueLabel={`${currentStep + 1} of ${steps.length}`}
|
||||
/>
|
||||
|
||||
@@ -59,14 +60,14 @@ export const WorkflowSendInvite = () => {
|
||||
<VerticalSteps
|
||||
hideProgressBars
|
||||
currentStep={currentStep}
|
||||
stepClassName="border border-default-200 dark:border-default-50 aria-[current]:bg-default-100 dark:aria-[current]:bg-prowler-blue-800 cursor-default"
|
||||
stepClassName="border border-border-neutral-primary aria-[current]:border-button-primary aria-[current]:text-text-neutral-primary cursor-default"
|
||||
steps={steps}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Mobile: Compact current step indicator */}
|
||||
<div className="sm:hidden">
|
||||
<div className="text-default-400 border-l-2 border-blue-500 py-1 pl-3 text-xs">
|
||||
<div className="text-text-neutral-secondary border-button-primary border-l-2 py-1 pl-3 text-xs">
|
||||
<div className="font-medium">
|
||||
Current: {steps[currentStep]?.title}
|
||||
</div>
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
|
||||
import type { ComponentProps } from "react";
|
||||
|
||||
import { Button } from "@/components/shadcn/button/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
import { Button } from "./button";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
|
||||
@@ -1,60 +0,0 @@
|
||||
import { Slot } from "@radix-ui/react-slot";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const buttonVariants = cva(
|
||||
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-primary text-primary-foreground hover:bg-primary/90",
|
||||
destructive:
|
||||
"bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
|
||||
outline:
|
||||
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||
ghost:
|
||||
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
size: {
|
||||
default: "h-9 px-4 py-2 has-[>svg]:px-3",
|
||||
sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
|
||||
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
|
||||
icon: "size-9",
|
||||
"icon-sm": "size-8",
|
||||
"icon-lg": "size-10",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
function Button({
|
||||
className,
|
||||
variant,
|
||||
size,
|
||||
asChild = false,
|
||||
...props
|
||||
}: React.ComponentProps<"button"> &
|
||||
VariantProps<typeof buttonVariants> & {
|
||||
asChild?: boolean;
|
||||
}) {
|
||||
const Comp = asChild ? Slot : "button";
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="button"
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Button, buttonVariants };
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
|
||||
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react";
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
import * as React from "react";
|
||||
|
||||
import { Button } from "@/components/shadcn/button/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
import { Button } from "./button";
|
||||
import { Input } from "./input";
|
||||
import { Textarea } from "./textarea";
|
||||
|
||||
@@ -15,7 +14,7 @@ function InputGroup({ className, ...props }: React.ComponentProps<"div">) {
|
||||
data-slot="input-group"
|
||||
role="group"
|
||||
className={cn(
|
||||
"group/input-group border-input dark:bg-input/30 relative flex w-full items-center rounded-md border shadow-xs transition-[color,box-shadow] outline-none",
|
||||
"group/input-group border-border-neutral-secondary bg-bg-neutral-secondary relative flex w-full items-center rounded-xl border transition-[color,box-shadow] outline-none",
|
||||
"h-9 min-w-0 has-[>textarea]:h-auto",
|
||||
|
||||
// Variants based on alignment.
|
||||
@@ -25,7 +24,7 @@ function InputGroup({ className, ...props }: React.ComponentProps<"div">) {
|
||||
"has-[>[data-align=block-end]]:h-auto has-[>[data-align=block-end]]:flex-col has-[>[data-align=block-end]]:[&>input]:pt-3",
|
||||
|
||||
// Focus state.
|
||||
"has-[[data-slot=input-group-control]:focus-visible]:border-ring has-[[data-slot=input-group-control]:focus-visible]:ring-ring/50 has-[[data-slot=input-group-control]:focus-visible]:ring-[3px]",
|
||||
"has-[[data-slot=input-group-control]:focus-visible]:border-button-primary has-[[data-slot=input-group-control]:focus-visible]:ring-button-primary/20 has-[[data-slot=input-group-control]:focus-visible]:ring-2",
|
||||
|
||||
// Error state.
|
||||
"has-[[data-slot][aria-invalid=true]]:ring-destructive/20 has-[[data-slot][aria-invalid=true]]:border-destructive dark:has-[[data-slot][aria-invalid=true]]:ring-destructive/40",
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
/** biome-ignore-all lint/performance/noImgElement: "AI Elements is framework agnostic" */
|
||||
|
||||
"use client";
|
||||
|
||||
import type { ChatStatus, FileUIPart } from "ai";
|
||||
@@ -37,9 +35,9 @@ import {
|
||||
useState,
|
||||
} from "react";
|
||||
|
||||
import { Button } from "@/components/shadcn/button/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
import { Button } from "./button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
import * as SelectPrimitive from "@radix-ui/react-select";
|
||||
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react";
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
|
||||
|
||||
128
ui/components/lighthouse/banner-client.tsx
Normal file
128
ui/components/lighthouse/banner-client.tsx
Normal file
@@ -0,0 +1,128 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
import { Card, CardContent } from "@/components/shadcn/card/card";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
import { LighthouseIcon } from "../icons";
|
||||
|
||||
const AnimatedGradientCard = ({
|
||||
message,
|
||||
href,
|
||||
}: {
|
||||
message: string;
|
||||
href: string;
|
||||
}) => {
|
||||
const interactiveRef = useRef<HTMLDivElement>(null);
|
||||
const curXRef = useRef(0);
|
||||
const curYRef = useRef(0);
|
||||
const tgXRef = useRef(0);
|
||||
const tgYRef = useRef(0);
|
||||
const [isSafari, setIsSafari] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setIsSafari(/^((?!chrome|android).)*safari/i.test(navigator.userAgent));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
let animationFrameId: number;
|
||||
|
||||
const move = () => {
|
||||
if (!interactiveRef.current) return;
|
||||
|
||||
curXRef.current += (tgXRef.current - curXRef.current) / 20;
|
||||
curYRef.current += (tgYRef.current - curYRef.current) / 20;
|
||||
|
||||
interactiveRef.current.style.transform = `translate(${Math.round(curXRef.current)}px, ${Math.round(curYRef.current)}px)`;
|
||||
|
||||
animationFrameId = requestAnimationFrame(move);
|
||||
};
|
||||
|
||||
animationFrameId = requestAnimationFrame(move);
|
||||
|
||||
return () => {
|
||||
cancelAnimationFrame(animationFrameId);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleMouseMove = (event: React.MouseEvent<HTMLDivElement>) => {
|
||||
if (interactiveRef.current) {
|
||||
const rect = interactiveRef.current.getBoundingClientRect();
|
||||
tgXRef.current = event.clientX - rect.left;
|
||||
tgYRef.current = event.clientY - rect.top;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Link href={href} className="mb-8 block w-full">
|
||||
<Card
|
||||
variant="base"
|
||||
className="group relative overflow-hidden"
|
||||
onMouseMove={handleMouseMove}
|
||||
>
|
||||
<svg className="hidden">
|
||||
<defs>
|
||||
<filter id="blurMe">
|
||||
<feGaussianBlur
|
||||
in="SourceGraphic"
|
||||
stdDeviation="10"
|
||||
result="blur"
|
||||
/>
|
||||
<feColorMatrix
|
||||
in="blur"
|
||||
mode="matrix"
|
||||
values="1 0 0 0 0 0 1 0 0 0 0 0 1 0 0 0 0 0 18 -8"
|
||||
result="goo"
|
||||
/>
|
||||
<feBlend in="SourceGraphic" in2="goo" />
|
||||
</filter>
|
||||
</defs>
|
||||
</svg>
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
"pointer-events-none absolute inset-0 blur-lg",
|
||||
isSafari ? "blur-2xl" : "[filter:url(#blurMe)_blur(40px)]",
|
||||
)}
|
||||
>
|
||||
<div className="animate-first absolute [top:calc(50%-60%)] [left:calc(50%-60%)] h-[120%] w-[120%] [transform-origin:center_center] opacity-100 [mix-blend-mode:hard-light] [background:radial-gradient(circle_at_center,_var(--bg-neutral-tertiary)_0,_transparent_50%)_no-repeat]" />
|
||||
|
||||
<div className="animate-second absolute [top:calc(50%-60%)] [left:calc(50%-60%)] h-[120%] w-[120%] [transform-origin:calc(50%-200px)] opacity-80 [mix-blend-mode:hard-light] [background:radial-gradient(circle_at_center,_var(--bg-button-primary)_0,_transparent_50%)_no-repeat]" />
|
||||
|
||||
<div className="animate-third absolute [top:calc(50%-60%)] [left:calc(50%-60%)] h-[120%] w-[120%] [transform-origin:calc(50%+200px)] opacity-70 [mix-blend-mode:hard-light] [background:radial-gradient(circle_at_center,_var(--bg-button-primary-hover)_0,_transparent_50%)_no-repeat]" />
|
||||
|
||||
<div
|
||||
ref={interactiveRef}
|
||||
className="absolute -top-1/2 -left-1/2 h-full w-full opacity-60 [mix-blend-mode:hard-light] [background:radial-gradient(circle_at_center,_var(--bg-button-primary-press)_0,_transparent_50%)_no-repeat]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<CardContent className="relative z-10">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex h-10 w-10 items-center justify-center">
|
||||
<LighthouseIcon size={24} />
|
||||
</div>
|
||||
<p className="text-text-neutral-primary text-base font-semibold">
|
||||
{message}
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
);
|
||||
};
|
||||
|
||||
export const LighthouseBannerClient = ({
|
||||
isConfigured,
|
||||
}: {
|
||||
isConfigured: boolean;
|
||||
}) => {
|
||||
const message = isConfigured
|
||||
? "Use Lighthouse to review your findings and gain insights"
|
||||
: "Enable Lighthouse to secure your cloud with AI insights";
|
||||
const href = isConfigured ? "/lighthouse" : "/lighthouse/config";
|
||||
|
||||
return <AnimatedGradientCard message={message} href={href} />;
|
||||
};
|
||||
@@ -1,52 +1,13 @@
|
||||
import { Bot } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
|
||||
import { isLighthouseConfigured } from "@/actions/lighthouse/lighthouse";
|
||||
|
||||
interface BannerConfig {
|
||||
message: string;
|
||||
href: string;
|
||||
gradient: string;
|
||||
}
|
||||
|
||||
const renderBanner = ({ message, href, gradient }: BannerConfig) => (
|
||||
<Link href={href} className="mb-4 block w-full">
|
||||
<div
|
||||
className={`w-full rounded-lg ${gradient} focus:ring-opacity-50 shadow-lg transition-all duration-200 hover:shadow-xl focus:ring-2 focus:outline-none`}
|
||||
>
|
||||
<div className="px-4 py-3">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-white/20 backdrop-blur-sm">
|
||||
<Bot size={24} className="text-white" />
|
||||
</div>
|
||||
<p className="text-md font-medium text-white">{message}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
import { LighthouseBannerClient } from "./banner-client";
|
||||
|
||||
export const LighthouseBanner = async () => {
|
||||
try {
|
||||
const isConfigured = await isLighthouseConfigured();
|
||||
|
||||
if (!isConfigured) {
|
||||
return renderBanner({
|
||||
message: "Enable Lighthouse to secure your cloud with AI insights",
|
||||
href: "/lighthouse/config",
|
||||
gradient:
|
||||
"bg-linear-to-r from-green-500 to-blue-500 hover:from-green-600 hover:to-blue-600 focus:ring-green-500/50 dark:from-green-600 dark:to-blue-600 dark:hover:from-green-700 dark:hover:to-blue-700 dark:focus:ring-green-400/50",
|
||||
});
|
||||
} else {
|
||||
return renderBanner({
|
||||
message: "Use Lighthouse to review your findings and gain insights",
|
||||
href: "/lighthouse",
|
||||
gradient:
|
||||
"bg-linear-to-r from-green-500 to-blue-500 hover:from-green-600 hover:to-blue-600 focus:ring-green-500/50 dark:from-green-600 dark:to-blue-600 dark:hover:from-green-700 dark:hover:to-blue-700 dark:focus:ring-green-400/50",
|
||||
});
|
||||
}
|
||||
return <LighthouseBannerClient isConfigured={isConfigured} />;
|
||||
} catch (error) {
|
||||
console.error("Error getting banner state:", error);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -2,17 +2,12 @@
|
||||
|
||||
import { useChat } from "@ai-sdk/react";
|
||||
import { DefaultChatTransport } from "ai";
|
||||
import { ChevronDown, Copy, Plus, RotateCcw } from "lucide-react";
|
||||
import { Copy, Plus, RotateCcw } from "lucide-react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { Streamdown } from "streamdown";
|
||||
|
||||
import { getLighthouseModelIds } from "@/actions/lighthouse/lighthouse";
|
||||
import { Action, Actions } from "@/components/lighthouse/ai-elements/actions";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/lighthouse/ai-elements/dropdown-menu";
|
||||
import {
|
||||
PromptInput,
|
||||
PromptInputBody,
|
||||
@@ -22,10 +17,17 @@ import {
|
||||
PromptInputTools,
|
||||
} from "@/components/lighthouse/ai-elements/prompt-input";
|
||||
import { Loader } from "@/components/lighthouse/loader";
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
Combobox,
|
||||
} from "@/components/shadcn";
|
||||
import { useToast } from "@/components/ui";
|
||||
import { CustomButton } from "@/components/ui/custom";
|
||||
import { CustomLink } from "@/components/ui/custom/custom-link";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { LighthouseProvider } from "@/types/lighthouse";
|
||||
|
||||
interface Model {
|
||||
@@ -92,14 +94,8 @@ export const Chat = ({
|
||||
// Consolidated UI state
|
||||
const [uiState, setUiState] = useState<{
|
||||
inputValue: string;
|
||||
isDropdownOpen: boolean;
|
||||
modelSearchTerm: string;
|
||||
hoveredProvider: LighthouseProvider | "";
|
||||
}>({
|
||||
inputValue: "",
|
||||
isDropdownOpen: false,
|
||||
modelSearchTerm: "",
|
||||
hoveredProvider: defaultProviderId || initialProviders[0]?.id || "",
|
||||
});
|
||||
|
||||
// Error handling
|
||||
@@ -107,13 +103,7 @@ export const Chat = ({
|
||||
|
||||
// Provider and model management
|
||||
const [providers, setProviders] = useState<Provider[]>(initialProviders);
|
||||
const [providerLoadState, setProviderLoadState] = useState<{
|
||||
loaded: Set<LighthouseProvider>;
|
||||
loading: Set<LighthouseProvider>;
|
||||
}>({
|
||||
loaded: new Set(),
|
||||
loading: new Set(),
|
||||
});
|
||||
const loadedProvidersRef = useRef<Set<LighthouseProvider>>(new Set());
|
||||
|
||||
// Initialize selectedModel with defaults from props
|
||||
const [selectedModel, setSelectedModel] = useState<SelectedModel>(() => {
|
||||
@@ -135,20 +125,23 @@ export const Chat = ({
|
||||
const selectedModelRef = useRef(selectedModel);
|
||||
selectedModelRef.current = selectedModel;
|
||||
|
||||
// Load models for all providers on mount
|
||||
useEffect(() => {
|
||||
initialProviders.forEach((provider) => {
|
||||
loadModelsForProvider(provider.id);
|
||||
});
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
// Load all models for a specific provider
|
||||
const loadModelsForProvider = async (providerType: LighthouseProvider) => {
|
||||
setProviderLoadState((prev) => {
|
||||
// Skip if already loaded or currently loading
|
||||
if (prev.loaded.has(providerType) || prev.loading.has(providerType)) {
|
||||
return prev;
|
||||
}
|
||||
// Skip if already loaded
|
||||
if (loadedProvidersRef.current.has(providerType)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Mark as loading
|
||||
return {
|
||||
...prev,
|
||||
loading: new Set([...Array.from(prev.loading), providerType]),
|
||||
};
|
||||
});
|
||||
// Mark as loaded
|
||||
loadedProvidersRef.current.add(providerType);
|
||||
|
||||
try {
|
||||
const response = await getLighthouseModelIds(providerType);
|
||||
@@ -169,24 +162,11 @@ export const Chat = ({
|
||||
setProviders((prev) =>
|
||||
prev.map((p) => (p.id === providerType ? { ...p, models } : p)),
|
||||
);
|
||||
|
||||
// Mark as loaded and remove from loading
|
||||
setProviderLoadState((prev) => ({
|
||||
loaded: new Set([...Array.from(prev.loaded), providerType]),
|
||||
loading: new Set(
|
||||
Array.from(prev.loading).filter((id) => id !== providerType),
|
||||
),
|
||||
}));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Error loading models for ${providerType}:`, error);
|
||||
// Remove from loading state on error
|
||||
setProviderLoadState((prev) => ({
|
||||
...prev,
|
||||
loading: new Set(
|
||||
Array.from(prev.loading).filter((id) => id !== providerType),
|
||||
),
|
||||
}));
|
||||
// Remove from loaded on error so it can be retried
|
||||
loadedProvidersRef.current.delete(providerType);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -292,22 +272,6 @@ export const Chat = ({
|
||||
}
|
||||
}, [messages, status]);
|
||||
|
||||
// Handle dropdown state changes
|
||||
useEffect(() => {
|
||||
if (uiState.isDropdownOpen && uiState.hoveredProvider) {
|
||||
loadModelsForProvider(uiState.hoveredProvider as LighthouseProvider);
|
||||
}
|
||||
}, [uiState.isDropdownOpen, uiState.hoveredProvider, loadModelsForProvider]);
|
||||
|
||||
// Filter models based on search term
|
||||
const currentProvider = providers.find(
|
||||
(p) => p.id === uiState.hoveredProvider,
|
||||
);
|
||||
const filteredModels =
|
||||
currentProvider?.models.filter((model) =>
|
||||
model.name.toLowerCase().includes(uiState.modelSearchTerm.toLowerCase()),
|
||||
) || [];
|
||||
|
||||
// Handlers
|
||||
const handleNewChat = () => {
|
||||
setMessages([]);
|
||||
@@ -321,61 +285,62 @@ export const Chat = ({
|
||||
modelName: string,
|
||||
) => {
|
||||
setSelectedModel({ providerType, modelId, modelName });
|
||||
setUiState((prev) => ({
|
||||
...prev,
|
||||
isDropdownOpen: false,
|
||||
modelSearchTerm: "", // Reset search when selecting
|
||||
}));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-background relative flex h-[calc(100vh-(--spacing(16)))] min-w-0 flex-col">
|
||||
<div className="relative flex h-[calc(100vh-(--spacing(16)))] min-w-0 flex-col overflow-hidden">
|
||||
{/* Header with New Chat button */}
|
||||
{messages.length > 0 && (
|
||||
<div className="border-default-200 dark:border-default-100 border-b px-4 py-3">
|
||||
<div className="border-default-200 dark:border-default-100 border-b px-2 py-3 sm:px-4">
|
||||
<div className="flex items-center justify-end">
|
||||
<CustomButton
|
||||
ariaLabel="Start new chat"
|
||||
variant="bordered"
|
||||
<Button
|
||||
aria-label="Start new chat"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
startContent={<Plus className="h-4 w-4" />}
|
||||
onPress={handleNewChat}
|
||||
onClick={handleNewChat}
|
||||
className="gap-1"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
New Chat
|
||||
</CustomButton>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!hasConfig && (
|
||||
<div className="bg-background/80 absolute inset-0 z-50 flex items-center justify-center backdrop-blur-sm">
|
||||
<div className="bg-card max-w-md rounded-lg p-6 text-center shadow-lg">
|
||||
<h3 className="mb-2 text-lg font-semibold">
|
||||
LLM Provider Configuration Required
|
||||
</h3>
|
||||
<p className="text-muted-foreground mb-4">
|
||||
Please configure an LLM provider to use Lighthouse AI.
|
||||
</p>
|
||||
<CustomLink
|
||||
href="/lighthouse/config"
|
||||
className="bg-primary text-primary-foreground hover:bg-primary/90 inline-flex items-center justify-center rounded-md px-4 py-2"
|
||||
target="_self"
|
||||
size="sm"
|
||||
>
|
||||
Configure Provider
|
||||
</CustomLink>
|
||||
</div>
|
||||
<Card
|
||||
variant="base"
|
||||
padding="lg"
|
||||
className="max-w-md text-center shadow-lg"
|
||||
>
|
||||
<CardHeader>
|
||||
<CardTitle>LLM Provider Configuration Required</CardTitle>
|
||||
<CardDescription>
|
||||
Please configure an LLM provider to use Lighthouse AI.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<CustomLink
|
||||
href="/lighthouse/config"
|
||||
className="bg-primary text-primary-foreground hover:bg-primary/90 inline-flex items-center justify-center rounded-md px-4 py-2"
|
||||
target="_self"
|
||||
size="sm"
|
||||
>
|
||||
Configure Provider
|
||||
</CustomLink>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error Banner */}
|
||||
{(error || errorMessage) && (
|
||||
<div className="mx-4 mt-4 rounded-lg border border-red-200 bg-red-50 p-4 dark:border-red-800 dark:bg-red-900/20">
|
||||
<div className="border-border-error-primary bg-bg-fail-secondary mx-2 mt-4 rounded-lg border p-4 sm:mx-4">
|
||||
<div className="flex items-start">
|
||||
<div className="shrink-0">
|
||||
<svg
|
||||
className="h-5 w-5 text-red-400"
|
||||
className="text-text-error h-5 w-5"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
>
|
||||
@@ -387,26 +352,24 @@ export const Chat = ({
|
||||
</svg>
|
||||
</div>
|
||||
<div className="ml-3">
|
||||
<h3 className="text-sm font-medium text-red-800 dark:text-red-200">
|
||||
Error
|
||||
</h3>
|
||||
<p className="mt-1 text-sm text-red-700 dark:text-red-300">
|
||||
<h3 className="text-text-error text-sm font-medium">Error</h3>
|
||||
<p className="text-text-neutral-secondary mt-1 text-sm">
|
||||
{errorMessage ||
|
||||
error?.message ||
|
||||
"An error occurred. Please retry your message."}
|
||||
</p>
|
||||
{/* Original error details for native errors */}
|
||||
{error && (error as any).status && (
|
||||
<p className="mt-1 text-xs text-red-600 dark:text-red-400">
|
||||
<p className="text-text-neutral-tertiary mt-1 text-xs">
|
||||
Status: {(error as any).status}
|
||||
</p>
|
||||
)}
|
||||
{error && (error as any).body && (
|
||||
<details className="mt-2">
|
||||
<summary className="cursor-pointer text-xs text-red-600 hover:text-red-800 dark:text-red-400 dark:hover:text-red-300">
|
||||
<summary className="text-text-neutral-tertiary hover:text-text-neutral-secondary cursor-pointer text-xs">
|
||||
Show details
|
||||
</summary>
|
||||
<pre className="mt-1 max-h-20 overflow-auto rounded bg-red-100 p-2 text-xs text-red-800 dark:bg-red-900/30 dark:text-red-200">
|
||||
<pre className="bg-bg-neutral-tertiary text-text-neutral-secondary mt-1 max-h-20 overflow-auto rounded p-2 text-xs">
|
||||
{JSON.stringify((error as any).body, null, 2)}
|
||||
</pre>
|
||||
</details>
|
||||
@@ -417,31 +380,32 @@ export const Chat = ({
|
||||
)}
|
||||
|
||||
{messages.length === 0 && !errorMessage && !error ? (
|
||||
<div className="flex flex-1 items-center justify-center p-4">
|
||||
<div className="flex flex-1 items-center justify-center px-2 py-4 sm:p-4">
|
||||
<div className="w-full max-w-2xl">
|
||||
<h2 className="mb-4 text-center font-sans text-xl">Suggestions</h2>
|
||||
<div className="grid gap-2 sm:grid-cols-2">
|
||||
{SUGGESTED_ACTIONS.map((action, index) => (
|
||||
<CustomButton
|
||||
<Button
|
||||
key={`suggested-action-${index}`}
|
||||
ariaLabel={`Send message: ${action.action}`}
|
||||
onPress={() => {
|
||||
aria-label={`Send message: ${action.action}`}
|
||||
onClick={() => {
|
||||
sendMessage({
|
||||
text: action.action,
|
||||
});
|
||||
}}
|
||||
className="hover:bg-muted flex h-auto w-full flex-col items-start justify-start rounded-xl border bg-gray-50 px-4 py-3.5 text-left font-sans text-sm dark:bg-gray-900"
|
||||
variant="outline"
|
||||
className="flex h-auto w-full flex-col items-start justify-start rounded-xl px-4 py-3.5 text-left font-sans text-sm"
|
||||
>
|
||||
<span>{action.title}</span>
|
||||
<span className="text-muted-foreground">{action.label}</span>
|
||||
</CustomButton>
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className="no-scrollbar flex flex-1 flex-col gap-4 overflow-y-auto p-4"
|
||||
className="no-scrollbar flex flex-1 flex-col gap-4 overflow-y-auto px-2 py-4 sm:p-4"
|
||||
ref={messagesContainerRef}
|
||||
>
|
||||
{messages.map((message, idx) => {
|
||||
@@ -470,7 +434,7 @@ export const Chat = ({
|
||||
<div
|
||||
className={`max-w-[80%] rounded-lg px-4 py-2 ${
|
||||
message.role === "user"
|
||||
? "bg-primary text-primary-foreground dark:text-black!"
|
||||
? "bg-bg-neutral-tertiary border-border-neutral-secondary border"
|
||||
: "bg-muted"
|
||||
}`}
|
||||
>
|
||||
@@ -478,11 +442,7 @@ export const Chat = ({
|
||||
{isStreamingAssistant && !messageText ? (
|
||||
<Loader size="default" text="Thinking..." />
|
||||
) : (
|
||||
<div
|
||||
className={
|
||||
message.role === "user" ? "dark:text-black!" : ""
|
||||
}
|
||||
>
|
||||
<div>
|
||||
<Streamdown
|
||||
parseIncompleteMarkdown={true}
|
||||
shikiTheme={["github-light", "github-dark"]}
|
||||
@@ -578,117 +538,33 @@ export const Chat = ({
|
||||
|
||||
<PromptInputToolbar>
|
||||
<PromptInputTools>
|
||||
{/* Model Selector */}
|
||||
<DropdownMenu
|
||||
open={uiState.isDropdownOpen}
|
||||
onOpenChange={(open) =>
|
||||
setUiState((prev) => ({ ...prev, isDropdownOpen: open }))
|
||||
}
|
||||
>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="hover:bg-accent text-muted-foreground hover:text-foreground flex items-center gap-1.5 rounded-md px-2.5 py-1.5 text-sm font-medium transition-colors"
|
||||
>
|
||||
<span>{selectedModel.modelName}</span>
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
align="start"
|
||||
className="bg-background w-[400px] border p-0 shadow-lg"
|
||||
>
|
||||
<div className="flex h-[300px]">
|
||||
{/* Left column - Providers */}
|
||||
<div className="border-default-200 dark:border-default-100 bg-muted/30 w-[180px] overflow-y-auto border-r p-1">
|
||||
{providers.map((provider) => (
|
||||
<div
|
||||
key={provider.id}
|
||||
onMouseEnter={() => {
|
||||
setUiState((prev) => ({
|
||||
...prev,
|
||||
hoveredProvider: provider.id,
|
||||
modelSearchTerm: "", // Reset search when changing provider
|
||||
}));
|
||||
loadModelsForProvider(provider.id);
|
||||
}}
|
||||
className={cn(
|
||||
"flex cursor-default items-center justify-between rounded-sm px-3 py-2 text-sm transition-colors",
|
||||
uiState.hoveredProvider === provider.id
|
||||
? "bg-gray-100 dark:bg-gray-800"
|
||||
: "hover:ring-default-200 dark:hover:ring-default-700 hover:bg-gray-100 hover:ring-1 dark:hover:bg-gray-800",
|
||||
)}
|
||||
>
|
||||
<span className="font-medium">{provider.name}</span>
|
||||
<ChevronDown className="h-4 w-4 -rotate-90" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Right column - Models */}
|
||||
<div className="flex flex-1 flex-col">
|
||||
{/* Search bar */}
|
||||
<div className="border-default-200 dark:border-default-100 border-b p-2">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search models..."
|
||||
value={uiState.modelSearchTerm}
|
||||
onChange={(e) =>
|
||||
setUiState((prev) => ({
|
||||
...prev,
|
||||
modelSearchTerm: e.target.value,
|
||||
}))
|
||||
}
|
||||
className="placeholder:text-muted-foreground w-full rounded-md border border-gray-200 bg-transparent px-3 py-1.5 text-sm outline-hidden focus:border-gray-400 dark:border-gray-700 dark:focus:border-gray-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Models list */}
|
||||
<div className="flex-1 overflow-y-auto p-1">
|
||||
{uiState.hoveredProvider &&
|
||||
providerLoadState.loading.has(
|
||||
uiState.hoveredProvider as LighthouseProvider,
|
||||
) ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<Loader size="sm" text="Loading models..." />
|
||||
</div>
|
||||
) : filteredModels.length === 0 ? (
|
||||
<div className="text-muted-foreground flex items-center justify-center py-8 text-sm">
|
||||
{uiState.modelSearchTerm
|
||||
? "No models found"
|
||||
: "No models available"}
|
||||
</div>
|
||||
) : (
|
||||
filteredModels.map((model) => (
|
||||
<button
|
||||
key={model.id}
|
||||
type="button"
|
||||
onClick={() =>
|
||||
uiState.hoveredProvider &&
|
||||
handleModelSelect(
|
||||
uiState.hoveredProvider as LighthouseProvider,
|
||||
model.id,
|
||||
model.name,
|
||||
)
|
||||
}
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground hover:ring-default-200 dark:hover:ring-default-700 relative flex w-full cursor-default items-center rounded-sm px-3 py-2 text-left text-sm outline-hidden transition-colors hover:bg-gray-100 hover:ring-1 dark:hover:bg-gray-800",
|
||||
selectedModel.modelId === model.id &&
|
||||
selectedModel.providerType ===
|
||||
uiState.hoveredProvider
|
||||
? "bg-accent text-accent-foreground"
|
||||
: "",
|
||||
)}
|
||||
>
|
||||
{model.name}
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
{/* Model Selector - Combobox */}
|
||||
<Combobox
|
||||
value={`${selectedModel.providerType}:${selectedModel.modelId}`}
|
||||
onValueChange={(value) => {
|
||||
const [providerType, modelId] = value.split(":");
|
||||
const provider = providers.find((p) => p.id === providerType);
|
||||
const model = provider?.models.find((m) => m.id === modelId);
|
||||
if (provider && model) {
|
||||
handleModelSelect(
|
||||
providerType as LighthouseProvider,
|
||||
modelId,
|
||||
model.name,
|
||||
);
|
||||
}
|
||||
}}
|
||||
groups={providers.map((provider) => ({
|
||||
heading: provider.name,
|
||||
options: provider.models.map((model) => ({
|
||||
value: `${provider.id}:${model.id}`,
|
||||
label: model.name,
|
||||
})),
|
||||
}))}
|
||||
placeholder={selectedModel.modelName || "Select model..."}
|
||||
searchPlaceholder="Search models..."
|
||||
emptyMessage="No model found."
|
||||
showSelectedFirst={true}
|
||||
/>
|
||||
</PromptInputTools>
|
||||
|
||||
{/* Submit Button */}
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
getLighthouseProviderByType,
|
||||
updateLighthouseProviderByType,
|
||||
} from "@/actions/lighthouse/lighthouse";
|
||||
import { CustomButton } from "@/components/ui/custom";
|
||||
import { FormButtons } from "@/components/ui/form";
|
||||
import type { LighthouseProvider } from "@/types/lighthouse";
|
||||
|
||||
import { getMainFields, getProviderConfig } from "./llm-provider-registry";
|
||||
@@ -88,7 +88,8 @@ export const ConnectLLMProvider = ({
|
||||
};
|
||||
};
|
||||
|
||||
const handleConnect = async () => {
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!providerConfig) return;
|
||||
|
||||
setStatus("connecting");
|
||||
@@ -146,25 +147,26 @@ export const ConnectLLMProvider = ({
|
||||
if (error) setError(null);
|
||||
};
|
||||
|
||||
const getButtonText = () => {
|
||||
if (status === "idle") {
|
||||
if (error && existingProviderId) return "Retry Connection";
|
||||
return isEditMode ? "Continue" : "Connect";
|
||||
}
|
||||
const getSubmitText = () => {
|
||||
if (error && existingProviderId) return "Retry Connection";
|
||||
return isEditMode ? "Continue" : "Connect";
|
||||
};
|
||||
|
||||
const statusText = {
|
||||
const getLoadingText = () => {
|
||||
if (status === "idle") return "Connecting...";
|
||||
|
||||
const statusText: Record<Exclude<Status, "idle">, string> = {
|
||||
connecting: "Connecting...",
|
||||
verifying: "Verifying...",
|
||||
"loading-models": "Loading models...",
|
||||
};
|
||||
|
||||
return statusText[status] || "Connecting...";
|
||||
};
|
||||
|
||||
if (!providerConfig) {
|
||||
return (
|
||||
<div className="flex h-64 items-center justify-center">
|
||||
<div className="text-sm text-red-600 dark:text-red-400">
|
||||
<div className="text-text-error text-sm">
|
||||
Provider configuration not found: {provider}
|
||||
</div>
|
||||
</div>
|
||||
@@ -174,7 +176,7 @@ export const ConnectLLMProvider = ({
|
||||
if (isFetching) {
|
||||
return (
|
||||
<div className="flex h-64 items-center justify-center">
|
||||
<div className="text-sm text-gray-600 dark:text-gray-400">
|
||||
<div className="text-text-neutral-secondary text-sm">
|
||||
Loading provider configuration...
|
||||
</div>
|
||||
</div>
|
||||
@@ -193,7 +195,7 @@ export const ConnectLLMProvider = ({
|
||||
? `Update ${providerConfig.name}`
|
||||
: `Connect to ${providerConfig.name}`}
|
||||
</h2>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-300">
|
||||
<p className="text-text-neutral-secondary text-sm">
|
||||
{isEditMode
|
||||
? `Update your API credentials or settings for ${providerConfig.name}.`
|
||||
: `Enter your API credentials to connect to ${providerConfig.name}.`}
|
||||
@@ -201,12 +203,12 @@ export const ConnectLLMProvider = ({
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="rounded-lg border border-red-200 bg-red-50 p-4 dark:border-red-800 dark:bg-red-900/20">
|
||||
<p className="text-sm text-red-600 dark:text-red-400">{error}</p>
|
||||
<div className="border-border-error-primary bg-bg-fail-secondary rounded-lg border p-4">
|
||||
<p className="text-text-error text-sm">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col gap-4">
|
||||
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
|
||||
{mainFields.map((field) => (
|
||||
<div key={field.name}>
|
||||
<label
|
||||
@@ -215,10 +217,10 @@ export const ConnectLLMProvider = ({
|
||||
>
|
||||
{field.label}{" "}
|
||||
{!isEditMode && field.required && (
|
||||
<span className="text-red-500">*</span>
|
||||
<span className="text-text-error">*</span>
|
||||
)}
|
||||
{isEditMode && (
|
||||
<span className="text-xs text-gray-500">
|
||||
<span className="text-text-neutral-tertiary text-xs">
|
||||
(leave empty to keep existing)
|
||||
</span>
|
||||
)}
|
||||
@@ -233,39 +235,18 @@ export const ConnectLLMProvider = ({
|
||||
? `Enter new ${field.label.toLowerCase()} or leave empty`
|
||||
: field.placeholder
|
||||
}
|
||||
className="w-full rounded-lg border border-gray-300 px-3 py-2 dark:border-gray-700 dark:bg-gray-800"
|
||||
className="border-border-neutral-primary bg-bg-neutral-primary w-full rounded-lg border px-3 py-2"
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<div className="mt-4 flex justify-end gap-4">
|
||||
<CustomButton
|
||||
type="button"
|
||||
ariaLabel="Cancel"
|
||||
className="w-full bg-transparent"
|
||||
variant="faded"
|
||||
size="lg"
|
||||
radius="lg"
|
||||
onPress={() => router.push("/lighthouse/config")}
|
||||
isDisabled={isLoading}
|
||||
>
|
||||
<span>Cancel</span>
|
||||
</CustomButton>
|
||||
<CustomButton
|
||||
ariaLabel={isEditMode ? "Update" : "Connect"}
|
||||
className="w-full"
|
||||
variant="solid"
|
||||
color="action"
|
||||
size="lg"
|
||||
radius="lg"
|
||||
isLoading={isLoading}
|
||||
isDisabled={!isFormValid}
|
||||
onPress={handleConnect}
|
||||
>
|
||||
{getButtonText()}
|
||||
</CustomButton>
|
||||
</div>
|
||||
</div>
|
||||
<FormButtons
|
||||
onCancel={() => router.push("/lighthouse/config")}
|
||||
submitText={getSubmitText()}
|
||||
loadingText={getLoadingText()}
|
||||
isDisabled={!isFormValid || isLoading}
|
||||
/>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -9,8 +9,7 @@ import * as z from "zod";
|
||||
import { deleteLighthouseProviderByType } from "@/actions/lighthouse/lighthouse";
|
||||
import { DeleteIcon } from "@/components/icons";
|
||||
import { useToast } from "@/components/ui";
|
||||
import { CustomButton } from "@/components/ui/custom";
|
||||
import { Form } from "@/components/ui/form";
|
||||
import { Form, FormButtons } from "@/components/ui/form";
|
||||
import type { LighthouseProvider } from "@/types/lighthouse";
|
||||
|
||||
const formSchema = z.object({
|
||||
@@ -57,34 +56,19 @@ export const DeleteLLMProviderForm = ({
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form action={onSubmitClient}>
|
||||
<input type="hidden" name="providerType" value={providerType} />
|
||||
<div className="flex w-full justify-center sm:gap-6">
|
||||
<CustomButton
|
||||
type="button"
|
||||
ariaLabel="Cancel"
|
||||
className="w-full bg-transparent"
|
||||
variant="faded"
|
||||
size="lg"
|
||||
radius="lg"
|
||||
onPress={() => setIsOpen(false)}
|
||||
isDisabled={isLoading}
|
||||
>
|
||||
<span>Cancel</span>
|
||||
</CustomButton>
|
||||
|
||||
<CustomButton
|
||||
type="submit"
|
||||
ariaLabel="Delete"
|
||||
className="w-full"
|
||||
variant="solid"
|
||||
color="danger"
|
||||
size="lg"
|
||||
isLoading={isLoading}
|
||||
startContent={!isLoading && <DeleteIcon size={24} />}
|
||||
>
|
||||
{isLoading ? <>Loading</> : <span>Delete</span>}
|
||||
</CustomButton>
|
||||
</div>
|
||||
<input
|
||||
type="hidden"
|
||||
name="providerType"
|
||||
value={providerType}
|
||||
aria-label="Provider Type"
|
||||
/>
|
||||
<FormButtons
|
||||
setIsOpen={setIsOpen}
|
||||
submitText="Delete"
|
||||
submitColor="danger"
|
||||
rightIcon={<DeleteIcon size={24} />}
|
||||
isDisabled={isLoading}
|
||||
/>
|
||||
</form>
|
||||
</Form>
|
||||
);
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { Icon } from "@iconify/react";
|
||||
import { SaveIcon } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import * as z from "zod";
|
||||
@@ -11,8 +10,16 @@ import {
|
||||
getTenantConfig,
|
||||
updateTenantConfig,
|
||||
} from "@/actions/lighthouse/lighthouse";
|
||||
import { SaveIcon } from "@/components/icons";
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/shadcn";
|
||||
import { useToast } from "@/components/ui";
|
||||
import { CustomButton, CustomTextarea } from "@/components/ui/custom";
|
||||
import { CustomTextarea } from "@/components/ui/custom";
|
||||
import { Form } from "@/components/ui/form";
|
||||
|
||||
const lighthouseSettingsSchema = z.object({
|
||||
@@ -97,55 +104,58 @@ export const LighthouseSettings = () => {
|
||||
|
||||
if (isFetchingData) {
|
||||
return (
|
||||
<div className="rounded-lg border border-gray-200 bg-white p-6 dark:border-gray-800 dark:bg-gray-900">
|
||||
<h2 className="mb-4 text-xl font-semibold">Settings</h2>
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Icon
|
||||
icon="heroicons:arrow-path"
|
||||
className="h-8 w-8 animate-spin text-gray-400"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Card variant="base" padding="lg">
|
||||
<CardHeader>
|
||||
<CardTitle>Settings</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Icon
|
||||
icon="heroicons:arrow-path"
|
||||
className="h-8 w-8 animate-spin text-gray-400"
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border border-gray-200 bg-white p-6 dark:border-gray-800 dark:bg-gray-900">
|
||||
<h2 className="mb-4 text-xl font-semibold">Settings</h2>
|
||||
<Card variant="base" padding="lg">
|
||||
<CardHeader>
|
||||
<CardTitle>Settings</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
className="flex flex-col gap-6"
|
||||
>
|
||||
<CustomTextarea
|
||||
control={form.control}
|
||||
name="businessContext"
|
||||
label="Business Context"
|
||||
labelPlacement="inside"
|
||||
placeholder="Enter business context and relevant information for the chatbot (max 1000 characters)"
|
||||
variant="bordered"
|
||||
minRows={4}
|
||||
maxRows={8}
|
||||
description={`${form.watch("businessContext")?.length || 0}/1000 characters`}
|
||||
/>
|
||||
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
className="flex flex-col gap-6"
|
||||
>
|
||||
<CustomTextarea
|
||||
control={form.control}
|
||||
name="businessContext"
|
||||
label="Business Context"
|
||||
labelPlacement="inside"
|
||||
placeholder="Enter business context and relevant information for the chatbot (max 1000 characters)"
|
||||
variant="bordered"
|
||||
minRows={4}
|
||||
maxRows={8}
|
||||
description={`${form.watch("businessContext")?.length || 0}/1000 characters`}
|
||||
isInvalid={!!form.formState.errors.businessContext}
|
||||
/>
|
||||
|
||||
<div className="flex w-full justify-end">
|
||||
<CustomButton
|
||||
type="submit"
|
||||
ariaLabel="Save Settings"
|
||||
variant="solid"
|
||||
color="action"
|
||||
size="md"
|
||||
isLoading={isLoading}
|
||||
startContent={!isLoading && <SaveIcon size={20} />}
|
||||
>
|
||||
{isLoading ? "Saving..." : "Save"}
|
||||
</CustomButton>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
</div>
|
||||
<div className="flex w-full justify-end">
|
||||
<Button
|
||||
type="submit"
|
||||
aria-label="Save Settings"
|
||||
disabled={isLoading}
|
||||
>
|
||||
{!isLoading && <SaveIcon size={20} />}
|
||||
{isLoading ? "Saving..." : "Save"}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
"use client";
|
||||
|
||||
import { Icon } from "@iconify/react";
|
||||
import Link from "next/link";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import {
|
||||
getLighthouseProviders,
|
||||
getTenantConfig,
|
||||
} from "@/actions/lighthouse/lighthouse";
|
||||
import { CustomButton } from "@/components/ui/custom";
|
||||
import { Button, Card, CardContent, CardHeader } from "@/components/shadcn";
|
||||
|
||||
import { getAllProviders } from "./llm-provider-registry";
|
||||
|
||||
@@ -104,31 +105,32 @@ export const LLMProvidersTable = () => {
|
||||
<h2 className="mb-4 text-xl font-semibold">LLM Providers</h2>
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{[1, 2, 3].map((i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="flex flex-col gap-4 rounded-lg border border-gray-200 bg-white p-6 dark:border-gray-800 dark:bg-gray-900"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="h-10 w-10 animate-pulse rounded-full bg-gray-200 dark:bg-gray-700" />
|
||||
<div className="flex flex-1 flex-col gap-2">
|
||||
<div className="h-5 w-32 animate-pulse rounded bg-gray-200 dark:bg-gray-700" />
|
||||
<div className="h-3 w-48 animate-pulse rounded bg-gray-200 dark:bg-gray-700" />
|
||||
<Card key={i} variant="base" padding="lg">
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="bg-bg-neutral-tertiary h-10 w-10 animate-pulse rounded-full" />
|
||||
<div className="flex flex-1 flex-col gap-2">
|
||||
<div className="bg-bg-neutral-tertiary h-5 w-32 animate-pulse rounded" />
|
||||
<div className="bg-bg-neutral-tertiary h-3 w-48 animate-pulse rounded" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<div className="flex-grow space-y-3">
|
||||
<div>
|
||||
<div className="mb-2 h-4 w-16 animate-pulse rounded bg-gray-200 dark:bg-gray-700" />
|
||||
<div className="h-4 w-28 animate-pulse rounded bg-gray-200 dark:bg-gray-700" />
|
||||
<CardContent className="flex flex-col gap-4">
|
||||
<div className="flex-grow space-y-3">
|
||||
<div>
|
||||
<div className="bg-bg-neutral-tertiary mb-2 h-4 w-16 animate-pulse rounded" />
|
||||
<div className="bg-bg-neutral-tertiary h-4 w-28 animate-pulse rounded" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="bg-bg-neutral-tertiary mb-2 h-4 w-24 animate-pulse rounded" />
|
||||
<div className="bg-bg-neutral-tertiary h-4 w-36 animate-pulse rounded" />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="mb-2 h-4 w-24 animate-pulse rounded bg-gray-200 dark:bg-gray-700" />
|
||||
<div className="h-4 w-36 animate-pulse rounded bg-gray-200 dark:bg-gray-700" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="h-10 w-full animate-pulse rounded-lg bg-gray-200 dark:bg-gray-700" />
|
||||
</div>
|
||||
<div className="bg-bg-neutral-tertiary h-10 w-full animate-pulse rounded-lg" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
@@ -145,90 +147,99 @@ export const LLMProvidersTable = () => {
|
||||
const showConfigure = provider.isConnected;
|
||||
|
||||
return (
|
||||
<div
|
||||
<Card
|
||||
key={provider.id}
|
||||
className="flex flex-col gap-4 rounded-lg border border-gray-200 bg-white p-6 dark:border-gray-800 dark:bg-gray-900"
|
||||
variant="base"
|
||||
padding="lg"
|
||||
className="h-full"
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-3">
|
||||
<Icon icon={provider.icon} width={40} height={40} />
|
||||
<div className="flex flex-1 flex-col">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="text-lg font-semibold">
|
||||
{provider.provider}
|
||||
</h3>
|
||||
{provider.isDefaultProvider && (
|
||||
<span className="rounded-full bg-blue-100 px-2 py-0.5 text-xs font-medium text-blue-800 dark:bg-blue-900/30 dark:text-blue-400">
|
||||
Default
|
||||
</span>
|
||||
)}
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-3">
|
||||
<Icon icon={provider.icon} width={40} height={40} />
|
||||
<div className="flex flex-1 flex-col">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="text-lg font-semibold">
|
||||
{provider.provider}
|
||||
</h3>
|
||||
{provider.isDefaultProvider && (
|
||||
<span className="rounded-full bg-blue-100 px-2 py-0.5 text-xs font-medium text-blue-800 dark:bg-blue-900/30 dark:text-blue-400">
|
||||
Default
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">
|
||||
{provider.description}
|
||||
</p>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">
|
||||
{provider.description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
{/* Status and Model Info */}
|
||||
<div className="flex-grow space-y-3">
|
||||
<div>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
Status
|
||||
</p>
|
||||
<p
|
||||
className={`text-sm ${
|
||||
provider.isConnected && provider.isActive
|
||||
? "font-bold text-green-600 dark:text-green-500"
|
||||
: "text-gray-500 dark:text-gray-500"
|
||||
}`}
|
||||
>
|
||||
{provider.isConnected
|
||||
? provider.isActive
|
||||
? "Connected"
|
||||
: "Connection Failed"
|
||||
: "Not configured"}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{provider.defaultModel && (
|
||||
<CardContent className="flex flex-1 flex-col justify-between gap-4">
|
||||
{/* Status and Model Info */}
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
Default Model
|
||||
<p className="text-text-neutral-secondary text-sm">
|
||||
Status
|
||||
</p>
|
||||
<p className="text-sm text-gray-700 dark:text-gray-300">
|
||||
{provider.defaultModel}
|
||||
<p
|
||||
className={`text-sm ${
|
||||
provider.isConnected && provider.isActive
|
||||
? "text-button-primary font-bold"
|
||||
: "text-text-neutral-secondary text-sm"
|
||||
}`}
|
||||
>
|
||||
{provider.isConnected
|
||||
? provider.isActive
|
||||
? "Connected"
|
||||
: "Connection Failed"
|
||||
: "Not configured"}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{provider.defaultModel && (
|
||||
<div>
|
||||
<p className="text-text-neutral-secondary text-sm">
|
||||
Default Model
|
||||
</p>
|
||||
<p className="text-text-neutral-secondary text-sm">
|
||||
{provider.defaultModel}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Action Button */}
|
||||
{showConnect && (
|
||||
<Button
|
||||
aria-label={`Connect ${provider.provider}`}
|
||||
className="w-full"
|
||||
asChild
|
||||
>
|
||||
<Link
|
||||
href={`/lighthouse/config/connect?provider=${provider.id}`}
|
||||
>
|
||||
Connect
|
||||
</Link>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Action Button */}
|
||||
{showConnect && (
|
||||
<CustomButton
|
||||
asLink={`/lighthouse/config/connect?provider=${provider.id}`}
|
||||
ariaLabel={`Connect ${provider.provider}`}
|
||||
variant="solid"
|
||||
color="action"
|
||||
size="md"
|
||||
className="w-full"
|
||||
>
|
||||
Connect
|
||||
</CustomButton>
|
||||
)}
|
||||
|
||||
{showConfigure && (
|
||||
<CustomButton
|
||||
asLink={`/lighthouse/config/connect?provider=${provider.id}&mode=edit`}
|
||||
ariaLabel={`Configure ${provider.provider}`}
|
||||
variant="bordered"
|
||||
color="action"
|
||||
size="md"
|
||||
className="w-full"
|
||||
>
|
||||
Configure
|
||||
</CustomButton>
|
||||
)}
|
||||
</div>
|
||||
{showConfigure && (
|
||||
<Button
|
||||
aria-label={`Configure ${provider.provider}`}
|
||||
variant="outline"
|
||||
className="w-full"
|
||||
asChild
|
||||
>
|
||||
<Link
|
||||
href={`/lighthouse/config/connect?provider=${provider.id}&mode=edit`}
|
||||
>
|
||||
Configure
|
||||
</Link>
|
||||
</Button>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
@@ -41,7 +41,7 @@ const Loader = ({
|
||||
>
|
||||
<SpinnerIcon
|
||||
size={loaderSizes[size]}
|
||||
className="text-prowler-green animate-spin"
|
||||
className="text-muted-foreground animate-spin"
|
||||
/>
|
||||
{text && <span className="text-muted-foreground text-sm">{text}</span>}
|
||||
<span className="sr-only">{text || "Loading..."}</span>
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
getTenantConfig,
|
||||
updateTenantConfig,
|
||||
} from "@/actions/lighthouse/lighthouse";
|
||||
import { CustomButton } from "@/components/ui/custom";
|
||||
import { Button } from "@/components/shadcn";
|
||||
import type { LighthouseProvider } from "@/types/lighthouse";
|
||||
|
||||
import {
|
||||
@@ -155,7 +155,7 @@ export const SelectModel = ({
|
||||
<h2 className="mb-2 text-xl font-semibold">
|
||||
{isEditMode ? "Update Default Model" : "Select Default Model"}
|
||||
</h2>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-300">
|
||||
<p className="text-text-neutral-secondary text-sm">
|
||||
{isEditMode
|
||||
? "Update the default model to use with this provider."
|
||||
: "Choose the default model to use with this provider."}
|
||||
@@ -164,7 +164,7 @@ export const SelectModel = ({
|
||||
<button
|
||||
onClick={() => fetchModels(true)}
|
||||
disabled={isLoading}
|
||||
className="flex items-center gap-2 rounded-lg px-3 py-2 text-sm font-medium text-gray-700 hover:bg-gray-100 disabled:opacity-50 dark:text-gray-300 dark:hover:bg-gray-800"
|
||||
className="text-text-neutral-secondary hover:bg-bg-neutral-tertiary flex items-center gap-2 rounded-lg px-3 py-2 text-sm font-medium disabled:opacity-50"
|
||||
aria-label="Refresh models"
|
||||
>
|
||||
<Icon
|
||||
@@ -176,8 +176,8 @@ export const SelectModel = ({
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="rounded-lg border border-red-200 bg-red-50 p-4 dark:border-red-800 dark:bg-red-900/20">
|
||||
<p className="text-sm text-red-600 dark:text-red-400">{error}</p>
|
||||
<div className="border-border-error-primary bg-bg-fail-secondary rounded-lg border p-4">
|
||||
<p className="text-text-error text-sm">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -185,14 +185,14 @@ export const SelectModel = ({
|
||||
<div className="relative">
|
||||
<Icon
|
||||
icon="heroicons:magnifying-glass"
|
||||
className="pointer-events-none absolute top-1/2 left-3 h-5 w-5 -translate-y-1/2 text-gray-400"
|
||||
className="text-text-neutral-tertiary pointer-events-none absolute top-1/2 left-3 h-5 w-5 -translate-y-1/2"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search models..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="w-full rounded-lg border border-gray-300 py-2.5 pr-4 pl-11 text-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none dark:border-gray-700 dark:bg-gray-800 dark:text-white"
|
||||
className="border-border-neutral-primary bg-bg-neutral-primary focus:border-button-primary focus:ring-button-primary w-full rounded-lg border py-2.5 pr-4 pl-11 text-sm focus:ring-1 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
@@ -201,32 +201,32 @@ export const SelectModel = ({
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Icon
|
||||
icon="heroicons:arrow-path"
|
||||
className="h-8 w-8 animate-spin text-gray-400"
|
||||
className="text-text-neutral-tertiary h-8 w-8 animate-spin"
|
||||
/>
|
||||
</div>
|
||||
) : models.length === 0 ? (
|
||||
<div className="rounded-lg border border-gray-200 bg-gray-50 p-8 text-center dark:border-gray-700 dark:bg-gray-800">
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
<div className="border-border-neutral-secondary bg-bg-neutral-tertiary rounded-lg border p-8 text-center">
|
||||
<p className="text-text-neutral-secondary text-sm">
|
||||
No models available. Click refresh to fetch models.
|
||||
</p>
|
||||
</div>
|
||||
) : filteredModels.length === 0 ? (
|
||||
<div className="rounded-lg border border-gray-200 bg-gray-50 p-8 text-center dark:border-gray-700 dark:bg-gray-800">
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
<div className="border-border-neutral-secondary bg-bg-neutral-tertiary rounded-lg border p-8 text-center">
|
||||
<p className="text-text-neutral-secondary text-sm">
|
||||
No models found matching "{searchQuery}"
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="max-h-[calc(100vh-380px)] overflow-y-auto rounded-lg border border-gray-200 dark:border-gray-700">
|
||||
<div className="border-border-neutral-secondary minimal-scrollbar max-h-[calc(100vh-380px)] overflow-y-auto rounded-lg border">
|
||||
{filteredModels.map((model) => (
|
||||
<label
|
||||
key={model.id}
|
||||
htmlFor={`model-${provider}-${model.id}`}
|
||||
aria-label={model.name}
|
||||
className={`block cursor-pointer border-b border-gray-200 px-6 py-4 transition-colors last:border-b-0 dark:border-gray-700 ${
|
||||
className={`border-border-neutral-primary block cursor-pointer border-b px-6 py-4 transition-colors last:border-b-0 ${
|
||||
selectedModel === model.id
|
||||
? "bg-blue-50 dark:bg-blue-900/20"
|
||||
: "hover:bg-gray-50 dark:hover:bg-gray-800"
|
||||
? "bg-bg-neutral-secondary"
|
||||
: "hover:bg-bg-neutral-tertiary"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-4">
|
||||
@@ -241,7 +241,7 @@ export const SelectModel = ({
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium">{model.name}</span>
|
||||
{isRecommended(model.id) && (
|
||||
<span className="inline-flex items-center gap-1 rounded-full bg-blue-100 px-2 py-0.5 text-xs font-medium text-blue-800 dark:bg-blue-900/30 dark:text-blue-400">
|
||||
<span className="bg-bg-data-info text-text-success-primary inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs font-medium">
|
||||
<Icon icon="heroicons:star-solid" className="h-3 w-3" />
|
||||
Recommended
|
||||
</span>
|
||||
@@ -255,17 +255,13 @@ export const SelectModel = ({
|
||||
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex justify-end">
|
||||
<CustomButton
|
||||
ariaLabel="Select Model"
|
||||
variant="solid"
|
||||
color="action"
|
||||
size="md"
|
||||
isDisabled={!selectedModel || isSaving}
|
||||
isLoading={isSaving}
|
||||
onPress={handleSelect}
|
||||
<Button
|
||||
aria-label="Select Model"
|
||||
disabled={!selectedModel || isSaving}
|
||||
onClick={handleSelect}
|
||||
>
|
||||
{isSaving ? "Saving..." : "Select"}
|
||||
</CustomButton>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -64,20 +64,21 @@ export const WorkflowConnectLLM = () => {
|
||||
classNames={{
|
||||
base: "px-0.5 mb-5",
|
||||
label: "text-small",
|
||||
value: "text-small text-default-400",
|
||||
value: "text-small text-button-primary",
|
||||
indicator: "bg-button-primary",
|
||||
}}
|
||||
label="Steps"
|
||||
maxValue={steps.length - 1}
|
||||
maxValue={steps.length}
|
||||
minValue={0}
|
||||
showValueLabel={true}
|
||||
size="md"
|
||||
value={currentStep}
|
||||
value={currentStep + 1}
|
||||
valueLabel={`${currentStep + 1} of ${steps.length}`}
|
||||
/>
|
||||
<VerticalSteps
|
||||
hideProgressBars
|
||||
currentStep={currentStep}
|
||||
stepClassName="border border-default-200 dark:border-default-50 aria-[current]:bg-default-100 dark:aria-[current]:bg-prowler-blue-800 cursor-default"
|
||||
stepClassName="border border-border-neutral-primary aria-[current]:bg-bg-neutral-primary cursor-default"
|
||||
steps={steps}
|
||||
/>
|
||||
<Spacer y={4} />
|
||||
|
||||
@@ -6,12 +6,9 @@ import { Controller, useForm } from "react-hook-form";
|
||||
import * as z from "zod";
|
||||
|
||||
import { createProviderGroup } from "@/actions/manage-groups";
|
||||
import { Button } from "@/components/shadcn";
|
||||
import { useToast } from "@/components/ui";
|
||||
import {
|
||||
CustomButton,
|
||||
CustomDropdownSelection,
|
||||
CustomInput,
|
||||
} from "@/components/ui/custom";
|
||||
import { CustomDropdownSelection, CustomInput } from "@/components/ui/custom";
|
||||
import { Form } from "@/components/ui/form";
|
||||
import { ApiError } from "@/types";
|
||||
|
||||
@@ -123,7 +120,6 @@ export const AddGroupForm = ({
|
||||
placeholder="Enter the provider group name"
|
||||
variant="flat"
|
||||
isRequired
|
||||
isInvalid={!!form.formState.errors.name}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -178,18 +174,10 @@ export const AddGroupForm = ({
|
||||
|
||||
{/* Submit Button */}
|
||||
<div className="flex w-full justify-end sm:gap-6">
|
||||
<CustomButton
|
||||
type="submit"
|
||||
ariaLabel="Create Group"
|
||||
className="w-1/2"
|
||||
variant="solid"
|
||||
color="action"
|
||||
size="md"
|
||||
isLoading={isLoading}
|
||||
startContent={!isLoading && <SaveIcon size={24} />}
|
||||
>
|
||||
{isLoading ? <>Loading</> : <span>Create Group</span>}
|
||||
</CustomButton>
|
||||
<Button type="submit" className="w-1/2" disabled={isLoading}>
|
||||
{!isLoading && <SaveIcon size={24} />}
|
||||
{isLoading ? "Loading" : "Create Group"}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
|
||||
@@ -8,8 +8,8 @@ import * as z from "zod";
|
||||
|
||||
import { deleteProviderGroup } from "@/actions/manage-groups/manage-groups";
|
||||
import { DeleteIcon } from "@/components/icons";
|
||||
import { Button } from "@/components/shadcn";
|
||||
import { useToast } from "@/components/ui";
|
||||
import { CustomButton } from "@/components/ui/custom";
|
||||
import { Form } from "@/components/ui/form";
|
||||
|
||||
const formSchema = z.object({
|
||||
@@ -57,33 +57,26 @@ export const DeleteGroupForm = ({
|
||||
<Form {...form}>
|
||||
<form action={onSubmitClient}>
|
||||
<input type="hidden" name="id" value={groupId} />
|
||||
<div className="flex w-full justify-center sm:gap-6">
|
||||
<CustomButton
|
||||
<div className="flex w-full justify-end gap-4">
|
||||
<Button
|
||||
type="button"
|
||||
ariaLabel="Cancel"
|
||||
className="w-full bg-transparent"
|
||||
variant="faded"
|
||||
variant="ghost"
|
||||
size="lg"
|
||||
radius="lg"
|
||||
onPress={() => setIsOpen(false)}
|
||||
isDisabled={isLoading}
|
||||
onClick={() => setIsOpen(false)}
|
||||
disabled={isLoading}
|
||||
>
|
||||
<span>Cancel</span>
|
||||
</CustomButton>
|
||||
Cancel
|
||||
</Button>
|
||||
|
||||
<CustomButton
|
||||
<Button
|
||||
type="submit"
|
||||
ariaLabel="Delete"
|
||||
className="w-full"
|
||||
variant="solid"
|
||||
color="danger"
|
||||
variant="destructive"
|
||||
size="lg"
|
||||
radius="lg"
|
||||
isLoading={isLoading}
|
||||
startContent={!isLoading && <DeleteIcon size={24} />}
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading ? <>Loading</> : <span>Delete</span>}
|
||||
</CustomButton>
|
||||
{!isLoading && <DeleteIcon size={24} />}
|
||||
{isLoading ? "Loading" : "Delete"}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
|
||||
@@ -8,12 +8,9 @@ import { Controller, useForm } from "react-hook-form";
|
||||
import * as z from "zod";
|
||||
|
||||
import { updateProviderGroup } from "@/actions/manage-groups/manage-groups";
|
||||
import { Button } from "@/components/shadcn";
|
||||
import { useToast } from "@/components/ui";
|
||||
import {
|
||||
CustomButton,
|
||||
CustomDropdownSelection,
|
||||
CustomInput,
|
||||
} from "@/components/ui/custom";
|
||||
import { CustomDropdownSelection, CustomInput } from "@/components/ui/custom";
|
||||
import { Form } from "@/components/ui/form";
|
||||
import { ApiError } from "@/types";
|
||||
|
||||
@@ -160,7 +157,6 @@ export const EditGroupForm = ({
|
||||
placeholder="Enter the provider group name"
|
||||
variant="flat"
|
||||
isRequired
|
||||
isInvalid={!!form.formState.errors.name}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -241,32 +237,21 @@ export const EditGroupForm = ({
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="flex w-full justify-end sm:gap-6">
|
||||
<CustomButton
|
||||
<div className="flex w-full justify-end gap-4">
|
||||
<Button
|
||||
type="button"
|
||||
ariaLabel="Cancel"
|
||||
className="w-fit bg-transparent"
|
||||
variant="faded"
|
||||
size="md"
|
||||
onPress={() => {
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
router.push("/manage-groups");
|
||||
}}
|
||||
isDisabled={isLoading}
|
||||
disabled={isLoading}
|
||||
>
|
||||
<span>Cancel</span>
|
||||
</CustomButton>
|
||||
<CustomButton
|
||||
type="submit"
|
||||
ariaLabel="Update Group"
|
||||
className="w-1/2"
|
||||
variant="solid"
|
||||
color="action"
|
||||
size="md"
|
||||
isLoading={isLoading}
|
||||
startContent={!isLoading && <SaveIcon size={24} />}
|
||||
>
|
||||
{isLoading ? <>Loading</> : <span>Update Group</span>}
|
||||
</CustomButton>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" className="w-1/2" disabled={isLoading}>
|
||||
{!isLoading && <SaveIcon size={24} />}
|
||||
{isLoading ? "Loading" : "Update Group"}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
|
||||
@@ -1,20 +1,17 @@
|
||||
"use client";
|
||||
|
||||
import { SettingsIcon } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
|
||||
import { CustomButton } from "../ui/custom";
|
||||
import { Button } from "@/components/shadcn";
|
||||
|
||||
export const ManageGroupsButton = () => {
|
||||
return (
|
||||
<CustomButton
|
||||
asLink="/manage-groups"
|
||||
ariaLabel="Manage Groups"
|
||||
variant="dashed"
|
||||
color="warning"
|
||||
size="md"
|
||||
startContent={<SettingsIcon size={20} />}
|
||||
>
|
||||
Manage Groups
|
||||
</CustomButton>
|
||||
<Button asChild variant="outline">
|
||||
<Link href="/manage-groups">
|
||||
<SettingsIcon size={20} />
|
||||
Manage Groups
|
||||
</Link>
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
"use client";
|
||||
|
||||
import { Button } from "@heroui/button";
|
||||
import {
|
||||
Dropdown,
|
||||
DropdownItem,
|
||||
@@ -18,6 +17,7 @@ import { useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
|
||||
import { VerticalDotsIcon } from "@/components/icons";
|
||||
import { Button } from "@/components/shadcn";
|
||||
import { CustomAlertModal } from "@/components/ui/custom";
|
||||
|
||||
import { DeleteGroupForm } from "../forms";
|
||||
@@ -48,12 +48,12 @@ export function DataTableRowActions<ProviderProps>({
|
||||
|
||||
<div className="relative flex items-center justify-end gap-2">
|
||||
<Dropdown
|
||||
className="dark:bg-prowler-blue-800 shadow-xl"
|
||||
className="border-border-neutral-secondary bg-bg-neutral-secondary border shadow-xl"
|
||||
placement="bottom"
|
||||
>
|
||||
<DropdownTrigger>
|
||||
<Button isIconOnly radius="full" size="sm" variant="light">
|
||||
<VerticalDotsIcon className="text-default-400" />
|
||||
<Button variant="ghost" size="icon-sm" className="rounded-full">
|
||||
<VerticalDotsIcon className="text-slate-400" />
|
||||
</Button>
|
||||
</DropdownTrigger>
|
||||
<DropdownMenu
|
||||
@@ -76,13 +76,13 @@ export function DataTableRowActions<ProviderProps>({
|
||||
<DropdownSection title="Danger zone">
|
||||
<DropdownItem
|
||||
key="delete"
|
||||
className="text-danger"
|
||||
className="text-text-error"
|
||||
color="danger"
|
||||
description="Delete the provider group permanently"
|
||||
textValue="Delete Provider Group"
|
||||
startContent={
|
||||
<DeleteDocumentBulkIcon
|
||||
className={clsx(iconClasses, "!text-danger")}
|
||||
className={clsx(iconClasses, "!text-text-error")}
|
||||
/>
|
||||
}
|
||||
onPress={() => setIsDeleteOpen(true)}
|
||||
|
||||
@@ -1,33 +1,20 @@
|
||||
import { Card } from "@heroui/card";
|
||||
import { Skeleton } from "@heroui/skeleton";
|
||||
import React from "react";
|
||||
|
||||
import { Card } from "@/components/shadcn/card/card";
|
||||
import { Skeleton } from "@/components/shadcn/skeleton/skeleton";
|
||||
|
||||
export const SkeletonTableGroups = () => {
|
||||
return (
|
||||
<Card className="flex h-full w-full flex-col gap-5 p-4" radius="sm">
|
||||
<Card variant="base" padding="md" className="flex flex-col gap-4">
|
||||
{/* Table headers */}
|
||||
<div className="hidden justify-between md:flex">
|
||||
<Skeleton className="w-1/12 rounded-lg">
|
||||
<div className="bg-default-200 h-8"></div>
|
||||
</Skeleton>
|
||||
<Skeleton className="w-2/12 rounded-lg">
|
||||
<div className="bg-default-200 h-8"></div>
|
||||
</Skeleton>
|
||||
<Skeleton className="w-2/12 rounded-lg">
|
||||
<div className="bg-default-200 h-8"></div>
|
||||
</Skeleton>
|
||||
<Skeleton className="w-2/12 rounded-lg">
|
||||
<div className="bg-default-200 h-8"></div>
|
||||
</Skeleton>
|
||||
<Skeleton className="w-2/12 rounded-lg">
|
||||
<div className="bg-default-200 h-8"></div>
|
||||
</Skeleton>
|
||||
<Skeleton className="w-1/12 rounded-lg">
|
||||
<div className="bg-default-200 h-8"></div>
|
||||
</Skeleton>
|
||||
<Skeleton className="w-1/12 rounded-lg">
|
||||
<div className="bg-default-200 h-8"></div>
|
||||
</Skeleton>
|
||||
<div className="hidden gap-4 md:flex">
|
||||
<Skeleton className="h-8 w-1/12" />
|
||||
<Skeleton className="h-8 w-2/12" />
|
||||
<Skeleton className="h-8 w-2/12" />
|
||||
<Skeleton className="h-8 w-2/12" />
|
||||
<Skeleton className="h-8 w-2/12" />
|
||||
<Skeleton className="h-8 w-1/12" />
|
||||
<Skeleton className="h-8 w-1/12" />
|
||||
</div>
|
||||
|
||||
{/* Table body */}
|
||||
@@ -35,29 +22,15 @@ export const SkeletonTableGroups = () => {
|
||||
{[...Array(3)].map((_, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="flex flex-col items-center justify-between md:flex-row md:gap-4"
|
||||
className="flex flex-col gap-4 md:flex-row md:items-center"
|
||||
>
|
||||
<Skeleton className="mb-2 w-full rounded-lg md:mb-0 md:w-1/12">
|
||||
<div className="bg-default-300 h-12"></div>
|
||||
</Skeleton>
|
||||
<Skeleton className="mb-2 w-full rounded-lg md:mb-0 md:w-2/12">
|
||||
<div className="bg-default-300 h-12"></div>
|
||||
</Skeleton>
|
||||
<Skeleton className="mb-2 hidden rounded-lg sm:flex md:mb-0 md:w-2/12">
|
||||
<div className="bg-default-300 h-12"></div>
|
||||
</Skeleton>
|
||||
<Skeleton className="mb-2 hidden rounded-lg sm:flex md:mb-0 md:w-2/12">
|
||||
<div className="bg-default-300 h-12"></div>
|
||||
</Skeleton>
|
||||
<Skeleton className="mb-2 hidden rounded-lg sm:flex md:mb-0 md:w-2/12">
|
||||
<div className="bg-default-300 h-12"></div>
|
||||
</Skeleton>
|
||||
<Skeleton className="mb-2 hidden rounded-lg sm:flex md:mb-0 md:w-1/12">
|
||||
<div className="bg-default-300 h-12"></div>
|
||||
</Skeleton>
|
||||
<Skeleton className="mb-2 hidden rounded-lg sm:flex md:mb-0 md:w-1/12">
|
||||
<div className="bg-default-300 h-12"></div>
|
||||
</Skeleton>
|
||||
<Skeleton className="h-12 w-full md:w-1/12" />
|
||||
<Skeleton className="h-12 w-full md:w-2/12" />
|
||||
<Skeleton className="hidden h-12 md:block md:w-2/12" />
|
||||
<Skeleton className="hidden h-12 md:block md:w-2/12" />
|
||||
<Skeleton className="hidden h-12 md:block md:w-2/12" />
|
||||
<Skeleton className="hidden h-12 md:block md:w-1/12" />
|
||||
<Skeleton className="hidden h-12 md:block md:w-1/12" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user