refactor(ui): redo the whole app with styles (#9234)

This commit is contained in:
Alejandro Bailo
2025-11-19 11:37:17 +01:00
committed by GitHub
parent c418c59b53
commit c23e2502f3
267 changed files with 5339 additions and 6952 deletions

View File

@@ -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) - 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) - 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) - 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 ### 🔄 Changed

View File

@@ -123,7 +123,7 @@ export const getThreatScore = async ({
} = {}) => { } = {}) => {
const headers = await getAuthHeaders({ contentType: false }); const headers = await getAuthHeaders({ contentType: false });
const url = new URL(`${apiBaseUrl}/overviews/threat-score`); const url = new URL(`${apiBaseUrl}/overviews/threatscore`);
// Handle multiple filters // Handle multiple filters
Object.entries(filters).forEach(([key, value]) => { Object.entries(filters).forEach(([key, value]) => {

View File

@@ -1,6 +1,5 @@
import { Spacer } from "@heroui/spacer"; import { Spacer } from "@heroui/spacer";
import Image from "next/image"; import { Suspense } from "react";
import React, { Suspense } from "react";
import { import {
getComplianceAttributes, getComplianceAttributes,
@@ -8,16 +7,15 @@ import {
getComplianceRequirements, getComplianceRequirements,
} from "@/actions/compliances"; } from "@/actions/compliances";
import { import {
BarChart,
BarChartSkeleton,
ClientAccordionWrapper, ClientAccordionWrapper,
ComplianceHeader, ComplianceHeader,
ComplianceScanInfo, RequirementsStatusCard,
HeatmapChart, RequirementsStatusCardSkeleton,
HeatmapChartSkeleton, // SectionsFailureRateCard,
PieChart, // SectionsFailureRateCardSkeleton,
PieChartSkeleton,
SkeletonAccordion, SkeletonAccordion,
TopFailedSectionsCard,
TopFailedSectionsCardSkeleton,
} from "@/components/compliance"; } from "@/components/compliance";
import { getComplianceIcon } from "@/components/icons/compliance/IconCompliance"; import { getComplianceIcon } from "@/components/icons/compliance/IconCompliance";
import { ContentLayout } from "@/components/ui"; import { ContentLayout } from "@/components/ui";
@@ -42,38 +40,6 @@ interface ComplianceDetailSearchParams {
pageSize?: string; 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({ export default async function ComplianceDetail({
params, params,
searchParams, searchParams,
@@ -98,8 +64,8 @@ export default async function ComplianceDetail({
const formattedTitle = compliancetitle.split("-").join(" "); const formattedTitle = compliancetitle.split("-").join(" ");
const pageTitle = version const pageTitle = version
? `Compliance Details: ${formattedTitle} - ${version}` ? `${formattedTitle} - ${version}`
: `Compliance Details: ${formattedTitle}`; : `${formattedTitle}`;
let selectedScan: ScanEntity | null = null; 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 // Use compliance_name from attributes if available, otherwise fallback to formatted title
const complianceName = attributesData?.data?.[0]?.attributes?.compliance_name; const complianceName = attributesData?.data?.[0]?.attributes?.compliance_name;
const finalPageTitle = complianceName const finalPageTitle = complianceName ? `${complianceName}` : pageTitle;
? `Compliance Details: ${complianceName}`
: pageTitle;
return ( return (
<ContentLayout <ContentLayout title={finalPageTitle}>
title={finalPageTitle} <ComplianceHeader
icon={ scans={[]}
logoPath ? ( uniqueRegions={uniqueRegions}
<ComplianceIconSmall logoPath={logoPath} title={compliancetitle} /> showSearch={false}
) : ( framework={compliancetitle}
"fluent-mdl2:compliance-audit" showProviders={false}
) logoPath={logoPath}
} complianceTitle={compliancetitle}
> selectedScan={selectedScan}
{selectedScanId && selectedScan && ( />
<div className="flex max-w-[328px] flex-col items-start"> {attributesData?.data?.[0]?.attributes?.framework ===
<div className="rounded-lg bg-gray-50 p-2 dark:bg-gray-800"> "ProwlerThreatScore" &&
<ComplianceScanInfo scan={selectedScan} /> selectedScanId && (
<div className="flex w-full justify-end">
<ThreatScoreDownloadButton scanId={selectedScanId} />
</div> </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 <Suspense
key={searchParamsKey} key={searchParamsKey}
fallback={ fallback={
<div className="flex flex-col gap-8"> <div className="flex flex-col gap-8">
<ChartsWrapper logoPath={logoPath}> <div className="flex flex-col gap-6 md:flex-row md:flex-wrap md:items-stretch">
<PieChartSkeleton /> <RequirementsStatusCardSkeleton />
<BarChartSkeleton /> <TopFailedSectionsCardSkeleton />
<HeatmapChartSkeleton /> {/* <SectionsFailureRateCardSkeleton /> */}
</ChartsWrapper> </div>
<SkeletonAccordion /> <SkeletonAccordion />
</div> </div>
} }
@@ -182,7 +128,6 @@ export default async function ComplianceDetail({
scanId={selectedScanId || ""} scanId={selectedScanId || ""}
region={regionFilter} region={regionFilter}
filter={cisProfileFilter} filter={cisProfileFilter}
logoPath={logoPath}
attributesData={attributesData} attributesData={attributesData}
/> />
</Suspense> </Suspense>
@@ -195,14 +140,12 @@ const SSRComplianceContent = async ({
scanId, scanId,
region, region,
filter, filter,
logoPath,
attributesData, attributesData,
}: { }: {
complianceId: string; complianceId: string;
scanId: string; scanId: string;
region?: string; region?: string;
filter?: string; filter?: string;
logoPath?: string;
attributesData: AttributesData; attributesData: AttributesData;
}) => { }) => {
const requirementsData = await getComplianceRequirements({ const requirementsData = await getComplianceRequirements({
@@ -215,11 +158,11 @@ const SSRComplianceContent = async ({
if (!scanId || type === "tasks") { if (!scanId || type === "tasks") {
return ( return (
<div className="flex flex-col gap-8"> <div className="flex flex-col gap-8">
<ChartsWrapper logoPath={logoPath}> <div className="flex flex-col gap-6 md:flex-row md:flex-wrap md:items-stretch">
<PieChart pass={0} fail={0} manual={0} /> <RequirementsStatusCard pass={0} fail={0} manual={0} />
<BarChart sections={[]} /> <TopFailedSectionsCard sections={[]} />
<HeatmapChart categories={[]} /> {/* <SectionsFailureRateCard categories={[]} /> */}
</ChartsWrapper> </div>
<ClientAccordionWrapper items={[]} defaultExpandedKeys={[]} /> <ClientAccordionWrapper items={[]} defaultExpandedKeys={[]} />
</div> </div>
); );
@@ -232,7 +175,7 @@ const SSRComplianceContent = async ({
requirementsData, requirementsData,
filter, filter,
); );
const categoryHeatmapData = mapper.calculateCategoryHeatmapData(data); // const categoryHeatmapData = mapper.calculateCategoryHeatmapData(data);
const totalRequirements: RequirementsTotals = data.reduce( const totalRequirements: RequirementsTotals = data.reduce(
(acc: RequirementsTotals, framework: Framework) => ({ (acc: RequirementsTotals, framework: Framework) => ({
pass: acc.pass + framework.pass, pass: acc.pass + framework.pass,
@@ -246,17 +189,17 @@ const SSRComplianceContent = async ({
return ( return (
<div className="flex flex-col gap-8"> <div className="flex flex-col gap-8">
<ChartsWrapper logoPath={logoPath}> <div className="flex flex-col gap-6 md:flex-row md:items-stretch">
<PieChart <RequirementsStatusCard
pass={totalRequirements.pass} pass={totalRequirements.pass}
fail={totalRequirements.fail} fail={totalRequirements.fail}
manual={totalRequirements.manual} manual={totalRequirements.manual}
/> />
<BarChart sections={topFailedSections} /> <TopFailedSectionsCard sections={topFailedSections} />
<HeatmapChart categories={categoryHeatmapData} /> {/* <SectionsFailureRateCard categories={categoryHeatmapData} /> */}
</ChartsWrapper> </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 <ClientAccordionWrapper
hideExpandButton={complianceId.includes("mitre_attack")} hideExpandButton={complianceId.includes("mitre_attack")}
items={accordionItems} items={accordionItems}

View File

@@ -1,9 +1,9 @@
"use client"; "use client";
import { Button } from "@heroui/button";
import { DownloadIcon } from "lucide-react"; import { DownloadIcon } from "lucide-react";
import { useState } from "react"; import { useState } from "react";
import { Button } from "@/components/shadcn/button/button";
import { toast } from "@/components/ui"; import { toast } from "@/components/ui";
import { downloadThreatScorePdf } from "@/lib/helper"; import { downloadThreatScorePdf } from "@/lib/helper";
@@ -27,18 +27,15 @@ export const ThreatScoreDownloadButton = ({
return ( return (
<Button <Button
color="success" variant="default"
variant="solid"
startContent={
<DownloadIcon
className={isDownloading ? "animate-download-icon" : ""}
size={16}
/>
}
onPress={handleDownload}
isLoading={isDownloading}
size="sm" size="sm"
onClick={handleDownload}
disabled={isDownloading}
> >
<DownloadIcon
className={isDownloading ? "animate-download-icon" : ""}
size={16}
/>
PDF ThreatScore Report PDF ThreatScore Report
</Button> </Button>
); );

View File

@@ -1,4 +1,3 @@
export const dynamic = "force-dynamic";
import { Suspense } from "react"; import { Suspense } from "react";
import { import {
@@ -138,7 +137,7 @@ export default async function Compliance({
{selectedScanId ? ( {selectedScanId ? (
<> <>
<div className="mb-6 flex flex-col gap-6"> <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"> <div className="flex-1">
<ComplianceHeader <ComplianceHeader
scans={expandedScansData} scans={expandedScansData}

View File

@@ -4,8 +4,14 @@ import { Icon } from "@iconify/react";
import * as Sentry from "@sentry/nextjs"; import * as Sentry from "@sentry/nextjs";
import { useEffect } from "react"; import { useEffect } from "react";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui"; import { Button } from "@/components/shadcn";
import { CustomButton } from "@/components/ui/custom"; import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/shadcn/card/card";
import { CustomLink } from "@/components/ui/custom/custom-link"; import { CustomLink } from "@/components/ui/custom/custom-link";
import { SentryErrorSource, SentryErrorType } from "@/sentry"; import { SentryErrorSource, SentryErrorType } from "@/sentry";
@@ -75,37 +81,39 @@ export default function Error({
return ( return (
<div className="flex min-h-screen items-center justify-center p-4"> <div className="flex min-h-screen items-center justify-center p-4">
<Alert className="w-full max-w-lg"> <Card variant="base" className="w-full max-w-lg">
<Icon <CardHeader>
icon={is500Error ? "tabler:server-off" : "tabler:rocket-off"} <div className="flex items-start gap-3">
className="h-5 w-5" <Icon
/> icon={is500Error ? "tabler:server-off" : "tabler:rocket-off"}
<AlertTitle className="text-lg"> className="mt-0.5 h-5 w-5 flex-shrink-0 text-red-500"
{is500Error />
? "Server temporarily unavailable" <div className="flex flex-col gap-2">
: "An unexpected error occurred"} <CardTitle className="text-lg">
</AlertTitle> {is500Error
<AlertDescription className="mb-5"> ? "Server temporarily unavailable"
{is500Error : "An unexpected error occurred"}
? "The server is experiencing issues. Our team has been notified and is working on it. Please try again in a few moments." </CardTitle>
: "We're sorry for the inconvenience. Please try again or contact support if the problem persists."} <CardDescription className="text-sm">
</AlertDescription> {is500Error
<div className="flex items-center justify-start gap-3"> ? "The server is experiencing issues. Our team has been notified and is working on it. Please try again in a few moments."
<CustomButton : "We're sorry for the inconvenience. Please try again or contact support if the problem persists."}
onPress={reset} </CardDescription>
variant="solid" </div>
color="primary" </div>
size="sm" </CardHeader>
startContent={<Icon icon="tabler:refresh" className="h-4 w-4" />} <CardContent>
ariaLabel="Try Again" <div className="flex items-center justify-start gap-3">
> <Button onClick={reset} size="sm" className="gap-2">
Try Again <Icon icon="tabler:refresh" className="h-4 w-4" />
</CustomButton> Try Again
<CustomLink href="/" target="_self" className="font-bold"> </Button>
Go to Overview <CustomLink href="/" target="_self" className="font-bold">
</CustomLink> Go to Overview
</div> </CustomLink>
</Alert> </div>
</CardContent>
</Card>
</div> </div>
); );
} }

View File

@@ -3,6 +3,7 @@ import React from "react";
import { getIntegrations } from "@/actions/integrations"; import { getIntegrations } from "@/actions/integrations";
import { getProviders } from "@/actions/providers"; import { getProviders } from "@/actions/providers";
import { S3IntegrationsManager } from "@/components/integrations/s3/s3-integrations-manager"; import { S3IntegrationsManager } from "@/components/integrations/s3/s3-integrations-manager";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/shadcn";
import { ContentLayout } from "@/components/ui"; import { ContentLayout } from "@/components/ui";
interface S3IntegrationsProps { interface S3IntegrationsProps {
@@ -62,29 +63,31 @@ export default async function S3Integrations({
results to S3 buckets. results to S3 buckets.
</p> </p>
<div className="rounded-lg border border-gray-200 bg-gray-50 p-4 dark:border-gray-700 dark:bg-gray-800"> <Card variant="base" padding="lg">
<h3 className="mb-3 text-sm font-semibold text-gray-900 dark:text-gray-100"> <CardHeader className="mb-0 pb-3">
Features: <CardTitle>Features</CardTitle>
</h3> </CardHeader>
<ul className="grid grid-cols-1 gap-2 text-sm text-gray-600 md:grid-cols-2 dark:text-gray-300"> <CardContent className="pt-0">
<li className="flex items-center gap-2"> <ul className="grid grid-cols-1 gap-2 text-sm text-gray-600 md:grid-cols-2 dark:text-gray-300">
<span className="h-1.5 w-1.5 rounded-full bg-green-500" /> <li className="flex items-center gap-2">
Automated scan result exports <span className="bg-button-primary h-1.5 w-1.5 rounded-full" />
</li> Automated scan result exports
<li className="flex items-center gap-2"> </li>
<span className="h-1.5 w-1.5 rounded-full bg-green-500" /> <li className="flex items-center gap-2">
Multi-Cloud support <span className="bg-button-primary h-1.5 w-1.5 rounded-full" />
</li> Multi-Cloud support
<li className="flex items-center gap-2"> </li>
<span className="h-1.5 w-1.5 rounded-full bg-green-500" /> <li className="flex items-center gap-2">
Configurable export paths <span className="bg-button-primary h-1.5 w-1.5 rounded-full" />
</li> Configurable export paths
<li className="flex items-center gap-2"> </li>
<span className="h-1.5 w-1.5 rounded-full bg-green-500" /> <li className="flex items-center gap-2">
IAM role and static credentials <span className="bg-button-primary h-1.5 w-1.5 rounded-full" />
</li> IAM role and static credentials
</ul> </li>
</div> </ul>
</CardContent>
</Card>
</div> </div>
<S3IntegrationsManager <S3IntegrationsManager

View File

@@ -3,6 +3,7 @@ import React from "react";
import { getIntegrations } from "@/actions/integrations"; import { getIntegrations } from "@/actions/integrations";
import { getProviders } from "@/actions/providers"; import { getProviders } from "@/actions/providers";
import { SecurityHubIntegrationsManager } from "@/components/integrations/security-hub/security-hub-integrations-manager"; import { SecurityHubIntegrationsManager } from "@/components/integrations/security-hub/security-hub-integrations-manager";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/shadcn";
import { ContentLayout } from "@/components/ui"; import { ContentLayout } from "@/components/ui";
interface SecurityHubIntegrationsProps { interface SecurityHubIntegrationsProps {
@@ -60,29 +61,31 @@ export default async function SecurityHubIntegrations({
security findings for centralized monitoring and compliance. security findings for centralized monitoring and compliance.
</p> </p>
<div className="rounded-lg border border-gray-200 bg-gray-50 p-4 dark:border-gray-700 dark:bg-gray-800"> <Card variant="base" padding="lg">
<h3 className="mb-3 text-sm font-semibold text-gray-900 dark:text-gray-100"> <CardHeader className="mb-0 pb-3">
Features: <CardTitle>Features</CardTitle>
</h3> </CardHeader>
<ul className="grid grid-cols-1 gap-2 text-sm text-gray-600 md:grid-cols-2 dark:text-gray-300"> <CardContent className="pt-0">
<li className="flex items-center gap-2"> <ul className="grid grid-cols-1 gap-2 text-sm text-gray-600 md:grid-cols-2 dark:text-gray-300">
<span className="h-1.5 w-1.5 rounded-full bg-green-500" /> <li className="flex items-center gap-2">
Automated findings export <span className="bg-button-primary h-1.5 w-1.5 rounded-full" />
</li> Automated findings export
<li className="flex items-center gap-2"> </li>
<span className="h-1.5 w-1.5 rounded-full bg-green-500" /> <li className="flex items-center gap-2">
Multi-region support <span className="bg-button-primary h-1.5 w-1.5 rounded-full" />
</li> Multi-region support
<li className="flex items-center gap-2"> </li>
<span className="h-1.5 w-1.5 rounded-full bg-green-500" /> <li className="flex items-center gap-2">
Send failed findings only <span className="bg-button-primary h-1.5 w-1.5 rounded-full" />
</li> Send failed findings only
<li className="flex items-center gap-2"> </li>
<span className="h-1.5 w-1.5 rounded-full bg-green-500" /> <li className="flex items-center gap-2">
Archive previous findings <span className="bg-button-primary h-1.5 w-1.5 rounded-full" />
</li> Archive previous findings
</ul> </li>
</div> </ul>
</CardContent>
</Card>
</div> </div>
<SecurityHubIntegrationsManager <SecurityHubIntegrationsManager

View File

@@ -1,5 +1,6 @@
import { getIntegrations } from "@/actions/integrations"; import { getIntegrations } from "@/actions/integrations";
import { JiraIntegrationsManager } from "@/components/integrations/jira/jira-integrations-manager"; import { JiraIntegrationsManager } from "@/components/integrations/jira/jira-integrations-manager";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/shadcn";
import { ContentLayout } from "@/components/ui"; import { ContentLayout } from "@/components/ui";
interface JiraIntegrationsProps { interface JiraIntegrationsProps {
@@ -55,29 +56,31 @@ export default async function JiraIntegrations({
security findings in your Jira projects. security findings in your Jira projects.
</p> </p>
<div className="rounded-lg border border-gray-200 bg-gray-50 p-4 dark:border-gray-700 dark:bg-gray-800"> <Card variant="base" padding="lg">
<h3 className="mb-3 text-sm font-semibold text-gray-900 dark:text-gray-100"> <CardHeader className="mb-0 pb-3">
Features: <CardTitle>Features</CardTitle>
</h3> </CardHeader>
<ul className="grid grid-cols-1 gap-2 text-sm text-gray-600 md:grid-cols-2 dark:text-gray-300"> <CardContent className="pt-0">
<li className="flex items-center gap-2"> <ul className="grid grid-cols-1 gap-2 text-sm text-gray-600 md:grid-cols-2 dark:text-gray-300">
<span className="h-1.5 w-1.5 rounded-full bg-green-500" /> <li className="flex items-center gap-2">
Automated issue creation <span className="bg-button-primary h-1.5 w-1.5 rounded-full" />
</li> Automated issue creation
<li className="flex items-center gap-2"> </li>
<span className="h-1.5 w-1.5 rounded-full bg-green-500" /> <li className="flex items-center gap-2">
Multi-Cloud support <span className="bg-button-primary h-1.5 w-1.5 rounded-full" />
</li> Multi-Cloud support
<li className="flex items-center gap-2"> </li>
<span className="h-1.5 w-1.5 rounded-full bg-green-500" /> <li className="flex items-center gap-2">
Flexible issue tracking <span className="bg-button-primary h-1.5 w-1.5 rounded-full" />
</li> Flexible issue tracking
<li className="flex items-center gap-2"> </li>
<span className="h-1.5 w-1.5 rounded-full bg-green-500" /> <li className="flex items-center gap-2">
Project-specific configuration <span className="bg-button-primary h-1.5 w-1.5 rounded-full" />
</li> Project-specific configuration
</ul> </li>
</div> </ul>
</CardContent>
</Card>
</div> </div>
<JiraIntegrationsManager <JiraIntegrationsManager

View File

@@ -1,15 +1,17 @@
import { Spacer } from "@heroui/spacer"; import { Spacer } from "@heroui/spacer";
import Link from "next/link";
import React, { Suspense } from "react"; import React, { Suspense } from "react";
import { getInvitations } from "@/actions/invitations/invitation"; import { getInvitations } from "@/actions/invitations/invitation";
import { getRoles } from "@/actions/roles"; import { getRoles } from "@/actions/roles";
import { FilterControls } from "@/components/filters"; import { FilterControls } from "@/components/filters";
import { filterInvitations } from "@/components/filters/data-filters"; import { filterInvitations } from "@/components/filters/data-filters";
import { SendInvitationButton } from "@/components/invitations"; import { AddIcon } from "@/components/icons";
import { import {
ColumnsInvitation, ColumnsInvitation,
SkeletonTableInvitation, SkeletonTableInvitation,
} from "@/components/invitations/table"; } from "@/components/invitations/table";
import { Button } from "@/components/shadcn";
import { ContentLayout } from "@/components/ui"; import { ContentLayout } from "@/components/ui";
import { DataTable, DataTableFilterCustom } from "@/components/ui/table"; import { DataTable, DataTableFilterCustom } from "@/components/ui/table";
import { InvitationProps, Role, SearchParamsProps } from "@/types"; import { InvitationProps, Role, SearchParamsProps } from "@/types";
@@ -25,10 +27,17 @@ export default async function Invitations({
return ( return (
<ContentLayout title="Invitations" icon="lucide:mail"> <ContentLayout title="Invitations" icon="lucide:mail">
<FilterControls search /> <FilterControls search />
<Spacer y={8} />
<SendInvitationButton /> <div className="flex flex-row items-center justify-between">
<Spacer y={4} /> <DataTableFilterCustom filters={filterInvitations || []} />
<DataTableFilterCustom filters={filterInvitations || []} />
<Button asChild>
<Link href="/invitations/new">
Send Invitation
<AddIcon size={20} />
</Link>
</Button>
</div>
<Spacer y={8} /> <Spacer y={8} />
<Suspense key={searchParamsKey} fallback={<SkeletonTableInvitation />}> <Suspense key={searchParamsKey} fallback={<SkeletonTableInvitation />}>

View File

@@ -13,8 +13,9 @@ import {
} from "@/actions/lighthouse/lighthouse"; } from "@/actions/lighthouse/lighthouse";
import { DeleteLLMProviderForm } from "@/components/lighthouse/forms/delete-llm-provider-form"; import { DeleteLLMProviderForm } from "@/components/lighthouse/forms/delete-llm-provider-form";
import { WorkflowConnectLLM } from "@/components/lighthouse/workflow"; import { WorkflowConnectLLM } from "@/components/lighthouse/workflow";
import { Button } from "@/components/shadcn";
import { NavigationHeader } from "@/components/ui"; import { NavigationHeader } from "@/components/ui";
import { CustomAlertModal, CustomButton } from "@/components/ui/custom"; import { CustomAlertModal } from "@/components/ui/custom";
import type { LighthouseProvider } from "@/types/lighthouse"; import type { LighthouseProvider } from "@/types/lighthouse";
interface ConnectLLMLayoutProps { interface ConnectLLMLayoutProps {
@@ -89,33 +90,28 @@ export default function ConnectLLMLayout({ children }: ConnectLLMLayoutProps) {
<> <>
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
{!isDefaultProvider && ( {!isDefaultProvider && (
<CustomButton <Button
ariaLabel="Set as Default Provider" aria-label="Set as Default Provider"
variant="bordered" variant="outline"
size="sm" size="sm"
startContent={ onClick={handleSetDefault}
<Icon icon="heroicons:star" className="h-4 w-4" />
}
onPress={handleSetDefault}
className="w-full sm:w-auto" className="w-full sm:w-auto"
> >
<Icon icon="heroicons:star" className="h-4 w-4" />
Set as Default Set as Default
</CustomButton> </Button>
)} )}
<CustomButton <Button
ariaLabel="Delete Provider" aria-label="Delete Provider"
variant="bordered" variant="destructive"
color="danger"
size="sm" size="sm"
startContent={ onClick={() => setIsDeleteOpen(true)}
<Icon icon="heroicons:trash" className="h-4 w-4" />
}
onPress={() => setIsDeleteOpen(true)}
className="w-full sm:w-auto" className="w-full sm:w-auto"
> >
<Icon icon="heroicons:trash" className="h-4 w-4" />
Delete Provider Delete Provider
</CustomButton> </Button>
</div> </div>
<Spacer y={4} /> <Spacer y={4} />
</> </>

View File

@@ -14,12 +14,12 @@ import {
OracleCloudProviderBadge, OracleCloudProviderBadge,
} from "@/components/icons/providers-badge"; } from "@/components/icons/providers-badge";
import { import {
Select, MultiSelect,
SelectContent, MultiSelectContent,
SelectItem, MultiSelectItem,
SelectTrigger, MultiSelectTrigger,
SelectValue, MultiSelectValue,
} from "@/components/shadcn"; } from "@/components/shadcn/select/multiselect";
import type { ProviderProps, ProviderType } from "@/types/providers"; import type { ProviderProps, ProviderType } from "@/types/providers";
const PROVIDER_ICON: Record<ProviderType, ReactNode> = { const PROVIDER_ICON: Record<ProviderType, ReactNode> = {
@@ -91,7 +91,7 @@ export function AccountsSelector({ providers }: AccountsSelectorProps) {
}; };
const selectedLabel = () => { const selectedLabel = () => {
if (selectedIds.length === 0) return null; // placeholder visible if (selectedIds.length === 0) return null;
if (selectedIds.length === 1) { if (selectedIds.length === 1) {
const p = providers.find((pr) => pr.id === selectedIds[0]); const p = providers.find((pr) => pr.id === selectedIds[0]);
const name = p ? p.attributes.alias || p.attributes.uid : 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 Filter by cloud provider account. {filterDescription}. Select one or
more accounts to view findings. more accounts to view findings.
</label> </label>
<Select <MultiSelect values={selectedIds} onValuesChange={handleMultiValueChange}>
multiple <MultiSelectTrigger
selectedValues={selectedIds} id="accounts-selector"
onMultiValueChange={handleMultiValueChange} aria-labelledby="accounts-label"
ariaLabel="Cloud provider accounts filter" >
> {selectedLabel() || <MultiSelectValue placeholder="All accounts" />}
<SelectTrigger id="accounts-selector" aria-labelledby="accounts-label"> </MultiSelectTrigger>
<SelectValue placeholder="All accounts"> <MultiSelectContent search={false}>
{selectedLabel()}
</SelectValue>
</SelectTrigger>
<SelectContent align="start">
{visibleProviders.length > 0 ? ( {visibleProviders.length > 0 ? (
visibleProviders.map((p) => { visibleProviders.map((p) => {
const id = p.id; const id = p.id;
@@ -136,14 +132,15 @@ export function AccountsSelector({ providers }: AccountsSelectorProps) {
const providerType = p.attributes.provider as ProviderType; const providerType = p.attributes.provider as ProviderType;
const icon = PROVIDER_ICON[providerType]; const icon = PROVIDER_ICON[providerType];
return ( return (
<SelectItem <MultiSelectItem
key={id} key={id}
value={id} value={id}
badgeLabel={displayName}
aria-label={`${displayName} account (${providerType.toUpperCase()})`} aria-label={`${displayName} account (${providerType.toUpperCase()})`}
> >
<span aria-hidden="true">{icon}</span> <span aria-hidden="true">{icon}</span>
<span className="truncate">{displayName}</span> <span className="truncate">{displayName}</span>
</SelectItem> </MultiSelectItem>
); );
}) })
) : ( ) : (
@@ -153,8 +150,8 @@ export function AccountsSelector({ providers }: AccountsSelectorProps) {
: "No connected accounts available"} : "No connected accounts available"}
</div> </div>
)} )}
</SelectContent> </MultiSelectContent>
</Select> </MultiSelect>
</div> </div>
); );
} }

View File

@@ -4,12 +4,12 @@ import { useRouter, useSearchParams } from "next/navigation";
import { lazy, Suspense } from "react"; import { lazy, Suspense } from "react";
import { import {
Select, MultiSelect,
SelectContent, MultiSelectContent,
SelectItem, MultiSelectItem,
SelectTrigger, MultiSelectTrigger,
SelectValue, MultiSelectValue,
} from "@/components/shadcn"; } from "@/components/shadcn/select/multiselect";
import { type ProviderProps, ProviderType } from "@/types/providers"; import { type ProviderProps, ProviderType } from "@/types/providers";
const AWSProviderBadge = lazy(() => const AWSProviderBadge = lazy(() =>
@@ -147,7 +147,7 @@ export const ProviderTypeSelector = ({
}; };
const selectedLabel = () => { const selectedLabel = () => {
if (selectedTypes.length === 0) return null; // placeholder visible if (selectedTypes.length === 0) return null;
if (selectedTypes.length === 1) { if (selectedTypes.length === 1) {
const providerType = selectedTypes[0] as ProviderType; const providerType = selectedTypes[0] as ProviderType;
return ( return (
@@ -174,39 +174,36 @@ export const ProviderTypeSelector = ({
Filter by cloud provider type. Select one or more providers to view Filter by cloud provider type. Select one or more providers to view
findings. findings.
</label> </label>
<Select <MultiSelect
multiple values={selectedTypes}
selectedValues={selectedTypes} onValuesChange={handleMultiValueChange}
onMultiValueChange={handleMultiValueChange}
ariaLabel="Cloud provider type filter"
> >
<SelectTrigger <MultiSelectTrigger
id="provider-type-selector" id="provider-type-selector"
aria-labelledby="provider-type-label" aria-labelledby="provider-type-label"
> >
<SelectValue placeholder="All providers"> {selectedLabel() || <MultiSelectValue placeholder="All providers" />}
{selectedLabel()} </MultiSelectTrigger>
</SelectValue> <MultiSelectContent search={false}>
</SelectTrigger>
<SelectContent>
{availableTypes.length > 0 ? ( {availableTypes.length > 0 ? (
availableTypes.map((providerType) => ( availableTypes.map((providerType) => (
<SelectItem <MultiSelectItem
key={providerType} key={providerType}
value={providerType} value={providerType}
badgeLabel={PROVIDER_DATA[providerType].label}
aria-label={`${PROVIDER_DATA[providerType].label} provider`} aria-label={`${PROVIDER_DATA[providerType].label} provider`}
> >
<span aria-hidden="true">{renderIcon(providerType)}</span> <span aria-hidden="true">{renderIcon(providerType)}</span>
<span>{PROVIDER_DATA[providerType].label}</span> <span>{PROVIDER_DATA[providerType].label}</span>
</SelectItem> </MultiSelectItem>
)) ))
) : ( ) : (
<div className="px-3 py-2 text-sm text-slate-500 dark:text-slate-400"> <div className="px-3 py-2 text-sm text-slate-500 dark:text-slate-400">
No connected providers available No connected providers available
</div> </div>
)} )}
</SelectContent> </MultiSelectContent>
</Select> </MultiSelect>
</div> </div>
); );
}; };

View File

@@ -22,6 +22,11 @@ export default async function NewOverviewPage({
}: { }: {
searchParams: Promise<SearchParamsProps>; 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 resolvedSearchParams = await searchParams;
const providersData = await getProviders({ page: 1, pageSize: 200 }); const providersData = await getProviders({ page: 1, pageSize: 200 });

View File

@@ -2,22 +2,8 @@ import { Spacer } from "@heroui/spacer";
import { Suspense } from "react"; import { Suspense } from "react";
import { getLatestFindings } from "@/actions/findings/findings"; import { getLatestFindings } from "@/actions/findings/findings";
import { import { getProviders } from "@/actions/providers";
getFindingsBySeverity, import { LinkToFindings } from "@/components/overview";
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 { ColumnNewFindingsToDate } from "@/components/overview/new-findings-table/table/column-new-findings-to-date"; 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 { SkeletonTableNewFindings } from "@/components/overview/new-findings-table/table/skeleton-table-new-findings";
import { ContentLayout } from "@/components/ui"; import { ContentLayout } from "@/components/ui";
@@ -25,6 +11,20 @@ import { DataTable } from "@/components/ui/table";
import { createDict } from "@/lib/helper"; import { createDict } from "@/lib/helper";
import { FindingProps, SearchParamsProps } from "@/types"; 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["; const FILTER_PREFIX = "filter[";
// Extract only query params that start with "filter[" for API calls // 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 resolvedSearchParams = await searchParams;
const searchParamsKey = JSON.stringify(resolvedSearchParams || {}); const searchParamsKey = JSON.stringify(resolvedSearchParams || {});
const providersData = await getProviders({ page: 1, pageSize: 200 });
return ( return (
<ContentLayout title="Overview" icon="lucide:square-chart-gantt"> <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="flex flex-col gap-6 md:flex-row md:flex-wrap md:items-stretch">
<div className="col-span-12 lg:col-span-4"> <Suspense fallback={<ThreatScoreSkeleton />}>
<Suspense fallback={<SkeletonProvidersOverview />}> <ThreatScoreSSR searchParams={resolvedSearchParams} />
<SSRProvidersOverview /> </Suspense>
</Suspense>
</div>
<div className="col-span-12 lg:col-span-4"> <Suspense fallback={<StatusChartSkeleton />}>
<Suspense fallback={<SkeletonFindingsBySeverityChart />}> <CheckFindingsSSR searchParams={resolvedSearchParams} />
<SSRFindingsBySeverity searchParams={resolvedSearchParams} /> </Suspense>
</Suspense>
</div>
<div className="col-span-12 lg:col-span-4"> <Suspense fallback={<RiskSeverityChartSkeleton />}>
<Suspense fallback={<SkeletonFindingsByStatusChart />}> <RiskSeverityChartSSR searchParams={resolvedSearchParams} />
<SSRFindingsByStatus searchParams={resolvedSearchParams} /> </Suspense>
</Suspense> </div>
</div>
<div className="col-span-12"> <div className="mt-6">
<Spacer y={16} /> <Spacer y={16} />
<Suspense <Suspense key={searchParamsKey} fallback={<SkeletonTableNewFindings />}>
key={searchParamsKey} <SSRDataNewFindingsTable searchParams={resolvedSearchParams} />
fallback={<SkeletonTableNewFindings />} </Suspense>
>
<SSRDataNewFindingsTable searchParams={resolvedSearchParams} />
</Suspense>
</div>
</div> </div>
</ContentLayout> </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 ({ const SSRDataNewFindingsTable = async ({
searchParams, searchParams,
}: { }: {
@@ -188,6 +129,7 @@ const SSRDataNewFindingsTable = async ({
return ( return (
<> <>
<LighthouseBanner />
<div className="relative flex w-full"> <div className="relative flex w-full">
<div className="flex w-full items-center gap-2"> <div className="flex w-full items-center gap-2">
<h3 className="text-sm font-bold uppercase"> <h3 className="text-sm font-bold uppercase">
@@ -203,8 +145,6 @@ const SSRDataNewFindingsTable = async ({
</div> </div>
<Spacer y={4} /> <Spacer y={4} />
<LighthouseBanner />
<DataTable <DataTable
key={`dashboard-${Date.now()}`} key={`dashboard-${Date.now()}`}
columns={ColumnNewFindingsToDate} columns={ColumnNewFindingsToDate}

View File

@@ -69,7 +69,6 @@ export default async function Resources({
values: uniqueServices, values: uniqueServices,
}, },
]} ]}
defaultOpen={true}
/> />
<Spacer y={8} /> <Spacer y={8} />
<Suspense key={searchParamsKey} fallback={<SkeletonTableResources />}> <Suspense key={searchParamsKey} fallback={<SkeletonTableResources />}>

View File

@@ -1,12 +1,14 @@
import { Spacer } from "@heroui/spacer"; import { Spacer } from "@heroui/spacer";
import Link from "next/link";
import { Suspense } from "react"; import { Suspense } from "react";
import { getRoles } from "@/actions/roles"; import { getRoles } from "@/actions/roles";
import { FilterControls } from "@/components/filters"; import { FilterControls } from "@/components/filters";
import { filterRoles } from "@/components/filters/data-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 { ColumnsRoles } from "@/components/roles/table";
import { SkeletonTableRoles } from "@/components/roles/table"; import { SkeletonTableRoles } from "@/components/roles/table";
import { Button } from "@/components/shadcn";
import { ContentLayout } from "@/components/ui"; import { ContentLayout } from "@/components/ui";
import { DataTable, DataTableFilterCustom } from "@/components/ui/table"; import { DataTable, DataTableFilterCustom } from "@/components/ui/table";
import { SearchParamsProps } from "@/types"; import { SearchParamsProps } from "@/types";
@@ -22,10 +24,17 @@ export default async function Roles({
return ( return (
<ContentLayout title="Roles" icon="lucide:user-cog"> <ContentLayout title="Roles" icon="lucide:user-cog">
<FilterControls search /> <FilterControls search />
<Spacer y={8} />
<AddRoleButton /> <div className="flex flex-row items-center justify-between">
<Spacer y={4} /> <DataTableFilterCustom filters={filterRoles || []} />
<DataTableFilterCustom filters={filterRoles || []} />
<Button asChild>
<Link href="/roles/new">
Add Role
<AddIcon size={20} />
</Link>
</Button>
</div>
<Spacer y={8} /> <Spacer y={8} />
<Suspense key={searchParamsKey} fallback={<SkeletonTableRoles />}> <Suspense key={searchParamsKey} fallback={<SkeletonTableRoles />}>

View File

@@ -1,13 +1,15 @@
import { Spacer } from "@heroui/spacer"; import { Spacer } from "@heroui/spacer";
import Link from "next/link";
import { Suspense } from "react"; import { Suspense } from "react";
import { getRoles } from "@/actions/roles/roles"; import { getRoles } from "@/actions/roles/roles";
import { getUsers } from "@/actions/users/users"; import { getUsers } from "@/actions/users/users";
import { FilterControls } from "@/components/filters"; import { FilterControls } from "@/components/filters";
import { filterUsers } from "@/components/filters/data-filters"; import { filterUsers } from "@/components/filters/data-filters";
import { AddIcon } from "@/components/icons";
import { Button } from "@/components/shadcn";
import { ContentLayout } from "@/components/ui"; import { ContentLayout } from "@/components/ui";
import { DataTable, DataTableFilterCustom } from "@/components/ui/table"; import { DataTable, DataTableFilterCustom } from "@/components/ui/table";
import { AddUserButton } from "@/components/users";
import { ColumnsUser, SkeletonTableUser } from "@/components/users/table"; import { ColumnsUser, SkeletonTableUser } from "@/components/users/table";
import { Role, SearchParamsProps, UserProps } from "@/types"; import { Role, SearchParamsProps, UserProps } from "@/types";
@@ -22,10 +24,17 @@ export default async function Users({
return ( return (
<ContentLayout title="Users" icon="lucide:user"> <ContentLayout title="Users" icon="lucide:user">
<FilterControls search /> <FilterControls search />
<Spacer y={8} />
<AddUserButton /> <div className="flex flex-row items-center justify-between">
<Spacer y={4} /> <DataTableFilterCustom filters={filterUsers || []} />
<DataTableFilterCustom filters={filterUsers || []} />
<Button asChild>
<Link href="/invitations/new">
Invite User
<AddIcon size={20} />
</Link>
</Button>
</div>
<Spacer y={8} /> <Spacer y={8} />
<Suspense key={searchParamsKey} fallback={<SkeletonTableUser />}> <Suspense key={searchParamsKey} fallback={<SkeletonTableUser />}>

View File

@@ -9,6 +9,12 @@ import { useTheme } from "next-themes";
import { FC } from "react"; import { FC } from "react";
import React from "react"; import React from "react";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/shadcn/tooltip";
import { MoonFilledIcon, SunFilledIcon } from "./icons"; import { MoonFilledIcon, SunFilledIcon } from "./icons";
export interface ThemeSwitchProps { export interface ThemeSwitchProps {
@@ -41,43 +47,50 @@ export const ThemeSwitch: FC<ThemeSwitchProps> = ({
}); });
return ( return (
<Component <Tooltip>
{...getBaseProps({ <TooltipTrigger asChild>
className: clsx( <Component
"px-px transition-opacity hover:opacity-80 cursor-pointer", {...getBaseProps({
className, className: clsx(
classNames?.base, "px-px transition-opacity hover:opacity-80 cursor-pointer",
), className,
})} classNames?.base,
> ),
<VisuallyHidden> })}
<input {...getInputProps()} /> >
</VisuallyHidden> <VisuallyHidden>
<div <input {...getInputProps()} />
{...getWrapperProps()} </VisuallyHidden>
className={slots.wrapper({ <div
class: clsx( {...getWrapperProps()}
[ className={slots.wrapper({
"h-auto w-auto", class: clsx(
"bg-transparent", [
"rounded-lg", "h-auto w-auto",
"flex items-center justify-center", "bg-transparent",
"group-data-[selected=true]:bg-transparent", "rounded-lg",
"!text-default-500", "flex items-center justify-center",
"pt-px", "group-data-[selected=true]:bg-transparent",
"px-0", "!text-default-500",
"mx-0", "pt-px",
], "px-0",
classNames?.wrapper, "mx-0",
), ],
})} classNames?.wrapper,
> ),
{!isSelected || isSSR ? ( })}
<SunFilledIcon size={22} /> >
) : ( {!isSelected || isSSR ? (
<MoonFilledIcon size={22} /> <SunFilledIcon size={22} />
)} ) : (
</div> <MoonFilledIcon size={22} />
</Component> )}
</div>
</Component>
</TooltipTrigger>
<TooltipContent>
{isSelected || isSSR ? "Switch to Dark Mode" : "Switch to Light Mode"}
</TooltipContent>
</Tooltip>
); );
}; };

View File

@@ -14,7 +14,7 @@ export const AuthFooterLink = ({
return ( return (
<p className="text-small text-center"> <p className="text-small text-center">
{text}&nbsp; {text}&nbsp;
<CustomLink size="base" href={href} target="_self"> <CustomLink size="md" href={href} target="_self">
{linkText} {linkText}
</CustomLink> </CustomLink>
</p> </p>

View File

@@ -1,6 +1,5 @@
"use client"; "use client";
import { Button } from "@heroui/button";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import { Icon } from "@iconify/react"; import { Icon } from "@iconify/react";
import { useRouter, useSearchParams } from "next/navigation"; 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 { AuthFooterLink } from "@/components/auth/oss/auth-footer-link";
import { AuthLayout } from "@/components/auth/oss/auth-layout"; import { AuthLayout } from "@/components/auth/oss/auth-layout";
import { SocialButtons } from "@/components/auth/oss/social-buttons"; import { SocialButtons } from "@/components/auth/oss/social-buttons";
import { Button } from "@/components/shadcn";
import { useToast } from "@/components/ui"; 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 { Form } from "@/components/ui/form";
import { SignInFormData, signInSchema } from "@/types"; import { SignInFormData, signInSchema } from "@/types";
@@ -156,32 +156,21 @@ export const SignInForm = ({
type="email" type="email"
label="Email" label="Email"
placeholder="Enter your email" placeholder="Enter your email"
isInvalid={!!form.formState.errors.email}
showFormMessage showFormMessage
/> />
{!isSamlMode && ( {!isSamlMode && (
<CustomInput <CustomInput control={form.control} name="password" password />
control={form.control}
name="password"
password
isInvalid={!!form.formState.errors.password}
/>
)} )}
<CustomButton <Button
type="submit" type="submit"
ariaLabel="Log in" aria-label="Log in"
ariaDisabled={isLoading} aria-disabled={isLoading}
className="w-full" className="w-full"
variant="solid" disabled={isLoading}
color="action"
size="md"
radius="md"
isLoading={isLoading}
isDisabled={isLoading}
> >
{isLoading ? <span>Loading</span> : <span>Log in</span>} {isLoading ? "Loading..." : "Log in"}
</CustomButton> </Button>
</form> </form>
</Form> </Form>
@@ -197,21 +186,19 @@ export const SignInForm = ({
/> />
)} )}
<Button <Button
startContent={ variant="outline"
!isSamlMode && ( className="w-full gap-2"
<Icon
className="text-default-500"
icon="mdi:shield-key"
width={24}
/>
)
}
variant="bordered"
className="w-full"
onClick={() => { onClick={() => {
form.setValue("isSamlMode", !isSamlMode); form.setValue("isSamlMode", !isSamlMode);
}} }}
> >
{!isSamlMode && (
<Icon
className="text-default-500"
icon="mdi:shield-key"
width={24}
/>
)}
{isSamlMode ? "Back" : "Continue with SAML SSO"} {isSamlMode ? "Back" : "Continue with SAML SSO"}
</Button> </Button>
</div> </div>

View File

@@ -11,8 +11,9 @@ import { AuthFooterLink } from "@/components/auth/oss/auth-footer-link";
import { AuthLayout } from "@/components/auth/oss/auth-layout"; import { AuthLayout } from "@/components/auth/oss/auth-layout";
import { PasswordRequirementsMessage } from "@/components/auth/oss/password-validator"; import { PasswordRequirementsMessage } from "@/components/auth/oss/password-validator";
import { SocialButtons } from "@/components/auth/oss/social-buttons"; import { SocialButtons } from "@/components/auth/oss/social-buttons";
import { Button } from "@/components/shadcn";
import { useToast } from "@/components/ui"; 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 { CustomLink } from "@/components/ui/custom/custom-link";
import { import {
Form, Form,
@@ -133,7 +134,6 @@ export const SignUpForm = ({
type="text" type="text"
label="Name" label="Name"
placeholder="Enter your name" placeholder="Enter your name"
isInvalid={!!form.formState.errors.name}
/> />
<CustomInput <CustomInput
control={form.control} control={form.control}
@@ -142,7 +142,6 @@ export const SignUpForm = ({
label="Company name" label="Company name"
placeholder="Enter your company name" placeholder="Enter your company name"
isRequired={false} isRequired={false}
isInvalid={!!form.formState.errors.company}
/> />
<CustomInput <CustomInput
control={form.control} control={form.control}
@@ -150,15 +149,9 @@ export const SignUpForm = ({
type="email" type="email"
label="Email" label="Email"
placeholder="Enter your email" placeholder="Enter your email"
isInvalid={!!form.formState.errors.email}
showFormMessage showFormMessage
/> />
<CustomInput <CustomInput control={form.control} name="password" password />
control={form.control}
name="password"
password
isInvalid={!!form.formState.errors.password}
/>
<PasswordRequirementsMessage <PasswordRequirementsMessage
password={form.watch("password") || ""} password={form.watch("password") || ""}
/> />
@@ -176,7 +169,6 @@ export const SignUpForm = ({
placeholder={invitationToken} placeholder={invitationToken}
defaultValue={invitationToken} defaultValue={invitationToken}
isRequired={false} isRequired={false}
isInvalid={!!form.formState.errors.invitationToken}
isDisabled={invitationToken !== null && true} isDisabled={invitationToken !== null && true}
/> />
)} )}
@@ -194,6 +186,7 @@ export const SignUpForm = ({
size="sm" size="sm"
checked={field.value} checked={field.value}
onChange={(e) => field.onChange(e.target.checked)} onChange={(e) => field.onChange(e.target.checked)}
color="default"
> >
I agree with the&nbsp; I agree with the&nbsp;
<CustomLink <CustomLink
@@ -205,26 +198,21 @@ export const SignUpForm = ({
&nbsp;of Prowler &nbsp;of Prowler
</Checkbox> </Checkbox>
</FormControl> </FormControl>
<FormMessage className="text-system-error dark:text-system-error" /> <FormMessage className="text-text-error" />
</> </>
)} )}
/> />
)} )}
<CustomButton <Button
type="submit" type="submit"
ariaLabel="Sign up" aria-label="Sign up"
ariaDisabled={isLoading} aria-disabled={isLoading}
className="w-full" className="w-full"
variant="solid" disabled={isLoading}
color="action"
size="md"
radius="md"
isLoading={isLoading}
isDisabled={isLoading}
> >
{isLoading ? <span>Loading</span> : <span>Sign up</span>} {isLoading ? "Loading..." : "Sign up"}
</CustomButton> </Button>
</form> </form>
</Form> </Form>

View File

@@ -1,7 +1,7 @@
import { Button } from "@heroui/button";
import { Tooltip } from "@heroui/tooltip"; import { Tooltip } from "@heroui/tooltip";
import { Icon } from "@iconify/react"; import { Icon } from "@iconify/react";
import { Button } from "@/components/shadcn";
import { CustomLink } from "@/components/ui/custom/custom-link"; import { CustomLink } from "@/components/ui/custom/custom-link";
export const SocialButtons = ({ export const SocialButtons = ({
@@ -32,14 +32,22 @@ export const SocialButtons = ({
> >
<span> <span>
<Button <Button
startContent={<Icon icon="flat-color-icons:google" width={24} />} variant="outline"
variant="bordered"
className="w-full" className="w-full"
as="a" asChild={isGoogleOAuthEnabled}
href={googleAuthUrl} disabled={!isGoogleOAuthEnabled}
isDisabled={!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> </Button>
</span> </span>
</Tooltip> </Tooltip>
@@ -59,16 +67,15 @@ export const SocialButtons = ({
> >
<span> <span>
<Button <Button
startContent={ variant="outline"
<Icon className="text-default-500" icon="fe:github" width={24} />
}
variant="bordered"
className="w-full" className="w-full"
as="a" asChild={isGithubOAuthEnabled}
href={githubAuthUrl} disabled={!isGithubOAuthEnabled}
isDisabled={!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> </Button>
</span> </span>
</Tooltip> </Tooltip>

View File

@@ -58,7 +58,7 @@ export const ClientAccordionWrapper = ({
return ( return (
<div> <div>
{!hideExpandButton && ( {!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 <button
onClick={handleToggleExpand} onClick={handleToggleExpand}
aria-label={isExpanded ? "Collapse all" : "Expand all"} aria-label={isExpanded ? "Collapse all" : "Expand all"}

View File

@@ -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="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"> <div className="overflow-hidden md:min-w-0 md:flex-1">
<span <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} title={label}
> >
{label.charAt(0).toUpperCase() + label.slice(1)} {label.charAt(0).toUpperCase() + label.slice(1)}
@@ -57,8 +57,8 @@ export const ComplianceAccordionTitle = ({
delay={0} delay={0}
closeDelay={0} closeDelay={0}
> >
<div <span
className="h-full bg-[#3CEC6D] transition-all duration-200 hover:brightness-110" className="inline-block h-full bg-[#3CEC6D] transition-all duration-200 hover:brightness-110"
style={{ style={{
width: `${passPercentage}%`, width: `${passPercentage}%`,
marginRight: pass > 0 ? "2px" : "0", marginRight: pass > 0 ? "2px" : "0",
@@ -81,8 +81,8 @@ export const ComplianceAccordionTitle = ({
delay={0} delay={0}
closeDelay={0} closeDelay={0}
> >
<div <span
className="h-full bg-[#FB718F] transition-all duration-200 hover:brightness-110" className="inline-block h-full bg-[#FB718F] transition-all duration-200 hover:brightness-110"
style={{ style={{
width: `${failPercentage}%`, width: `${failPercentage}%`,
marginRight: manual > 0 ? "2px" : "0", marginRight: manual > 0 ? "2px" : "0",
@@ -105,8 +105,8 @@ export const ComplianceAccordionTitle = ({
delay={0} delay={0}
closeDelay={0} closeDelay={0}
> >
<div <span
className="h-full bg-[#868994] transition-all duration-200 hover:brightness-110" className="inline-block h-full bg-[#868994] transition-all duration-200 hover:brightness-110"
style={{ width: `${manualPercentage}%` }} style={{ width: `${manualPercentage}%` }}
/> />
</Tooltip> </Tooltip>

View File

@@ -1,11 +1,11 @@
"use client"; "use client";
import { Card, CardBody } from "@heroui/card";
import { Progress } from "@heroui/progress"; import { Progress } from "@heroui/progress";
import Image from "next/image"; import Image from "next/image";
import { useRouter, useSearchParams } from "next/navigation"; 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 { DownloadIconButton, toast } from "@/components/ui";
import { downloadComplianceCsv } from "@/lib/helper"; import { downloadComplianceCsv } from "@/lib/helper";
import { ScanEntity } from "@/types/scans"; import { ScanEntity } from "@/types/scans";
@@ -48,23 +48,6 @@ export const ComplianceCard: React.FC<ComplianceCardProps> = ({
(passingRequirements / totalRequirements) * 100, (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) => { const getRatingColor = (ratingPercentage: number) => {
if (ratingPercentage <= 10) { if (ratingPercentage <= 10) {
return "danger"; return "danger";
@@ -112,11 +95,13 @@ export const ComplianceCard: React.FC<ComplianceCardProps> = ({
}; };
return ( return (
<Card fullWidth isHoverable shadow="sm"> <Card
<CardBody variant="base"
className="dark:bg-prowler-blue-800 flex cursor-pointer flex-row items-center justify-between gap-4" padding="md"
onClick={navigateToDetail} className="cursor-pointer transition-shadow hover:shadow-md"
> onClick={navigateToDetail}
>
<CardContent className="p-0">
<div className="flex w-full items-center gap-4"> <div className="flex w-full items-center gap-4">
{getComplianceIcon(title) && ( {getComplianceIcon(title) && (
<Image <Image
@@ -169,11 +154,10 @@ export const ComplianceCard: React.FC<ComplianceCardProps> = ({
isDownloading={isDownloading} isDownloading={isDownloading}
/> />
</div> </div>
{/* <small>{getScanChange()}</small> */}
</div> </div>
</div> </div>
</div> </div>
</CardBody> </CardContent>
</Card> </Card>
); );
}; };

View File

@@ -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>
);
};

View File

@@ -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>
);
}

View File

@@ -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) => { export const HeatmapChart = ({ categories = [] }: HeatmapChartProps) => {
const { theme } = useTheme(); const { theme } = useTheme();
const [hoveredItem, setHoveredItem] = useState<CategoryData | null>(null); const [hoveredItem, setHoveredItem] = useState<CategoryData | null>(null);
@@ -49,10 +43,9 @@ export const HeatmapChart = ({ categories = [] }: HeatmapChartProps) => {
// Check if there are no items with data // Check if there are no items with data
if (!categories.length || heatmapData.length === 0) { if (!categories.length || heatmapData.length === 0) {
return ( return (
<div className="flex w-[400px] flex-col items-center justify-between lg:w-[400px]"> <div className="flex w-full flex-col items-center justify-center">
{title} <div className="flex h-[250px] w-full items-center justify-center">
<div className="flex h-[320px] w-full items-center justify-center"> <p className="text-sm text-slate-400">No category data available</p>
<p className="text-sm text-gray-500">No category data available</p>
</div> </div>
</div> </div>
); );
@@ -72,9 +65,7 @@ export const HeatmapChart = ({ categories = [] }: HeatmapChartProps) => {
}; };
return ( return (
<div className="flex h-[320px] w-[400px] flex-col items-center justify-between lg:w-[400px]"> <div className="flex h-full w-full flex-col items-center justify-center">
{title}
<div className="h-full w-full p-2"> <div className="h-full w-full p-2">
<div <div
className={cn( className={cn(

View File

@@ -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>
);
};

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -1,10 +1,12 @@
"use client"; "use client";
import { Spacer } from "@heroui/spacer"; 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 { 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 { DataCompliance } from "./data-compliance";
import { SelectScanComplianceDataProps } from "./scan-selector"; import { SelectScanComplianceDataProps } from "./scan-selector";
@@ -15,6 +17,10 @@ interface ComplianceHeaderProps {
showRegionFilter?: boolean; showRegionFilter?: boolean;
framework?: string; // Framework name to show specific filters framework?: string; // Framework name to show specific filters
showProviders?: boolean; showProviders?: boolean;
hideFilters?: boolean;
logoPath?: string;
complianceTitle?: string;
selectedScan?: ScanEntity | null;
} }
export const ComplianceHeader = ({ export const ComplianceHeader = ({
@@ -24,6 +30,10 @@ export const ComplianceHeader = ({
showRegionFilter = true, showRegionFilter = true,
framework, framework,
showProviders = true, showProviders = true,
hideFilters = false,
logoPath,
complianceTitle,
selectedScan,
}: ComplianceHeaderProps) => { }: ComplianceHeaderProps) => {
const frameworkFilters = []; const frameworkFilters = [];
@@ -54,18 +64,39 @@ export const ComplianceHeader = ({
const allFilters = [...frameworkFilters, ...regionFilters]; const allFilters = [...frameworkFilters, ...regionFilters];
const hasContent =
showProviders ||
showSearch ||
(!hideFilters && allFilters.length > 0) ||
selectedScan;
return ( return (
<> <>
{(showProviders || showSearch) && ( {hasContent && (
<> <div className="flex w-full items-start justify-between gap-6">
<div className="flex items-start justify-start gap-4"> <div className="flex flex-1 flex-col justify-end gap-4">
{selectedScan && <ComplianceScanInfo scan={selectedScan} />}
{showProviders && <DataCompliance scans={scans} />} {showProviders && <DataCompliance scans={scans} />}
{showSearch && <FilterControls search />} {!hideFilters && allFilters.length > 0 && (
<DataTableFilterCustom filters={allFilters} />
)}
</div> </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} />} {hasContent && <Spacer y={8} />}
<Spacer y={8} />
</> </>
); );
}; };

View File

@@ -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 { ProviderType, ScanProps } from "@/types";
import { ComplianceScanInfo } from "./compliance-scan-info"; import { ComplianceScanInfo } from "./compliance-scan-info";
@@ -21,37 +28,33 @@ export const ScanSelector = ({
selectedScanId, selectedScanId,
onSelectionChange, onSelectionChange,
}: SelectScanComplianceDataProps) => { }: SelectScanComplianceDataProps) => {
const selectedScan = scans.find((item) => item.id === selectedScanId);
return ( return (
<Select <Select
aria-label="Select a Scan" value={selectedScanId}
placeholder="Select a scan" onValueChange={(value) => {
classNames={{ if (value && value !== selectedScanId) {
trigger: "w-full min-w-[365px] rounded-lg", onSelectionChange(value);
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);
} }
}} }}
renderValue={() => {
const selectedItem = scans.find((item) => item.id === selectedScanId);
return selectedItem ? (
<ComplianceScanInfo scan={selectedItem} />
) : (
"Select a scan"
);
}}
> >
{scans.map((scan) => ( <SelectTrigger className="w-full min-w-[365px]">
<SelectItem key={scan.id} textValue={scan.attributes.name || "- -"}> <SelectValue placeholder="Select a scan">
<ComplianceScanInfo scan={scan} /> {selectedScan ? (
</SelectItem> <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> </Select>
); );
}; };

View File

@@ -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-requeriment-title";
export * from "./compliance-accordion/compliance-accordion-title"; export * from "./compliance-accordion/compliance-accordion-title";
export * from "./compliance-card"; 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/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/cis-details";
export * from "./compliance-custom-details/ens-details"; export * from "./compliance-custom-details/ens-details";
export * from "./compliance-custom-details/iso-details"; export * from "./compliance-custom-details/iso-details";

View File

@@ -1,40 +1,45 @@
"use client"; "use client";
import Link from "next/link";
import React from "react"; 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 { InfoIcon } from "../icons/Icons";
import { CustomButton } from "../ui/custom";
export const NoScansAvailable = () => { export const NoScansAvailable = () => {
return ( return (
<div className="flex h-full min-h-[calc(100vh-56px)] items-center justify-center"> <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="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"> <Card variant="base" padding="lg">
<div className="flex w-full items-center justify-between gap-6"> <CardContent>
<div className="flex items-start gap-4"> <div className="flex w-full items-center justify-between gap-6">
<InfoIcon className="mt-1 h-5 w-5 text-gray-400 dark:text-gray-300" /> <div className="flex items-start gap-4">
<div> <InfoIcon className="mt-1 h-5 w-5 text-gray-400 dark:text-gray-300" />
<h2 className="mb-1 text-base font-medium text-gray-900 dark:text-white"> <div>
No Scans available <h2 className="mb-1 text-base font-medium text-gray-900 dark:text-white">
</h2> No Scans available
<p className="text-sm text-gray-500 dark:text-gray-300"> </h2>
A scan must be completed before generating a compliance <p className="text-sm text-gray-500 dark:text-gray-300">
report. A scan must be completed before generating a compliance
</p> report.
</p>
</div>
</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> </div>
<CustomButton </CardContent>
asLink="/scans" </Card>
className="shrink-0"
ariaLabel="Go to Scans page"
variant="solid"
color="action"
size="sm"
>
Go to Scans
</CustomButton>
</div>
</div>
</div> </div>
</div> </div>
); );

View File

@@ -1,6 +1,5 @@
"use client"; "use client";
import { Button } from "@heroui/button";
import { Card, CardBody } from "@heroui/card"; import { Card, CardBody } from "@heroui/card";
import { Progress } from "@heroui/progress"; import { Progress } from "@heroui/progress";
import { DownloadIcon, FileTextIcon } from "lucide-react"; import { DownloadIcon, FileTextIcon } from "lucide-react";
@@ -8,6 +7,7 @@ import { useRouter, useSearchParams } from "next/navigation";
import { useState } from "react"; import { useState } from "react";
import { ThreatScoreLogo } from "@/components/compliance/threatscore-logo"; import { ThreatScoreLogo } from "@/components/compliance/threatscore-logo";
import { Button } from "@/components/shadcn/button/button";
import { toast } from "@/components/ui"; import { toast } from "@/components/ui";
import { downloadComplianceCsv, downloadThreatScorePdf } from "@/lib/helper"; import { downloadComplianceCsv, downloadThreatScorePdf } from "@/lib/helper";
import type { ScanEntity } from "@/types/scans"; import type { ScanEntity } from "@/types/scans";
@@ -41,7 +41,7 @@ export const ThreatScoreBadge = ({
const getTextColor = () => { const getTextColor = () => {
if (score >= 80) return "text-success"; if (score >= 80) return "text-success";
if (score >= 40) return "text-warning"; if (score >= 40) return "text-warning";
return "text-danger"; return "text-text-error";
}; };
const handleCardClick = () => { const handleCardClick = () => {
@@ -121,24 +121,25 @@ export const ThreatScoreBadge = ({
<div className="flex gap-2"> <div className="flex gap-2">
<Button <Button
size="sm" size="sm"
variant="ghost" variant="outline"
className="text-default-500 hover:text-primary flex-1" className="flex-1"
startContent={<DownloadIcon size={14} className="text-primary" />} onClick={handleDownloadPdf}
onPress={handleDownloadPdf} disabled={isDownloadingPdf || isDownloadingCsv}
isLoading={isDownloadingPdf}
isDisabled={isDownloadingCsv}
> >
<DownloadIcon
size={14}
className={isDownloadingPdf ? "animate-download-icon" : ""}
/>
PDF PDF
</Button> </Button>
<Button <Button
size="sm" size="sm"
variant="ghost" variant="outline"
className="text-default-500 hover:text-primary flex-1" className="flex-1"
startContent={<FileTextIcon size={14} className="text-primary" />} onClick={handleDownloadCsv}
onPress={handleDownloadCsv} disabled={isDownloadingCsv || isDownloadingPdf}
isLoading={isDownloadingCsv}
isDisabled={isDownloadingPdf}
> >
<FileTextIcon size={14} />
CSV CSV
</Button> </Button>
</div> </div>

View File

@@ -14,6 +14,11 @@ import {
DropdownMenuTrigger, DropdownMenuTrigger,
Separator, Separator,
} from "@/components/shadcn"; } from "@/components/shadcn";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/shadcn/tooltip";
import { hasNewFeeds, markFeedsAsSeen } from "@/lib/feeds-storage"; import { hasNewFeeds, markFeedsAsSeen } from "@/lib/feeds-storage";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
@@ -49,30 +54,39 @@ export function FeedsClient({ feedData, error }: FeedsClientProps) {
return ( return (
<DropdownMenu onOpenChange={handleOpenChange}> <DropdownMenu onOpenChange={handleOpenChange}>
<DropdownMenuTrigger asChild> <Tooltip>
<Button <TooltipTrigger asChild>
variant="outline" <DropdownMenuTrigger asChild>
className="relative h-8 w-8 rounded-full bg-transparent p-2" <Button
aria-label={ variant="outline"
hasUnseenFeeds className="border-border-input-primary-fill relative h-8 w-8 rounded-full bg-transparent p-2"
? "New updates available - Click to view" aria-label={
: "Check for updates" hasUnseenFeeds
} ? "New updates available - Click to view"
> : "Check for updates"
<BellRing }
size={18} >
className={cn( <BellRing
hasFeeds && hasUnseenFeeds && "text-prowler-green animate-pulse", size={18}
)} className={cn(
/> hasFeeds &&
{hasFeeds && hasUnseenFeeds && ( hasUnseenFeeds &&
<span className="absolute top-0 right-0 flex h-2 w-2"> "text-button-primary animate-pulse",
<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> {hasFeeds && hasUnseenFeeds && (
)} <span className="absolute top-0 right-0 flex h-2 w-2">
</Button> <span className="bg-button-primary absolute inline-flex h-full w-full animate-ping rounded-full opacity-75"></span>
</DropdownMenuTrigger> <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 <DropdownMenuContent
align="end" align="end"
@@ -143,7 +157,7 @@ function FeedTimelineItem({ item, isLast }: FeedTimelineItemProps) {
<div className="group relative flex gap-3 px-3 py-2"> <div className="group relative flex gap-3 px-3 py-2">
{/* Timeline dot */} {/* Timeline dot */}
<div className="relative flex flex-col items-center"> <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 && ( {!isLast && (
<div className="h-full w-px bg-slate-200 dark:bg-slate-700" /> <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)]" 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"> <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} {item.title}
</h4> </h4>
{version && ( {version && (
<Badge <Badge
variant="secondary" 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} v{version}
</Badge> </Badge>
@@ -182,7 +196,7 @@ function FeedTimelineItem({ item, isLast }: FeedTimelineItemProps) {
{relativeTime} {relativeTime}
</time> </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> <span className="text-[11px] font-medium">Read more</span>
<ExternalLink size={10} /> <ExternalLink size={10} />
</div> </div>

View File

@@ -1,9 +1,10 @@
"use client"; "use client";
import { CrossIcon } from "@/components/icons"; import { XCircle } from "lucide-react";
import { useUrlFilters } from "@/hooks/use-url-filters"; import { useUrlFilters } from "@/hooks/use-url-filters";
import { CustomButton } from "../ui/custom/custom-button"; import { Button } from "../shadcn";
export interface ClearFiltersButtonProps { export interface ClearFiltersButtonProps {
className?: string; className?: string;
@@ -12,7 +13,6 @@ export interface ClearFiltersButtonProps {
} }
export const ClearFiltersButton = ({ export const ClearFiltersButton = ({
className = "w-full md:w-fit",
text = "Clear all filters", text = "Clear all filters",
ariaLabel = "Reset", ariaLabel = "Reset",
}: ClearFiltersButtonProps) => { }: ClearFiltersButtonProps) => {
@@ -23,16 +23,9 @@ export const ClearFiltersButton = ({
} }
return ( return (
<CustomButton <Button aria-label={ariaLabel} onClick={clearAllFilters} variant="link">
ariaLabel={ariaLabel} <XCircle className="mr-0.5 size-4" />
className={className}
onPress={clearAllFilters}
variant="dashed"
size="md"
endContent={<CrossIcon size={24} />}
radius="sm"
>
{text} {text}
</CustomButton> </Button>
); );
}; };

View File

@@ -25,7 +25,7 @@ export const CustomCheckboxMutedFindings = () => {
}; };
return ( return (
<div className="flex h-full"> <div className="flex h-full text-nowrap">
<Checkbox <Checkbox
classNames={{ classNames={{
label: "text-small", label: "text-small",

View File

@@ -61,13 +61,30 @@ export const CustomDatePicker = () => {
return ( return (
<div className="flex w-full flex-col md:gap-2"> <div className="flex w-full flex-col md:gap-2">
<DatePicker <DatePicker
style={{
borderRadius: "0.5rem",
}}
aria-label="Select a Date" aria-label="Select a Date"
label="Date" classNames={{
labelPlacement="inside" 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={ CalendarTopContent={
<ButtonGroup <ButtonGroup
fullWidth 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" radius="full"
size="sm" size="sm"
variant="flat" variant="flat"
@@ -93,8 +110,6 @@ export const CustomDatePicker = () => {
}} }}
value={value} value={value}
onChange={handleDateChange} onChange={handleDateChange}
size="sm"
variant="flat"
/> />
</div> </div>
); );

View File

@@ -54,16 +54,23 @@ export const CustomSearchInput: React.FC = () => {
return ( return (
<Input <Input
variant="flat" style={{
borderRadius: "0.5rem",
}}
classNames={{ 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" aria-label="Search"
label="Search"
placeholder="Search..." placeholder="Search..."
labelPlacement="inside"
value={searchQuery} value={searchQuery}
startContent={<SearchIcon className="text-default-400" width={16} />} startContent={
<SearchIcon className="text-bg-button-secondary shrink-0" width={16} />
}
onChange={(e) => { onChange={(e) => {
const value = e.target.value; const value = e.target.value;
setSearchQuery(value); setSearchQuery(value);
@@ -71,13 +78,14 @@ export const CustomSearchInput: React.FC = () => {
}} }}
endContent={ endContent={
searchQuery && ( searchQuery && (
<button onClick={clearIconSearch} className="focus:outline-none"> <button
<XCircle className="text-default-400 h-4 w-4" /> onClick={clearIconSearch}
className="text-bg-button-secondary shrink-0 focus:outline-none"
>
<XCircle className="text-bg-button-secondary h-4 w-4" />
</button> </button>
) )
} }
radius="sm"
size="sm"
/> />
); );
}; };

View File

@@ -1,13 +1,11 @@
"use client"; "use client";
import { Spacer } from "@heroui/spacer"; import { Spacer } from "@heroui/spacer";
import { useSearchParams } from "next/navigation"; import React from "react";
import React, { useEffect, useState } from "react";
import { FilterOption } from "@/types"; import { FilterOption } from "@/types";
import { DataTableFilterCustom } from "../ui/table"; import { DataTableFilterCustom } from "../ui/table";
import { ClearFiltersButton } from "./clear-filters-button";
import { CustomAccountSelection } from "./custom-account-selection"; import { CustomAccountSelection } from "./custom-account-selection";
import { CustomCheckboxMutedFindings } from "./custom-checkbox-muted-findings"; import { CustomCheckboxMutedFindings } from "./custom-checkbox-muted-findings";
import { CustomDatePicker } from "./custom-date-picker"; import { CustomDatePicker } from "./custom-date-picker";
@@ -23,7 +21,6 @@ export interface FilterControlsProps {
accounts?: boolean; accounts?: boolean;
mutedFindings?: boolean; mutedFindings?: boolean;
customFilters?: FilterOption[]; customFilters?: FilterOption[];
showClearButton?: boolean;
} }
export const FilterControls: React.FC<FilterControlsProps> = ({ export const FilterControls: React.FC<FilterControlsProps> = ({
@@ -33,40 +30,22 @@ export const FilterControls: React.FC<FilterControlsProps> = ({
regions = false, regions = false,
accounts = false, accounts = false,
mutedFindings = false, mutedFindings = false,
showClearButton = true,
customFilters, 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 ( return (
<div className="flex flex-col"> <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"> <div className="flex flex-col items-start gap-4 md:flex-row md:items-center">
{search && <CustomSearchInput />} <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">
{providers && <CustomSelectProvider />} {search && <CustomSearchInput />}
{date && <CustomDatePicker />} {providers && <CustomSelectProvider />}
{regions && <CustomRegionSelection />} {date && <CustomDatePicker />}
{accounts && <CustomAccountSelection />} {regions && <CustomRegionSelection />}
{mutedFindings && <CustomCheckboxMutedFindings />} {accounts && <CustomAccountSelection />}
{!customFilters && hasFilters && showClearButton && ( {mutedFindings && <CustomCheckboxMutedFindings />}
<ClearFiltersButton /> </div>
)}
</div> </div>
<Spacer y={8} /> <Spacer y={8} />
{customFilters && ( {customFilters && <DataTableFilterCustom filters={customFilters} />}
<DataTableFilterCustom
filters={customFilters}
showClearButton={showClearButton}
defaultOpen
/>
)}
</div> </div>
); );
}; };

View File

@@ -282,7 +282,7 @@ export const SendToJiraModal = ({
))} ))}
</Select> </Select>
</FormControl> </FormControl>
<FormMessage className="text-system-error text-xs" /> <FormMessage className="text-text-error text-xs" />
</> </>
)} )}
/> />
@@ -366,105 +366,38 @@ export const SendToJiraModal = ({
))} ))}
</Select> </Select>
</FormControl> </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 */} {/* No integrations or none connected message */}
{!isFetchingIntegrations && {!isFetchingIntegrations &&
(integrations.length === 0 || !hasConnectedIntegration) && ( (integrations.length === 0 || !hasConnectedIntegration) ? (
<CustomBanner <CustomBanner
title="Jira integration is not available" title="Jira integration is not available"
message="Please add or connect an integration first" message="Please add or connect an integration first"
buttonLabel="Configure" buttonLabel="Configure"
buttonLink="/integrations/jira" buttonLink="/integrations/jira"
/> />
)} ) : (
<FormButtons
<FormButtons setIsOpen={setOpenForFormButtons}
setIsOpen={setOpenForFormButtons} onCancel={() => onOpenChange(false)}
onCancel={() => onOpenChange(false)} submitText="Send to Jira"
submitText="Send to Jira" cancelText="Cancel"
cancelText="Cancel" loadingText="Sending..."
loadingText="Sending..." isDisabled={
isDisabled={ !form.formState.isValid ||
!form.formState.isValid || form.formState.isSubmitting ||
form.formState.isSubmitting || isFetchingIntegrations ||
isFetchingIntegrations || integrations.length === 0 ||
integrations.length === 0 || !hasConnectedIntegration
!hasConnectedIntegration }
} rightIcon={<Send size={20} />}
rightIcon={<Send size={20} />} />
/> )}
</form> </form>
</Form> </Form>
</CustomAlertModal> </CustomAlertModal>

View File

@@ -71,7 +71,9 @@ const FindingDetailsCell = ({ row }: { row: any }) => {
return ( return (
<div className="flex max-w-10 justify-center"> <div className="flex max-w-10 justify-center">
<TriggerSheet <TriggerSheet
triggerComponent={<InfoIcon className="text-primary" size={16} />} triggerComponent={
<InfoIcon className="text-button-primary" size={16} />
}
title="Finding Details" title="Finding Details"
description="View the finding details" description="View the finding details"
defaultOpen={isOpen} defaultOpen={isOpen}

View File

@@ -1,6 +1,5 @@
"use client"; "use client";
import { Button } from "@heroui/button";
import { import {
Dropdown, Dropdown,
DropdownItem, DropdownItem,
@@ -14,6 +13,7 @@ import { useState } from "react";
import { SendToJiraModal } from "@/components/findings/send-to-jira-modal"; import { SendToJiraModal } from "@/components/findings/send-to-jira-modal";
import { VerticalDotsIcon } from "@/components/icons"; import { VerticalDotsIcon } from "@/components/icons";
import { JiraIcon } from "@/components/icons/services/IconServices"; import { JiraIcon } from "@/components/icons/services/IconServices";
import { Button } from "@/components/shadcn";
import type { FindingProps } from "@/types/components"; import type { FindingProps } from "@/types/components";
interface DataTableRowActionsProps { interface DataTableRowActionsProps {
@@ -38,12 +38,12 @@ export function DataTableRowActions({ row }: DataTableRowActionsProps) {
<div className="relative flex items-center justify-end gap-2"> <div className="relative flex items-center justify-end gap-2">
<Dropdown <Dropdown
className="dark:bg-prowler-blue-800 shadow-xl" className="border-border-neutral-secondary bg-bg-neutral-secondary border shadow-xl"
placement="bottom" placement="bottom"
> >
<DropdownTrigger> <DropdownTrigger>
<Button isIconOnly radius="full" size="sm" variant="light"> <Button variant="ghost" size="icon-sm" className="rounded-full">
<VerticalDotsIcon className="text-default-400" /> <VerticalDotsIcon className="text-slate-400" />
</Button> </Button>
</DropdownTrigger> </DropdownTrigger>
<DropdownMenu <DropdownMenu

View File

@@ -1,6 +1,6 @@
import { Tooltip } from "@heroui/tooltip"; import { Tooltip } from "@heroui/tooltip";
import { CustomButton } from "@/components/ui/custom/custom-button"; import { Button } from "@/components/shadcn";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
interface DeltaIndicatorProps { interface DeltaIndicatorProps {
@@ -18,16 +18,21 @@ export const DeltaIndicator = ({ delta }: DeltaIndicatorProps) => {
? "New finding." ? "New finding."
: "Status changed since the previous scan."} : "Status changed since the previous scan."}
</span> </span>
<CustomButton <Button
ariaLabel="Learn more about findings" aria-label="Learn more about findings"
color="transparent" variant="link"
size="sm" size="default"
className="text-primary h-auto min-w-0 p-0" className="text-button-primary h-auto min-w-0 p-0 text-xs"
asLink="https://docs.prowler.com/projects/prowler-open-source/en/latest/tutorials/prowler-app/#step-8-analyze-the-findings" asChild
target="_blank"
> >
Learn more <a
</CustomButton> 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> </div>
} }
> >

View File

@@ -5,8 +5,14 @@ import { Tooltip } from "@heroui/tooltip";
import { ExternalLink, Link } from "lucide-react"; import { ExternalLink, Link } from "lucide-react";
import ReactMarkdown from "react-markdown"; import ReactMarkdown from "react-markdown";
import {
Card,
CardAction,
CardContent,
CardHeader,
CardTitle,
} from "@/components/shadcn";
import { CodeSnippet } from "@/components/ui/code-snippet/code-snippet"; import { CodeSnippet } from "@/components/ui/code-snippet/code-snippet";
import { CustomSection } from "@/components/ui/custom";
import { CustomLink } from "@/components/ui/custom/custom-link"; import { CustomLink } from "@/components/ui/custom/custom-link";
import { EntityInfoShort, InfoField } from "@/components/ui/entities"; import { EntityInfoShort, InfoField } from "@/components/ui/entities";
import { DateWithTime } from "@/components/ui/entities/date-with-time"; import { DateWithTime } from "@/components/ui/entities/date-with-time";
@@ -92,7 +98,13 @@ export const FindingDetail = ({
isMuted={attributes.muted} isMuted={attributes.muted}
mutedReason={attributes.muted_reason || ""} 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 <div
className={`rounded-lg px-3 py-1 text-sm font-semibold ${ className={`rounded-lg px-3 py-1 text-sm font-semibold ${
attributes.status === "PASS" attributes.status === "PASS"
@@ -104,258 +116,265 @@ export const FindingDetail = ({
> >
{renderValue(attributes.status)} {renderValue(attributes.status)}
</div> </div>
</div> </CardHeader>
</div> <CardContent className="flex flex-col gap-4">
<div className="flex flex-wrap gap-4">
{/* Check Metadata */} <EntityInfoShort
<CustomSection title="Finding Details"> cloudProvider={providerDetails.provider as ProviderType}
<div className="flex flex-wrap gap-4"> entityAlias={providerDetails.alias}
<EntityInfoShort entityId={providerDetails.uid}
cloudProvider={providerDetails.provider as ProviderType} showConnectionStatus={providerDetails.connection.connected}
entityAlias={providerDetails.alias} />
entityId={providerDetails.uid} <InfoField label="Service">
showConnectionStatus={providerDetails.connection.connected} {attributes.check_metadata.servicename}
/>
<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>
</InfoField> </InfoField>
)} <InfoField label="Region">{resource.region}</InfoField>
<InfoField label="Severity" variant="simple"> <InfoField label="First Seen">
<SeverityBadge severity={attributes.severity || "-"} /> <DateWithTime inline dateTime={attributes.first_seen_at || "-"} />
</InfoField> </InfoField>
</div> {attributes.delta && (
<InfoField label="Finding ID" variant="simple"> <InfoField
<CodeSnippet value={findingDetails.id} /> label="Delta"
</InfoField> tooltipContent="Indicates whether the finding is new (NEW), has changed status (CHANGED), or remains unchanged (NONE) compared to previous scans."
<InfoField label="Check ID" variant="simple"> className="capitalize"
<CodeSnippet value={attributes.check_id} /> >
</InfoField> <div className="flex items-center gap-2">
<InfoField label="Finding UID" variant="simple"> <DeltaIndicator delta={attributes.delta} />
<CodeSnippet value={attributes.uid} /> {attributes.delta}
</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>
)}
</div> </div>
</InfoField> </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.status === "FAIL" && (
{attributes.check_metadata.remediation.code.cli && ( <InfoField label="Risk" variant="simple">
<InfoField label="CLI Command" variant="simple"> <Snippet
<Snippet className="bg-gray-50 py-1 dark:bg-slate-800"> className="max-w-full py-2"
<span className="text-xs whitespace-pre-line"> color="danger"
{attributes.check_metadata.remediation.code.cli} hideCopyButton
</span> hideSymbol
</Snippet> >
</InfoField>
)}
{/* Remediation Steps section */}
{attributes.check_metadata.remediation.code.other && (
<InfoField label="Remediation Steps">
<MarkdownContainer> <MarkdownContainer>
{attributes.check_metadata.remediation.code.other} {attributes.check_metadata.risk}
</MarkdownContainer> </MarkdownContainer>
</InfoField> </Snippet>
)}
{/* 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} />
</InfoField> </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> </div>
); );
}; };

View File

@@ -1,11 +1,39 @@
import React from "react"; 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 = () => { export const SkeletonTableFindings = () => {
const columns = 7;
const rows = 4;
return ( return (
<div className="bg-card rounded-xl border p-4 shadow-sm"> <Card variant="base" padding="md" className="flex flex-col gap-4">
<SkeletonTable rows={4} columns={7} /> {/* Table headers */}
</div> <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>
); );
}; };

View File

@@ -11,9 +11,14 @@ interface HorizontalBarChartProps {
data: BarDataPoint[]; data: BarDataPoint[];
height?: number; height?: number;
title?: string; 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 [hoveredIndex, setHoveredIndex] = useState<number | null>(null);
const total = data.reduce((sum, d) => sum + (Number(d.value) || 0), 0); 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)} onMouseLeave={() => !isEmpty && setHoveredIndex(null)}
> >
{/* Label */} {/* Label */}
<div className="w-20 shrink-0"> <div className={`w-20 md:${labelWidth} shrink-0`}>
<span <span
className="text-text-neutral-secondary text-sm font-medium" className="text-text-neutral-secondary block truncate text-sm font-medium"
style={{ style={{
opacity: isFaded ? 0.5 : 1, opacity: isFaded ? 0.5 : 1,
transition: "opacity 0.2s", transition: "opacity 0.2s",
}} }}
title={item.name}
> >
{item.name === "Informational" ? "Info" : item.name} {item.name === "Informational" ? "Info" : item.name}
</span> </span>
@@ -134,17 +140,17 @@ export function HorizontalBarChart({ data, title }: HorizontalBarChartProps) {
{/* Percentage and Count */} {/* Percentage and Count */}
<div <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={{ style={{
opacity: isFaded ? 0.5 : 1, opacity: isFaded ? 0.5 : 1,
transition: "opacity 0.2s", 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}% {isEmpty ? "0" : item.percentage}%
</span> </span>
<span className="font-medium"></span> <span className="shrink-0 font-medium"></span>
<span className="font-bold"> <span className="font-bold whitespace-nowrap">
{isEmpty ? "0" : item.value.toLocaleString()} {isEmpty ? "0" : item.value.toLocaleString()}
</span> </span>
</div> </div>

View File

@@ -32,11 +32,11 @@ export function AlertPill({
> >
<AlertTriangle <AlertTriangle
size={iconSize} size={iconSize}
style={{ color: "var(--color-text-error)" }} style={{ color: "var(--color-text-text-error)" }}
/> />
<span <span
className={cn(textSizeClass, "font-semibold")} className={cn(textSizeClass, "font-semibold")}
style={{ color: "var(--color-text-error)" }} style={{ color: "var(--color-text-text-error)" }}
> >
{value} {value}
</span> </span>

View File

@@ -70,10 +70,10 @@ function getMapColors(): MapColorsConfig {
landStroke: landStroke:
getVar("--border-neutral-tertiary") || DEFAULT_MAP_COLORS.landStroke, getVar("--border-neutral-tertiary") || DEFAULT_MAP_COLORS.landStroke,
pointDefault: pointDefault:
getVar("--text-error-primary") || DEFAULT_MAP_COLORS.pointDefault, getVar("--text-text-error") || DEFAULT_MAP_COLORS.pointDefault,
pointSelected: pointSelected:
getVar("--bg-button-primary") || DEFAULT_MAP_COLORS.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; return colors;

View File

@@ -1131,7 +1131,7 @@ export const LighthouseIcon: React.FC<IconSvgProps> = ({
width={size || width} width={size || width}
height={size || height} height={size || height}
fill="none" fill="none"
stroke="#8ce112" stroke="var(--bg-button-primary)"
strokeWidth="12" strokeWidth="12"
strokeLinecap="round" strokeLinecap="round"
strokeLinejoin="round" strokeLinejoin="round"
@@ -1188,3 +1188,69 @@ export const BellIcon: React.FC<IconSvgProps> = ({
</svg> </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>
);
};

View File

@@ -1,16 +1,18 @@
"use client"; "use client";
import { Card, CardBody, CardHeader } from "@heroui/card";
import { SettingsIcon } from "lucide-react"; import { SettingsIcon } from "lucide-react";
import Link from "next/link";
import { JiraIcon } from "@/components/icons/services/IconServices"; 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 { CustomLink } from "@/components/ui/custom/custom-link";
import { Card, CardContent, CardHeader } from "../../shadcn";
export const JiraIntegrationCard = () => { export const JiraIntegrationCard = () => {
return ( return (
<Card className="dark:bg-gray-800"> <Card variant="base" padding="lg">
<CardHeader className="gap-2"> <CardHeader>
<div className="flex w-full flex-col items-start gap-2 sm:flex-row sm:items-center sm:justify-between"> <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"> <div className="flex items-center gap-3">
<JiraIcon size={40} /> <JiraIcon size={40} />
@@ -33,26 +35,21 @@ export const JiraIntegrationCard = () => {
</div> </div>
</div> </div>
<div className="flex items-center gap-2 self-end sm:self-center"> <div className="flex items-center gap-2 self-end sm:self-center">
<CustomButton <Button asChild size="sm">
size="sm" <Link href="/integrations/jira">
variant="bordered" <SettingsIcon size={14} />
startContent={<SettingsIcon size={14} />} Manage
asLink="/integrations/jira" </Link>
ariaLabel="Manage Jira integrations" </Button>
>
Manage
</CustomButton>
</div> </div>
</div> </div>
</CardHeader> </CardHeader>
<CardBody> <CardContent>
<div className="flex flex-col gap-4"> <p className="text-sm text-gray-600 dark:text-gray-300">
<p className="text-sm text-gray-600 dark:text-gray-300"> Configure and manage your Jira integrations to automatically create
Configure and manage your Jira integrations to automatically create issues for security findings in your Jira projects.
issues for security findings in your Jira projects. </p>
</p> </CardContent>
</div>
</CardBody>
</Card> </Card>
); );
}; };

View File

@@ -152,7 +152,6 @@ export const JiraIntegrationForm = ({
placeholder="your-domain.atlassian.net" placeholder="your-domain.atlassian.net"
isRequired isRequired
isDisabled={isLoading} isDisabled={isLoading}
isInvalid={!!form.formState.errors.domain}
/> />
)} )}
@@ -165,7 +164,6 @@ export const JiraIntegrationForm = ({
labelPlacement="inside" labelPlacement="inside"
placeholder="your-domain.atlassian.net" placeholder="your-domain.atlassian.net"
isDisabled={isLoading} isDisabled={isLoading}
isInvalid={!!form.formState.errors.domain}
/> />
)} )}
@@ -178,7 +176,6 @@ export const JiraIntegrationForm = ({
placeholder="user@example.com" placeholder="user@example.com"
isRequired isRequired
isDisabled={isLoading} isDisabled={isLoading}
isInvalid={!!form.formState.errors.user_mail}
/> />
<CustomInput <CustomInput
@@ -190,7 +187,6 @@ export const JiraIntegrationForm = ({
placeholder="Enter your Jira API token" placeholder="Enter your Jira API token"
isRequired isRequired
isDisabled={isLoading} 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"> <div className="rounded-lg border border-blue-200 bg-blue-50 p-4 dark:border-blue-800 dark:bg-blue-900/20">

View File

@@ -1,6 +1,5 @@
"use client"; "use client";
import { Card, CardBody, CardHeader } from "@heroui/card";
import { format } from "date-fns"; import { format } from "date-fns";
import { PlusIcon, Trash2Icon } from "lucide-react"; import { PlusIcon, Trash2Icon } from "lucide-react";
import { useState } from "react"; import { useState } from "react";
@@ -16,13 +15,15 @@ import {
IntegrationCardHeader, IntegrationCardHeader,
IntegrationSkeleton, IntegrationSkeleton,
} from "@/components/integrations/shared"; } from "@/components/integrations/shared";
import { Button } from "@/components/shadcn";
import { useToast } from "@/components/ui"; 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 { DataTablePagination } from "@/components/ui/table/data-table-pagination";
import { triggerTestConnectionWithDelay } from "@/lib/integrations/test-connection-helper"; import { triggerTestConnectionWithDelay } from "@/lib/integrations/test-connection-helper";
import { MetaDataProps } from "@/types"; import { MetaDataProps } from "@/types";
import { IntegrationProps } from "@/types/integrations"; import { IntegrationProps } from "@/types/integrations";
import { Card, CardContent, CardHeader } from "../../shadcn";
import { JiraIntegrationForm } from "./jira-integration-form"; import { JiraIntegrationForm } from "./jira-integration-form";
interface JiraIntegrationsManagerProps { interface JiraIntegrationsManagerProps {
@@ -214,38 +215,33 @@ export const JiraIntegrationsManager = ({
title="Delete Jira Integration" title="Delete Jira Integration"
description="This action cannot be undone. This will permanently delete your Jira integration." description="This action cannot be undone. This will permanently delete your Jira integration."
> >
<div className="flex w-full justify-center gap-6"> <div className="flex w-full justify-end gap-4">
<CustomButton <Button
type="button" type="button"
ariaLabel="Cancel" variant="ghost"
className="w-full bg-transparent"
variant="faded"
size="lg" size="lg"
onPress={() => { onClick={() => {
setIsDeleteOpen(false); setIsDeleteOpen(false);
setIntegrationToDelete(null); setIntegrationToDelete(null);
}} }}
isDisabled={isDeleting !== null} disabled={isDeleting !== null}
> >
<span>Cancel</span> Cancel
</CustomButton> </Button>
<CustomButton <Button
type="button" type="button"
ariaLabel="Delete" variant="destructive"
className="w-full"
variant="solid"
color="danger"
size="lg" size="lg"
isLoading={isDeleting !== null} disabled={isDeleting !== null}
startContent={!isDeleting && <Trash2Icon size={24} />} onClick={() =>
onPress={() =>
integrationToDelete && integrationToDelete &&
handleDeleteIntegration(integrationToDelete.id) handleDeleteIntegration(integrationToDelete.id)
} }
> >
{!isDeleting && <Trash2Icon size={24} />}
{isDeleting ? "Deleting..." : "Delete"} {isDeleting ? "Deleting..." : "Delete"}
</CustomButton> </Button>
</div> </div>
</CustomAlertModal> </CustomAlertModal>
@@ -278,14 +274,10 @@ export const JiraIntegrationsManager = ({
: `${integrations.length} integration${integrations.length !== 1 ? "s" : ""} configured`} : `${integrations.length} integration${integrations.length !== 1 ? "s" : ""} configured`}
</p> </p>
</div> </div>
<CustomButton <Button onClick={handleAddIntegration}>
color="action" <PlusIcon size={16} />
startContent={<PlusIcon size={16} />}
onPress={handleAddIntegration}
ariaLabel="Add integration"
>
Add Integration Add Integration
</CustomButton> </Button>
</div> </div>
{/* Integrations List */} {/* Integrations List */}
@@ -300,8 +292,8 @@ export const JiraIntegrationsManager = ({
) : integrations.length > 0 ? ( ) : integrations.length > 0 ? (
<div className="grid gap-4"> <div className="grid gap-4">
{integrations.map((integration) => ( {integrations.map((integration) => (
<Card key={integration.id} className="dark:bg-gray-800"> <Card key={integration.id} variant="base">
<CardHeader className="pb-2"> <CardHeader>
<IntegrationCardHeader <IntegrationCardHeader
icon={<JiraIcon size={32} />} icon={<JiraIcon size={32} />}
title={`${integration.attributes.configuration.domain}`} title={`${integration.attributes.configuration.domain}`}
@@ -311,7 +303,7 @@ export const JiraIntegrationsManager = ({
/> />
</CardHeader> </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="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"> <div className="text-xs text-gray-500 dark:text-gray-300">
{integration.attributes.connection_last_checked_at && ( {integration.attributes.connection_last_checked_at && (
@@ -335,7 +327,7 @@ export const JiraIntegrationsManager = ({
isTesting={isTesting === integration.id} isTesting={isTesting === integration.id}
/> />
</div> </div>
</CardBody> </CardContent>
</Card> </Card>
))} ))}
</div> </div>

View File

@@ -1,16 +1,18 @@
"use client"; "use client";
import { Card, CardBody, CardHeader } from "@heroui/card";
import { SettingsIcon } from "lucide-react"; import { SettingsIcon } from "lucide-react";
import Link from "next/link";
import { AmazonS3Icon } from "@/components/icons/services/IconServices"; 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 { CustomLink } from "@/components/ui/custom/custom-link";
import { Card, CardContent, CardHeader } from "../../shadcn";
export const S3IntegrationCard = () => { export const S3IntegrationCard = () => {
return ( return (
<Card className="dark:bg-gray-800"> <Card variant="base" padding="lg">
<CardHeader className="gap-2"> <CardHeader>
<div className="flex w-full flex-col items-start gap-2 sm:flex-row sm:items-center sm:justify-between"> <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"> <div className="flex items-center gap-3">
<AmazonS3Icon size={40} /> <AmazonS3Icon size={40} />
@@ -33,26 +35,21 @@ export const S3IntegrationCard = () => {
</div> </div>
</div> </div>
<div className="flex items-center gap-2 self-end sm:self-center"> <div className="flex items-center gap-2 self-end sm:self-center">
<CustomButton <Button asChild size="sm">
size="sm" <Link href="/integrations/amazon-s3">
variant="bordered" <SettingsIcon size={14} />
startContent={<SettingsIcon size={14} />} Manage
asLink="/integrations/amazon-s3" </Link>
ariaLabel="Manage S3 integrations" </Button>
>
Manage
</CustomButton>
</div> </div>
</div> </div>
</CardHeader> </CardHeader>
<CardBody> <CardContent>
<div className="flex flex-col gap-4"> <p className="text-sm text-gray-600 dark:text-gray-300">
<p className="text-sm text-gray-600 dark:text-gray-300"> Configure and manage your Amazon S3 integrations to automatically
Configure and manage your Amazon S3 integrations to automatically export security findings to your S3 buckets.
export security findings to your S3 buckets. </p>
</p> </CardContent>
</div>
</CardBody>
</Card> </Card>
); );
}; };

View File

@@ -282,7 +282,6 @@ export const S3IntegrationForm = ({
providers={providers} providers={providers}
label="Cloud Providers" label="Cloud Providers"
placeholder="Select providers to integrate with" placeholder="Select providers to integrate with"
isInvalid={!!form.formState.errors.providers}
selectionMode="multiple" selectionMode="multiple"
enableSearch={true} enableSearch={true}
/> />
@@ -301,7 +300,6 @@ export const S3IntegrationForm = ({
placeholder="my-security-findings-bucket" placeholder="my-security-findings-bucket"
variant="bordered" variant="bordered"
isRequired isRequired
isInvalid={!!form.formState.errors.bucket_name}
/> />
<CustomInput <CustomInput
@@ -313,7 +311,6 @@ export const S3IntegrationForm = ({
placeholder="output" placeholder="output"
variant="bordered" variant="bordered"
isRequired isRequired
isInvalid={!!form.formState.errors.output_directory}
/> />
</div> </div>
</> </>

View File

@@ -1,6 +1,5 @@
"use client"; "use client";
import { Card, CardBody, CardHeader } from "@heroui/card";
import { format } from "date-fns"; import { format } from "date-fns";
import { PlusIcon, Trash2Icon } from "lucide-react"; import { PlusIcon, Trash2Icon } from "lucide-react";
import { useState } from "react"; import { useState } from "react";
@@ -16,14 +15,16 @@ import {
IntegrationCardHeader, IntegrationCardHeader,
IntegrationSkeleton, IntegrationSkeleton,
} from "@/components/integrations/shared"; } from "@/components/integrations/shared";
import { Button } from "@/components/shadcn";
import { useToast } from "@/components/ui"; 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 { DataTablePagination } from "@/components/ui/table/data-table-pagination";
import { triggerTestConnectionWithDelay } from "@/lib/integrations/test-connection-helper"; import { triggerTestConnectionWithDelay } from "@/lib/integrations/test-connection-helper";
import { MetaDataProps } from "@/types"; import { MetaDataProps } from "@/types";
import { IntegrationProps } from "@/types/integrations"; import { IntegrationProps } from "@/types/integrations";
import { ProviderProps } from "@/types/providers"; import { ProviderProps } from "@/types/providers";
import { Card, CardContent, CardHeader } from "../../shadcn";
import { S3IntegrationForm } from "./s3-integration-form"; import { S3IntegrationForm } from "./s3-integration-form";
interface S3IntegrationsManagerProps { interface S3IntegrationsManagerProps {
@@ -214,38 +215,33 @@ export const S3IntegrationsManager = ({
title="Delete S3 Integration" title="Delete S3 Integration"
description="This action cannot be undone. This will permanently delete your S3 integration." description="This action cannot be undone. This will permanently delete your S3 integration."
> >
<div className="flex w-full justify-center gap-6"> <div className="flex w-full justify-end gap-4">
<CustomButton <Button
type="button" type="button"
ariaLabel="Cancel" variant="ghost"
className="w-full bg-transparent"
variant="faded"
size="lg" size="lg"
onPress={() => { onClick={() => {
setIsDeleteOpen(false); setIsDeleteOpen(false);
setIntegrationToDelete(null); setIntegrationToDelete(null);
}} }}
isDisabled={isDeleting !== null} disabled={isDeleting !== null}
> >
<span>Cancel</span> Cancel
</CustomButton> </Button>
<CustomButton <Button
type="button" type="button"
ariaLabel="Delete" variant="destructive"
className="w-full"
variant="solid"
color="danger"
size="lg" size="lg"
isLoading={isDeleting !== null} disabled={isDeleting !== null}
startContent={!isDeleting && <Trash2Icon size={24} />} onClick={() =>
onPress={() =>
integrationToDelete && integrationToDelete &&
handleDeleteIntegration(integrationToDelete.id) handleDeleteIntegration(integrationToDelete.id)
} }
> >
{!isDeleting && <Trash2Icon size={24} />}
{isDeleting ? "Deleting..." : "Delete"} {isDeleting ? "Deleting..." : "Delete"}
</CustomButton> </Button>
</div> </div>
</CustomAlertModal> </CustomAlertModal>
@@ -284,14 +280,10 @@ export const S3IntegrationsManager = ({
: `${integrations.length} integration${integrations.length !== 1 ? "s" : ""} configured`} : `${integrations.length} integration${integrations.length !== 1 ? "s" : ""} configured`}
</p> </p>
</div> </div>
<CustomButton <Button onClick={handleAddIntegration}>
color="action" <PlusIcon size={16} />
startContent={<PlusIcon size={16} />}
onPress={handleAddIntegration}
ariaLabel="Add integration"
>
Add Integration Add Integration
</CustomButton> </Button>
</div> </div>
{/* Integrations List */} {/* Integrations List */}
@@ -306,8 +298,8 @@ export const S3IntegrationsManager = ({
) : integrations.length > 0 ? ( ) : integrations.length > 0 ? (
<div className="grid gap-4"> <div className="grid gap-4">
{integrations.map((integration) => ( {integrations.map((integration) => (
<Card key={integration.id} className="dark:bg-gray-800"> <Card key={integration.id} variant="base">
<CardHeader className="pb-2"> <CardHeader>
<IntegrationCardHeader <IntegrationCardHeader
icon={<AmazonS3Icon size={32} />} icon={<AmazonS3Icon size={32} />}
title={ title={
@@ -326,7 +318,7 @@ export const S3IntegrationsManager = ({
/> />
</CardHeader> </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="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"> <div className="text-xs text-gray-500 dark:text-gray-300">
{integration.attributes.connection_last_checked_at && ( {integration.attributes.connection_last_checked_at && (
@@ -351,7 +343,7 @@ export const S3IntegrationsManager = ({
isTesting={isTesting === integration.id} isTesting={isTesting === integration.id}
/> />
</div> </div>
</CardBody> </CardContent>
</Card> </Card>
))} ))}
</div> </div>

View File

@@ -12,8 +12,9 @@ import { z } from "zod";
import { createSamlConfig, updateSamlConfig } from "@/actions/integrations"; import { createSamlConfig, updateSamlConfig } from "@/actions/integrations";
import { AddIcon } from "@/components/icons"; import { AddIcon } from "@/components/icons";
import { Button, Card, CardContent, CardHeader } from "@/components/shadcn";
import { useToast } from "@/components/ui"; 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 { CustomLink } from "@/components/ui/custom/custom-link";
import { SnippetChip } from "@/components/ui/entities"; import { SnippetChip } from "@/components/ui/entities";
import { FormButtons } from "@/components/ui/form"; 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"> <Card variant="inner">
<h3 className="text-lg font-semibold"> <CardHeader className="mb-2">
Identity Provider Configuration 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>
<div> <span className="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
<span className="text-default-500 mb-2 block text-sm font-medium"> Audience:
ACS URL: </span>
</span> <SnippetChip
<SnippetChip value="urn:prowler.com:sp"
value={acsUrl} ariaLabel="Copy Audience to clipboard"
ariaLabel="Copy ACS URL to clipboard" className="h-10 w-full"
className="w-full" />
/> </div>
</div>
<div> <div>
<span className="text-default-500 mb-2 block text-sm font-medium"> <span className="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
Audience: Name ID Format:
</span> </span>
<SnippetChip <span className="w-full text-sm text-gray-600 dark:text-gray-400">
value="urn:prowler.com:sp" urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress
ariaLabel="Copy Audience to clipboard" </span>
className="w-full" </div>
/>
</div>
<div> <div>
<span className="text-default-500 mb-2 block text-sm font-medium"> <span className="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
Name ID Format: Supported Assertion Attributes:
</span> </span>
<span className="text-default-600 w-full text-sm"> <ul className="ml-4 flex flex-col gap-1 text-sm text-gray-600 dark:text-gray-400">
urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress <li> firstName</li>
</span> <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&apos;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>
<div> </Card>
<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&apos;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>
<div className="flex flex-col items-start gap-2"> <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> Metadata XML File <span className="text-red-500">*</span>
</span> </span>
<CustomButton <Button
type="button" type="button"
ariaLabel="Select Metadata XML File" variant="outline"
isDisabled={isPending} disabled={isPending}
onPress={() => { onClick={() => {
const fileInput = document.getElementById( const fileInput = document.getElementById(
"metadata_xml_file", "metadata_xml_file",
) as HTMLInputElement; ) as HTMLInputElement;
@@ -369,8 +371,7 @@ export const SamlConfigForm = ({
fileInput.click(); fileInput.click();
} }
}} }}
startContent={<AddIcon size={20} />} className={`justify-start gap-2 ${
className={`rounded-medium text-default-500 h-10 justify-start border-2 ${
( (
clientErrors.metadata_xml === null clientErrors.metadata_xml === null
? undefined ? undefined
@@ -379,10 +380,11 @@ export const SamlConfigForm = ({
? "border-red-500" ? "border-red-500"
: uploadedFile : uploadedFile
? "border-green-500 bg-green-50 dark:bg-green-900/20" ? "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 ? ( {uploadedFile ? (
<span className="flex items-center gap-2"> <span className="flex items-center gap-2">
<span className="max-w-36 truncate">{uploadedFile.name}</span> <span className="max-w-36 truncate">{uploadedFile.name}</span>
@@ -391,7 +393,7 @@ export const SamlConfigForm = ({
"Choose File" "Choose File"
)} )}
</span> </span>
</CustomButton> </Button>
<input <input
type="file" type="file"

View File

@@ -1,14 +1,15 @@
"use client"; "use client";
import { Card, CardBody, CardHeader } from "@heroui/card";
import { CheckIcon, Trash2Icon } from "lucide-react"; import { CheckIcon, Trash2Icon } from "lucide-react";
import { useState } from "react"; import { useState } from "react";
import { deleteSamlConfig } from "@/actions/integrations"; import { deleteSamlConfig } from "@/actions/integrations";
import { Button } from "@/components/shadcn";
import { useToast } from "@/components/ui"; 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 { CustomLink } from "@/components/ui/custom/custom-link";
import { Card, CardContent, CardHeader } from "../../shadcn";
import { SamlConfigForm } from "./saml-config-form"; import { SamlConfigForm } from "./saml-config-form";
export const SamlIntegrationCard = ({ samlConfig }: { samlConfig?: any }) => { export const SamlIntegrationCard = ({ samlConfig }: { samlConfig?: any }) => {
@@ -60,12 +61,12 @@ export const SamlIntegrationCard = ({ samlConfig }: { samlConfig?: any }) => {
/> />
</CustomAlertModal> </CustomAlertModal>
<Card className="dark:bg-prowler-blue-400"> <Card variant="base" padding="lg">
<CardHeader className="gap-2"> <CardHeader>
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<h4 className="text-lg font-bold">SAML SSO Integration</h4> <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> </div>
<p className="text-xs text-gray-500"> <p className="text-xs text-gray-500">
{id ? ( {id ? (
@@ -81,39 +82,32 @@ export const SamlIntegrationCard = ({ samlConfig }: { samlConfig?: any }) => {
</p> </p>
</div> </div>
</CardHeader> </CardHeader>
<CardBody> <CardContent>
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="text-sm"> <div className="text-sm">
<span className="font-medium">Status: </span> <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"} {id ? "Enabled" : "Disabled"}
</span> </span>
</div> </div>
<div className="flex gap-2"> <div className="flex gap-2">
<CustomButton <Button size="sm" onClick={() => setIsSamlModalOpen(true)}>
size="sm"
ariaLabel="Configure SAML SSO"
color="action"
onPress={() => setIsSamlModalOpen(true)}
>
{id ? "Update" : "Enable"} {id ? "Update" : "Enable"}
</CustomButton> </Button>
{id && ( {id && (
<CustomButton <Button
size="sm" size="sm"
ariaLabel="Remove SAML SSO" variant="destructive"
color="danger" disabled={isDeleting}
variant="bordered" onClick={handleRemoveSaml}
isLoading={isDeleting}
startContent={!isDeleting ? <Trash2Icon size={16} /> : null}
onPress={handleRemoveSaml}
> >
Remove {!isDeleting && <Trash2Icon size={16} />}
</CustomButton> {isDeleting ? "Removing..." : "Remove"}
</Button>
)} )}
</div> </div>
</div> </div>
</CardBody> </CardContent>
</Card> </Card>
</> </>
); );

View File

@@ -1,16 +1,18 @@
"use client"; "use client";
import { Card, CardBody, CardHeader } from "@heroui/card";
import { SettingsIcon } from "lucide-react"; import { SettingsIcon } from "lucide-react";
import Link from "next/link";
import { AWSSecurityHubIcon } from "@/components/icons/services/IconServices"; 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 { CustomLink } from "@/components/ui/custom/custom-link";
import { Card, CardContent, CardHeader } from "../../shadcn";
export const SecurityHubIntegrationCard = () => { export const SecurityHubIntegrationCard = () => {
return ( return (
<Card className="dark:bg-gray-800"> <Card variant="base" padding="lg">
<CardHeader className="gap-2"> <CardHeader>
<div className="flex w-full flex-col items-start gap-2 sm:flex-row sm:items-center sm:justify-between"> <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"> <div className="flex items-center gap-3">
<AWSSecurityHubIcon size={40} /> <AWSSecurityHubIcon size={40} />
@@ -33,26 +35,21 @@ export const SecurityHubIntegrationCard = () => {
</div> </div>
</div> </div>
<div className="flex items-center gap-2 self-end sm:self-center"> <div className="flex items-center gap-2 self-end sm:self-center">
<CustomButton <Button asChild size="sm">
size="sm" <Link href="/integrations/aws-security-hub">
variant="bordered" <SettingsIcon size={14} />
startContent={<SettingsIcon size={14} />} Manage
asLink="/integrations/aws-security-hub" </Link>
ariaLabel="Manage Security Hub integrations" </Button>
>
Manage
</CustomButton>
</div> </div>
</div> </div>
</CardHeader> </CardHeader>
<CardBody> <CardContent>
<div className="flex flex-col gap-4"> <p className="text-sm text-gray-600 dark:text-gray-300">
<p className="text-sm text-gray-600 dark:text-gray-300"> Configure and manage your AWS Security Hub integrations to
Configure and manage your AWS Security Hub integrations to automatically send security findings for centralized monitoring.
automatically send security findings for centralized monitoring. </p>
</p> </CardContent>
</div>
</CardBody>
</Card> </Card>
); );
}; };

View File

@@ -352,6 +352,7 @@ export const SecurityHubIntegrationForm = ({
isSelected={Boolean(field.value)} isSelected={Boolean(field.value)}
onValueChange={field.onChange} onValueChange={field.onChange}
size="sm" size="sm"
color="default"
> >
<span className="text-sm"> <span className="text-sm">
Send only findings with status FAIL Send only findings with status FAIL
@@ -370,6 +371,7 @@ export const SecurityHubIntegrationForm = ({
isSelected={Boolean(field.value)} isSelected={Boolean(field.value)}
onValueChange={field.onChange} onValueChange={field.onChange}
size="sm" size="sm"
color="default"
> >
<span className="text-sm">Archive previous findings</span> <span className="text-sm">Archive previous findings</span>
</Checkbox> </Checkbox>
@@ -387,6 +389,7 @@ export const SecurityHubIntegrationForm = ({
isSelected={field.value} isSelected={field.value}
onValueChange={field.onChange} onValueChange={field.onChange}
size="sm" size="sm"
color="default"
> >
<span className="text-sm"> <span className="text-sm">
Use custom credentials (By default, AWS account ones Use custom credentials (By default, AWS account ones

View File

@@ -1,6 +1,5 @@
"use client"; "use client";
import { Card, CardBody, CardHeader } from "@heroui/card";
import { Chip } from "@heroui/chip"; import { Chip } from "@heroui/chip";
import { format } from "date-fns"; import { format } from "date-fns";
import { PlusIcon, Trash2Icon } from "lucide-react"; import { PlusIcon, Trash2Icon } from "lucide-react";
@@ -17,14 +16,16 @@ import {
IntegrationCardHeader, IntegrationCardHeader,
IntegrationSkeleton, IntegrationSkeleton,
} from "@/components/integrations/shared"; } from "@/components/integrations/shared";
import { Button } from "@/components/shadcn";
import { useToast } from "@/components/ui"; 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 { DataTablePagination } from "@/components/ui/table/data-table-pagination";
import { triggerTestConnectionWithDelay } from "@/lib/integrations/test-connection-helper"; import { triggerTestConnectionWithDelay } from "@/lib/integrations/test-connection-helper";
import { MetaDataProps } from "@/types"; import { MetaDataProps } from "@/types";
import { IntegrationProps } from "@/types/integrations"; import { IntegrationProps } from "@/types/integrations";
import { ProviderProps } from "@/types/providers"; import { ProviderProps } from "@/types/providers";
import { Card, CardContent, CardHeader } from "../../shadcn";
import { SecurityHubIntegrationForm } from "./security-hub-integration-form"; import { SecurityHubIntegrationForm } from "./security-hub-integration-form";
interface SecurityHubIntegrationsManagerProps { interface SecurityHubIntegrationsManagerProps {
@@ -264,38 +265,33 @@ export const SecurityHubIntegrationsManager = ({
title="Delete Security Hub Integration" title="Delete Security Hub Integration"
description="This action cannot be undone. This will permanently delete your 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"> <div className="flex w-full justify-end gap-4">
<CustomButton <Button
type="button" type="button"
ariaLabel="Cancel" variant="ghost"
className="w-full bg-transparent"
variant="faded"
size="lg" size="lg"
onPress={() => { onClick={() => {
setIsDeleteOpen(false); setIsDeleteOpen(false);
setIntegrationToDelete(null); setIntegrationToDelete(null);
}} }}
isDisabled={isDeleting !== null} disabled={isDeleting !== null}
> >
<span>Cancel</span> Cancel
</CustomButton> </Button>
<CustomButton <Button
type="button" type="button"
ariaLabel="Delete" variant="destructive"
className="w-full"
variant="solid"
color="danger"
size="lg" size="lg"
isLoading={isDeleting !== null} disabled={isDeleting !== null}
startContent={!isDeleting && <Trash2Icon size={24} />} onClick={() =>
onPress={() =>
integrationToDelete && integrationToDelete &&
handleDeleteIntegration(integrationToDelete.id) handleDeleteIntegration(integrationToDelete.id)
} }
> >
{!isDeleting && <Trash2Icon size={24} />}
{isDeleting ? "Deleting..." : "Delete"} {isDeleting ? "Deleting..." : "Delete"}
</CustomButton> </Button>
</div> </div>
</CustomAlertModal> </CustomAlertModal>
@@ -334,14 +330,10 @@ export const SecurityHubIntegrationsManager = ({
: `${integrations.length} integration${integrations.length !== 1 ? "s" : ""} configured`} : `${integrations.length} integration${integrations.length !== 1 ? "s" : ""} configured`}
</p> </p>
</div> </div>
<CustomButton <Button onClick={handleAddIntegration}>
color="action" <PlusIcon size={16} />
startContent={<PlusIcon size={16} />}
onPress={handleAddIntegration}
ariaLabel="Add integration"
>
Add Integration Add Integration
</CustomButton> </Button>
</div> </div>
{isOperationLoading ? ( {isOperationLoading ? (
@@ -359,8 +351,8 @@ export const SecurityHubIntegrationsManager = ({
const providerDetails = getProviderDetails(integration); const providerDetails = getProviderDetails(integration);
return ( return (
<Card key={integration.id} className="dark:bg-gray-800"> <Card key={integration.id} variant="base">
<CardHeader className="pb-2"> <CardHeader>
<IntegrationCardHeader <IntegrationCardHeader
icon={<AWSSecurityHubIcon size={32} />} icon={<AWSSecurityHubIcon size={32} />}
title={providerDetails.displayName} title={providerDetails.displayName}
@@ -388,7 +380,7 @@ export const SecurityHubIntegrationsManager = ({
}} }}
/> />
</CardHeader> </CardHeader>
<CardBody className="pt-0"> <CardContent className="pt-0">
<div className="flex flex-col gap-3"> <div className="flex flex-col gap-3">
{enabledRegions.length > 0 && ( {enabledRegions.length > 0 && (
<div className="flex flex-wrap gap-1"> <div className="flex flex-wrap gap-1">
@@ -397,7 +389,7 @@ export const SecurityHubIntegrationsManager = ({
key={region} key={region}
size="sm" size="sm"
variant="flat" variant="flat"
className="bg-default-100" className="bg-bg-neutral-secondary"
> >
{region} {region}
</Chip> </Chip>
@@ -428,7 +420,7 @@ export const SecurityHubIntegrationsManager = ({
/> />
</div> </div>
</div> </div>
</CardBody> </CardContent>
</Card> </Card>
); );
})} })}

View File

@@ -8,7 +8,7 @@ import {
Trash2Icon, Trash2Icon,
} from "lucide-react"; } from "lucide-react";
import { CustomButton } from "@/components/ui/custom"; import { Button } from "@/components/shadcn";
import { IntegrationProps } from "@/types/integrations"; import { IntegrationProps } from "@/types/integrations";
interface IntegrationActionButtonsProps { interface IntegrationActionButtonsProps {
@@ -34,69 +34,57 @@ export const IntegrationActionButtons = ({
}: IntegrationActionButtonsProps) => { }: IntegrationActionButtonsProps) => {
return ( return (
<div className="flex flex-col gap-2 sm:flex-row sm:items-center"> <div className="flex flex-col gap-2 sm:flex-row sm:items-center">
<CustomButton <Button
size="sm" size="sm"
variant="bordered" variant="outline"
startContent={<TestTube size={14} />} onClick={() => onTestConnection(integration.id)}
onPress={() => onTestConnection(integration.id)} disabled={!integration.attributes.enabled || isTesting}
isLoading={isTesting}
isDisabled={!integration.attributes.enabled || isTesting}
ariaLabel="Test connection"
className="w-full sm:w-auto" className="w-full sm:w-auto"
> >
Test <TestTube size={14} />
</CustomButton> {isTesting ? "Testing..." : "Test"}
</Button>
{onEditConfiguration && ( {onEditConfiguration && (
<CustomButton <Button
size="sm" size="sm"
variant="bordered" variant="outline"
startContent={<SettingsIcon size={14} />} onClick={() => onEditConfiguration(integration)}
onPress={() => onEditConfiguration(integration)}
ariaLabel="Edit configuration"
className="w-full sm:w-auto" className="w-full sm:w-auto"
> >
<SettingsIcon size={14} />
Config Config
</CustomButton> </Button>
)} )}
{showCredentialsButton && ( {showCredentialsButton && (
<CustomButton <Button
size="sm" size="sm"
variant="bordered" variant="outline"
startContent={<LockIcon size={14} />} onClick={() => onEditCredentials(integration)}
onPress={() => onEditCredentials(integration)}
ariaLabel="Edit credentials"
className="w-full sm:w-auto" className="w-full sm:w-auto"
> >
<LockIcon size={14} />
Credentials Credentials
</CustomButton> </Button>
)} )}
<CustomButton <Button
size="sm" size="sm"
variant="bordered" variant="outline"
color={integration.attributes.enabled ? "warning" : "primary"} onClick={() => onToggleEnabled(integration)}
startContent={<Power size={14} />} disabled={isTesting}
onPress={() => onToggleEnabled(integration)}
isDisabled={isTesting}
ariaLabel={
integration.attributes.enabled
? "Disable integration"
: "Enable integration"
}
className="w-full sm:w-auto" className="w-full sm:w-auto"
> >
<Power size={14} />
{integration.attributes.enabled ? "Disable" : "Enable"} {integration.attributes.enabled ? "Disable" : "Enable"}
</CustomButton> </Button>
<CustomButton <Button
size="sm" size="sm"
color="danger" variant="outline"
variant="bordered" onClick={() => onDelete(integration)}
startContent={<Trash2Icon size={14} />} 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"
onPress={() => onDelete(integration)}
ariaLabel="Delete integration"
className="w-full sm:w-auto"
> >
<Trash2Icon size={14} />
Delete Delete
</CustomButton> </Button>
</div> </div>
); );
}; };

View File

@@ -7,8 +7,8 @@ import * as z from "zod";
import { revokeInvite } from "@/actions/invitations/invitation"; import { revokeInvite } from "@/actions/invitations/invitation";
import { DeleteIcon } from "@/components/icons"; import { DeleteIcon } from "@/components/icons";
import { Button } from "@/components/shadcn";
import { useToast } from "@/components/ui"; import { useToast } from "@/components/ui";
import { CustomButton } from "@/components/ui/custom";
import { Form } from "@/components/ui/form"; import { Form } from "@/components/ui/form";
const formSchema = z.object({ const formSchema = z.object({
@@ -62,33 +62,26 @@ export const DeleteForm = ({
<Form {...form}> <Form {...form}>
<form onSubmit={form.handleSubmit(onSubmitClient)}> <form onSubmit={form.handleSubmit(onSubmitClient)}>
<input type="hidden" name="id" value={invitationId} /> <input type="hidden" name="id" value={invitationId} />
<div className="flex w-full justify-center sm:gap-6"> <div className="flex w-full justify-end gap-4">
<CustomButton <Button
type="button" type="button"
ariaLabel="Cancel" variant="ghost"
className="w-full bg-transparent"
variant="faded"
size="lg" size="lg"
radius="lg" onClick={() => setIsOpen(false)}
onPress={() => setIsOpen(false)} disabled={isLoading}
isDisabled={isLoading}
> >
<span>Cancel</span> Cancel
</CustomButton> </Button>
<CustomButton <Button
type="submit" type="submit"
ariaLabel="Revoke" variant="destructive"
className="w-full"
variant="solid"
color="danger"
size="lg" size="lg"
radius="lg" disabled={isLoading}
isLoading={isLoading}
startContent={!isLoading && <DeleteIcon size={24} />}
> >
{isLoading ? <>Loading</> : <span>Revoke</span>} {!isLoading && <DeleteIcon size={24} />}
</CustomButton> {isLoading ? "Loading" : "Revoke"}
</Button>
</div> </div>
</form> </form>
</Form> </Form>

View File

@@ -6,12 +6,13 @@ import { Controller, useForm } from "react-hook-form";
import * as z from "zod"; import * as z from "zod";
import { updateInvite } from "@/actions/invitations/invitation"; import { updateInvite } from "@/actions/invitations/invitation";
import { SaveIcon } from "@/components/icons";
import { useToast } from "@/components/ui"; 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 { Form, FormButtons } from "@/components/ui/form";
import { editInviteFormSchema } from "@/types"; import { editInviteFormSchema } from "@/types";
import { Card, CardContent } from "../../shadcn";
export const EditForm = ({ export const EditForm = ({
invitationId, invitationId,
invitationEmail, invitationEmail,
@@ -94,22 +95,24 @@ export const EditForm = ({
onSubmit={form.handleSubmit(onSubmitClient)} onSubmit={form.handleSubmit(onSubmitClient)}
className="flex flex-col gap-4" className="flex flex-col gap-4"
> >
<div className="flex flex-row justify-center gap-4 rounded-lg bg-gray-50 p-3"> <Card variant="inner">
<div className="text-small flex items-center text-gray-600"> <CardContent className="flex flex-row justify-center gap-4">
<MailIcon className="mr-2 h-4 w-4" /> <div className="text-small text-text-neutral-secondary flex items-center">
<span className="text-gray-500">Email:</span> <MailIcon className="text-text-neutral-secondary mr-2 h-4 w-4" />
<span className="ml-2 font-semibold text-gray-900"> <span className="text-text-neutral-secondary">Email:</span>
{invitationEmail} <span className="text-text-neutral-secondary ml-2 font-semibold">
</span> {invitationEmail}
</div> </span>
<div className="text-small flex items-center text-gray-600"> </div>
<ShieldIcon className="mr-2 h-4 w-4" /> <div className="text-small flex items-center text-gray-600">
<span className="text-gray-500">Role:</span> <ShieldIcon className="text-text-neutral-secondary mr-2 h-4 w-4" />
<span className="ml-2 font-semibold text-gray-900"> <span className="text-text-neutral-secondary">Role:</span>
{currentRole} <span className="text-text-neutral-secondary ml-2 font-semibold">
</span> {currentRole}
</div> </span>
</div> </div>
</CardContent>
</Card>
<div> <div>
<CustomInput <CustomInput
@@ -121,7 +124,6 @@ export const EditForm = ({
placeholder={invitationEmail} placeholder={invitationEmail}
variant="flat" variant="flat"
isRequired={false} isRequired={false}
isInvalid={!!form.formState.errors.invitationEmail}
/> />
</div> </div>
<div> <div>
@@ -157,33 +159,7 @@ export const EditForm = ({
</div> </div>
<input type="hidden" name="invitationId" value={invitationId} /> <input type="hidden" name="invitationId" value={invitationId} />
<div className="flex w-full justify-center sm:gap-6"> <FormButtons setIsOpen={setIsOpen} isDisabled={isLoading} />
<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>
</form> </form>
</Form> </Form>
); );

View File

@@ -1,2 +1 @@
export * from "./invitation-details"; export * from "./invitation-details";
export * from "./send-invitation-button";

View File

@@ -1,11 +1,11 @@
"use client"; "use client";
import { Card, CardBody } from "@heroui/card";
import { Divider } from "@heroui/divider";
import { Snippet } from "@heroui/snippet"; import { Snippet } from "@heroui/snippet";
import Link from "next/link";
import { AddIcon } from "../icons"; 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"; import { DateWithTime } from "../ui/entities";
interface InvitationDetailsProps { interface InvitationDetailsProps {
@@ -35,9 +35,11 @@ const InfoField = ({
children: React.ReactNode; children: React.ReactNode;
}) => ( }) => (
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
<span className="text-xs font-bold text-gray-500">{label}</span> <span className="text-text-neutral-secondary text-xs font-bold">
<div className="flex items-center rounded-lg bg-gray-50 p-3"> {label}
<span className="text-small text-gray-900">{children}</span> </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>
</div> </div>
); );
@@ -53,17 +55,9 @@ export const InvitationDetails = ({ attributes }: InvitationDetailsProps) => {
return ( return (
<div className="flex flex-col gap-x-4 gap-y-8"> <div className="flex flex-col gap-x-4 gap-y-8">
<Card <Card variant="base" padding="lg">
isBlurred <CardHeader>Invitation details</CardHeader>
className="bg-background/60 dark:bg-prowler-blue-800 border-none" <CardContent>
shadow="sm"
>
<CardBody>
<h2 className="text-md text-foreground/90 font-bold">
Invitation details
</h2>
<Divider className="my-4" />
<div className="flex flex-col gap-3"> <div className="flex flex-col gap-3">
<InfoField label="Email">{attributes.email}</InfoField> <InfoField label="Email">{attributes.email}</InfoField>
@@ -88,8 +82,8 @@ export const InvitationDetails = ({ attributes }: InvitationDetailsProps) => {
</div> </div>
</div> </div>
<Divider className="my-4" /> <Separator className="my-4" />
<h3 className="text-small text-foreground/90 pb-2 font-bold"> <h3 className="text-text-neutral-primary pb-2 text-sm font-bold">
Share this link with the user: Share this link with the user:
</h3> </h3>
@@ -100,26 +94,22 @@ export const InvitationDetails = ({ attributes }: InvitationDetailsProps) => {
}} }}
hideSymbol hideSymbol
variant="bordered" 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} {invitationLink}
</p> </p>
</Snippet> </Snippet>
</div> </div>
</CardBody> </CardContent>
</Card> </Card>
<div className="flex w-full items-center justify-end"> <div className="flex w-full items-center justify-end">
<CustomButton <Button asChild size="default" className="gap-2">
asLink="/invitations/" <Link href="/invitations/">
ariaLabel="Send Invitation" Back to Invitations
variant="solid" <AddIcon size={20} />
color="action" </Link>
size="md" </Button>
endContent={<AddIcon size={20} />}
>
Back to Invitations
</CustomButton>
</div> </div>
</div> </div>
); );

View File

@@ -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>
);
};

View File

@@ -1,6 +1,5 @@
"use client"; "use client";
import { Button } from "@heroui/button";
import { import {
Dropdown, Dropdown,
DropdownItem, DropdownItem,
@@ -19,6 +18,7 @@ import { useRouter } from "next/navigation";
import { useState } from "react"; import { useState } from "react";
import { VerticalDotsIcon } from "@/components/icons"; import { VerticalDotsIcon } from "@/components/icons";
import { Button } from "@/components/shadcn";
import { CustomAlertModal } from "@/components/ui/custom"; import { CustomAlertModal } from "@/components/ui/custom";
import { DeleteForm, EditForm } from "../forms"; import { DeleteForm, EditForm } from "../forms";
@@ -68,12 +68,12 @@ export function DataTableRowActions<InvitationProps>({
<div className="relative flex items-center justify-end gap-2"> <div className="relative flex items-center justify-end gap-2">
<Dropdown <Dropdown
className="dark:bg-prowler-blue-800 shadow-xl" className="border-border-neutral-secondary bg-bg-neutral-secondary border shadow-xl"
placement="bottom" placement="bottom"
> >
<DropdownTrigger> <DropdownTrigger>
<Button isIconOnly radius="full" size="sm" variant="light"> <Button variant="ghost" size="icon-sm" className="rounded-full">
<VerticalDotsIcon className="text-default-400" /> <VerticalDotsIcon className="text-slate-400" />
</Button> </Button>
</DropdownTrigger> </DropdownTrigger>
<DropdownMenu <DropdownMenu
@@ -109,13 +109,13 @@ export function DataTableRowActions<InvitationProps>({
<DropdownSection title="Danger zone"> <DropdownSection title="Danger zone">
<DropdownItem <DropdownItem
key="delete" key="delete"
className="text-danger" className="text-text-error"
color="danger" color="danger"
description="Delete the invitation permanently" description="Delete the invitation permanently"
textValue="Delete Invitation" textValue="Delete Invitation"
startContent={ startContent={
<DeleteDocumentBulkIcon <DeleteDocumentBulkIcon
className={clsx(iconClasses, "!text-danger")} className={clsx(iconClasses, "!text-text-error")}
/> />
} }
onPress={() => setIsDeleteOpen(true)} onPress={() => setIsDeleteOpen(true)}

View File

@@ -1,30 +1,19 @@
import { Card } from "@heroui/card";
import { Skeleton } from "@heroui/skeleton";
import React from "react"; import React from "react";
import { Card } from "@/components/shadcn/card/card";
import { Skeleton } from "@/components/shadcn/skeleton/skeleton";
export const SkeletonTableInvitation = () => { export const SkeletonTableInvitation = () => {
return ( 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 */} {/* Table headers */}
<div className="hidden justify-between md:flex"> <div className="hidden gap-4 md:flex">
<Skeleton className="w-2/12 rounded-lg"> <Skeleton className="h-8 w-2/12" />
<div className="bg-default-200 h-8"></div> <Skeleton className="h-8 w-2/12" />
</Skeleton> <Skeleton className="h-8 w-2/12" />
<Skeleton className="w-2/12 rounded-lg"> <Skeleton className="h-8 w-2/12" />
<div className="bg-default-200 h-8"></div> <Skeleton className="h-8 w-2/12" />
</Skeleton> <Skeleton className="h-8 w-1/12" />
<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> </div>
{/* Table body */} {/* Table body */}
@@ -32,26 +21,14 @@ export const SkeletonTableInvitation = () => {
{[...Array(10)].map((_, index) => ( {[...Array(10)].map((_, index) => (
<div <div
key={index} 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"> <Skeleton className="h-12 w-full md:w-2/12" />
<div className="bg-default-300 h-12"></div> <Skeleton className="h-12 w-full md:w-2/12" />
</Skeleton> <Skeleton className="hidden h-12 md:block md:w-2/12" />
<Skeleton className="mb-2 w-full rounded-lg md:mb-0 md:w-2/12"> <Skeleton className="hidden h-12 md:block md:w-2/12" />
<div className="bg-default-300 h-12"></div> <Skeleton className="hidden h-12 md:block md:w-2/12" />
</Skeleton> <Skeleton className="hidden h-12 md:block md:w-1/12" />
<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>
</div> </div>
))} ))}
</div> </div>

View File

@@ -8,8 +8,9 @@ import { Controller, useForm } from "react-hook-form";
import * as z from "zod"; import * as z from "zod";
import { sendInvite } from "@/actions/invitations/invitation"; import { sendInvite } from "@/actions/invitations/invitation";
import { Button } from "@/components/shadcn";
import { useToast } from "@/components/ui"; 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 { Form } from "@/components/ui/form";
import { ApiError } from "@/types"; import { ApiError } from "@/types";
@@ -104,7 +105,6 @@ export const SendInvitationForm = ({
placeholder="Enter the email address" placeholder="Enter the email address"
variant="flat" variant="flat"
isRequired isRequired
isInvalid={!!form.formState.errors.email}
/> />
<Controller <Controller
@@ -135,7 +135,7 @@ export const SendInvitationForm = ({
)} )}
</Select> </Select>
{form.formState.errors.roleId && ( {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} {form.formState.errors.roleId.message}
</p> </p>
)} )}
@@ -145,18 +145,22 @@ export const SendInvitationForm = ({
{/* Submit Button */} {/* Submit Button */}
<div className="flex w-full justify-end sm:gap-6"> <div className="flex w-full justify-end sm:gap-6">
<CustomButton <Button
type="submit" type="submit"
ariaLabel="Send Invitation"
className="w-1/2" className="w-1/2"
variant="solid" variant="default"
color="action"
size="lg" size="lg"
isLoading={isLoading} disabled={isLoading}
startContent={!isLoading && <SaveIcon size={24} />}
> >
{isLoading ? <>Loading</> : <span>Send Invitation</span>} {isLoading ? (
</CustomButton> <>Loading</>
) : (
<>
<SaveIcon size={20} />
<span>Send Invitation</span>
</>
)}
</Button>
</div> </div>
</form> </form>
</Form> </Form>

View File

@@ -1,6 +1,5 @@
"use client"; "use client";
import type { ButtonProps } from "@heroui/button";
import { cn } from "@heroui/theme"; import { cn } from "@heroui/theme";
import { useControlledState } from "@react-stately/utils"; import { useControlledState } from "@react-stately/utils";
import { domAnimation, LazyMotion, m } from "framer-motion"; import { domAnimation, LazyMotion, m } from "framer-motion";
@@ -26,7 +25,13 @@ export interface VerticalStepsProps
* *
* @default "primary" * @default "primary"
*/ */
color?: ButtonProps["color"]; color?:
| "primary"
| "secondary"
| "success"
| "warning"
| "danger"
| "default";
/** /**
* The current step index. * The current step index.
*/ */
@@ -123,7 +128,7 @@ export const VerticalSteps = React.forwardRef<
switch (color) { switch (color) {
case "primary": 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))]"; fgColor = "[--step-fg-color:hsl(var(--heroui-primary-foreground))]";
break; break;
case "secondary": case "secondary":

View File

@@ -44,13 +44,14 @@ export const WorkflowSendInvite = () => {
base: "px-0.5 mb-3 sm:mb-5", base: "px-0.5 mb-3 sm:mb-5",
label: "text-xs sm:text-small", label: "text-xs sm:text-small",
value: "text-xs sm:text-small text-default-400", value: "text-xs sm:text-small text-default-400",
indicator: "bg-button-primary",
}} }}
label="Steps" label="Steps"
maxValue={steps.length - 1} maxValue={steps.length}
minValue={0} minValue={0}
showValueLabel={true} showValueLabel={true}
size="sm" size="sm"
value={currentStep} value={currentStep + 1}
valueLabel={`${currentStep + 1} of ${steps.length}`} valueLabel={`${currentStep + 1} of ${steps.length}`}
/> />
@@ -59,14 +60,14 @@ export const WorkflowSendInvite = () => {
<VerticalSteps <VerticalSteps
hideProgressBars hideProgressBars
currentStep={currentStep} 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} steps={steps}
/> />
</div> </div>
{/* Mobile: Compact current step indicator */} {/* Mobile: Compact current step indicator */}
<div className="sm:hidden"> <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"> <div className="font-medium">
Current: {steps[currentStep]?.title} Current: {steps[currentStep]?.title}
</div> </div>

View File

@@ -2,9 +2,9 @@
import type { ComponentProps } from "react"; import type { ComponentProps } from "react";
import { Button } from "@/components/shadcn/button/button";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { Button } from "./button";
import { import {
Tooltip, Tooltip,
TooltipContent, TooltipContent,

View File

@@ -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 };

View File

@@ -2,7 +2,6 @@
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"; import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react"; import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react";
import * as React from "react";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";

View File

@@ -1,11 +1,10 @@
"use client"; "use client";
import { cva, type VariantProps } from "class-variance-authority"; 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 { cn } from "@/lib/utils";
import { Button } from "./button";
import { Input } from "./input"; import { Input } from "./input";
import { Textarea } from "./textarea"; import { Textarea } from "./textarea";
@@ -15,7 +14,7 @@ function InputGroup({ className, ...props }: React.ComponentProps<"div">) {
data-slot="input-group" data-slot="input-group"
role="group" role="group"
className={cn( 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", "h-9 min-w-0 has-[>textarea]:h-auto",
// Variants based on alignment. // 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", "has-[>[data-align=block-end]]:h-auto has-[>[data-align=block-end]]:flex-col has-[>[data-align=block-end]]:[&>input]:pt-3",
// Focus state. // 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. // 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", "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",

View File

@@ -1,5 +1,3 @@
import * as React from "react";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
function Input({ className, type, ...props }: React.ComponentProps<"input">) { function Input({ className, type, ...props }: React.ComponentProps<"input">) {

View File

@@ -1,5 +1,3 @@
/** biome-ignore-all lint/performance/noImgElement: "AI Elements is framework agnostic" */
"use client"; "use client";
import type { ChatStatus, FileUIPart } from "ai"; import type { ChatStatus, FileUIPart } from "ai";
@@ -37,9 +35,9 @@ import {
useState, useState,
} from "react"; } from "react";
import { Button } from "@/components/shadcn/button/button";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { Button } from "./button";
import { import {
DropdownMenu, DropdownMenu,
DropdownMenuContent, DropdownMenuContent,

View File

@@ -2,7 +2,6 @@
import * as SelectPrimitive from "@radix-ui/react-select"; import * as SelectPrimitive from "@radix-ui/react-select";
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react"; import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react";
import * as React from "react";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";

View File

@@ -1,5 +1,3 @@
import * as React from "react";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) { function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {

View 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} />;
};

View File

@@ -1,52 +1,13 @@
import { Bot } from "lucide-react";
import Link from "next/link";
import { isLighthouseConfigured } from "@/actions/lighthouse/lighthouse"; import { isLighthouseConfigured } from "@/actions/lighthouse/lighthouse";
interface BannerConfig { import { LighthouseBannerClient } from "./banner-client";
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>
);
export const LighthouseBanner = async () => { export const LighthouseBanner = async () => {
try { try {
const isConfigured = await isLighthouseConfigured(); const isConfigured = await isLighthouseConfigured();
if (!isConfigured) { return <LighthouseBannerClient isConfigured={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",
});
}
} catch (error) { } catch (error) {
console.error("Error getting banner state:", error);
return null; return null;
} }
}; };

View File

@@ -2,17 +2,12 @@
import { useChat } from "@ai-sdk/react"; import { useChat } from "@ai-sdk/react";
import { DefaultChatTransport } from "ai"; 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 { useEffect, useRef, useState } from "react";
import { Streamdown } from "streamdown"; import { Streamdown } from "streamdown";
import { getLighthouseModelIds } from "@/actions/lighthouse/lighthouse"; import { getLighthouseModelIds } from "@/actions/lighthouse/lighthouse";
import { Action, Actions } from "@/components/lighthouse/ai-elements/actions"; import { Action, Actions } from "@/components/lighthouse/ai-elements/actions";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuTrigger,
} from "@/components/lighthouse/ai-elements/dropdown-menu";
import { import {
PromptInput, PromptInput,
PromptInputBody, PromptInputBody,
@@ -22,10 +17,17 @@ import {
PromptInputTools, PromptInputTools,
} from "@/components/lighthouse/ai-elements/prompt-input"; } from "@/components/lighthouse/ai-elements/prompt-input";
import { Loader } from "@/components/lighthouse/loader"; import { Loader } from "@/components/lighthouse/loader";
import {
Button,
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
Combobox,
} from "@/components/shadcn";
import { useToast } from "@/components/ui"; import { useToast } from "@/components/ui";
import { CustomButton } from "@/components/ui/custom";
import { CustomLink } from "@/components/ui/custom/custom-link"; import { CustomLink } from "@/components/ui/custom/custom-link";
import { cn } from "@/lib/utils";
import type { LighthouseProvider } from "@/types/lighthouse"; import type { LighthouseProvider } from "@/types/lighthouse";
interface Model { interface Model {
@@ -92,14 +94,8 @@ export const Chat = ({
// Consolidated UI state // Consolidated UI state
const [uiState, setUiState] = useState<{ const [uiState, setUiState] = useState<{
inputValue: string; inputValue: string;
isDropdownOpen: boolean;
modelSearchTerm: string;
hoveredProvider: LighthouseProvider | "";
}>({ }>({
inputValue: "", inputValue: "",
isDropdownOpen: false,
modelSearchTerm: "",
hoveredProvider: defaultProviderId || initialProviders[0]?.id || "",
}); });
// Error handling // Error handling
@@ -107,13 +103,7 @@ export const Chat = ({
// Provider and model management // Provider and model management
const [providers, setProviders] = useState<Provider[]>(initialProviders); const [providers, setProviders] = useState<Provider[]>(initialProviders);
const [providerLoadState, setProviderLoadState] = useState<{ const loadedProvidersRef = useRef<Set<LighthouseProvider>>(new Set());
loaded: Set<LighthouseProvider>;
loading: Set<LighthouseProvider>;
}>({
loaded: new Set(),
loading: new Set(),
});
// Initialize selectedModel with defaults from props // Initialize selectedModel with defaults from props
const [selectedModel, setSelectedModel] = useState<SelectedModel>(() => { const [selectedModel, setSelectedModel] = useState<SelectedModel>(() => {
@@ -135,20 +125,23 @@ export const Chat = ({
const selectedModelRef = useRef(selectedModel); const selectedModelRef = useRef(selectedModel);
selectedModelRef.current = 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 // Load all models for a specific provider
const loadModelsForProvider = async (providerType: LighthouseProvider) => { const loadModelsForProvider = async (providerType: LighthouseProvider) => {
setProviderLoadState((prev) => { // Skip if already loaded
// Skip if already loaded or currently loading if (loadedProvidersRef.current.has(providerType)) {
if (prev.loaded.has(providerType) || prev.loading.has(providerType)) { return;
return prev; }
}
// Mark as loading // Mark as loaded
return { loadedProvidersRef.current.add(providerType);
...prev,
loading: new Set([...Array.from(prev.loading), providerType]),
};
});
try { try {
const response = await getLighthouseModelIds(providerType); const response = await getLighthouseModelIds(providerType);
@@ -169,24 +162,11 @@ export const Chat = ({
setProviders((prev) => setProviders((prev) =>
prev.map((p) => (p.id === providerType ? { ...p, models } : p)), 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) { } catch (error) {
console.error(`Error loading models for ${providerType}:`, error); console.error(`Error loading models for ${providerType}:`, error);
// Remove from loading state on error // Remove from loaded on error so it can be retried
setProviderLoadState((prev) => ({ loadedProvidersRef.current.delete(providerType);
...prev,
loading: new Set(
Array.from(prev.loading).filter((id) => id !== providerType),
),
}));
} }
}; };
@@ -292,22 +272,6 @@ export const Chat = ({
} }
}, [messages, status]); }, [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 // Handlers
const handleNewChat = () => { const handleNewChat = () => {
setMessages([]); setMessages([]);
@@ -321,61 +285,62 @@ export const Chat = ({
modelName: string, modelName: string,
) => { ) => {
setSelectedModel({ providerType, modelId, modelName }); setSelectedModel({ providerType, modelId, modelName });
setUiState((prev) => ({
...prev,
isDropdownOpen: false,
modelSearchTerm: "", // Reset search when selecting
}));
}; };
return ( 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 */} {/* Header with New Chat button */}
{messages.length > 0 && ( {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"> <div className="flex items-center justify-end">
<CustomButton <Button
ariaLabel="Start new chat" aria-label="Start new chat"
variant="bordered" variant="outline"
size="sm" size="sm"
startContent={<Plus className="h-4 w-4" />} onClick={handleNewChat}
onPress={handleNewChat}
className="gap-1" className="gap-1"
> >
<Plus className="h-4 w-4" />
New Chat New Chat
</CustomButton> </Button>
</div> </div>
</div> </div>
)} )}
{!hasConfig && ( {!hasConfig && (
<div className="bg-background/80 absolute inset-0 z-50 flex items-center justify-center backdrop-blur-sm"> <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"> <Card
<h3 className="mb-2 text-lg font-semibold"> variant="base"
LLM Provider Configuration Required padding="lg"
</h3> className="max-w-md text-center shadow-lg"
<p className="text-muted-foreground mb-4"> >
Please configure an LLM provider to use Lighthouse AI. <CardHeader>
</p> <CardTitle>LLM Provider Configuration Required</CardTitle>
<CustomLink <CardDescription>
href="/lighthouse/config" Please configure an LLM provider to use Lighthouse AI.
className="bg-primary text-primary-foreground hover:bg-primary/90 inline-flex items-center justify-center rounded-md px-4 py-2" </CardDescription>
target="_self" </CardHeader>
size="sm" <CardContent>
> <CustomLink
Configure Provider href="/lighthouse/config"
</CustomLink> className="bg-primary text-primary-foreground hover:bg-primary/90 inline-flex items-center justify-center rounded-md px-4 py-2"
</div> target="_self"
size="sm"
>
Configure Provider
</CustomLink>
</CardContent>
</Card>
</div> </div>
)} )}
{/* Error Banner */} {/* Error Banner */}
{(error || errorMessage) && ( {(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="flex items-start">
<div className="shrink-0"> <div className="shrink-0">
<svg <svg
className="h-5 w-5 text-red-400" className="text-text-error h-5 w-5"
viewBox="0 0 20 20" viewBox="0 0 20 20"
fill="currentColor" fill="currentColor"
> >
@@ -387,26 +352,24 @@ export const Chat = ({
</svg> </svg>
</div> </div>
<div className="ml-3"> <div className="ml-3">
<h3 className="text-sm font-medium text-red-800 dark:text-red-200"> <h3 className="text-text-error text-sm font-medium">Error</h3>
Error <p className="text-text-neutral-secondary mt-1 text-sm">
</h3>
<p className="mt-1 text-sm text-red-700 dark:text-red-300">
{errorMessage || {errorMessage ||
error?.message || error?.message ||
"An error occurred. Please retry your message."} "An error occurred. Please retry your message."}
</p> </p>
{/* Original error details for native errors */} {/* Original error details for native errors */}
{error && (error as any).status && ( {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} Status: {(error as any).status}
</p> </p>
)} )}
{error && (error as any).body && ( {error && (error as any).body && (
<details className="mt-2"> <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 Show details
</summary> </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)} {JSON.stringify((error as any).body, null, 2)}
</pre> </pre>
</details> </details>
@@ -417,31 +380,32 @@ export const Chat = ({
)} )}
{messages.length === 0 && !errorMessage && !error ? ( {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"> <div className="w-full max-w-2xl">
<h2 className="mb-4 text-center font-sans text-xl">Suggestions</h2> <h2 className="mb-4 text-center font-sans text-xl">Suggestions</h2>
<div className="grid gap-2 sm:grid-cols-2"> <div className="grid gap-2 sm:grid-cols-2">
{SUGGESTED_ACTIONS.map((action, index) => ( {SUGGESTED_ACTIONS.map((action, index) => (
<CustomButton <Button
key={`suggested-action-${index}`} key={`suggested-action-${index}`}
ariaLabel={`Send message: ${action.action}`} aria-label={`Send message: ${action.action}`}
onPress={() => { onClick={() => {
sendMessage({ sendMessage({
text: action.action, 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>{action.title}</span>
<span className="text-muted-foreground">{action.label}</span> <span className="text-muted-foreground">{action.label}</span>
</CustomButton> </Button>
))} ))}
</div> </div>
</div> </div>
</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} ref={messagesContainerRef}
> >
{messages.map((message, idx) => { {messages.map((message, idx) => {
@@ -470,7 +434,7 @@ export const Chat = ({
<div <div
className={`max-w-[80%] rounded-lg px-4 py-2 ${ className={`max-w-[80%] rounded-lg px-4 py-2 ${
message.role === "user" message.role === "user"
? "bg-primary text-primary-foreground dark:text-black!" ? "bg-bg-neutral-tertiary border-border-neutral-secondary border"
: "bg-muted" : "bg-muted"
}`} }`}
> >
@@ -478,11 +442,7 @@ export const Chat = ({
{isStreamingAssistant && !messageText ? ( {isStreamingAssistant && !messageText ? (
<Loader size="default" text="Thinking..." /> <Loader size="default" text="Thinking..." />
) : ( ) : (
<div <div>
className={
message.role === "user" ? "dark:text-black!" : ""
}
>
<Streamdown <Streamdown
parseIncompleteMarkdown={true} parseIncompleteMarkdown={true}
shikiTheme={["github-light", "github-dark"]} shikiTheme={["github-light", "github-dark"]}
@@ -578,117 +538,33 @@ export const Chat = ({
<PromptInputToolbar> <PromptInputToolbar>
<PromptInputTools> <PromptInputTools>
{/* Model Selector */} {/* Model Selector - Combobox */}
<DropdownMenu <Combobox
open={uiState.isDropdownOpen} value={`${selectedModel.providerType}:${selectedModel.modelId}`}
onOpenChange={(open) => onValueChange={(value) => {
setUiState((prev) => ({ ...prev, isDropdownOpen: open })) const [providerType, modelId] = value.split(":");
} const provider = providers.find((p) => p.id === providerType);
> const model = provider?.models.find((m) => m.id === modelId);
<DropdownMenuTrigger asChild> if (provider && model) {
<button handleModelSelect(
type="button" providerType as LighthouseProvider,
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" modelId,
> model.name,
<span>{selectedModel.modelName}</span> );
<ChevronDown className="h-4 w-4" /> }
</button> }}
</DropdownMenuTrigger> groups={providers.map((provider) => ({
<DropdownMenuContent heading: provider.name,
align="start" options: provider.models.map((model) => ({
className="bg-background w-[400px] border p-0 shadow-lg" value: `${provider.id}:${model.id}`,
> label: model.name,
<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"> placeholder={selectedModel.modelName || "Select model..."}
{providers.map((provider) => ( searchPlaceholder="Search models..."
<div emptyMessage="No model found."
key={provider.id} showSelectedFirst={true}
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>
</PromptInputTools> </PromptInputTools>
{/* Submit Button */} {/* Submit Button */}

View File

@@ -8,7 +8,7 @@ import {
getLighthouseProviderByType, getLighthouseProviderByType,
updateLighthouseProviderByType, updateLighthouseProviderByType,
} from "@/actions/lighthouse/lighthouse"; } from "@/actions/lighthouse/lighthouse";
import { CustomButton } from "@/components/ui/custom"; import { FormButtons } from "@/components/ui/form";
import type { LighthouseProvider } from "@/types/lighthouse"; import type { LighthouseProvider } from "@/types/lighthouse";
import { getMainFields, getProviderConfig } from "./llm-provider-registry"; 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; if (!providerConfig) return;
setStatus("connecting"); setStatus("connecting");
@@ -146,25 +147,26 @@ export const ConnectLLMProvider = ({
if (error) setError(null); if (error) setError(null);
}; };
const getButtonText = () => { const getSubmitText = () => {
if (status === "idle") { if (error && existingProviderId) return "Retry Connection";
if (error && existingProviderId) return "Retry Connection"; return isEditMode ? "Continue" : "Connect";
return isEditMode ? "Continue" : "Connect"; };
}
const statusText = { const getLoadingText = () => {
if (status === "idle") return "Connecting...";
const statusText: Record<Exclude<Status, "idle">, string> = {
connecting: "Connecting...", connecting: "Connecting...",
verifying: "Verifying...", verifying: "Verifying...",
"loading-models": "Loading models...", "loading-models": "Loading models...",
}; };
return statusText[status] || "Connecting..."; return statusText[status] || "Connecting...";
}; };
if (!providerConfig) { if (!providerConfig) {
return ( return (
<div className="flex h-64 items-center justify-center"> <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} Provider configuration not found: {provider}
</div> </div>
</div> </div>
@@ -174,7 +176,7 @@ export const ConnectLLMProvider = ({
if (isFetching) { if (isFetching) {
return ( return (
<div className="flex h-64 items-center justify-center"> <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... Loading provider configuration...
</div> </div>
</div> </div>
@@ -193,7 +195,7 @@ export const ConnectLLMProvider = ({
? `Update ${providerConfig.name}` ? `Update ${providerConfig.name}`
: `Connect to ${providerConfig.name}`} : `Connect to ${providerConfig.name}`}
</h2> </h2>
<p className="text-sm text-gray-600 dark:text-gray-300"> <p className="text-text-neutral-secondary text-sm">
{isEditMode {isEditMode
? `Update your API credentials or settings for ${providerConfig.name}.` ? `Update your API credentials or settings for ${providerConfig.name}.`
: `Enter your API credentials to connect to ${providerConfig.name}.`} : `Enter your API credentials to connect to ${providerConfig.name}.`}
@@ -201,12 +203,12 @@ export const ConnectLLMProvider = ({
</div> </div>
{error && ( {error && (
<div className="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 rounded-lg border p-4">
<p className="text-sm text-red-600 dark:text-red-400">{error}</p> <p className="text-text-error text-sm">{error}</p>
</div> </div>
)} )}
<div className="flex flex-col gap-4"> <form onSubmit={handleSubmit} className="flex flex-col gap-4">
{mainFields.map((field) => ( {mainFields.map((field) => (
<div key={field.name}> <div key={field.name}>
<label <label
@@ -215,10 +217,10 @@ export const ConnectLLMProvider = ({
> >
{field.label}{" "} {field.label}{" "}
{!isEditMode && field.required && ( {!isEditMode && field.required && (
<span className="text-red-500">*</span> <span className="text-text-error">*</span>
)} )}
{isEditMode && ( {isEditMode && (
<span className="text-xs text-gray-500"> <span className="text-text-neutral-tertiary text-xs">
(leave empty to keep existing) (leave empty to keep existing)
</span> </span>
)} )}
@@ -233,39 +235,18 @@ export const ConnectLLMProvider = ({
? `Enter new ${field.label.toLowerCase()} or leave empty` ? `Enter new ${field.label.toLowerCase()} or leave empty`
: field.placeholder : 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>
))} ))}
<div className="mt-4 flex justify-end gap-4"> <FormButtons
<CustomButton onCancel={() => router.push("/lighthouse/config")}
type="button" submitText={getSubmitText()}
ariaLabel="Cancel" loadingText={getLoadingText()}
className="w-full bg-transparent" isDisabled={!isFormValid || isLoading}
variant="faded" />
size="lg" </form>
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>
</div> </div>
); );
}; };

View File

@@ -9,8 +9,7 @@ import * as z from "zod";
import { deleteLighthouseProviderByType } from "@/actions/lighthouse/lighthouse"; import { deleteLighthouseProviderByType } from "@/actions/lighthouse/lighthouse";
import { DeleteIcon } from "@/components/icons"; import { DeleteIcon } from "@/components/icons";
import { useToast } from "@/components/ui"; import { useToast } from "@/components/ui";
import { CustomButton } from "@/components/ui/custom"; import { Form, FormButtons } from "@/components/ui/form";
import { Form } from "@/components/ui/form";
import type { LighthouseProvider } from "@/types/lighthouse"; import type { LighthouseProvider } from "@/types/lighthouse";
const formSchema = z.object({ const formSchema = z.object({
@@ -57,34 +56,19 @@ export const DeleteLLMProviderForm = ({
return ( return (
<Form {...form}> <Form {...form}>
<form action={onSubmitClient}> <form action={onSubmitClient}>
<input type="hidden" name="providerType" value={providerType} /> <input
<div className="flex w-full justify-center sm:gap-6"> type="hidden"
<CustomButton name="providerType"
type="button" value={providerType}
ariaLabel="Cancel" aria-label="Provider Type"
className="w-full bg-transparent" />
variant="faded" <FormButtons
size="lg" setIsOpen={setIsOpen}
radius="lg" submitText="Delete"
onPress={() => setIsOpen(false)} submitColor="danger"
isDisabled={isLoading} rightIcon={<DeleteIcon size={24} />}
> 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>
</form> </form>
</Form> </Form>
); );

View File

@@ -2,7 +2,6 @@
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import { Icon } from "@iconify/react"; import { Icon } from "@iconify/react";
import { SaveIcon } from "lucide-react";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import * as z from "zod"; import * as z from "zod";
@@ -11,8 +10,16 @@ import {
getTenantConfig, getTenantConfig,
updateTenantConfig, updateTenantConfig,
} from "@/actions/lighthouse/lighthouse"; } from "@/actions/lighthouse/lighthouse";
import { SaveIcon } from "@/components/icons";
import {
Button,
Card,
CardContent,
CardHeader,
CardTitle,
} from "@/components/shadcn";
import { useToast } from "@/components/ui"; import { useToast } from "@/components/ui";
import { CustomButton, CustomTextarea } from "@/components/ui/custom"; import { CustomTextarea } from "@/components/ui/custom";
import { Form } from "@/components/ui/form"; import { Form } from "@/components/ui/form";
const lighthouseSettingsSchema = z.object({ const lighthouseSettingsSchema = z.object({
@@ -97,55 +104,58 @@ export const LighthouseSettings = () => {
if (isFetchingData) { if (isFetchingData) {
return ( return (
<div className="rounded-lg border border-gray-200 bg-white p-6 dark:border-gray-800 dark:bg-gray-900"> <Card variant="base" padding="lg">
<h2 className="mb-4 text-xl font-semibold">Settings</h2> <CardHeader>
<div className="flex items-center justify-center py-12"> <CardTitle>Settings</CardTitle>
<Icon </CardHeader>
icon="heroicons:arrow-path" <CardContent>
className="h-8 w-8 animate-spin text-gray-400" <div className="flex items-center justify-center py-12">
/> <Icon
</div> icon="heroicons:arrow-path"
</div> className="h-8 w-8 animate-spin text-gray-400"
/>
</div>
</CardContent>
</Card>
); );
} }
return ( return (
<div className="rounded-lg border border-gray-200 bg-white p-6 dark:border-gray-800 dark:bg-gray-900"> <Card variant="base" padding="lg">
<h2 className="mb-4 text-xl font-semibold">Settings</h2> <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}> <div className="flex w-full justify-end">
<form <Button
onSubmit={form.handleSubmit(onSubmit)} type="submit"
className="flex flex-col gap-6" aria-label="Save Settings"
> disabled={isLoading}
<CustomTextarea >
control={form.control} {!isLoading && <SaveIcon size={20} />}
name="businessContext" {isLoading ? "Saving..." : "Save"}
label="Business Context" </Button>
labelPlacement="inside" </div>
placeholder="Enter business context and relevant information for the chatbot (max 1000 characters)" </form>
variant="bordered" </Form>
minRows={4} </CardContent>
maxRows={8} </Card>
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>
); );
}; };

View File

@@ -1,13 +1,14 @@
"use client"; "use client";
import { Icon } from "@iconify/react"; import { Icon } from "@iconify/react";
import Link from "next/link";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { import {
getLighthouseProviders, getLighthouseProviders,
getTenantConfig, getTenantConfig,
} from "@/actions/lighthouse/lighthouse"; } from "@/actions/lighthouse/lighthouse";
import { CustomButton } from "@/components/ui/custom"; import { Button, Card, CardContent, CardHeader } from "@/components/shadcn";
import { getAllProviders } from "./llm-provider-registry"; import { getAllProviders } from "./llm-provider-registry";
@@ -104,31 +105,32 @@ export const LLMProvidersTable = () => {
<h2 className="mb-4 text-xl font-semibold">LLM Providers</h2> <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"> <div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
{[1, 2, 3].map((i) => ( {[1, 2, 3].map((i) => (
<div <Card key={i} variant="base" padding="lg">
key={i} <CardHeader>
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="bg-bg-neutral-tertiary h-10 w-10 animate-pulse rounded-full" />
<div className="flex items-center gap-3"> <div className="flex flex-1 flex-col gap-2">
<div className="h-10 w-10 animate-pulse rounded-full bg-gray-200 dark:bg-gray-700" /> <div className="bg-bg-neutral-tertiary h-5 w-32 animate-pulse rounded" />
<div className="flex flex-1 flex-col gap-2"> <div className="bg-bg-neutral-tertiary h-3 w-48 animate-pulse rounded" />
<div className="h-5 w-32 animate-pulse rounded bg-gray-200 dark:bg-gray-700" /> </div>
<div className="h-3 w-48 animate-pulse rounded bg-gray-200 dark:bg-gray-700" />
</div> </div>
</div> </CardHeader>
<div className="flex-grow space-y-3"> <CardContent className="flex flex-col gap-4">
<div> <div className="flex-grow space-y-3">
<div className="mb-2 h-4 w-16 animate-pulse rounded bg-gray-200 dark:bg-gray-700" /> <div>
<div className="h-4 w-28 animate-pulse rounded bg-gray-200 dark:bg-gray-700" /> <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>
<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 className="bg-bg-neutral-tertiary h-10 w-full animate-pulse rounded-lg" />
</div> </CardContent>
</Card>
))} ))}
</div> </div>
</div> </div>
@@ -145,90 +147,99 @@ export const LLMProvidersTable = () => {
const showConfigure = provider.isConnected; const showConfigure = provider.isConnected;
return ( return (
<div <Card
key={provider.id} 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 */} {/* Header */}
<div className="flex items-center gap-3"> <CardHeader>
<Icon icon={provider.icon} width={40} height={40} /> <div className="flex items-center gap-3">
<div className="flex flex-1 flex-col"> <Icon icon={provider.icon} width={40} height={40} />
<div className="flex items-center gap-2"> <div className="flex flex-1 flex-col">
<h3 className="text-lg font-semibold"> <div className="flex items-center gap-2">
{provider.provider} <h3 className="text-lg font-semibold">
</h3> {provider.provider}
{provider.isDefaultProvider && ( </h3>
<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"> {provider.isDefaultProvider && (
Default <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">
</span> Default
)} </span>
)}
</div>
<p className="text-xs text-gray-500 dark:text-gray-400">
{provider.description}
</p>
</div> </div>
<p className="text-xs text-gray-500 dark:text-gray-400">
{provider.description}
</p>
</div> </div>
</div> </CardHeader>
{/* Status and Model Info */} <CardContent className="flex flex-1 flex-col justify-between gap-4">
<div className="flex-grow space-y-3"> {/* Status and Model Info */}
<div> <div className="space-y-3">
<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 && (
<div> <div>
<p className="text-sm text-gray-600 dark:text-gray-400"> <p className="text-text-neutral-secondary text-sm">
Default Model Status
</p> </p>
<p className="text-sm text-gray-700 dark:text-gray-300"> <p
{provider.defaultModel} 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> </p>
</div> </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 */} {showConfigure && (
{showConnect && ( <Button
<CustomButton aria-label={`Configure ${provider.provider}`}
asLink={`/lighthouse/config/connect?provider=${provider.id}`} variant="outline"
ariaLabel={`Connect ${provider.provider}`} className="w-full"
variant="solid" asChild
color="action" >
size="md" <Link
className="w-full" href={`/lighthouse/config/connect?provider=${provider.id}&mode=edit`}
> >
Connect Configure
</CustomButton> </Link>
)} </Button>
)}
{showConfigure && ( </CardContent>
<CustomButton </Card>
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>
); );
})} })}
</div> </div>

View File

@@ -41,7 +41,7 @@ const Loader = ({
> >
<SpinnerIcon <SpinnerIcon
size={loaderSizes[size]} 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>} {text && <span className="text-muted-foreground text-sm">{text}</span>}
<span className="sr-only">{text || "Loading..."}</span> <span className="sr-only">{text || "Loading..."}</span>

View File

@@ -8,7 +8,7 @@ import {
getTenantConfig, getTenantConfig,
updateTenantConfig, updateTenantConfig,
} from "@/actions/lighthouse/lighthouse"; } from "@/actions/lighthouse/lighthouse";
import { CustomButton } from "@/components/ui/custom"; import { Button } from "@/components/shadcn";
import type { LighthouseProvider } from "@/types/lighthouse"; import type { LighthouseProvider } from "@/types/lighthouse";
import { import {
@@ -155,7 +155,7 @@ export const SelectModel = ({
<h2 className="mb-2 text-xl font-semibold"> <h2 className="mb-2 text-xl font-semibold">
{isEditMode ? "Update Default Model" : "Select Default Model"} {isEditMode ? "Update Default Model" : "Select Default Model"}
</h2> </h2>
<p className="text-sm text-gray-600 dark:text-gray-300"> <p className="text-text-neutral-secondary text-sm">
{isEditMode {isEditMode
? "Update the default model to use with this provider." ? "Update the default model to use with this provider."
: "Choose 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 <button
onClick={() => fetchModels(true)} onClick={() => fetchModels(true)}
disabled={isLoading} 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" aria-label="Refresh models"
> >
<Icon <Icon
@@ -176,8 +176,8 @@ export const SelectModel = ({
</div> </div>
{error && ( {error && (
<div className="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 rounded-lg border p-4">
<p className="text-sm text-red-600 dark:text-red-400">{error}</p> <p className="text-text-error text-sm">{error}</p>
</div> </div>
)} )}
@@ -185,14 +185,14 @@ export const SelectModel = ({
<div className="relative"> <div className="relative">
<Icon <Icon
icon="heroicons:magnifying-glass" 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 <input
type="text" type="text"
placeholder="Search models..." placeholder="Search models..."
value={searchQuery} value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)} 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> </div>
)} )}
@@ -201,32 +201,32 @@ export const SelectModel = ({
<div className="flex items-center justify-center py-12"> <div className="flex items-center justify-center py-12">
<Icon <Icon
icon="heroicons:arrow-path" 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> </div>
) : models.length === 0 ? ( ) : 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"> <div className="border-border-neutral-secondary bg-bg-neutral-tertiary rounded-lg border p-8 text-center">
<p className="text-sm text-gray-600 dark:text-gray-400"> <p className="text-text-neutral-secondary text-sm">
No models available. Click refresh to fetch models. No models available. Click refresh to fetch models.
</p> </p>
</div> </div>
) : filteredModels.length === 0 ? ( ) : 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"> <div className="border-border-neutral-secondary bg-bg-neutral-tertiary rounded-lg border p-8 text-center">
<p className="text-sm text-gray-600 dark:text-gray-400"> <p className="text-text-neutral-secondary text-sm">
No models found matching &quot;{searchQuery}&quot; No models found matching &quot;{searchQuery}&quot;
</p> </p>
</div> </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) => ( {filteredModels.map((model) => (
<label <label
key={model.id} key={model.id}
htmlFor={`model-${provider}-${model.id}`} htmlFor={`model-${provider}-${model.id}`}
aria-label={model.name} 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 selectedModel === model.id
? "bg-blue-50 dark:bg-blue-900/20" ? "bg-bg-neutral-secondary"
: "hover:bg-gray-50 dark:hover:bg-gray-800" : "hover:bg-bg-neutral-tertiary"
}`} }`}
> >
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
@@ -241,7 +241,7 @@ export const SelectModel = ({
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="text-sm font-medium">{model.name}</span> <span className="text-sm font-medium">{model.name}</span>
{isRecommended(model.id) && ( {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" /> <Icon icon="heroicons:star-solid" className="h-3 w-3" />
Recommended Recommended
</span> </span>
@@ -255,17 +255,13 @@ export const SelectModel = ({
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
<div className="flex justify-end"> <div className="flex justify-end">
<CustomButton <Button
ariaLabel="Select Model" aria-label="Select Model"
variant="solid" disabled={!selectedModel || isSaving}
color="action" onClick={handleSelect}
size="md"
isDisabled={!selectedModel || isSaving}
isLoading={isSaving}
onPress={handleSelect}
> >
{isSaving ? "Saving..." : "Select"} {isSaving ? "Saving..." : "Select"}
</CustomButton> </Button>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -64,20 +64,21 @@ export const WorkflowConnectLLM = () => {
classNames={{ classNames={{
base: "px-0.5 mb-5", base: "px-0.5 mb-5",
label: "text-small", label: "text-small",
value: "text-small text-default-400", value: "text-small text-button-primary",
indicator: "bg-button-primary",
}} }}
label="Steps" label="Steps"
maxValue={steps.length - 1} maxValue={steps.length}
minValue={0} minValue={0}
showValueLabel={true} showValueLabel={true}
size="md" size="md"
value={currentStep} value={currentStep + 1}
valueLabel={`${currentStep + 1} of ${steps.length}`} valueLabel={`${currentStep + 1} of ${steps.length}`}
/> />
<VerticalSteps <VerticalSteps
hideProgressBars hideProgressBars
currentStep={currentStep} 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} steps={steps}
/> />
<Spacer y={4} /> <Spacer y={4} />

View File

@@ -6,12 +6,9 @@ import { Controller, useForm } from "react-hook-form";
import * as z from "zod"; import * as z from "zod";
import { createProviderGroup } from "@/actions/manage-groups"; import { createProviderGroup } from "@/actions/manage-groups";
import { Button } from "@/components/shadcn";
import { useToast } from "@/components/ui"; import { useToast } from "@/components/ui";
import { import { CustomDropdownSelection, CustomInput } from "@/components/ui/custom";
CustomButton,
CustomDropdownSelection,
CustomInput,
} from "@/components/ui/custom";
import { Form } from "@/components/ui/form"; import { Form } from "@/components/ui/form";
import { ApiError } from "@/types"; import { ApiError } from "@/types";
@@ -123,7 +120,6 @@ export const AddGroupForm = ({
placeholder="Enter the provider group name" placeholder="Enter the provider group name"
variant="flat" variant="flat"
isRequired isRequired
isInvalid={!!form.formState.errors.name}
/> />
</div> </div>
@@ -178,18 +174,10 @@ export const AddGroupForm = ({
{/* Submit Button */} {/* Submit Button */}
<div className="flex w-full justify-end sm:gap-6"> <div className="flex w-full justify-end sm:gap-6">
<CustomButton <Button type="submit" className="w-1/2" disabled={isLoading}>
type="submit" {!isLoading && <SaveIcon size={24} />}
ariaLabel="Create Group" {isLoading ? "Loading" : "Create Group"}
className="w-1/2" </Button>
variant="solid"
color="action"
size="md"
isLoading={isLoading}
startContent={!isLoading && <SaveIcon size={24} />}
>
{isLoading ? <>Loading</> : <span>Create Group</span>}
</CustomButton>
</div> </div>
</form> </form>
</Form> </Form>

View File

@@ -8,8 +8,8 @@ import * as z from "zod";
import { deleteProviderGroup } from "@/actions/manage-groups/manage-groups"; import { deleteProviderGroup } from "@/actions/manage-groups/manage-groups";
import { DeleteIcon } from "@/components/icons"; import { DeleteIcon } from "@/components/icons";
import { Button } from "@/components/shadcn";
import { useToast } from "@/components/ui"; import { useToast } from "@/components/ui";
import { CustomButton } from "@/components/ui/custom";
import { Form } from "@/components/ui/form"; import { Form } from "@/components/ui/form";
const formSchema = z.object({ const formSchema = z.object({
@@ -57,33 +57,26 @@ export const DeleteGroupForm = ({
<Form {...form}> <Form {...form}>
<form action={onSubmitClient}> <form action={onSubmitClient}>
<input type="hidden" name="id" value={groupId} /> <input type="hidden" name="id" value={groupId} />
<div className="flex w-full justify-center sm:gap-6"> <div className="flex w-full justify-end gap-4">
<CustomButton <Button
type="button" type="button"
ariaLabel="Cancel" variant="ghost"
className="w-full bg-transparent"
variant="faded"
size="lg" size="lg"
radius="lg" onClick={() => setIsOpen(false)}
onPress={() => setIsOpen(false)} disabled={isLoading}
isDisabled={isLoading}
> >
<span>Cancel</span> Cancel
</CustomButton> </Button>
<CustomButton <Button
type="submit" type="submit"
ariaLabel="Delete" variant="destructive"
className="w-full"
variant="solid"
color="danger"
size="lg" size="lg"
radius="lg" disabled={isLoading}
isLoading={isLoading}
startContent={!isLoading && <DeleteIcon size={24} />}
> >
{isLoading ? <>Loading</> : <span>Delete</span>} {!isLoading && <DeleteIcon size={24} />}
</CustomButton> {isLoading ? "Loading" : "Delete"}
</Button>
</div> </div>
</form> </form>
</Form> </Form>

View File

@@ -8,12 +8,9 @@ import { Controller, useForm } from "react-hook-form";
import * as z from "zod"; import * as z from "zod";
import { updateProviderGroup } from "@/actions/manage-groups/manage-groups"; import { updateProviderGroup } from "@/actions/manage-groups/manage-groups";
import { Button } from "@/components/shadcn";
import { useToast } from "@/components/ui"; import { useToast } from "@/components/ui";
import { import { CustomDropdownSelection, CustomInput } from "@/components/ui/custom";
CustomButton,
CustomDropdownSelection,
CustomInput,
} from "@/components/ui/custom";
import { Form } from "@/components/ui/form"; import { Form } from "@/components/ui/form";
import { ApiError } from "@/types"; import { ApiError } from "@/types";
@@ -160,7 +157,6 @@ export const EditGroupForm = ({
placeholder="Enter the provider group name" placeholder="Enter the provider group name"
variant="flat" variant="flat"
isRequired isRequired
isInvalid={!!form.formState.errors.name}
/> />
</div> </div>
@@ -241,32 +237,21 @@ export const EditGroupForm = ({
</p> </p>
)} )}
<div className="flex w-full justify-end sm:gap-6"> <div className="flex w-full justify-end gap-4">
<CustomButton <Button
type="button" type="button"
ariaLabel="Cancel" variant="ghost"
className="w-fit bg-transparent" onClick={() => {
variant="faded"
size="md"
onPress={() => {
router.push("/manage-groups"); router.push("/manage-groups");
}} }}
isDisabled={isLoading} disabled={isLoading}
> >
<span>Cancel</span> Cancel
</CustomButton> </Button>
<CustomButton <Button type="submit" className="w-1/2" disabled={isLoading}>
type="submit" {!isLoading && <SaveIcon size={24} />}
ariaLabel="Update Group" {isLoading ? "Loading" : "Update Group"}
className="w-1/2" </Button>
variant="solid"
color="action"
size="md"
isLoading={isLoading}
startContent={!isLoading && <SaveIcon size={24} />}
>
{isLoading ? <>Loading</> : <span>Update Group</span>}
</CustomButton>
</div> </div>
</form> </form>
</Form> </Form>

View File

@@ -1,20 +1,17 @@
"use client"; "use client";
import { SettingsIcon } from "lucide-react"; import { SettingsIcon } from "lucide-react";
import Link from "next/link";
import { CustomButton } from "../ui/custom"; import { Button } from "@/components/shadcn";
export const ManageGroupsButton = () => { export const ManageGroupsButton = () => {
return ( return (
<CustomButton <Button asChild variant="outline">
asLink="/manage-groups" <Link href="/manage-groups">
ariaLabel="Manage Groups" <SettingsIcon size={20} />
variant="dashed" Manage Groups
color="warning" </Link>
size="md" </Button>
startContent={<SettingsIcon size={20} />}
>
Manage Groups
</CustomButton>
); );
}; };

View File

@@ -1,6 +1,5 @@
"use client"; "use client";
import { Button } from "@heroui/button";
import { import {
Dropdown, Dropdown,
DropdownItem, DropdownItem,
@@ -18,6 +17,7 @@ import { useRouter } from "next/navigation";
import { useState } from "react"; import { useState } from "react";
import { VerticalDotsIcon } from "@/components/icons"; import { VerticalDotsIcon } from "@/components/icons";
import { Button } from "@/components/shadcn";
import { CustomAlertModal } from "@/components/ui/custom"; import { CustomAlertModal } from "@/components/ui/custom";
import { DeleteGroupForm } from "../forms"; import { DeleteGroupForm } from "../forms";
@@ -48,12 +48,12 @@ export function DataTableRowActions<ProviderProps>({
<div className="relative flex items-center justify-end gap-2"> <div className="relative flex items-center justify-end gap-2">
<Dropdown <Dropdown
className="dark:bg-prowler-blue-800 shadow-xl" className="border-border-neutral-secondary bg-bg-neutral-secondary border shadow-xl"
placement="bottom" placement="bottom"
> >
<DropdownTrigger> <DropdownTrigger>
<Button isIconOnly radius="full" size="sm" variant="light"> <Button variant="ghost" size="icon-sm" className="rounded-full">
<VerticalDotsIcon className="text-default-400" /> <VerticalDotsIcon className="text-slate-400" />
</Button> </Button>
</DropdownTrigger> </DropdownTrigger>
<DropdownMenu <DropdownMenu
@@ -76,13 +76,13 @@ export function DataTableRowActions<ProviderProps>({
<DropdownSection title="Danger zone"> <DropdownSection title="Danger zone">
<DropdownItem <DropdownItem
key="delete" key="delete"
className="text-danger" className="text-text-error"
color="danger" color="danger"
description="Delete the provider group permanently" description="Delete the provider group permanently"
textValue="Delete Provider Group" textValue="Delete Provider Group"
startContent={ startContent={
<DeleteDocumentBulkIcon <DeleteDocumentBulkIcon
className={clsx(iconClasses, "!text-danger")} className={clsx(iconClasses, "!text-text-error")}
/> />
} }
onPress={() => setIsDeleteOpen(true)} onPress={() => setIsDeleteOpen(true)}

View File

@@ -1,33 +1,20 @@
import { Card } from "@heroui/card";
import { Skeleton } from "@heroui/skeleton";
import React from "react"; import React from "react";
import { Card } from "@/components/shadcn/card/card";
import { Skeleton } from "@/components/shadcn/skeleton/skeleton";
export const SkeletonTableGroups = () => { export const SkeletonTableGroups = () => {
return ( 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 */} {/* Table headers */}
<div className="hidden justify-between md:flex"> <div className="hidden gap-4 md:flex">
<Skeleton className="w-1/12 rounded-lg"> <Skeleton className="h-8 w-1/12" />
<div className="bg-default-200 h-8"></div> <Skeleton className="h-8 w-2/12" />
</Skeleton> <Skeleton className="h-8 w-2/12" />
<Skeleton className="w-2/12 rounded-lg"> <Skeleton className="h-8 w-2/12" />
<div className="bg-default-200 h-8"></div> <Skeleton className="h-8 w-2/12" />
</Skeleton> <Skeleton className="h-8 w-1/12" />
<Skeleton className="w-2/12 rounded-lg"> <Skeleton className="h-8 w-1/12" />
<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> </div>
{/* Table body */} {/* Table body */}
@@ -35,29 +22,15 @@ export const SkeletonTableGroups = () => {
{[...Array(3)].map((_, index) => ( {[...Array(3)].map((_, index) => (
<div <div
key={index} 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"> <Skeleton className="h-12 w-full md:w-1/12" />
<div className="bg-default-300 h-12"></div> <Skeleton className="h-12 w-full md:w-2/12" />
</Skeleton> <Skeleton className="hidden h-12 md:block md:w-2/12" />
<Skeleton className="mb-2 w-full rounded-lg md:mb-0 md:w-2/12"> <Skeleton className="hidden h-12 md:block md:w-2/12" />
<div className="bg-default-300 h-12"></div> <Skeleton className="hidden h-12 md:block md:w-2/12" />
</Skeleton> <Skeleton className="hidden h-12 md:block md:w-1/12" />
<Skeleton className="mb-2 hidden rounded-lg sm:flex md:mb-0 md:w-2/12"> <Skeleton className="hidden h-12 md:block 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-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>
</div> </div>
))} ))}
</div> </div>

Some files were not shown because too many files have changed in this diff Show More