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)
- IaC (Infrastructure as Code) provider support for scanning remote repositories [(#8751)](https://github.com/prowler-cloud/prowler/pull/8751)
- External resource link to IaC findings for direct navigation to source code in Git repositories [(#9151)](https://github.com/prowler-cloud/prowler/pull/9151)
- New Overview page and new app styles [(#9234)](https://github.com/prowler-cloud/prowler/pull/9234)
### 🔄 Changed

View File

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

View File

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

View File

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

View File

@@ -1,4 +1,3 @@
export const dynamic = "force-dynamic";
import { Suspense } from "react";
import {
@@ -138,7 +137,7 @@ export default async function Compliance({
{selectedScanId ? (
<>
<div className="mb-6 flex flex-col gap-6">
<div className="flex items-start justify-between gap-6">
<div className="flex flex-col items-start justify-between lg:flex-row lg:gap-6">
<div className="flex-1">
<ComplianceHeader
scans={expandedScansData}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -22,6 +22,11 @@ export default async function NewOverviewPage({
}: {
searchParams: Promise<SearchParamsProps>;
}) {
//if cloud env throw a 500 err
if (process.env.NEXT_PUBLIC_IS_CLOUD_ENV === "true") {
throw new Error("500");
}
const resolvedSearchParams = await searchParams;
const providersData = await getProviders({ page: 1, pageSize: 200 });

View File

@@ -2,22 +2,8 @@ import { Spacer } from "@heroui/spacer";
import { Suspense } from "react";
import { getLatestFindings } from "@/actions/findings/findings";
import {
getFindingsBySeverity,
getFindingsByStatus,
getProvidersOverview,
} from "@/actions/overview/overview";
import { FilterControls } from "@/components/filters";
import { LighthouseBanner } from "@/components/lighthouse";
import {
FindingsBySeverityChart,
FindingsByStatusChart,
LinkToFindings,
ProvidersOverview,
SkeletonFindingsBySeverityChart,
SkeletonFindingsByStatusChart,
SkeletonProvidersOverview,
} from "@/components/overview";
import { getProviders } from "@/actions/providers";
import { LinkToFindings } from "@/components/overview";
import { ColumnNewFindingsToDate } from "@/components/overview/new-findings-table/table/column-new-findings-to-date";
import { SkeletonTableNewFindings } from "@/components/overview/new-findings-table/table/skeleton-table-new-findings";
import { ContentLayout } from "@/components/ui";
@@ -25,6 +11,20 @@ import { DataTable } from "@/components/ui/table";
import { createDict } from "@/lib/helper";
import { FindingProps, SearchParamsProps } from "@/types";
import { LighthouseBanner } from "../../components/lighthouse/banner";
import { AccountsSelector } from "./new-overview/components/accounts-selector";
import { CheckFindingsSSR } from "./new-overview/components/check-findings";
import { ProviderTypeSelector } from "./new-overview/components/provider-type-selector";
import {
RiskSeverityChartSkeleton,
RiskSeverityChartSSR,
} from "./new-overview/components/risk-severity-chart";
import { StatusChartSkeleton } from "./new-overview/components/status-chart";
import {
ThreatScoreSkeleton,
ThreatScoreSSR,
} from "./new-overview/components/threat-score";
const FILTER_PREFIX = "filter[";
// Extract only query params that start with "filter[" for API calls
@@ -44,98 +44,39 @@ export default async function Home({
}) {
const resolvedSearchParams = await searchParams;
const searchParamsKey = JSON.stringify(resolvedSearchParams || {});
const providersData = await getProviders({ page: 1, pageSize: 200 });
return (
<ContentLayout title="Overview" icon="lucide:square-chart-gantt">
<FilterControls providers mutedFindings showClearButton={false} />
<div className="xxl:grid-cols-4 mb-6 grid grid-cols-1 gap-6 sm:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4">
<ProviderTypeSelector providers={providersData?.data ?? []} />
<AccountsSelector providers={providersData?.data ?? []} />
</div>
<div className="grid grid-cols-12 gap-12 lg:gap-6">
<div className="col-span-12 lg:col-span-4">
<Suspense fallback={<SkeletonProvidersOverview />}>
<SSRProvidersOverview />
<div className="flex flex-col gap-6 md:flex-row md:flex-wrap md:items-stretch">
<Suspense fallback={<ThreatScoreSkeleton />}>
<ThreatScoreSSR searchParams={resolvedSearchParams} />
</Suspense>
<Suspense fallback={<StatusChartSkeleton />}>
<CheckFindingsSSR searchParams={resolvedSearchParams} />
</Suspense>
<Suspense fallback={<RiskSeverityChartSkeleton />}>
<RiskSeverityChartSSR searchParams={resolvedSearchParams} />
</Suspense>
</div>
<div className="col-span-12 lg:col-span-4">
<Suspense fallback={<SkeletonFindingsBySeverityChart />}>
<SSRFindingsBySeverity searchParams={resolvedSearchParams} />
</Suspense>
</div>
<div className="col-span-12 lg:col-span-4">
<Suspense fallback={<SkeletonFindingsByStatusChart />}>
<SSRFindingsByStatus searchParams={resolvedSearchParams} />
</Suspense>
</div>
<div className="col-span-12">
<div className="mt-6">
<Spacer y={16} />
<Suspense
key={searchParamsKey}
fallback={<SkeletonTableNewFindings />}
>
<Suspense key={searchParamsKey} fallback={<SkeletonTableNewFindings />}>
<SSRDataNewFindingsTable searchParams={resolvedSearchParams} />
</Suspense>
</div>
</div>
</ContentLayout>
);
}
const SSRProvidersOverview = async () => {
const providersOverview = await getProvidersOverview({});
return (
<>
<h3 className="mb-4 text-sm font-bold uppercase">Providers Overview</h3>
<ProvidersOverview providersOverview={providersOverview} />
</>
);
};
const SSRFindingsByStatus = async ({
searchParams,
}: {
searchParams: SearchParamsProps | undefined | null;
}) => {
const filters = pickFilterParams(searchParams);
const findingsByStatus = await getFindingsByStatus({ filters });
return (
<>
<h3 className="mb-4 text-sm font-bold uppercase">Findings by Status</h3>
<FindingsByStatusChart findingsByStatus={findingsByStatus} />
</>
);
};
const SSRFindingsBySeverity = async ({
searchParams,
}: {
searchParams: SearchParamsProps | undefined | null;
}) => {
const defaultFilters = {
"filter[status]": "FAIL",
} as const;
const filters = pickFilterParams(searchParams);
const combinedFilters = { ...defaultFilters, ...filters };
const findingsBySeverity = await getFindingsBySeverity({
filters: combinedFilters,
});
return (
<>
<h3 className="mb-4 text-sm font-bold uppercase">
Failed Findings by Severity
</h3>
<FindingsBySeverityChart findingsBySeverity={findingsBySeverity} />
</>
);
};
const SSRDataNewFindingsTable = async ({
searchParams,
}: {
@@ -188,6 +129,7 @@ const SSRDataNewFindingsTable = async ({
return (
<>
<LighthouseBanner />
<div className="relative flex w-full">
<div className="flex w-full items-center gap-2">
<h3 className="text-sm font-bold uppercase">
@@ -203,8 +145,6 @@ const SSRDataNewFindingsTable = async ({
</div>
<Spacer y={4} />
<LighthouseBanner />
<DataTable
key={`dashboard-${Date.now()}`}
columns={ColumnNewFindingsToDate}

View File

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

View File

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

View File

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

View File

@@ -9,6 +9,12 @@ import { useTheme } from "next-themes";
import { FC } from "react";
import React from "react";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/shadcn/tooltip";
import { MoonFilledIcon, SunFilledIcon } from "./icons";
export interface ThemeSwitchProps {
@@ -41,6 +47,8 @@ export const ThemeSwitch: FC<ThemeSwitchProps> = ({
});
return (
<Tooltip>
<TooltipTrigger asChild>
<Component
{...getBaseProps({
className: clsx(
@@ -79,5 +87,10 @@ export const ThemeSwitch: FC<ThemeSwitchProps> = ({
)}
</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 (
<p className="text-small text-center">
{text}&nbsp;
<CustomLink size="base" href={href} target="_self">
<CustomLink size="md" href={href} target="_self">
{linkText}
</CustomLink>
</p>

View File

@@ -1,6 +1,5 @@
"use client";
import { Button } from "@heroui/button";
import { zodResolver } from "@hookform/resolvers/zod";
import { Icon } from "@iconify/react";
import { useRouter, useSearchParams } from "next/navigation";
@@ -13,8 +12,9 @@ import { AuthDivider } from "@/components/auth/oss/auth-divider";
import { AuthFooterLink } from "@/components/auth/oss/auth-footer-link";
import { AuthLayout } from "@/components/auth/oss/auth-layout";
import { SocialButtons } from "@/components/auth/oss/social-buttons";
import { Button } from "@/components/shadcn";
import { useToast } from "@/components/ui";
import { CustomButton, CustomInput } from "@/components/ui/custom";
import { CustomInput } from "@/components/ui/custom";
import { Form } from "@/components/ui/form";
import { SignInFormData, signInSchema } from "@/types";
@@ -156,32 +156,21 @@ export const SignInForm = ({
type="email"
label="Email"
placeholder="Enter your email"
isInvalid={!!form.formState.errors.email}
showFormMessage
/>
{!isSamlMode && (
<CustomInput
control={form.control}
name="password"
password
isInvalid={!!form.formState.errors.password}
/>
<CustomInput control={form.control} name="password" password />
)}
<CustomButton
<Button
type="submit"
ariaLabel="Log in"
ariaDisabled={isLoading}
aria-label="Log in"
aria-disabled={isLoading}
className="w-full"
variant="solid"
color="action"
size="md"
radius="md"
isLoading={isLoading}
isDisabled={isLoading}
disabled={isLoading}
>
{isLoading ? <span>Loading</span> : <span>Log in</span>}
</CustomButton>
{isLoading ? "Loading..." : "Log in"}
</Button>
</form>
</Form>
@@ -197,21 +186,19 @@ export const SignInForm = ({
/>
)}
<Button
startContent={
!isSamlMode && (
variant="outline"
className="w-full gap-2"
onClick={() => {
form.setValue("isSamlMode", !isSamlMode);
}}
>
{!isSamlMode && (
<Icon
className="text-default-500"
icon="mdi:shield-key"
width={24}
/>
)
}
variant="bordered"
className="w-full"
onClick={() => {
form.setValue("isSamlMode", !isSamlMode);
}}
>
)}
{isSamlMode ? "Back" : "Continue with SAML SSO"}
</Button>
</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 { PasswordRequirementsMessage } from "@/components/auth/oss/password-validator";
import { SocialButtons } from "@/components/auth/oss/social-buttons";
import { Button } from "@/components/shadcn";
import { useToast } from "@/components/ui";
import { CustomButton, CustomInput } from "@/components/ui/custom";
import { CustomInput } from "@/components/ui/custom";
import { CustomLink } from "@/components/ui/custom/custom-link";
import {
Form,
@@ -133,7 +134,6 @@ export const SignUpForm = ({
type="text"
label="Name"
placeholder="Enter your name"
isInvalid={!!form.formState.errors.name}
/>
<CustomInput
control={form.control}
@@ -142,7 +142,6 @@ export const SignUpForm = ({
label="Company name"
placeholder="Enter your company name"
isRequired={false}
isInvalid={!!form.formState.errors.company}
/>
<CustomInput
control={form.control}
@@ -150,15 +149,9 @@ export const SignUpForm = ({
type="email"
label="Email"
placeholder="Enter your email"
isInvalid={!!form.formState.errors.email}
showFormMessage
/>
<CustomInput
control={form.control}
name="password"
password
isInvalid={!!form.formState.errors.password}
/>
<CustomInput control={form.control} name="password" password />
<PasswordRequirementsMessage
password={form.watch("password") || ""}
/>
@@ -176,7 +169,6 @@ export const SignUpForm = ({
placeholder={invitationToken}
defaultValue={invitationToken}
isRequired={false}
isInvalid={!!form.formState.errors.invitationToken}
isDisabled={invitationToken !== null && true}
/>
)}
@@ -194,6 +186,7 @@ export const SignUpForm = ({
size="sm"
checked={field.value}
onChange={(e) => field.onChange(e.target.checked)}
color="default"
>
I agree with the&nbsp;
<CustomLink
@@ -205,26 +198,21 @@ export const SignUpForm = ({
&nbsp;of Prowler
</Checkbox>
</FormControl>
<FormMessage className="text-system-error dark:text-system-error" />
<FormMessage className="text-text-error" />
</>
)}
/>
)}
<CustomButton
<Button
type="submit"
ariaLabel="Sign up"
ariaDisabled={isLoading}
aria-label="Sign up"
aria-disabled={isLoading}
className="w-full"
variant="solid"
color="action"
size="md"
radius="md"
isLoading={isLoading}
isDisabled={isLoading}
disabled={isLoading}
>
{isLoading ? <span>Loading</span> : <span>Sign up</span>}
</CustomButton>
{isLoading ? "Loading..." : "Sign up"}
</Button>
</form>
</Form>

View File

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

View File

@@ -58,7 +58,7 @@ export const ClientAccordionWrapper = ({
return (
<div>
{!hideExpandButton && (
<div className="mt-[-16px] flex justify-end text-xs font-medium text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300">
<div className="text-text-neutral-tertiary hover:text-text-neutral-primary mt-[-16px] flex justify-end text-xs font-medium transition-colors">
<button
onClick={handleToggleExpand}
aria-label={isExpanded ? "Collapse all" : "Expand all"}

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

View File

@@ -1,11 +1,11 @@
"use client";
import { Card, CardBody } from "@heroui/card";
import { Progress } from "@heroui/progress";
import Image from "next/image";
import { useRouter, useSearchParams } from "next/navigation";
import React, { useState } from "react";
import { useState } from "react";
import { Card, CardContent } from "@/components/shadcn/card/card";
import { DownloadIconButton, toast } from "@/components/ui";
import { downloadComplianceCsv } from "@/lib/helper";
import { ScanEntity } from "@/types/scans";
@@ -48,23 +48,6 @@ export const ComplianceCard: React.FC<ComplianceCardProps> = ({
(passingRequirements / totalRequirements) * 100,
);
// Calculates the percentage change in passing requirements compared to the previous scan.
//
// const prevRatingPercentage = Math.floor(
// (prevPassingRequirements / prevTotalRequirements) * 100,
// );
// const getScanChange = () => {
// const scanDifference = ratingPercentage - prevRatingPercentage;
// if (scanDifference < 0 && scanDifference <= -1) {
// return `${scanDifference}% from last scan`;
// }
// if (scanDifference > 0 && scanDifference >= 1) {
// return `+${scanDifference}% from last scan`;
// }
// return "No changes from last scan";
// };
const getRatingColor = (ratingPercentage: number) => {
if (ratingPercentage <= 10) {
return "danger";
@@ -112,11 +95,13 @@ export const ComplianceCard: React.FC<ComplianceCardProps> = ({
};
return (
<Card fullWidth isHoverable shadow="sm">
<CardBody
className="dark:bg-prowler-blue-800 flex cursor-pointer flex-row items-center justify-between gap-4"
<Card
variant="base"
padding="md"
className="cursor-pointer transition-shadow hover:shadow-md"
onClick={navigateToDetail}
>
<CardContent className="p-0">
<div className="flex w-full items-center gap-4">
{getComplianceIcon(title) && (
<Image
@@ -169,11 +154,10 @@ export const ComplianceCard: React.FC<ComplianceCardProps> = ({
isDownloading={isDownloading}
/>
</div>
{/* <small>{getScanChange()}</small> */}
</div>
</div>
</div>
</CardBody>
</CardContent>
</Card>
);
};

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

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";
import { Spacer } from "@heroui/spacer";
import Image from "next/image";
import { FilterControls } from "@/components/filters";
import { DataTableFilterCustom } from "@/components/ui/table/data-table-filter-custom";
import { ScanEntity } from "@/types/scans";
import { ComplianceScanInfo } from "./compliance-scan-info";
import { DataCompliance } from "./data-compliance";
import { SelectScanComplianceDataProps } from "./scan-selector";
@@ -15,6 +17,10 @@ interface ComplianceHeaderProps {
showRegionFilter?: boolean;
framework?: string; // Framework name to show specific filters
showProviders?: boolean;
hideFilters?: boolean;
logoPath?: string;
complianceTitle?: string;
selectedScan?: ScanEntity | null;
}
export const ComplianceHeader = ({
@@ -24,6 +30,10 @@ export const ComplianceHeader = ({
showRegionFilter = true,
framework,
showProviders = true,
hideFilters = false,
logoPath,
complianceTitle,
selectedScan,
}: ComplianceHeaderProps) => {
const frameworkFilters = [];
@@ -54,18 +64,39 @@ export const ComplianceHeader = ({
const allFilters = [...frameworkFilters, ...regionFilters];
const hasContent =
showProviders ||
showSearch ||
(!hideFilters && allFilters.length > 0) ||
selectedScan;
return (
<>
{(showProviders || showSearch) && (
<>
<div className="flex items-start justify-start gap-4">
{hasContent && (
<div className="flex w-full items-start justify-between gap-6">
<div className="flex flex-1 flex-col justify-end gap-4">
{selectedScan && <ComplianceScanInfo scan={selectedScan} />}
{showProviders && <DataCompliance scans={scans} />}
{showSearch && <FilterControls search />}
</div>
</>
{!hideFilters && allFilters.length > 0 && (
<DataTableFilterCustom filters={allFilters} />
)}
{allFilters.length > 0 && <DataTableFilterCustom filters={allFilters} />}
<Spacer y={8} />
</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>
)}
{hasContent && <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 { ComplianceScanInfo } from "./compliance-scan-info";
@@ -21,37 +28,33 @@ export const ScanSelector = ({
selectedScanId,
onSelectionChange,
}: SelectScanComplianceDataProps) => {
const selectedScan = scans.find((item) => item.id === selectedScanId);
return (
<Select
aria-label="Select a Scan"
placeholder="Select a scan"
classNames={{
trigger: "w-full min-w-[365px] rounded-lg",
popoverContent: "rounded-lg",
}}
size="lg"
labelPlacement="outside"
selectedKeys={new Set([selectedScanId])}
onSelectionChange={(keys) => {
const newSelectedId = Array.from(keys)[0] as string;
if (newSelectedId && newSelectedId !== selectedScanId) {
onSelectionChange(newSelectedId);
value={selectedScanId}
onValueChange={(value) => {
if (value && value !== selectedScanId) {
onSelectionChange(value);
}
}}
renderValue={() => {
const selectedItem = scans.find((item) => item.id === selectedScanId);
return selectedItem ? (
<ComplianceScanInfo scan={selectedItem} />
>
<SelectTrigger className="w-full min-w-[365px]">
<SelectValue placeholder="Select a scan">
{selectedScan ? (
<ComplianceScanInfo scan={selectedScan} />
) : (
"Select a scan"
);
}}
>
)}
</SelectValue>
</SelectTrigger>
<SelectContent>
{scans.map((scan) => (
<SelectItem key={scan.id} textValue={scan.attributes.name || "- -"}>
<SelectItem key={scan.id} value={scan.id}>
<ComplianceScanInfo scan={scan} />
</SelectItem>
))}
</SelectContent>
</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-title";
export * from "./compliance-card";
export * from "./compliance-charts/bar-chart";
export * from "./compliance-charts/chart-skeletons";
export * from "./compliance-charts/heatmap-chart";
export * from "./compliance-charts/pie-chart";
export * from "./compliance-charts/requirements-status-card";
export * from "./compliance-charts/sections-failure-rate-card";
export * from "./compliance-charts/top-failed-sections-card";
export * from "./compliance-custom-details/cis-details";
export * from "./compliance-custom-details/ens-details";
export * from "./compliance-custom-details/iso-details";

View File

@@ -1,15 +1,19 @@
"use client";
import Link from "next/link";
import React from "react";
import { Button } from "@/components/shadcn/button/button";
import { Card, CardContent } from "@/components/shadcn/card/card";
import { InfoIcon } from "../icons/Icons";
import { CustomButton } from "../ui/custom";
export const NoScansAvailable = () => {
return (
<div className="flex h-full min-h-[calc(100vh-56px)] items-center justify-center">
<div className="mx-auto w-full max-w-2xl">
<div className="dark:bg-prowler-blue-400 flex items-center justify-start rounded-lg border border-gray-200 bg-white p-6 dark:border-gray-700">
<Card variant="base" padding="lg">
<CardContent>
<div className="flex w-full items-center justify-between gap-6">
<div className="flex items-start gap-4">
<InfoIcon className="mt-1 h-5 w-5 text-gray-400 dark:text-gray-300" />
@@ -23,18 +27,19 @@ export const NoScansAvailable = () => {
</p>
</div>
</div>
<CustomButton
asLink="/scans"
className="shrink-0"
ariaLabel="Go to Scans page"
variant="solid"
color="action"
<Button
asChild
variant="secondary"
size="sm"
className="shrink-0"
>
<Link href="/scans" aria-label="Go to Scans page">
Go to Scans
</CustomButton>
</div>
</Link>
</Button>
</div>
</CardContent>
</Card>
</div>
</div>
);

View File

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

View File

@@ -14,6 +14,11 @@ import {
DropdownMenuTrigger,
Separator,
} from "@/components/shadcn";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/shadcn/tooltip";
import { hasNewFeeds, markFeedsAsSeen } from "@/lib/feeds-storage";
import { cn } from "@/lib/utils";
@@ -49,10 +54,12 @@ export function FeedsClient({ feedData, error }: FeedsClientProps) {
return (
<DropdownMenu onOpenChange={handleOpenChange}>
<Tooltip>
<TooltipTrigger asChild>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
className="relative h-8 w-8 rounded-full bg-transparent p-2"
className="border-border-input-primary-fill relative h-8 w-8 rounded-full bg-transparent p-2"
aria-label={
hasUnseenFeeds
? "New updates available - Click to view"
@@ -62,17 +69,24 @@ export function FeedsClient({ feedData, error }: FeedsClientProps) {
<BellRing
size={18}
className={cn(
hasFeeds && hasUnseenFeeds && "text-prowler-green animate-pulse",
hasFeeds &&
hasUnseenFeeds &&
"text-button-primary animate-pulse",
)}
/>
{hasFeeds && hasUnseenFeeds && (
<span className="absolute top-0 right-0 flex h-2 w-2">
<span className="bg-prowler-green absolute inline-flex h-full w-full animate-ping rounded-full opacity-75"></span>
<span className="bg-prowler-green relative inline-flex h-2 w-2 rounded-full"></span>
<span className="bg-button-primary absolute inline-flex h-full w-full animate-ping rounded-full opacity-75"></span>
<span className="bg-button-primary relative inline-flex h-2 w-2 rounded-full"></span>
</span>
)}
</Button>
</DropdownMenuTrigger>
</TooltipTrigger>
<TooltipContent>
{hasUnseenFeeds ? "New updates available" : "Latest Updates"}
</TooltipContent>
</Tooltip>
<DropdownMenuContent
align="end"
@@ -143,7 +157,7 @@ function FeedTimelineItem({ item, isLast }: FeedTimelineItemProps) {
<div className="group relative flex gap-3 px-3 py-2">
{/* Timeline dot */}
<div className="relative flex flex-col items-center">
<div className="border-prowler-green bg-prowler-green z-10 h-2 w-2 rounded-full border-2" />
<div className="border-button-primary bg-button-primary z-10 h-2 w-2 rounded-full border-2" />
{!isLast && (
<div className="h-full w-px bg-slate-200 dark:bg-slate-700" />
)}
@@ -158,13 +172,13 @@ function FeedTimelineItem({ item, isLast }: FeedTimelineItemProps) {
className="backdrop-blur-0 block space-y-1 rounded-[12px] border border-transparent p-2 transition-all hover:border-slate-300 hover:bg-[#F8FAFC80] hover:backdrop-blur-[46px] dark:hover:border-[rgba(38,38,38,0.70)] dark:hover:bg-[rgba(23,23,23,0.50)]"
>
<div className="flex items-start justify-between gap-2">
<h4 className="group-hover:text-prowler-green dark:group-hover:text-prowler-green min-w-0 flex-1 text-sm leading-tight font-semibold break-words text-slate-900 dark:text-white">
<h4 className="group-hover:text-button-primary dark:group-hover:text-button-primary min-w-0 flex-1 text-sm leading-tight font-semibold break-words text-slate-900 dark:text-white">
{item.title}
</h4>
{version && (
<Badge
variant="secondary"
className="border-prowler-green bg-prowler-green/10 text-prowler-green dark:bg-prowler-green/20 shrink-0 text-[10px] font-semibold"
className="border-button-primary bg-button-primary/10 text-button-primary dark:bg-button-primary/20 shrink-0 text-[10px] font-semibold"
>
v{version}
</Badge>
@@ -182,7 +196,7 @@ function FeedTimelineItem({ item, isLast }: FeedTimelineItemProps) {
{relativeTime}
</time>
<div className="text-prowler-green flex items-center gap-1 opacity-0 transition-opacity group-hover:opacity-100">
<div className="text-button-primary flex items-center gap-1 opacity-0 transition-opacity group-hover:opacity-100">
<span className="text-[11px] font-medium">Read more</span>
<ExternalLink size={10} />
</div>

View File

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

View File

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

View File

@@ -61,13 +61,30 @@ export const CustomDatePicker = () => {
return (
<div className="flex w-full flex-col md:gap-2">
<DatePicker
style={{
borderRadius: "0.5rem",
}}
aria-label="Select a Date"
label="Date"
labelPlacement="inside"
classNames={{
base: "w-full [&]:!rounded-lg [&>*]:!rounded-lg",
selectorButton: "text-bg-button-secondary shrink-0",
input:
"text-bg-button-secondary placeholder:text-bg-button-secondary text-sm",
innerWrapper: "[&]:!rounded-lg",
inputWrapper:
"!border-border-input-primary !bg-bg-input-primary dark:!bg-input/30 dark:hover:!bg-input/50 hover:!bg-bg-neutral-secondary !border [&]:!rounded-lg !shadow-xs !transition-[color,box-shadow] focus-within:!border-border-input-primary-press focus-within:!ring-1 focus-within:!ring-border-input-primary-press focus-within:!ring-offset-1 !h-10 !px-4 !py-3 !outline-none",
segment: "text-bg-button-secondary",
}}
popoverProps={{
classNames: {
content:
"border-border-input-primary bg-bg-input-primary border rounded-lg",
},
}}
CalendarTopContent={
<ButtonGroup
fullWidth
className="bg-content1 dark:bg-prowler-blue-400 [&>button]:border-default-200/60 [&>button]:text-default-500 px-3 pt-3 pb-2"
className="bg-bg-neutral-secondary [&>button]:border-border-neutral-secondary [&>button]:text-bg-button-secondary px-3 pt-3 pb-2"
radius="full"
size="sm"
variant="flat"
@@ -93,8 +110,6 @@ export const CustomDatePicker = () => {
}}
value={value}
onChange={handleDateChange}
size="sm"
variant="flat"
/>
</div>
);

View File

@@ -54,16 +54,23 @@ export const CustomSearchInput: React.FC = () => {
return (
<Input
variant="flat"
style={{
borderRadius: "0.5rem",
}}
classNames={{
label: "tracking-tight font-light !text-default-600 text-sm z-0! pb-1",
base: "w-full [&]:!rounded-lg [&>*]:!rounded-lg",
input:
"text-bg-button-secondary placeholder:text-bg-button-secondary text-sm",
inputWrapper:
"!border-border-input-primary !bg-bg-input-primary dark:!bg-input/30 dark:hover:!bg-input/50 hover:!bg-bg-neutral-secondary !border [&]:!rounded-lg !shadow-xs !transition-[color,box-shadow] focus-within:!border-border-input-primary-press focus-within:!ring-1 focus-within:!ring-border-input-primary-press focus-within:!ring-offset-1 !h-10 !px-4 !py-3 !outline-none",
clearButton: "text-bg-button-secondary",
}}
aria-label="Search"
label="Search"
placeholder="Search..."
labelPlacement="inside"
value={searchQuery}
startContent={<SearchIcon className="text-default-400" width={16} />}
startContent={
<SearchIcon className="text-bg-button-secondary shrink-0" width={16} />
}
onChange={(e) => {
const value = e.target.value;
setSearchQuery(value);
@@ -71,13 +78,14 @@ export const CustomSearchInput: React.FC = () => {
}}
endContent={
searchQuery && (
<button onClick={clearIconSearch} className="focus:outline-none">
<XCircle className="text-default-400 h-4 w-4" />
<button
onClick={clearIconSearch}
className="text-bg-button-secondary shrink-0 focus:outline-none"
>
<XCircle className="text-bg-button-secondary h-4 w-4" />
</button>
)
}
radius="sm"
size="sm"
/>
);
};

View File

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

View File

@@ -282,7 +282,7 @@ export const SendToJiraModal = ({
))}
</Select>
</FormControl>
<FormMessage className="text-system-error text-xs" />
<FormMessage className="text-text-error text-xs" />
</>
)}
/>
@@ -366,90 +366,22 @@ export const SendToJiraModal = ({
))}
</Select>
</FormControl>
<FormMessage className="text-system-error text-xs" />
<FormMessage className="text-text-error text-xs" />
</>
)}
/>
)}
{/* Issue Type Selection - Enhanced Style */}
{/* {selectedProject && issueTypes.length > 0 && (
<FormField
control={form.control}
name="issueType"
render={({ field }) => (
<>
<FormControl>
<Select
label="Issue Type"
placeholder="Select an issue type"
selectedKeys={
field.value ? new Set([field.value]) : new Set()
}
onSelectionChange={(keys: Selection) => {
const value = getSelectedValue(keys);
field.onChange(value);
}}
variant="bordered"
labelPlacement="inside"
isInvalid={!!form.formState.errors.issueType}
classNames={{
trigger: "min-h-12",
popoverContent: "dark:bg-gray-800",
listboxWrapper: "max-h-[300px] dark:bg-gray-800",
label:
"tracking-tight font-light !text-default-500 text-xs z-0!",
value: "text-default-500 text-small dark:text-gray-300",
}}
listboxProps={{
topContent:
filteredIssueTypes.length > 5 ? (
<div className="sticky top-0 z-10 bg-content1 py-2 dark:bg-gray-800">
<Input
isClearable
placeholder="Search issue types..."
size="sm"
variant="bordered"
startContent={<Search size={16} />}
value={searchIssueTypeValue}
onValueChange={setSearchIssueTypeValue}
onClear={() => setSearchIssueTypeValue("")}
classNames={{
inputWrapper:
"border-default-200 bg-transparent hover:bg-default-100/50",
input: "text-small",
}}
/>
</div>
) : null,
}}
>
{filteredIssueTypes.map((type) => (
<SelectItem key={type} textValue={type}>
<div className="flex items-center py-1">
<span className="text-small">{type}</span>
</div>
</SelectItem>
))}
</Select>
</FormControl>
<FormMessage className="text-xs text-system-error" />
</>
)}
/>
)} */}
{/* No integrations or none connected message */}
{!isFetchingIntegrations &&
(integrations.length === 0 || !hasConnectedIntegration) && (
(integrations.length === 0 || !hasConnectedIntegration) ? (
<CustomBanner
title="Jira integration is not available"
message="Please add or connect an integration first"
buttonLabel="Configure"
buttonLink="/integrations/jira"
/>
)}
) : (
<FormButtons
setIsOpen={setOpenForFormButtons}
onCancel={() => onOpenChange(false)}
@@ -465,6 +397,7 @@ export const SendToJiraModal = ({
}
rightIcon={<Send size={20} />}
/>
)}
</form>
</Form>
</CustomAlertModal>

View File

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

View File

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

View File

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

View File

@@ -5,8 +5,14 @@ import { Tooltip } from "@heroui/tooltip";
import { ExternalLink, Link } from "lucide-react";
import ReactMarkdown from "react-markdown";
import {
Card,
CardAction,
CardContent,
CardHeader,
CardTitle,
} from "@/components/shadcn";
import { CodeSnippet } from "@/components/ui/code-snippet/code-snippet";
import { CustomSection } from "@/components/ui/custom";
import { CustomLink } from "@/components/ui/custom/custom-link";
import { EntityInfoShort, InfoField } from "@/components/ui/entities";
import { DateWithTime } from "@/components/ui/entities/date-with-time";
@@ -92,7 +98,13 @@ export const FindingDetail = ({
isMuted={attributes.muted}
mutedReason={attributes.muted_reason || ""}
/>
</div>
</div>
{/* Check Metadata */}
<Card variant="base" padding="lg">
<CardHeader className="flex items-center justify-between">
<CardTitle>Finding Details</CardTitle>
<div
className={`rounded-lg px-3 py-1 text-sm font-semibold ${
attributes.status === "PASS"
@@ -104,11 +116,8 @@ export const FindingDetail = ({
>
{renderValue(attributes.status)}
</div>
</div>
</div>
{/* Check Metadata */}
<CustomSection title="Finding Details">
</CardHeader>
<CardContent className="flex flex-col gap-4">
<div className="flex flex-wrap gap-4">
<EntityInfoShort
cloudProvider={providerDetails.provider as ProviderType}
@@ -188,13 +197,18 @@ export const FindingDetail = ({
<InfoField label="Recommendation">
<div className="flex flex-col gap-2">
<MarkdownContainer>
{attributes.check_metadata.remediation.recommendation.text}
{
attributes.check_metadata.remediation.recommendation
.text
}
</MarkdownContainer>
{attributes.check_metadata.remediation.recommendation.url && (
{attributes.check_metadata.remediation.recommendation
.url && (
<CustomLink
href={
attributes.check_metadata.remediation.recommendation.url
attributes.check_metadata.remediation.recommendation
.url
}
size="sm"
>
@@ -252,15 +266,15 @@ export const FindingDetail = ({
<InfoField label="Categories">
{attributes.check_metadata.categories?.join(", ") || "-"}
</InfoField>
</CustomSection>
</CardContent>
</Card>
{/* Resource Details */}
<CustomSection
title={
providerDetails.provider === "iac" ? (
<span className="flex items-center gap-2">
Resource Details
{gitUrl && (
<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}
@@ -272,13 +286,10 @@ export const FindingDetail = ({
<ExternalLink size={16} className="inline" />
</a>
</Tooltip>
</CardAction>
)}
</span>
) : (
"Resource Details"
)
}
>
</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)}
@@ -289,7 +300,9 @@ export const FindingDetail = ({
</div>
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<InfoField label="Service">{renderValue(resource.service)}</InfoField>
<InfoField label="Service">
{renderValue(resource.service)}
</InfoField>
<InfoField label="Region">{renderValue(resource.region)}</InfoField>
</div>
@@ -316,10 +329,15 @@ export const FindingDetail = ({
<DateWithTime inline dateTime={resource.updated_at || "-"} />
</InfoField>
</div>
</CustomSection>
</CardContent>
</Card>
{/* Add new Scan Details section */}
<CustomSection title="Scan Details">
<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">
@@ -355,7 +373,8 @@ export const FindingDetail = ({
</InfoField>
)}
</div>
</CustomSection>
</CardContent>
</Card>
</div>
);
};

View File

@@ -1,11 +1,39 @@
import React from "react";
import { SkeletonTable } from "../../ui/skeleton/skeleton";
import { Card } from "@/components/shadcn/card/card";
import { Skeleton } from "@/components/shadcn/skeleton/skeleton";
export const SkeletonTableFindings = () => {
const columns = 7;
const rows = 4;
return (
<div className="bg-card rounded-xl border p-4 shadow-sm">
<SkeletonTable rows={4} columns={7} />
<Card variant="base" padding="md" className="flex flex-col gap-4">
{/* Table headers */}
<div className="flex gap-4">
{Array.from({ length: columns }).map((_, index) => (
<Skeleton
key={`header-${index}`}
className="h-8"
style={{ width: `${100 / columns}%` }}
/>
))}
</div>
{/* Table body */}
<div className="flex flex-col gap-3">
{Array.from({ length: rows }).map((_, rowIndex) => (
<div key={`row-${rowIndex}`} className="flex gap-4">
{Array.from({ length: columns }).map((_, colIndex) => (
<Skeleton
key={`cell-${rowIndex}-${colIndex}`}
className="h-12"
style={{ width: `${100 / columns}%` }}
/>
))}
</div>
))}
</div>
</Card>
);
};

View File

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

View File

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

View File

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

View File

@@ -1131,7 +1131,7 @@ export const LighthouseIcon: React.FC<IconSvgProps> = ({
width={size || width}
height={size || height}
fill="none"
stroke="#8ce112"
stroke="var(--bg-button-primary)"
strokeWidth="12"
strokeLinecap="round"
strokeLinejoin="round"
@@ -1188,3 +1188,69 @@ export const BellIcon: React.FC<IconSvgProps> = ({
</svg>
);
};
export const SidebarExpandIcon: React.FC<IconSvgProps> = ({
size = 24,
width,
height,
...props
}) => {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width={size || width}
height={size || height}
viewBox="0 0 24 24"
fill="none"
{...props}
>
<path
d="M19 21H5C4.46957 21 3.96086 20.7893 3.58579 20.4142C3.21071 20.0391 3 19.5304 3 19V5C3 4.46957 3.21071 3.96086 3.58579 3.58579C3.96086 3.21071 4.46957 3 5 3H19C19.5304 3 20.0391 3.21071 20.4142 3.58579C20.7893 3.96086 21 4.46957 21 5V19C21 19.5304 20.7893 20.0391 20.4142 20.4142C20.0391 20.7893 19.5304 21 19 21Z"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M7.25 10L5.5 12L7.25 14M9.5 21V3"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
};
export const SidebarCollapseIcon: React.FC<IconSvgProps> = ({
size = 24,
width,
height,
...props
}) => {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width={size || width}
height={size || height}
viewBox="0 0 24 24"
fill="none"
{...props}
>
<path
d="M19 21H5C4.46957 21 3.96086 20.7893 3.58579 20.4142C3.21071 20.0391 3 19.5304 3 19V5C3 4.46957 3.21071 3.96086 3.58579 3.58579C3.96086 3.21071 4.46957 3 5 3H19C19.5304 3 20.0391 3.21071 20.4142 3.58579C20.7893 3.96086 21 4.46957 21 5V19C21 19.5304 20.7893 20.0391 20.4142 20.4142C20.0391 20.7893 19.5304 21 19 21Z"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M16.75 10L18.5 12L16.75 14M14.5 21V3"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
};

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,11 +1,11 @@
"use client";
import { Card, CardBody } from "@heroui/card";
import { Divider } from "@heroui/divider";
import { Snippet } from "@heroui/snippet";
import Link from "next/link";
import { AddIcon } from "../icons";
import { CustomButton } from "../ui/custom";
import { Button, Card, CardContent, CardHeader } from "../shadcn";
import { Separator } from "../shadcn/separator/separator";
import { DateWithTime } from "../ui/entities";
interface InvitationDetailsProps {
@@ -35,9 +35,11 @@ const InfoField = ({
children: React.ReactNode;
}) => (
<div className="flex flex-col gap-1">
<span className="text-xs font-bold text-gray-500">{label}</span>
<div className="flex items-center rounded-lg bg-gray-50 p-3">
<span className="text-small text-gray-900">{children}</span>
<span className="text-text-neutral-secondary text-xs font-bold">
{label}
</span>
<div className="border-border-input-primary bg-bg-input-primary flex items-center rounded-lg border p-3">
<span className="text-small text-text-neutral-primary">{children}</span>
</div>
</div>
);
@@ -53,17 +55,9 @@ export const InvitationDetails = ({ attributes }: InvitationDetailsProps) => {
return (
<div className="flex flex-col gap-x-4 gap-y-8">
<Card
isBlurred
className="bg-background/60 dark:bg-prowler-blue-800 border-none"
shadow="sm"
>
<CardBody>
<h2 className="text-md text-foreground/90 font-bold">
Invitation details
</h2>
<Divider className="my-4" />
<Card variant="base" padding="lg">
<CardHeader>Invitation details</CardHeader>
<CardContent>
<div className="flex flex-col gap-3">
<InfoField label="Email">{attributes.email}</InfoField>
@@ -88,8 +82,8 @@ export const InvitationDetails = ({ attributes }: InvitationDetailsProps) => {
</div>
</div>
<Divider className="my-4" />
<h3 className="text-small text-foreground/90 pb-2 font-bold">
<Separator className="my-4" />
<h3 className="text-text-neutral-primary pb-2 text-sm font-bold">
Share this link with the user:
</h3>
@@ -100,26 +94,22 @@ export const InvitationDetails = ({ attributes }: InvitationDetailsProps) => {
}}
hideSymbol
variant="bordered"
className="overflow-hidden bg-gray-50 py-1 text-ellipsis whitespace-nowrap dark:bg-slate-800"
className="bg-bg-neutral-secondary overflow-hidden py-1 text-ellipsis whitespace-nowrap"
>
<p className="no-scrollbar text-small w-fit overflow-hidden overflow-x-scroll text-ellipsis whitespace-nowrap">
<p className="no-scrollbar w-fit overflow-hidden overflow-x-scroll text-sm text-ellipsis whitespace-nowrap">
{invitationLink}
</p>
</Snippet>
</div>
</CardBody>
</CardContent>
</Card>
<div className="flex w-full items-center justify-end">
<CustomButton
asLink="/invitations/"
ariaLabel="Send Invitation"
variant="solid"
color="action"
size="md"
endContent={<AddIcon size={20} />}
>
<Button asChild size="default" className="gap-2">
<Link href="/invitations/">
Back to Invitations
</CustomButton>
<AddIcon size={20} />
</Link>
</Button>
</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";
import { Button } from "@heroui/button";
import {
Dropdown,
DropdownItem,
@@ -19,6 +18,7 @@ import { useRouter } from "next/navigation";
import { useState } from "react";
import { VerticalDotsIcon } from "@/components/icons";
import { Button } from "@/components/shadcn";
import { CustomAlertModal } from "@/components/ui/custom";
import { DeleteForm, EditForm } from "../forms";
@@ -68,12 +68,12 @@ export function DataTableRowActions<InvitationProps>({
<div className="relative flex items-center justify-end gap-2">
<Dropdown
className="dark:bg-prowler-blue-800 shadow-xl"
className="border-border-neutral-secondary bg-bg-neutral-secondary border shadow-xl"
placement="bottom"
>
<DropdownTrigger>
<Button isIconOnly radius="full" size="sm" variant="light">
<VerticalDotsIcon className="text-default-400" />
<Button variant="ghost" size="icon-sm" className="rounded-full">
<VerticalDotsIcon className="text-slate-400" />
</Button>
</DropdownTrigger>
<DropdownMenu
@@ -109,13 +109,13 @@ export function DataTableRowActions<InvitationProps>({
<DropdownSection title="Danger zone">
<DropdownItem
key="delete"
className="text-danger"
className="text-text-error"
color="danger"
description="Delete the invitation permanently"
textValue="Delete Invitation"
startContent={
<DeleteDocumentBulkIcon
className={clsx(iconClasses, "!text-danger")}
className={clsx(iconClasses, "!text-text-error")}
/>
}
onPress={() => setIsDeleteOpen(true)}

View File

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

View File

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

View File

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

View File

@@ -44,13 +44,14 @@ export const WorkflowSendInvite = () => {
base: "px-0.5 mb-3 sm:mb-5",
label: "text-xs sm:text-small",
value: "text-xs sm:text-small text-default-400",
indicator: "bg-button-primary",
}}
label="Steps"
maxValue={steps.length - 1}
maxValue={steps.length}
minValue={0}
showValueLabel={true}
size="sm"
value={currentStep}
value={currentStep + 1}
valueLabel={`${currentStep + 1} of ${steps.length}`}
/>
@@ -59,14 +60,14 @@ export const WorkflowSendInvite = () => {
<VerticalSteps
hideProgressBars
currentStep={currentStep}
stepClassName="border border-default-200 dark:border-default-50 aria-[current]:bg-default-100 dark:aria-[current]:bg-prowler-blue-800 cursor-default"
stepClassName="border border-border-neutral-primary aria-[current]:border-button-primary aria-[current]:text-text-neutral-primary cursor-default"
steps={steps}
/>
</div>
{/* Mobile: Compact current step indicator */}
<div className="sm:hidden">
<div className="text-default-400 border-l-2 border-blue-500 py-1 pl-3 text-xs">
<div className="text-text-neutral-secondary border-button-primary border-l-2 py-1 pl-3 text-xs">
<div className="font-medium">
Current: {steps[currentStep]?.title}
</div>

View File

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

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 { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react";
import * as React from "react";
import { cn } from "@/lib/utils";

View File

@@ -1,11 +1,10 @@
"use client";
import { cva, type VariantProps } from "class-variance-authority";
import * as React from "react";
import { Button } from "@/components/shadcn/button/button";
import { cn } from "@/lib/utils";
import { Button } from "./button";
import { Input } from "./input";
import { Textarea } from "./textarea";
@@ -15,7 +14,7 @@ function InputGroup({ className, ...props }: React.ComponentProps<"div">) {
data-slot="input-group"
role="group"
className={cn(
"group/input-group border-input dark:bg-input/30 relative flex w-full items-center rounded-md border shadow-xs transition-[color,box-shadow] outline-none",
"group/input-group border-border-neutral-secondary bg-bg-neutral-secondary relative flex w-full items-center rounded-xl border transition-[color,box-shadow] outline-none",
"h-9 min-w-0 has-[>textarea]:h-auto",
// Variants based on alignment.
@@ -25,7 +24,7 @@ function InputGroup({ className, ...props }: React.ComponentProps<"div">) {
"has-[>[data-align=block-end]]:h-auto has-[>[data-align=block-end]]:flex-col has-[>[data-align=block-end]]:[&>input]:pt-3",
// Focus state.
"has-[[data-slot=input-group-control]:focus-visible]:border-ring has-[[data-slot=input-group-control]:focus-visible]:ring-ring/50 has-[[data-slot=input-group-control]:focus-visible]:ring-[3px]",
"has-[[data-slot=input-group-control]:focus-visible]:border-button-primary has-[[data-slot=input-group-control]:focus-visible]:ring-button-primary/20 has-[[data-slot=input-group-control]:focus-visible]:ring-2",
// Error state.
"has-[[data-slot][aria-invalid=true]]:ring-destructive/20 has-[[data-slot][aria-invalid=true]]:border-destructive dark:has-[[data-slot][aria-invalid=true]]:ring-destructive/40",

View File

@@ -1,5 +1,3 @@
import * as React from "react";
import { cn } from "@/lib/utils";
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";
import type { ChatStatus, FileUIPart } from "ai";
@@ -37,9 +35,9 @@ import {
useState,
} from "react";
import { Button } from "@/components/shadcn/button/button";
import { cn } from "@/lib/utils";
import { Button } from "./button";
import {
DropdownMenu,
DropdownMenuContent,

View File

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

View File

@@ -1,5 +1,3 @@
import * as React from "react";
import { cn } from "@/lib/utils";
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";
interface BannerConfig {
message: string;
href: string;
gradient: string;
}
const renderBanner = ({ message, href, gradient }: BannerConfig) => (
<Link href={href} className="mb-4 block w-full">
<div
className={`w-full rounded-lg ${gradient} focus:ring-opacity-50 shadow-lg transition-all duration-200 hover:shadow-xl focus:ring-2 focus:outline-none`}
>
<div className="px-4 py-3">
<div className="flex items-center gap-4">
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-white/20 backdrop-blur-sm">
<Bot size={24} className="text-white" />
</div>
<p className="text-md font-medium text-white">{message}</p>
</div>
</div>
</div>
</Link>
);
import { LighthouseBannerClient } from "./banner-client";
export const LighthouseBanner = async () => {
try {
const isConfigured = await isLighthouseConfigured();
if (!isConfigured) {
return renderBanner({
message: "Enable Lighthouse to secure your cloud with AI insights",
href: "/lighthouse/config",
gradient:
"bg-linear-to-r from-green-500 to-blue-500 hover:from-green-600 hover:to-blue-600 focus:ring-green-500/50 dark:from-green-600 dark:to-blue-600 dark:hover:from-green-700 dark:hover:to-blue-700 dark:focus:ring-green-400/50",
});
} else {
return renderBanner({
message: "Use Lighthouse to review your findings and gain insights",
href: "/lighthouse",
gradient:
"bg-linear-to-r from-green-500 to-blue-500 hover:from-green-600 hover:to-blue-600 focus:ring-green-500/50 dark:from-green-600 dark:to-blue-600 dark:hover:from-green-700 dark:hover:to-blue-700 dark:focus:ring-green-400/50",
});
}
return <LighthouseBannerClient isConfigured={isConfigured} />;
} catch (error) {
console.error("Error getting banner state:", error);
return null;
}
};

View File

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

View File

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

View File

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

View File

@@ -2,7 +2,6 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { Icon } from "@iconify/react";
import { SaveIcon } from "lucide-react";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import * as z from "zod";
@@ -11,8 +10,16 @@ import {
getTenantConfig,
updateTenantConfig,
} from "@/actions/lighthouse/lighthouse";
import { SaveIcon } from "@/components/icons";
import {
Button,
Card,
CardContent,
CardHeader,
CardTitle,
} from "@/components/shadcn";
import { useToast } from "@/components/ui";
import { CustomButton, CustomTextarea } from "@/components/ui/custom";
import { CustomTextarea } from "@/components/ui/custom";
import { Form } from "@/components/ui/form";
const lighthouseSettingsSchema = z.object({
@@ -97,22 +104,28 @@ export const LighthouseSettings = () => {
if (isFetchingData) {
return (
<div className="rounded-lg border border-gray-200 bg-white p-6 dark:border-gray-800 dark:bg-gray-900">
<h2 className="mb-4 text-xl font-semibold">Settings</h2>
<Card variant="base" padding="lg">
<CardHeader>
<CardTitle>Settings</CardTitle>
</CardHeader>
<CardContent>
<div className="flex items-center justify-center py-12">
<Icon
icon="heroicons:arrow-path"
className="h-8 w-8 animate-spin text-gray-400"
/>
</div>
</div>
</CardContent>
</Card>
);
}
return (
<div className="rounded-lg border border-gray-200 bg-white p-6 dark:border-gray-800 dark:bg-gray-900">
<h2 className="mb-4 text-xl font-semibold">Settings</h2>
<Card variant="base" padding="lg">
<CardHeader>
<CardTitle>Settings</CardTitle>
</CardHeader>
<CardContent>
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
@@ -128,24 +141,21 @@ export const LighthouseSettings = () => {
minRows={4}
maxRows={8}
description={`${form.watch("businessContext")?.length || 0}/1000 characters`}
isInvalid={!!form.formState.errors.businessContext}
/>
<div className="flex w-full justify-end">
<CustomButton
<Button
type="submit"
ariaLabel="Save Settings"
variant="solid"
color="action"
size="md"
isLoading={isLoading}
startContent={!isLoading && <SaveIcon size={20} />}
aria-label="Save Settings"
disabled={isLoading}
>
{!isLoading && <SaveIcon size={20} />}
{isLoading ? "Saving..." : "Save"}
</CustomButton>
</Button>
</div>
</form>
</Form>
</div>
</CardContent>
</Card>
);
};

View File

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

View File

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

View File

@@ -8,7 +8,7 @@ import {
getTenantConfig,
updateTenantConfig,
} from "@/actions/lighthouse/lighthouse";
import { CustomButton } from "@/components/ui/custom";
import { Button } from "@/components/shadcn";
import type { LighthouseProvider } from "@/types/lighthouse";
import {
@@ -155,7 +155,7 @@ export const SelectModel = ({
<h2 className="mb-2 text-xl font-semibold">
{isEditMode ? "Update Default Model" : "Select Default Model"}
</h2>
<p className="text-sm text-gray-600 dark:text-gray-300">
<p className="text-text-neutral-secondary text-sm">
{isEditMode
? "Update the default model to use with this provider."
: "Choose the default model to use with this provider."}
@@ -164,7 +164,7 @@ export const SelectModel = ({
<button
onClick={() => fetchModels(true)}
disabled={isLoading}
className="flex items-center gap-2 rounded-lg px-3 py-2 text-sm font-medium text-gray-700 hover:bg-gray-100 disabled:opacity-50 dark:text-gray-300 dark:hover:bg-gray-800"
className="text-text-neutral-secondary hover:bg-bg-neutral-tertiary flex items-center gap-2 rounded-lg px-3 py-2 text-sm font-medium disabled:opacity-50"
aria-label="Refresh models"
>
<Icon
@@ -176,8 +176,8 @@ export const SelectModel = ({
</div>
{error && (
<div className="rounded-lg border border-red-200 bg-red-50 p-4 dark:border-red-800 dark:bg-red-900/20">
<p className="text-sm text-red-600 dark:text-red-400">{error}</p>
<div className="border-border-error-primary bg-bg-fail-secondary rounded-lg border p-4">
<p className="text-text-error text-sm">{error}</p>
</div>
)}
@@ -185,14 +185,14 @@ export const SelectModel = ({
<div className="relative">
<Icon
icon="heroicons:magnifying-glass"
className="pointer-events-none absolute top-1/2 left-3 h-5 w-5 -translate-y-1/2 text-gray-400"
className="text-text-neutral-tertiary pointer-events-none absolute top-1/2 left-3 h-5 w-5 -translate-y-1/2"
/>
<input
type="text"
placeholder="Search models..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="w-full rounded-lg border border-gray-300 py-2.5 pr-4 pl-11 text-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none dark:border-gray-700 dark:bg-gray-800 dark:text-white"
className="border-border-neutral-primary bg-bg-neutral-primary focus:border-button-primary focus:ring-button-primary w-full rounded-lg border py-2.5 pr-4 pl-11 text-sm focus:ring-1 focus:outline-none"
/>
</div>
)}
@@ -201,32 +201,32 @@ export const SelectModel = ({
<div className="flex items-center justify-center py-12">
<Icon
icon="heroicons:arrow-path"
className="h-8 w-8 animate-spin text-gray-400"
className="text-text-neutral-tertiary h-8 w-8 animate-spin"
/>
</div>
) : models.length === 0 ? (
<div className="rounded-lg border border-gray-200 bg-gray-50 p-8 text-center dark:border-gray-700 dark:bg-gray-800">
<p className="text-sm text-gray-600 dark:text-gray-400">
<div className="border-border-neutral-secondary bg-bg-neutral-tertiary rounded-lg border p-8 text-center">
<p className="text-text-neutral-secondary text-sm">
No models available. Click refresh to fetch models.
</p>
</div>
) : filteredModels.length === 0 ? (
<div className="rounded-lg border border-gray-200 bg-gray-50 p-8 text-center dark:border-gray-700 dark:bg-gray-800">
<p className="text-sm text-gray-600 dark:text-gray-400">
<div className="border-border-neutral-secondary bg-bg-neutral-tertiary rounded-lg border p-8 text-center">
<p className="text-text-neutral-secondary text-sm">
No models found matching &quot;{searchQuery}&quot;
</p>
</div>
) : (
<div className="max-h-[calc(100vh-380px)] overflow-y-auto rounded-lg border border-gray-200 dark:border-gray-700">
<div className="border-border-neutral-secondary minimal-scrollbar max-h-[calc(100vh-380px)] overflow-y-auto rounded-lg border">
{filteredModels.map((model) => (
<label
key={model.id}
htmlFor={`model-${provider}-${model.id}`}
aria-label={model.name}
className={`block cursor-pointer border-b border-gray-200 px-6 py-4 transition-colors last:border-b-0 dark:border-gray-700 ${
className={`border-border-neutral-primary block cursor-pointer border-b px-6 py-4 transition-colors last:border-b-0 ${
selectedModel === model.id
? "bg-blue-50 dark:bg-blue-900/20"
: "hover:bg-gray-50 dark:hover:bg-gray-800"
? "bg-bg-neutral-secondary"
: "hover:bg-bg-neutral-tertiary"
}`}
>
<div className="flex items-center gap-4">
@@ -241,7 +241,7 @@ export const SelectModel = ({
<div className="flex items-center gap-2">
<span className="text-sm font-medium">{model.name}</span>
{isRecommended(model.id) && (
<span className="inline-flex items-center gap-1 rounded-full bg-blue-100 px-2 py-0.5 text-xs font-medium text-blue-800 dark:bg-blue-900/30 dark:text-blue-400">
<span className="bg-bg-data-info text-text-success-primary inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs font-medium">
<Icon icon="heroicons:star-solid" className="h-3 w-3" />
Recommended
</span>
@@ -255,17 +255,13 @@ export const SelectModel = ({
<div className="flex flex-col gap-4">
<div className="flex justify-end">
<CustomButton
ariaLabel="Select Model"
variant="solid"
color="action"
size="md"
isDisabled={!selectedModel || isSaving}
isLoading={isSaving}
onPress={handleSelect}
<Button
aria-label="Select Model"
disabled={!selectedModel || isSaving}
onClick={handleSelect}
>
{isSaving ? "Saving..." : "Select"}
</CustomButton>
</Button>
</div>
</div>
</div>

View File

@@ -64,20 +64,21 @@ export const WorkflowConnectLLM = () => {
classNames={{
base: "px-0.5 mb-5",
label: "text-small",
value: "text-small text-default-400",
value: "text-small text-button-primary",
indicator: "bg-button-primary",
}}
label="Steps"
maxValue={steps.length - 1}
maxValue={steps.length}
minValue={0}
showValueLabel={true}
size="md"
value={currentStep}
value={currentStep + 1}
valueLabel={`${currentStep + 1} of ${steps.length}`}
/>
<VerticalSteps
hideProgressBars
currentStep={currentStep}
stepClassName="border border-default-200 dark:border-default-50 aria-[current]:bg-default-100 dark:aria-[current]:bg-prowler-blue-800 cursor-default"
stepClassName="border border-border-neutral-primary aria-[current]:bg-bg-neutral-primary cursor-default"
steps={steps}
/>
<Spacer y={4} />

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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