mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-10-04 02:04:06 +00:00
feat: resource view re-styling with new components (#9864)
This commit is contained in:
@@ -7,6 +7,7 @@ All notable changes to the **Prowler API** are documented in this file.
|
|||||||
### 🚀 Added
|
### 🚀 Added
|
||||||
|
|
||||||
- Attack Paths: Bedrock Code Interpreter and AttachRolePolicy privilege escalation queries [(#9885)](https://github.com/prowler-cloud/prowler/pull/9885)
|
- Attack Paths: Bedrock Code Interpreter and AttachRolePolicy privilege escalation queries [(#9885)](https://github.com/prowler-cloud/prowler/pull/9885)
|
||||||
|
- `provider_id` and `provider_id__in` filters for resources endpoints (`GET /resources` and `GET /resources/metadata/latest`) [(#9864)](https://github.com/prowler-cloud/prowler/pull/9864)
|
||||||
- Added memory optimizations for large compliance report generation [(#9444)](https://github.com/prowler-cloud/prowler/pull/9444)
|
- Added memory optimizations for large compliance report generation [(#9444)](https://github.com/prowler-cloud/prowler/pull/9444)
|
||||||
- `GET /api/v1/resources/{id}/events` endpoint to retrieve AWS resource modification history from CloudTrail [(#9101)](https://github.com/prowler-cloud/prowler/pull/9101)
|
- `GET /api/v1/resources/{id}/events` endpoint to retrieve AWS resource modification history from CloudTrail [(#9101)](https://github.com/prowler-cloud/prowler/pull/9101)
|
||||||
- Partial index on findings to speed up new failed findings queries [(#9904)](https://github.com/prowler-cloud/prowler/pull/9904)
|
- Partial index on findings to speed up new failed findings queries [(#9904)](https://github.com/prowler-cloud/prowler/pull/9904)
|
||||||
|
|||||||
@@ -453,6 +453,8 @@ class ResourceTagFilter(FilterSet):
|
|||||||
|
|
||||||
|
|
||||||
class ResourceFilter(ProviderRelationshipFilterSet):
|
class ResourceFilter(ProviderRelationshipFilterSet):
|
||||||
|
provider_id = UUIDFilter(field_name="provider__id", lookup_expr="exact")
|
||||||
|
provider_id__in = UUIDInFilter(field_name="provider__id", lookup_expr="in")
|
||||||
tag_key = CharFilter(method="filter_tag_key")
|
tag_key = CharFilter(method="filter_tag_key")
|
||||||
tag_value = CharFilter(method="filter_tag_value")
|
tag_value = CharFilter(method="filter_tag_value")
|
||||||
tag = CharFilter(method="filter_tag")
|
tag = CharFilter(method="filter_tag")
|
||||||
@@ -540,6 +542,8 @@ class ResourceFilter(ProviderRelationshipFilterSet):
|
|||||||
|
|
||||||
|
|
||||||
class LatestResourceFilter(ProviderRelationshipFilterSet):
|
class LatestResourceFilter(ProviderRelationshipFilterSet):
|
||||||
|
provider_id = UUIDFilter(field_name="provider__id", lookup_expr="exact")
|
||||||
|
provider_id__in = UUIDInFilter(field_name="provider__id", lookup_expr="in")
|
||||||
tag_key = CharFilter(method="filter_tag_key")
|
tag_key = CharFilter(method="filter_tag_key")
|
||||||
tag_value = CharFilter(method="filter_tag_value")
|
tag_value = CharFilter(method="filter_tag_value")
|
||||||
tag = CharFilter(method="filter_tag")
|
tag = CharFilter(method="filter_tag")
|
||||||
|
|||||||
@@ -8186,6 +8186,21 @@ paths:
|
|||||||
description: Multiple values may be separated by commas.
|
description: Multiple values may be separated by commas.
|
||||||
explode: false
|
explode: false
|
||||||
style: form
|
style: form
|
||||||
|
- in: query
|
||||||
|
name: filter[provider_id]
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
format: uuid
|
||||||
|
- in: query
|
||||||
|
name: filter[provider_id__in]
|
||||||
|
schema:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
type: string
|
||||||
|
format: uuid
|
||||||
|
description: Multiple values may be separated by commas.
|
||||||
|
explode: false
|
||||||
|
style: form
|
||||||
- in: query
|
- in: query
|
||||||
name: filter[provider_type]
|
name: filter[provider_type]
|
||||||
schema:
|
schema:
|
||||||
@@ -8646,6 +8661,21 @@ paths:
|
|||||||
description: Multiple values may be separated by commas.
|
description: Multiple values may be separated by commas.
|
||||||
explode: false
|
explode: false
|
||||||
style: form
|
style: form
|
||||||
|
- in: query
|
||||||
|
name: filter[provider_id]
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
format: uuid
|
||||||
|
- in: query
|
||||||
|
name: filter[provider_id__in]
|
||||||
|
schema:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
type: string
|
||||||
|
format: uuid
|
||||||
|
description: Multiple values may be separated by commas.
|
||||||
|
explode: false
|
||||||
|
style: form
|
||||||
- in: query
|
- in: query
|
||||||
name: filter[provider_type]
|
name: filter[provider_type]
|
||||||
schema:
|
schema:
|
||||||
@@ -8942,6 +8972,21 @@ paths:
|
|||||||
description: Multiple values may be separated by commas.
|
description: Multiple values may be separated by commas.
|
||||||
explode: false
|
explode: false
|
||||||
style: form
|
style: form
|
||||||
|
- in: query
|
||||||
|
name: filter[provider_id]
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
format: uuid
|
||||||
|
- in: query
|
||||||
|
name: filter[provider_id__in]
|
||||||
|
schema:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
type: string
|
||||||
|
format: uuid
|
||||||
|
description: Multiple values may be separated by commas.
|
||||||
|
explode: false
|
||||||
|
style: form
|
||||||
- in: query
|
- in: query
|
||||||
name: filter[provider_type]
|
name: filter[provider_type]
|
||||||
schema:
|
schema:
|
||||||
@@ -9244,6 +9289,21 @@ paths:
|
|||||||
description: Multiple values may be separated by commas.
|
description: Multiple values may be separated by commas.
|
||||||
explode: false
|
explode: false
|
||||||
style: form
|
style: form
|
||||||
|
- in: query
|
||||||
|
name: filter[provider_id]
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
format: uuid
|
||||||
|
- in: query
|
||||||
|
name: filter[provider_id__in]
|
||||||
|
schema:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
type: string
|
||||||
|
format: uuid
|
||||||
|
description: Multiple values may be separated by commas.
|
||||||
|
explode: false
|
||||||
|
style: form
|
||||||
- in: query
|
- in: query
|
||||||
name: filter[provider_type]
|
name: filter[provider_type]
|
||||||
schema:
|
schema:
|
||||||
|
|||||||
@@ -4356,6 +4356,108 @@ class TestResourceViewSet:
|
|||||||
assert attributes["types"] == [latest_scan_resource.type]
|
assert attributes["types"] == [latest_scan_resource.type]
|
||||||
assert "groups" in attributes
|
assert "groups" in attributes
|
||||||
|
|
||||||
|
def test_resources_latest_filter_by_provider_id(
|
||||||
|
self, authenticated_client, latest_scan_resource
|
||||||
|
):
|
||||||
|
"""Test that provider_id filter works on latest resources endpoint."""
|
||||||
|
provider = latest_scan_resource.provider
|
||||||
|
response = authenticated_client.get(
|
||||||
|
reverse("resource-latest"),
|
||||||
|
{"filter[provider_id]": str(provider.id)},
|
||||||
|
)
|
||||||
|
assert response.status_code == status.HTTP_200_OK
|
||||||
|
assert len(response.json()["data"]) == 1
|
||||||
|
assert (
|
||||||
|
response.json()["data"][0]["attributes"]["uid"] == latest_scan_resource.uid
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_resources_latest_filter_by_provider_id_in(
|
||||||
|
self, authenticated_client, latest_scan_resource
|
||||||
|
):
|
||||||
|
"""Test that provider_id__in filter works on latest resources endpoint."""
|
||||||
|
provider = latest_scan_resource.provider
|
||||||
|
response = authenticated_client.get(
|
||||||
|
reverse("resource-latest"),
|
||||||
|
{"filter[provider_id__in]": str(provider.id)},
|
||||||
|
)
|
||||||
|
assert response.status_code == status.HTTP_200_OK
|
||||||
|
assert len(response.json()["data"]) == 1
|
||||||
|
assert (
|
||||||
|
response.json()["data"][0]["attributes"]["uid"] == latest_scan_resource.uid
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_resources_latest_filter_by_provider_id_in_multiple(
|
||||||
|
self, authenticated_client, providers_fixture
|
||||||
|
):
|
||||||
|
"""Test that provider_id__in filter works with multiple provider IDs."""
|
||||||
|
provider1, provider2 = providers_fixture[0], providers_fixture[1]
|
||||||
|
tenant_id = str(provider1.tenant_id)
|
||||||
|
|
||||||
|
# Create completed scans for both providers
|
||||||
|
Scan.objects.create(
|
||||||
|
name="scan for provider 1",
|
||||||
|
provider=provider1,
|
||||||
|
trigger=Scan.TriggerChoices.MANUAL,
|
||||||
|
state=StateChoices.COMPLETED,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
)
|
||||||
|
Scan.objects.create(
|
||||||
|
name="scan for provider 2",
|
||||||
|
provider=provider2,
|
||||||
|
trigger=Scan.TriggerChoices.MANUAL,
|
||||||
|
state=StateChoices.COMPLETED,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create resources for each provider
|
||||||
|
resource1 = Resource.objects.create(
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
provider=provider1,
|
||||||
|
uid="resource_provider_1",
|
||||||
|
name="Resource Provider 1",
|
||||||
|
region="us-east-1",
|
||||||
|
service="ec2",
|
||||||
|
type="instance",
|
||||||
|
)
|
||||||
|
Resource.objects.create(
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
provider=provider2,
|
||||||
|
uid="resource_provider_2",
|
||||||
|
name="Resource Provider 2",
|
||||||
|
region="us-west-2",
|
||||||
|
service="s3",
|
||||||
|
type="bucket",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Test filtering by both providers
|
||||||
|
response = authenticated_client.get(
|
||||||
|
reverse("resource-latest"),
|
||||||
|
{"filter[provider_id__in]": f"{provider1.id},{provider2.id}"},
|
||||||
|
)
|
||||||
|
assert response.status_code == status.HTTP_200_OK
|
||||||
|
assert len(response.json()["data"]) == 2
|
||||||
|
|
||||||
|
# Test filtering by single provider returns only that provider's resource
|
||||||
|
response = authenticated_client.get(
|
||||||
|
reverse("resource-latest"),
|
||||||
|
{"filter[provider_id__in]": str(provider1.id)},
|
||||||
|
)
|
||||||
|
assert response.status_code == status.HTTP_200_OK
|
||||||
|
assert len(response.json()["data"]) == 1
|
||||||
|
assert response.json()["data"][0]["attributes"]["uid"] == resource1.uid
|
||||||
|
|
||||||
|
def test_resources_latest_filter_by_provider_id_no_match(
|
||||||
|
self, authenticated_client, latest_scan_resource
|
||||||
|
):
|
||||||
|
"""Test that provider_id filter returns empty when no match."""
|
||||||
|
non_existent_id = str(uuid4())
|
||||||
|
response = authenticated_client.get(
|
||||||
|
reverse("resource-latest"),
|
||||||
|
{"filter[provider_id]": non_existent_id},
|
||||||
|
)
|
||||||
|
assert response.status_code == status.HTTP_200_OK
|
||||||
|
assert len(response.json()["data"]) == 0
|
||||||
|
|
||||||
# Events endpoint tests
|
# Events endpoint tests
|
||||||
def test_events_non_aws_provider(self, authenticated_client, providers_fixture):
|
def test_events_non_aws_provider(self, authenticated_client, providers_fixture):
|
||||||
"""Test events endpoint rejects non-AWS providers."""
|
"""Test events endpoint rejects non-AWS providers."""
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ All notable changes to the **Prowler UI** are documented in this file.
|
|||||||
|
|
||||||
### 🔄 Changed
|
### 🔄 Changed
|
||||||
|
|
||||||
|
- Restyle resources view with improved resource detail drawer [(#9864)](https://github.com/prowler-cloud/prowler/pull/9864)
|
||||||
- Launch Scan page now displays all providers without pagination limit [(#9700)](https://github.com/prowler-cloud/prowler/pull/9700)
|
- Launch Scan page now displays all providers without pagination limit [(#9700)](https://github.com/prowler-cloud/prowler/pull/9700)
|
||||||
- Upgrade Next.js from 15.5.9 to 16.1.3 with ESLint 9 flat config migration [(#9826)](https://github.com/prowler-cloud/prowler/pull/9826)
|
- Upgrade Next.js from 15.5.9 to 16.1.3 with ESLint 9 flat config migration [(#9826)](https://github.com/prowler-cloud/prowler/pull/9826)
|
||||||
|
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ export const TimeRangeSelector = ({
|
|||||||
isLoading = false,
|
isLoading = false,
|
||||||
}: TimeRangeSelectorProps) => {
|
}: TimeRangeSelectorProps) => {
|
||||||
return (
|
return (
|
||||||
<div className="border-border-neutral-tertiary bg-bg-neutral-tertiary inline-flex items-center gap-2 rounded-full border">
|
<div className="border-border-neutral-tertiary bg-bg-neutral-tertiary inline-flex items-center rounded-full border">
|
||||||
{Object.entries(TIME_RANGE_OPTIONS).map(([key, range]) => (
|
{Object.entries(TIME_RANGE_OPTIONS).map(([key, range]) => (
|
||||||
<button
|
<button
|
||||||
key={key}
|
key={key}
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import {
|
|||||||
SkeletonTableFindings,
|
SkeletonTableFindings,
|
||||||
} from "@/components/findings/table";
|
} from "@/components/findings/table";
|
||||||
import { ContentLayout } from "@/components/ui";
|
import { ContentLayout } from "@/components/ui";
|
||||||
|
import { FilterTransitionWrapper } from "@/contexts";
|
||||||
import {
|
import {
|
||||||
createDict,
|
createDict,
|
||||||
createScanDetailsMapping,
|
createScanDetailsMapping,
|
||||||
@@ -36,8 +37,7 @@ export default async function Findings({
|
|||||||
searchParams: Promise<SearchParamsProps>;
|
searchParams: Promise<SearchParamsProps>;
|
||||||
}) {
|
}) {
|
||||||
const resolvedSearchParams = await searchParams;
|
const resolvedSearchParams = await searchParams;
|
||||||
const { searchParamsKey, encodedSort } =
|
const { encodedSort } = extractSortAndKey(resolvedSearchParams);
|
||||||
extractSortAndKey(resolvedSearchParams);
|
|
||||||
const { filters, query } = extractFiltersAndQuery(resolvedSearchParams);
|
const { filters, query } = extractFiltersAndQuery(resolvedSearchParams);
|
||||||
|
|
||||||
// Check if the searchParams contain any date or scan filter
|
// Check if the searchParams contain any date or scan filter
|
||||||
@@ -100,7 +100,6 @@ export default async function Findings({
|
|||||||
const resourceId = finding.relationships?.resources?.data?.[0]?.id;
|
const resourceId = finding.relationships?.resources?.data?.[0]?.id;
|
||||||
const scan = scanId ? scanDict[scanId] : undefined;
|
const scan = scanId ? scanDict[scanId] : undefined;
|
||||||
const providerId = scan?.relationships?.provider?.data?.id;
|
const providerId = scan?.relationships?.provider?.data?.id;
|
||||||
|
|
||||||
const resource = resourceId ? resourceDict[resourceId] : undefined;
|
const resource = resourceId ? resourceDict[resourceId] : undefined;
|
||||||
const provider = providerId ? providerDict[providerId] : undefined;
|
const provider = providerId ? providerDict[providerId] : undefined;
|
||||||
|
|
||||||
@@ -148,24 +147,26 @@ export default async function Findings({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<ContentLayout title="Findings" icon="lucide:tag">
|
<ContentLayout title="Findings" icon="lucide:tag">
|
||||||
<div className="mb-6">
|
<FilterTransitionWrapper>
|
||||||
<FindingsFilters
|
<div className="mb-6">
|
||||||
providers={providersData?.data || []}
|
<FindingsFilters
|
||||||
providerIds={providerIds}
|
providers={providersData?.data || []}
|
||||||
providerDetails={providerDetails}
|
providerIds={providerIds}
|
||||||
completedScans={completedScans || []}
|
providerDetails={providerDetails}
|
||||||
completedScanIds={completedScanIds}
|
completedScans={completedScans || []}
|
||||||
scanDetails={scanDetails}
|
completedScanIds={completedScanIds}
|
||||||
uniqueRegions={uniqueRegions}
|
scanDetails={scanDetails}
|
||||||
uniqueServices={uniqueServices}
|
uniqueRegions={uniqueRegions}
|
||||||
uniqueResourceTypes={uniqueResourceTypes}
|
uniqueServices={uniqueServices}
|
||||||
uniqueCategories={uniqueCategories}
|
uniqueResourceTypes={uniqueResourceTypes}
|
||||||
uniqueGroups={uniqueGroups}
|
uniqueCategories={uniqueCategories}
|
||||||
/>
|
uniqueGroups={uniqueGroups}
|
||||||
</div>
|
/>
|
||||||
<Suspense key={searchParamsKey} fallback={<SkeletonTableFindings />}>
|
</div>
|
||||||
<SSRDataTable searchParams={resolvedSearchParams} />
|
<Suspense fallback={<SkeletonTableFindings />}>
|
||||||
</Suspense>
|
<SSRDataTable searchParams={resolvedSearchParams} />
|
||||||
|
</Suspense>
|
||||||
|
</FilterTransitionWrapper>
|
||||||
{processedFinding && <FindingDetailsSheet finding={processedFinding} />}
|
{processedFinding && <FindingDetailsSheet finding={processedFinding} />}
|
||||||
</ContentLayout>
|
</ContentLayout>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -14,8 +14,8 @@ import {
|
|||||||
import { DeleteLLMProviderForm } from "@/components/lighthouse/forms/delete-llm-provider-form";
|
import { DeleteLLMProviderForm } from "@/components/lighthouse/forms/delete-llm-provider-form";
|
||||||
import { WorkflowConnectLLM } from "@/components/lighthouse/workflow";
|
import { WorkflowConnectLLM } from "@/components/lighthouse/workflow";
|
||||||
import { Button } from "@/components/shadcn";
|
import { Button } from "@/components/shadcn";
|
||||||
|
import { Modal } from "@/components/shadcn/modal";
|
||||||
import { NavigationHeader } from "@/components/ui";
|
import { NavigationHeader } from "@/components/ui";
|
||||||
import { CustomAlertModal } from "@/components/ui/custom";
|
|
||||||
import type { LighthouseProvider } from "@/types/lighthouse";
|
import type { LighthouseProvider } from "@/types/lighthouse";
|
||||||
|
|
||||||
interface ConnectLLMLayoutProps {
|
interface ConnectLLMLayoutProps {
|
||||||
@@ -63,8 +63,8 @@ export default function ConnectLLMLayout({ children }: ConnectLLMLayoutProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<CustomAlertModal
|
<Modal
|
||||||
isOpen={isDeleteOpen}
|
open={isDeleteOpen}
|
||||||
onOpenChange={setIsDeleteOpen}
|
onOpenChange={setIsDeleteOpen}
|
||||||
title="Are you absolutely sure?"
|
title="Are you absolutely sure?"
|
||||||
description="This action cannot be undone. This will permanently delete your LLM provider configuration and remove your data from the server."
|
description="This action cannot be undone. This will permanently delete your LLM provider configuration and remove your data from the server."
|
||||||
@@ -73,7 +73,7 @@ export default function ConnectLLMLayout({ children }: ConnectLLMLayoutProps) {
|
|||||||
providerType={provider}
|
providerType={provider}
|
||||||
setIsOpen={setIsDeleteOpen}
|
setIsOpen={setIsDeleteOpen}
|
||||||
/>
|
/>
|
||||||
</CustomAlertModal>
|
</Modal>
|
||||||
|
|
||||||
<NavigationHeader
|
<NavigationHeader
|
||||||
title={isEditMode ? "Configure LLM Provider" : "Connect LLM Provider"}
|
title={isEditMode ? "Configure LLM Provider" : "Connect LLM Provider"}
|
||||||
|
|||||||
@@ -11,8 +11,8 @@ import {
|
|||||||
updateMutedFindingsConfig,
|
updateMutedFindingsConfig,
|
||||||
} from "@/actions/processors";
|
} from "@/actions/processors";
|
||||||
import { Button, Card, Skeleton } from "@/components/shadcn";
|
import { Button, Card, Skeleton } from "@/components/shadcn";
|
||||||
|
import { Modal } from "@/components/shadcn/modal";
|
||||||
import { useToast } from "@/components/ui";
|
import { useToast } from "@/components/ui";
|
||||||
import { CustomAlertModal } from "@/components/ui/custom";
|
|
||||||
import { CustomLink } from "@/components/ui/custom/custom-link";
|
import { CustomLink } from "@/components/ui/custom/custom-link";
|
||||||
import { fontMono } from "@/config/fonts";
|
import { fontMono } from "@/config/fonts";
|
||||||
import {
|
import {
|
||||||
@@ -152,8 +152,8 @@ export function AdvancedMutelistForm() {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{/* Delete Confirmation Modal */}
|
{/* Delete Confirmation Modal */}
|
||||||
<CustomAlertModal
|
<Modal
|
||||||
isOpen={showDeleteConfirmation}
|
open={showDeleteConfirmation}
|
||||||
onOpenChange={setShowDeleteConfirmation}
|
onOpenChange={setShowDeleteConfirmation}
|
||||||
title="Delete Mutelist Configuration"
|
title="Delete Mutelist Configuration"
|
||||||
size="md"
|
size="md"
|
||||||
@@ -185,7 +185,7 @@ export function AdvancedMutelistForm() {
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</CustomAlertModal>
|
</Modal>
|
||||||
|
|
||||||
<Card variant="base" className="p-6">
|
<Card variant="base" className="p-6">
|
||||||
<form action={formAction} className="flex flex-col gap-4">
|
<form action={formAction} className="flex flex-col gap-4">
|
||||||
|
|||||||
@@ -8,8 +8,8 @@ import { useActionState, useEffect, useRef, useState } from "react";
|
|||||||
import { deleteMuteRule } from "@/actions/mute-rules";
|
import { deleteMuteRule } from "@/actions/mute-rules";
|
||||||
import { MuteRuleData } from "@/actions/mute-rules/types";
|
import { MuteRuleData } from "@/actions/mute-rules/types";
|
||||||
import { Button } from "@/components/shadcn";
|
import { Button } from "@/components/shadcn";
|
||||||
|
import { Modal } from "@/components/shadcn/modal";
|
||||||
import { useToast } from "@/components/ui";
|
import { useToast } from "@/components/ui";
|
||||||
import { CustomAlertModal } from "@/components/ui/custom";
|
|
||||||
import { DataTable } from "@/components/ui/table";
|
import { DataTable } from "@/components/ui/table";
|
||||||
import { MetaDataProps } from "@/types";
|
import { MetaDataProps } from "@/types";
|
||||||
|
|
||||||
@@ -82,8 +82,8 @@ export function MuteRulesTableClient({
|
|||||||
|
|
||||||
{/* Edit Modal */}
|
{/* Edit Modal */}
|
||||||
{selectedMuteRule && (
|
{selectedMuteRule && (
|
||||||
<CustomAlertModal
|
<Modal
|
||||||
isOpen={editModal.isOpen}
|
open={editModal.isOpen}
|
||||||
onOpenChange={editModal.onOpenChange}
|
onOpenChange={editModal.onOpenChange}
|
||||||
title="Edit Mute Rule"
|
title="Edit Mute Rule"
|
||||||
size="lg"
|
size="lg"
|
||||||
@@ -93,13 +93,13 @@ export function MuteRulesTableClient({
|
|||||||
onSuccess={handleEditSuccess}
|
onSuccess={handleEditSuccess}
|
||||||
onCancel={editModal.onClose}
|
onCancel={editModal.onClose}
|
||||||
/>
|
/>
|
||||||
</CustomAlertModal>
|
</Modal>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Delete Confirmation Modal */}
|
{/* Delete Confirmation Modal */}
|
||||||
{selectedMuteRule && (
|
{selectedMuteRule && (
|
||||||
<CustomAlertModal
|
<Modal
|
||||||
isOpen={deleteModal.isOpen}
|
open={deleteModal.isOpen}
|
||||||
onOpenChange={deleteModal.onOpenChange}
|
onOpenChange={deleteModal.onOpenChange}
|
||||||
title="Delete Mute Rule"
|
title="Delete Mute Rule"
|
||||||
size="md"
|
size="md"
|
||||||
@@ -138,7 +138,7 @@ export function MuteRulesTableClient({
|
|||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</CustomAlertModal>
|
</Modal>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -5,14 +5,15 @@ import {
|
|||||||
getLatestMetadataInfo,
|
getLatestMetadataInfo,
|
||||||
getLatestResources,
|
getLatestResources,
|
||||||
getMetadataInfo,
|
getMetadataInfo,
|
||||||
|
getResourceById,
|
||||||
getResources,
|
getResources,
|
||||||
} from "@/actions/resources";
|
} from "@/actions/resources";
|
||||||
import { FilterControls } from "@/components/filters";
|
import { ResourceDetailsSheet } from "@/components/resources/resource-details-sheet";
|
||||||
import { ResourcesFilters } from "@/components/resources/resources-filters";
|
import { ResourcesFilters } from "@/components/resources/resources-filters";
|
||||||
import { SkeletonTableResources } from "@/components/resources/skeleton/skeleton-table-resources";
|
import { SkeletonTableResources } from "@/components/resources/skeleton/skeleton-table-resources";
|
||||||
import { ColumnResources } from "@/components/resources/table/column-resources";
|
import { ResourcesTableWithSelection } from "@/components/resources/table";
|
||||||
import { ContentLayout } from "@/components/ui";
|
import { ContentLayout } from "@/components/ui";
|
||||||
import { DataTable } from "@/components/ui/table";
|
import { FilterTransitionWrapper } from "@/contexts";
|
||||||
import {
|
import {
|
||||||
createDict,
|
createDict,
|
||||||
extractFiltersAndQuery,
|
extractFiltersAndQuery,
|
||||||
@@ -20,10 +21,6 @@ import {
|
|||||||
hasDateOrScanFilter,
|
hasDateOrScanFilter,
|
||||||
replaceFieldKey,
|
replaceFieldKey,
|
||||||
} from "@/lib";
|
} from "@/lib";
|
||||||
import {
|
|
||||||
createProviderDetailsMappingById,
|
|
||||||
extractProviderIds,
|
|
||||||
} from "@/lib/provider-helpers";
|
|
||||||
import { ResourceProps, SearchParamsProps } from "@/types";
|
import { ResourceProps, SearchParamsProps } from "@/types";
|
||||||
|
|
||||||
export default async function Resources({
|
export default async function Resources({
|
||||||
@@ -32,49 +29,73 @@ export default async function Resources({
|
|||||||
searchParams: Promise<SearchParamsProps>;
|
searchParams: Promise<SearchParamsProps>;
|
||||||
}) {
|
}) {
|
||||||
const resolvedSearchParams = await searchParams;
|
const resolvedSearchParams = await searchParams;
|
||||||
const { searchParamsKey, encodedSort } =
|
const { encodedSort } = extractSortAndKey(resolvedSearchParams);
|
||||||
extractSortAndKey(resolvedSearchParams);
|
|
||||||
const { filters, query } = extractFiltersAndQuery(resolvedSearchParams);
|
const { filters, query } = extractFiltersAndQuery(resolvedSearchParams);
|
||||||
const outputFilters = replaceFieldKey(filters, "inserted_at", "updated_at");
|
const outputFilters = replaceFieldKey(filters, "inserted_at", "updated_at");
|
||||||
|
|
||||||
// Check if the searchParams contain any date or scan filter
|
// Check if the searchParams contain any date or scan filter
|
||||||
const hasDateOrScan = hasDateOrScanFilter(resolvedSearchParams);
|
const hasDateOrScan = hasDateOrScanFilter(resolvedSearchParams);
|
||||||
|
|
||||||
const [metadataInfoData, providersData] = await Promise.all([
|
// Check if there's a specific resource ID to fetch
|
||||||
(hasDateOrScan ? getMetadataInfo : getLatestMetadataInfo)({
|
const resourceId = resolvedSearchParams.resourceId?.toString();
|
||||||
query,
|
|
||||||
filters: outputFilters,
|
const [metadataInfoData, providersData, resourceByIdData] = await Promise.all(
|
||||||
sort: encodedSort,
|
[
|
||||||
}),
|
(hasDateOrScan ? getMetadataInfo : getLatestMetadataInfo)({
|
||||||
getProviders({ pageSize: 50 }),
|
query,
|
||||||
]);
|
filters: outputFilters,
|
||||||
|
sort: encodedSort,
|
||||||
|
}),
|
||||||
|
getProviders({ pageSize: 50 }),
|
||||||
|
resourceId
|
||||||
|
? getResourceById(resourceId, { include: ["provider"] })
|
||||||
|
: Promise.resolve(null),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Process the resource data to match the expected structure
|
||||||
|
const processedResource = resourceByIdData?.data
|
||||||
|
? (() => {
|
||||||
|
const resource = resourceByIdData.data;
|
||||||
|
const providerDict = createDict("providers", resourceByIdData);
|
||||||
|
|
||||||
|
const provider = {
|
||||||
|
data: providerDict[resource.relationships?.provider?.data?.id],
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
...resource,
|
||||||
|
relationships: {
|
||||||
|
...resource.relationships,
|
||||||
|
provider,
|
||||||
|
},
|
||||||
|
} as ResourceProps;
|
||||||
|
})()
|
||||||
|
: null;
|
||||||
|
|
||||||
// Extract unique regions, services, groups from the metadata endpoint
|
// Extract unique regions, services, groups from the metadata endpoint
|
||||||
const uniqueRegions = metadataInfoData?.data?.attributes?.regions || [];
|
const uniqueRegions = metadataInfoData?.data?.attributes?.regions || [];
|
||||||
const uniqueServices = metadataInfoData?.data?.attributes?.services || [];
|
const uniqueServices = metadataInfoData?.data?.attributes?.services || [];
|
||||||
const uniqueGroups = metadataInfoData?.data?.attributes?.groups || [];
|
const uniqueGroups = metadataInfoData?.data?.attributes?.groups || [];
|
||||||
|
|
||||||
// Extract provider IDs and details
|
|
||||||
const providerIds = providersData ? extractProviderIds(providersData) : [];
|
|
||||||
const providerDetails = providersData
|
|
||||||
? createProviderDetailsMappingById(providerIds, providersData)
|
|
||||||
: [];
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ContentLayout title="Resources" icon="lucide:warehouse">
|
<ContentLayout title="Resources" icon="lucide:warehouse">
|
||||||
<FilterControls search date />
|
<FilterTransitionWrapper>
|
||||||
<div className="flex flex-col gap-6">
|
<div className="mb-6">
|
||||||
<ResourcesFilters
|
<ResourcesFilters
|
||||||
providerIds={providerIds}
|
providers={providersData?.data || []}
|
||||||
providerDetails={providerDetails}
|
uniqueRegions={uniqueRegions}
|
||||||
uniqueRegions={uniqueRegions}
|
uniqueServices={uniqueServices}
|
||||||
uniqueServices={uniqueServices}
|
uniqueGroups={uniqueGroups}
|
||||||
uniqueGroups={uniqueGroups}
|
/>
|
||||||
/>
|
</div>
|
||||||
<Suspense key={searchParamsKey} fallback={<SkeletonTableResources />}>
|
<Suspense fallback={<SkeletonTableResources />}>
|
||||||
<SSRDataTable searchParams={resolvedSearchParams} />
|
<SSRDataTable searchParams={resolvedSearchParams} />
|
||||||
</Suspense>
|
</Suspense>
|
||||||
</div>
|
</FilterTransitionWrapper>
|
||||||
|
{processedResource && (
|
||||||
|
<ResourceDetailsSheet resource={processedResource} />
|
||||||
|
)}
|
||||||
</ContentLayout>
|
</ContentLayout>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -112,6 +133,7 @@ const SSRDataTable = async ({
|
|||||||
"region",
|
"region",
|
||||||
"service",
|
"service",
|
||||||
"type",
|
"type",
|
||||||
|
"groups",
|
||||||
"provider",
|
"provider",
|
||||||
"inserted_at",
|
"inserted_at",
|
||||||
"updated_at",
|
"updated_at",
|
||||||
@@ -150,9 +172,7 @@ const SSRDataTable = async ({
|
|||||||
<p>{resourcesData.errors[0].detail}</p>
|
<p>{resourcesData.errors[0].detail}</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<DataTable
|
<ResourcesTableWithSelection
|
||||||
key={`resources-${Date.now()}`}
|
|
||||||
columns={ColumnResources}
|
|
||||||
data={expandedResources || []}
|
data={expandedResources || []}
|
||||||
metadata={resourcesData?.meta}
|
metadata={resourcesData?.meta}
|
||||||
/>
|
/>
|
||||||
|
|||||||
+10
-1
@@ -1,4 +1,5 @@
|
|||||||
import { jwtDecode, type JwtPayload } from "jwt-decode";
|
import { jwtDecode, type JwtPayload } from "jwt-decode";
|
||||||
|
import { NextResponse } from "next/server";
|
||||||
import NextAuth, {
|
import NextAuth, {
|
||||||
type DefaultSession,
|
type DefaultSession,
|
||||||
type NextAuthConfig,
|
type NextAuthConfig,
|
||||||
@@ -277,6 +278,7 @@ export const authConfig = {
|
|||||||
callbacks: {
|
callbacks: {
|
||||||
authorized({ auth, request: { nextUrl } }) {
|
authorized({ auth, request: { nextUrl } }) {
|
||||||
const isLoggedIn = !!auth?.user;
|
const isLoggedIn = !!auth?.user;
|
||||||
|
const sessionError = auth?.error;
|
||||||
const isSignUpPage = nextUrl.pathname === "/sign-up";
|
const isSignUpPage = nextUrl.pathname === "/sign-up";
|
||||||
const isSignInPage = nextUrl.pathname === "/sign-in";
|
const isSignInPage = nextUrl.pathname === "/sign-in";
|
||||||
|
|
||||||
@@ -284,8 +286,15 @@ export const authConfig = {
|
|||||||
if (isSignUpPage || isSignInPage) return true;
|
if (isSignUpPage || isSignInPage) return true;
|
||||||
|
|
||||||
// For all other routes, require authentication
|
// For all other routes, require authentication
|
||||||
|
// Return NextResponse.redirect to preserve callbackUrl for post-login redirect
|
||||||
if (!isLoggedIn) {
|
if (!isLoggedIn) {
|
||||||
return false; // Will redirect to signIn page defined in pages config
|
const signInUrl = new URL("/sign-in", nextUrl.origin);
|
||||||
|
signInUrl.searchParams.set("callbackUrl", nextUrl.pathname);
|
||||||
|
// Include session error if present (e.g., RefreshAccessTokenError)
|
||||||
|
if (sessionError) {
|
||||||
|
signInUrl.searchParams.set("error", sessionError);
|
||||||
|
}
|
||||||
|
return NextResponse.redirect(signInUrl);
|
||||||
}
|
}
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ export const SignInForm = ({
|
|||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
const callbackUrl = searchParams.get("callbackUrl") || "/";
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const samlError = searchParams.get("sso_saml_failed");
|
const samlError = searchParams.get("sso_saml_failed");
|
||||||
@@ -122,7 +123,7 @@ export const SignInForm = ({
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (result?.message === "Success") {
|
if (result?.message === "Success") {
|
||||||
router.push("/");
|
router.push(callbackUrl);
|
||||||
} else if (result?.errors && "credentials" in result.errors) {
|
} else if (result?.errors && "credentials" in result.errors) {
|
||||||
const message = result.errors.credentials ?? "Invalid email or password";
|
const message = result.errors.credentials ?? "Invalid email or password";
|
||||||
|
|
||||||
|
|||||||
@@ -4,15 +4,16 @@ import { Input, Textarea } from "@heroui/input";
|
|||||||
import {
|
import {
|
||||||
Dispatch,
|
Dispatch,
|
||||||
SetStateAction,
|
SetStateAction,
|
||||||
useActionState,
|
|
||||||
useEffect,
|
useEffect,
|
||||||
useRef,
|
useRef,
|
||||||
|
useState,
|
||||||
|
useTransition,
|
||||||
} from "react";
|
} from "react";
|
||||||
|
|
||||||
import { createMuteRule } from "@/actions/mute-rules";
|
import { createMuteRule } from "@/actions/mute-rules";
|
||||||
import { MuteRuleActionState } from "@/actions/mute-rules/types";
|
import { MuteRuleActionState } from "@/actions/mute-rules/types";
|
||||||
|
import { Modal } from "@/components/shadcn/modal";
|
||||||
import { useToast } from "@/components/ui";
|
import { useToast } from "@/components/ui";
|
||||||
import { CustomAlertModal } from "@/components/ui/custom";
|
|
||||||
import { FormButtons } from "@/components/ui/form";
|
import { FormButtons } from "@/components/ui/form";
|
||||||
|
|
||||||
interface MuteFindingsModalProps {
|
interface MuteFindingsModalProps {
|
||||||
@@ -29,6 +30,8 @@ export function MuteFindingsModal({
|
|||||||
onComplete,
|
onComplete,
|
||||||
}: MuteFindingsModalProps) {
|
}: MuteFindingsModalProps) {
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
const [state, setState] = useState<MuteRuleActionState | null>(null);
|
||||||
|
const [isPending, startTransition] = useTransition();
|
||||||
|
|
||||||
// Use refs to avoid stale closures in useEffect
|
// Use refs to avoid stale closures in useEffect
|
||||||
const onCompleteRef = useRef(onComplete);
|
const onCompleteRef = useRef(onComplete);
|
||||||
@@ -37,18 +40,12 @@ export function MuteFindingsModal({
|
|||||||
const onOpenChangeRef = useRef(onOpenChange);
|
const onOpenChangeRef = useRef(onOpenChange);
|
||||||
onOpenChangeRef.current = onOpenChange;
|
onOpenChangeRef.current = onOpenChange;
|
||||||
|
|
||||||
const [state, formAction, isPending] = useActionState<
|
|
||||||
MuteRuleActionState,
|
|
||||||
FormData
|
|
||||||
>(createMuteRule, null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (state?.success) {
|
if (state?.success) {
|
||||||
toast({
|
toast({
|
||||||
title: "Success",
|
title: "Success",
|
||||||
description: state.success,
|
description: state.success,
|
||||||
});
|
});
|
||||||
// Call onComplete BEFORE closing the modal to ensure router.refresh() executes
|
|
||||||
onCompleteRef.current?.();
|
onCompleteRef.current?.();
|
||||||
onOpenChangeRef.current(false);
|
onOpenChangeRef.current(false);
|
||||||
} else if (state?.errors?.general) {
|
} else if (state?.errors?.general) {
|
||||||
@@ -65,13 +62,26 @@ export function MuteFindingsModal({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<CustomAlertModal
|
<Modal
|
||||||
isOpen={isOpen}
|
open={isOpen}
|
||||||
onOpenChange={onOpenChange}
|
onOpenChange={onOpenChange}
|
||||||
title="Mute Findings"
|
title="Mute Findings"
|
||||||
size="lg"
|
size="lg"
|
||||||
>
|
>
|
||||||
<form action={formAction} className="flex flex-col gap-4">
|
<form
|
||||||
|
className="flex flex-col gap-4"
|
||||||
|
onSubmit={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const formData = new FormData(e.currentTarget);
|
||||||
|
|
||||||
|
startTransition(() => {
|
||||||
|
void (async () => {
|
||||||
|
const result = await createMuteRule(null, formData);
|
||||||
|
setState(result);
|
||||||
|
})();
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
<input
|
<input
|
||||||
type="hidden"
|
type="hidden"
|
||||||
name="finding_ids"
|
name="finding_ids"
|
||||||
@@ -125,6 +135,6 @@ export function MuteFindingsModal({
|
|||||||
isDisabled={isPending}
|
isDisabled={isPending}
|
||||||
/>
|
/>
|
||||||
</form>
|
</form>
|
||||||
</CustomAlertModal>
|
</Modal>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,8 +15,8 @@ import {
|
|||||||
sendFindingToJira,
|
sendFindingToJira,
|
||||||
} from "@/actions/integrations/jira-dispatch";
|
} from "@/actions/integrations/jira-dispatch";
|
||||||
import { JiraIcon } from "@/components/icons/services/IconServices";
|
import { JiraIcon } from "@/components/icons/services/IconServices";
|
||||||
|
import { Modal } from "@/components/shadcn/modal";
|
||||||
import { useToast } from "@/components/ui";
|
import { useToast } from "@/components/ui";
|
||||||
import { CustomAlertModal } from "@/components/ui/custom";
|
|
||||||
import { CustomBanner } from "@/components/ui/custom/custom-banner";
|
import { CustomBanner } from "@/components/ui/custom/custom-banner";
|
||||||
import {
|
import {
|
||||||
Form,
|
Form,
|
||||||
@@ -215,8 +215,8 @@ export const SendToJiraModal = ({
|
|||||||
// }, [issueTypes, searchIssueTypeValue]);
|
// }, [issueTypes, searchIssueTypeValue]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<CustomAlertModal
|
<Modal
|
||||||
isOpen={isOpen}
|
open={isOpen}
|
||||||
onOpenChange={onOpenChange}
|
onOpenChange={onOpenChange}
|
||||||
title="Send Finding to Jira"
|
title="Send Finding to Jira"
|
||||||
description={
|
description={
|
||||||
@@ -389,6 +389,6 @@ export const SendToJiraModal = ({
|
|||||||
)}
|
)}
|
||||||
</form>
|
</form>
|
||||||
</Form>
|
</Form>
|
||||||
</CustomAlertModal>
|
</Modal>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -7,7 +7,8 @@ import { useSearchParams } from "next/navigation";
|
|||||||
import { FindingDetail } from "@/components/findings/table";
|
import { FindingDetail } from "@/components/findings/table";
|
||||||
import { DataTableRowActions } from "@/components/findings/table";
|
import { DataTableRowActions } from "@/components/findings/table";
|
||||||
import { Checkbox } from "@/components/shadcn";
|
import { Checkbox } from "@/components/shadcn";
|
||||||
import { DateWithTime, SnippetChip } from "@/components/ui/entities";
|
import { CodeSnippet } from "@/components/ui/code-snippet/code-snippet";
|
||||||
|
import { DateWithTime } from "@/components/ui/entities";
|
||||||
import {
|
import {
|
||||||
DataTableColumnHeader,
|
DataTableColumnHeader,
|
||||||
SeverityBadge,
|
SeverityBadge,
|
||||||
@@ -186,7 +187,7 @@ export function getColumnFindings(
|
|||||||
const resourceName = getResourceData(row, "name");
|
const resourceName = getResourceData(row, "name");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SnippetChip
|
<CodeSnippet
|
||||||
value={resourceName as string}
|
value={resourceName as string}
|
||||||
formatter={(value: string) => `...${value.slice(-10)}`}
|
formatter={(value: string) => `...${value.slice(-10)}`}
|
||||||
icon={<Database size={16} />}
|
icon={<Database size={16} />}
|
||||||
|
|||||||
@@ -1,12 +1,5 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import {
|
|
||||||
Dropdown,
|
|
||||||
DropdownItem,
|
|
||||||
DropdownMenu,
|
|
||||||
DropdownSection,
|
|
||||||
DropdownTrigger,
|
|
||||||
} from "@heroui/dropdown";
|
|
||||||
import { Row } from "@tanstack/react-table";
|
import { Row } from "@tanstack/react-table";
|
||||||
import { VolumeOff, VolumeX } from "lucide-react";
|
import { VolumeOff, VolumeX } from "lucide-react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
@@ -16,15 +9,32 @@ import { MuteFindingsModal } from "@/components/findings/mute-findings-modal";
|
|||||||
import { SendToJiraModal } from "@/components/findings/send-to-jira-modal";
|
import { SendToJiraModal } from "@/components/findings/send-to-jira-modal";
|
||||||
import { VerticalDotsIcon } from "@/components/icons";
|
import { VerticalDotsIcon } from "@/components/icons";
|
||||||
import { JiraIcon } from "@/components/icons/services/IconServices";
|
import { JiraIcon } from "@/components/icons/services/IconServices";
|
||||||
import type { FindingProps } from "@/types/components";
|
import {
|
||||||
|
ActionDropdown,
|
||||||
|
ActionDropdownItem,
|
||||||
|
} from "@/components/shadcn/dropdown";
|
||||||
|
|
||||||
import { FindingsSelectionContext } from "./findings-selection-context";
|
import { FindingsSelectionContext } from "./findings-selection-context";
|
||||||
|
|
||||||
interface DataTableRowActionsProps {
|
export interface FindingRowData {
|
||||||
row: Row<FindingProps>;
|
id: string;
|
||||||
|
attributes: {
|
||||||
|
muted?: boolean;
|
||||||
|
check_metadata?: {
|
||||||
|
checktitle?: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function DataTableRowActions({ row }: DataTableRowActionsProps) {
|
interface DataTableRowActionsProps<T extends FindingRowData> {
|
||||||
|
row: Row<T>;
|
||||||
|
onMuteComplete?: (findingIds: string[]) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DataTableRowActions<T extends FindingRowData>({
|
||||||
|
row,
|
||||||
|
onMuteComplete,
|
||||||
|
}: DataTableRowActionsProps<T>) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const finding = row.original;
|
const finding = row.original;
|
||||||
const [isJiraModalOpen, setIsJiraModalOpen] = useState(false);
|
const [isJiraModalOpen, setIsJiraModalOpen] = useState(false);
|
||||||
@@ -67,11 +77,31 @@ export function DataTableRowActions({ row }: DataTableRowActionsProps) {
|
|||||||
return "Mute this finding";
|
return "Mute this finding";
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const getMuteLabel = () => {
|
||||||
|
if (isMuted) return "Muted";
|
||||||
|
if (!isMuted && isCurrentSelected && hasMultipleSelected) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
Mute
|
||||||
|
<span className="ml-1 text-xs text-slate-500">
|
||||||
|
({selectedFindingIds.length})
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return "Mute";
|
||||||
|
};
|
||||||
|
|
||||||
const handleMuteComplete = () => {
|
const handleMuteComplete = () => {
|
||||||
// Always clear selection when a finding is muted because:
|
// Always clear selection when a finding is muted because:
|
||||||
// 1. If the muted finding was selected, its index now points to a different finding
|
// 1. If the muted finding was selected, its index now points to a different finding
|
||||||
// 2. rowSelection uses indices (0, 1, 2...) not IDs, so after refresh the wrong findings would appear selected
|
// 2. rowSelection uses indices (0, 1, 2...) not IDs, so after refresh the wrong findings would appear selected
|
||||||
clearSelection();
|
clearSelection();
|
||||||
|
if (onMuteComplete) {
|
||||||
|
onMuteComplete(getMuteIds());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
router.refresh();
|
router.refresh();
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -92,61 +122,43 @@ export function DataTableRowActions({ row }: DataTableRowActionsProps) {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="flex items-center justify-end">
|
<div className="flex items-center justify-end">
|
||||||
<Dropdown
|
<ActionDropdown
|
||||||
className="border-border-neutral-secondary bg-bg-neutral-secondary border shadow-xl"
|
trigger={
|
||||||
placement="bottom"
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label="Finding actions"
|
||||||
|
className="hover:bg-bg-neutral-tertiary rounded-md p-1 transition-colors"
|
||||||
|
>
|
||||||
|
<VerticalDotsIcon
|
||||||
|
size={20}
|
||||||
|
className="text-text-neutral-secondary"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
ariaLabel="Finding actions"
|
||||||
>
|
>
|
||||||
<DropdownTrigger aria-label="Finding actions">
|
<ActionDropdownItem
|
||||||
<VerticalDotsIcon
|
icon={
|
||||||
size={20}
|
isMuted ? (
|
||||||
className="text-text-neutral-secondary"
|
<VolumeOff className="size-5" />
|
||||||
/>
|
) : (
|
||||||
</DropdownTrigger>
|
<VolumeX className="size-5" />
|
||||||
<DropdownMenu
|
)
|
||||||
closeOnSelect
|
}
|
||||||
aria-label="Finding actions"
|
label={getMuteLabel()}
|
||||||
color="default"
|
description={getMuteDescription()}
|
||||||
variant="flat"
|
disabled={isMuted}
|
||||||
>
|
onSelect={() => {
|
||||||
<DropdownSection title="Actions">
|
setIsMuteModalOpen(true);
|
||||||
<DropdownItem
|
}}
|
||||||
key="mute"
|
/>
|
||||||
description={getMuteDescription()}
|
<ActionDropdownItem
|
||||||
textValue="Mute"
|
icon={<JiraIcon size={20} />}
|
||||||
isDisabled={isMuted}
|
label="Send to Jira"
|
||||||
startContent={
|
description="Create a Jira issue for this finding"
|
||||||
isMuted ? (
|
onSelect={() => setIsJiraModalOpen(true)}
|
||||||
<VolumeOff className="text-default-300 pointer-events-none size-5 shrink-0" />
|
/>
|
||||||
) : (
|
</ActionDropdown>
|
||||||
<VolumeX className="text-default-500 pointer-events-none size-5 shrink-0" />
|
|
||||||
)
|
|
||||||
}
|
|
||||||
onPress={() => setIsMuteModalOpen(true)}
|
|
||||||
>
|
|
||||||
{isMuted ? "Muted" : "Mute"}
|
|
||||||
{!isMuted && isCurrentSelected && hasMultipleSelected && (
|
|
||||||
<span className="ml-1 text-xs text-slate-500">
|
|
||||||
({selectedFindingIds.length})
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</DropdownItem>
|
|
||||||
<DropdownItem
|
|
||||||
key="jira"
|
|
||||||
description="Create a Jira issue for this finding"
|
|
||||||
textValue="Send to Jira"
|
|
||||||
startContent={
|
|
||||||
<JiraIcon
|
|
||||||
size={20}
|
|
||||||
className="text-default-500 pointer-events-none shrink-0"
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
onPress={() => setIsJiraModalOpen(true)}
|
|
||||||
>
|
|
||||||
Send to Jira
|
|
||||||
</DropdownItem>
|
|
||||||
</DropdownSection>
|
|
||||||
</DropdownMenu>
|
|
||||||
</Dropdown>
|
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -181,13 +181,13 @@ export const FindingDetail = ({
|
|||||||
|
|
||||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
|
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||||
<InfoField label="Check ID" variant="simple">
|
<InfoField label="Check ID" variant="simple">
|
||||||
<CodeSnippet value={attributes.check_id} />
|
<CodeSnippet value={attributes.check_id} className="max-w-full" />
|
||||||
</InfoField>
|
</InfoField>
|
||||||
<InfoField label="Finding ID" variant="simple">
|
<InfoField label="Finding ID" variant="simple">
|
||||||
<CodeSnippet value={findingDetails.id} />
|
<CodeSnippet value={findingDetails.id} className="max-w-full" />
|
||||||
</InfoField>
|
</InfoField>
|
||||||
<InfoField label="Finding UID" variant="simple">
|
<InfoField label="Finding UID" variant="simple">
|
||||||
<CodeSnippet value={attributes.uid} />
|
<CodeSnippet value={attributes.uid} className="max-w-full" />
|
||||||
</InfoField>
|
</InfoField>
|
||||||
<InfoField label="First seen" variant="simple">
|
<InfoField label="First seen" variant="simple">
|
||||||
<DateWithTime inline dateTime={attributes.first_seen_at || "-"} />
|
<DateWithTime inline dateTime={attributes.first_seen_at || "-"} />
|
||||||
|
|||||||
@@ -16,8 +16,8 @@ import {
|
|||||||
IntegrationSkeleton,
|
IntegrationSkeleton,
|
||||||
} from "@/components/integrations/shared";
|
} from "@/components/integrations/shared";
|
||||||
import { Button } from "@/components/shadcn";
|
import { Button } from "@/components/shadcn";
|
||||||
|
import { Modal } from "@/components/shadcn/modal";
|
||||||
import { useToast } from "@/components/ui";
|
import { useToast } from "@/components/ui";
|
||||||
import { CustomAlertModal } from "@/components/ui/custom";
|
|
||||||
import { DataTablePagination } from "@/components/ui/table/data-table-pagination";
|
import { DataTablePagination } from "@/components/ui/table/data-table-pagination";
|
||||||
import { triggerTestConnectionWithDelay } from "@/lib/integrations/test-connection-helper";
|
import { triggerTestConnectionWithDelay } from "@/lib/integrations/test-connection-helper";
|
||||||
import { MetaDataProps } from "@/types";
|
import { MetaDataProps } from "@/types";
|
||||||
@@ -209,8 +209,8 @@ export const JiraIntegrationsManager = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<CustomAlertModal
|
<Modal
|
||||||
isOpen={isDeleteOpen}
|
open={isDeleteOpen}
|
||||||
onOpenChange={setIsDeleteOpen}
|
onOpenChange={setIsDeleteOpen}
|
||||||
title="Delete Jira Integration"
|
title="Delete Jira Integration"
|
||||||
description="This action cannot be undone. This will permanently delete your Jira integration."
|
description="This action cannot be undone. This will permanently delete your Jira integration."
|
||||||
@@ -243,10 +243,10 @@ export const JiraIntegrationsManager = ({
|
|||||||
{isDeleting ? "Deleting..." : "Delete"}
|
{isDeleting ? "Deleting..." : "Delete"}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</CustomAlertModal>
|
</Modal>
|
||||||
|
|
||||||
<CustomAlertModal
|
<Modal
|
||||||
isOpen={isModalOpen}
|
open={isModalOpen}
|
||||||
onOpenChange={setIsModalOpen}
|
onOpenChange={setIsModalOpen}
|
||||||
title={
|
title={
|
||||||
editingIntegration
|
editingIntegration
|
||||||
@@ -259,7 +259,7 @@ export const JiraIntegrationsManager = ({
|
|||||||
onSuccess={handleFormSuccess}
|
onSuccess={handleFormSuccess}
|
||||||
onCancel={handleModalClose}
|
onCancel={handleModalClose}
|
||||||
/>
|
/>
|
||||||
</CustomAlertModal>
|
</Modal>
|
||||||
|
|
||||||
<div className="flex flex-col gap-6">
|
<div className="flex flex-col gap-6">
|
||||||
{/* Header with Add Button */}
|
{/* Header with Add Button */}
|
||||||
|
|||||||
@@ -16,8 +16,8 @@ import {
|
|||||||
IntegrationSkeleton,
|
IntegrationSkeleton,
|
||||||
} from "@/components/integrations/shared";
|
} from "@/components/integrations/shared";
|
||||||
import { Button } from "@/components/shadcn";
|
import { Button } from "@/components/shadcn";
|
||||||
|
import { Modal } from "@/components/shadcn/modal";
|
||||||
import { useToast } from "@/components/ui";
|
import { useToast } from "@/components/ui";
|
||||||
import { CustomAlertModal } from "@/components/ui/custom";
|
|
||||||
import { DataTablePagination } from "@/components/ui/table/data-table-pagination";
|
import { DataTablePagination } from "@/components/ui/table/data-table-pagination";
|
||||||
import { triggerTestConnectionWithDelay } from "@/lib/integrations/test-connection-helper";
|
import { triggerTestConnectionWithDelay } from "@/lib/integrations/test-connection-helper";
|
||||||
import { MetaDataProps } from "@/types";
|
import { MetaDataProps } from "@/types";
|
||||||
@@ -209,8 +209,8 @@ export const S3IntegrationsManager = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<CustomAlertModal
|
<Modal
|
||||||
isOpen={isDeleteOpen}
|
open={isDeleteOpen}
|
||||||
onOpenChange={setIsDeleteOpen}
|
onOpenChange={setIsDeleteOpen}
|
||||||
title="Delete S3 Integration"
|
title="Delete S3 Integration"
|
||||||
description="This action cannot be undone. This will permanently delete your S3 integration."
|
description="This action cannot be undone. This will permanently delete your S3 integration."
|
||||||
@@ -243,10 +243,10 @@ export const S3IntegrationsManager = ({
|
|||||||
{isDeleting ? "Deleting..." : "Delete"}
|
{isDeleting ? "Deleting..." : "Delete"}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</CustomAlertModal>
|
</Modal>
|
||||||
|
|
||||||
<CustomAlertModal
|
<Modal
|
||||||
isOpen={isModalOpen}
|
open={isModalOpen}
|
||||||
onOpenChange={setIsModalOpen}
|
onOpenChange={setIsModalOpen}
|
||||||
title={
|
title={
|
||||||
editMode === "configuration"
|
editMode === "configuration"
|
||||||
@@ -265,7 +265,7 @@ export const S3IntegrationsManager = ({
|
|||||||
onCancel={handleModalClose}
|
onCancel={handleModalClose}
|
||||||
editMode={editMode}
|
editMode={editMode}
|
||||||
/>
|
/>
|
||||||
</CustomAlertModal>
|
</Modal>
|
||||||
|
|
||||||
<div className="flex flex-col gap-6">
|
<div className="flex flex-col gap-6">
|
||||||
{/* Header with Add Button */}
|
{/* Header with Add Button */}
|
||||||
|
|||||||
@@ -5,8 +5,8 @@ import { useState } from "react";
|
|||||||
|
|
||||||
import { deleteSamlConfig } from "@/actions/integrations";
|
import { deleteSamlConfig } from "@/actions/integrations";
|
||||||
import { Button } from "@/components/shadcn";
|
import { Button } from "@/components/shadcn";
|
||||||
|
import { Modal } from "@/components/shadcn/modal";
|
||||||
import { useToast } from "@/components/ui";
|
import { useToast } from "@/components/ui";
|
||||||
import { CustomAlertModal } from "@/components/ui/custom";
|
|
||||||
import { CustomLink } from "@/components/ui/custom/custom-link";
|
import { CustomLink } from "@/components/ui/custom/custom-link";
|
||||||
|
|
||||||
import { Card, CardContent, CardHeader } from "../../shadcn";
|
import { Card, CardContent, CardHeader } from "../../shadcn";
|
||||||
@@ -53,8 +53,8 @@ export const SamlIntegrationCard = ({ samlConfig }: { samlConfig?: any }) => {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{/* Configure SAML Modal */}
|
{/* Configure SAML Modal */}
|
||||||
<CustomAlertModal
|
<Modal
|
||||||
isOpen={isSamlModalOpen}
|
open={isSamlModalOpen}
|
||||||
onOpenChange={setIsSamlModalOpen}
|
onOpenChange={setIsSamlModalOpen}
|
||||||
title="Configure SAML SSO"
|
title="Configure SAML SSO"
|
||||||
>
|
>
|
||||||
@@ -62,11 +62,11 @@ export const SamlIntegrationCard = ({ samlConfig }: { samlConfig?: any }) => {
|
|||||||
setIsOpen={setIsSamlModalOpen}
|
setIsOpen={setIsSamlModalOpen}
|
||||||
samlConfig={samlConfig}
|
samlConfig={samlConfig}
|
||||||
/>
|
/>
|
||||||
</CustomAlertModal>
|
</Modal>
|
||||||
|
|
||||||
{/* Delete Confirmation Modal */}
|
{/* Delete Confirmation Modal */}
|
||||||
<CustomAlertModal
|
<Modal
|
||||||
isOpen={isDeleteModalOpen}
|
open={isDeleteModalOpen}
|
||||||
onOpenChange={setIsDeleteModalOpen}
|
onOpenChange={setIsDeleteModalOpen}
|
||||||
title="Remove SAML Configuration"
|
title="Remove SAML Configuration"
|
||||||
size="md"
|
size="md"
|
||||||
@@ -98,7 +98,7 @@ export const SamlIntegrationCard = ({ samlConfig }: { samlConfig?: any }) => {
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</CustomAlertModal>
|
</Modal>
|
||||||
|
|
||||||
<Card variant="base" padding="lg">
|
<Card variant="base" padding="lg">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
|
|||||||
@@ -17,8 +17,8 @@ import {
|
|||||||
IntegrationSkeleton,
|
IntegrationSkeleton,
|
||||||
} from "@/components/integrations/shared";
|
} from "@/components/integrations/shared";
|
||||||
import { Button } from "@/components/shadcn";
|
import { Button } from "@/components/shadcn";
|
||||||
|
import { Modal } from "@/components/shadcn/modal";
|
||||||
import { useToast } from "@/components/ui";
|
import { useToast } from "@/components/ui";
|
||||||
import { CustomAlertModal } from "@/components/ui/custom";
|
|
||||||
import { DataTablePagination } from "@/components/ui/table/data-table-pagination";
|
import { DataTablePagination } from "@/components/ui/table/data-table-pagination";
|
||||||
import { triggerTestConnectionWithDelay } from "@/lib/integrations/test-connection-helper";
|
import { triggerTestConnectionWithDelay } from "@/lib/integrations/test-connection-helper";
|
||||||
import { MetaDataProps } from "@/types";
|
import { MetaDataProps } from "@/types";
|
||||||
@@ -259,8 +259,8 @@ export const SecurityHubIntegrationsManager = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<CustomAlertModal
|
<Modal
|
||||||
isOpen={isDeleteOpen}
|
open={isDeleteOpen}
|
||||||
onOpenChange={setIsDeleteOpen}
|
onOpenChange={setIsDeleteOpen}
|
||||||
title="Delete Security Hub Integration"
|
title="Delete Security Hub Integration"
|
||||||
description="This action cannot be undone. This will permanently delete your Security Hub integration."
|
description="This action cannot be undone. This will permanently delete your Security Hub integration."
|
||||||
@@ -293,10 +293,10 @@ export const SecurityHubIntegrationsManager = ({
|
|||||||
{isDeleting ? "Deleting..." : "Delete"}
|
{isDeleting ? "Deleting..." : "Delete"}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</CustomAlertModal>
|
</Modal>
|
||||||
|
|
||||||
<CustomAlertModal
|
<Modal
|
||||||
isOpen={isModalOpen}
|
open={isModalOpen}
|
||||||
onOpenChange={setIsModalOpen}
|
onOpenChange={setIsModalOpen}
|
||||||
title={
|
title={
|
||||||
editMode === "configuration"
|
editMode === "configuration"
|
||||||
@@ -316,7 +316,7 @@ export const SecurityHubIntegrationsManager = ({
|
|||||||
onCancel={handleModalClose}
|
onCancel={handleModalClose}
|
||||||
editMode={editMode}
|
editMode={editMode}
|
||||||
/>
|
/>
|
||||||
</CustomAlertModal>
|
</Modal>
|
||||||
|
|
||||||
<div className="flex flex-col gap-6">
|
<div className="flex flex-col gap-6">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ import { useState } from "react";
|
|||||||
|
|
||||||
import { VerticalDotsIcon } from "@/components/icons";
|
import { VerticalDotsIcon } from "@/components/icons";
|
||||||
import { Button } from "@/components/shadcn";
|
import { Button } from "@/components/shadcn";
|
||||||
import { CustomAlertModal } from "@/components/ui/custom";
|
import { Modal } from "@/components/shadcn/modal";
|
||||||
|
|
||||||
import { DeleteForm, EditForm } from "../forms";
|
import { DeleteForm, EditForm } from "../forms";
|
||||||
|
|
||||||
@@ -44,8 +44,8 @@ export function DataTableRowActions<InvitationProps>({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<CustomAlertModal
|
<Modal
|
||||||
isOpen={isEditOpen}
|
open={isEditOpen}
|
||||||
onOpenChange={setIsEditOpen}
|
onOpenChange={setIsEditOpen}
|
||||||
title="Edit invitation details"
|
title="Edit invitation details"
|
||||||
>
|
>
|
||||||
@@ -56,15 +56,15 @@ export function DataTableRowActions<InvitationProps>({
|
|||||||
roles={roles || []}
|
roles={roles || []}
|
||||||
setIsOpen={setIsEditOpen}
|
setIsOpen={setIsEditOpen}
|
||||||
/>
|
/>
|
||||||
</CustomAlertModal>
|
</Modal>
|
||||||
<CustomAlertModal
|
<Modal
|
||||||
isOpen={isDeleteOpen}
|
open={isDeleteOpen}
|
||||||
onOpenChange={setIsDeleteOpen}
|
onOpenChange={setIsDeleteOpen}
|
||||||
title="Are you absolutely sure?"
|
title="Are you absolutely sure?"
|
||||||
description="This action cannot be undone. This will permanently revoke your invitation."
|
description="This action cannot be undone. This will permanently revoke your invitation."
|
||||||
>
|
>
|
||||||
<DeleteForm invitationId={invitationId} setIsOpen={setIsDeleteOpen} />
|
<DeleteForm invitationId={invitationId} setIsOpen={setIsDeleteOpen} />
|
||||||
</CustomAlertModal>
|
</Modal>
|
||||||
|
|
||||||
<div className="relative flex items-center justify-end gap-2">
|
<div className="relative flex items-center justify-end gap-2">
|
||||||
<Dropdown
|
<Dropdown
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ import { useState } from "react";
|
|||||||
|
|
||||||
import { VerticalDotsIcon } from "@/components/icons";
|
import { VerticalDotsIcon } from "@/components/icons";
|
||||||
import { Button } from "@/components/shadcn";
|
import { Button } from "@/components/shadcn";
|
||||||
import { CustomAlertModal } from "@/components/ui/custom";
|
import { Modal } from "@/components/shadcn/modal";
|
||||||
|
|
||||||
import { DeleteGroupForm } from "../forms";
|
import { DeleteGroupForm } from "../forms";
|
||||||
|
|
||||||
@@ -37,14 +37,14 @@ export function DataTableRowActions<ProviderProps>({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<CustomAlertModal
|
<Modal
|
||||||
isOpen={isDeleteOpen}
|
open={isDeleteOpen}
|
||||||
onOpenChange={setIsDeleteOpen}
|
onOpenChange={setIsDeleteOpen}
|
||||||
title="Are you absolutely sure?"
|
title="Are you absolutely sure?"
|
||||||
description="This action cannot be undone. This will permanently delete your provider account and remove your data from the server."
|
description="This action cannot be undone. This will permanently delete your provider account and remove your data from the server."
|
||||||
>
|
>
|
||||||
<DeleteGroupForm groupId={groupId} setIsOpen={setIsDeleteOpen} />
|
<DeleteGroupForm groupId={groupId} setIsOpen={setIsDeleteOpen} />
|
||||||
</CustomAlertModal>
|
</Modal>
|
||||||
|
|
||||||
<div className="relative flex items-center justify-end gap-2">
|
<div className="relative flex items-center justify-end gap-2">
|
||||||
<Dropdown
|
<Dropdown
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ export const ProviderInfo = ({
|
|||||||
return (
|
return (
|
||||||
<div className="flex items-center text-sm">
|
<div className="flex items-center text-sm">
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
{getProviderLogo(provider)}
|
<div className="shrink-0">{getProviderLogo(provider)}</div>
|
||||||
{getIcon()}
|
{getIcon()}
|
||||||
<span className="font-medium">{providerAlias || providerUID}</span>
|
<span className="font-medium">{providerAlias || providerUID}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ import { useState } from "react";
|
|||||||
import { checkConnectionProvider } from "@/actions/providers/providers";
|
import { checkConnectionProvider } from "@/actions/providers/providers";
|
||||||
import { VerticalDotsIcon } from "@/components/icons";
|
import { VerticalDotsIcon } from "@/components/icons";
|
||||||
import { Button } from "@/components/shadcn";
|
import { Button } from "@/components/shadcn";
|
||||||
import { CustomAlertModal } from "@/components/ui/custom";
|
import { Modal } from "@/components/shadcn/modal";
|
||||||
|
|
||||||
import { EditForm } from "../forms";
|
import { EditForm } from "../forms";
|
||||||
import { DeleteForm } from "../forms/delete-form";
|
import { DeleteForm } from "../forms/delete-form";
|
||||||
@@ -61,8 +61,8 @@ export function DataTableRowActions<ProviderProps>({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<CustomAlertModal
|
<Modal
|
||||||
isOpen={isEditOpen}
|
open={isEditOpen}
|
||||||
onOpenChange={setIsEditOpen}
|
onOpenChange={setIsEditOpen}
|
||||||
title="Edit Provider Alias"
|
title="Edit Provider Alias"
|
||||||
>
|
>
|
||||||
@@ -71,15 +71,15 @@ export function DataTableRowActions<ProviderProps>({
|
|||||||
providerAlias={providerAlias}
|
providerAlias={providerAlias}
|
||||||
setIsOpen={setIsEditOpen}
|
setIsOpen={setIsEditOpen}
|
||||||
/>
|
/>
|
||||||
</CustomAlertModal>
|
</Modal>
|
||||||
<CustomAlertModal
|
<Modal
|
||||||
isOpen={isDeleteOpen}
|
open={isDeleteOpen}
|
||||||
onOpenChange={setIsDeleteOpen}
|
onOpenChange={setIsDeleteOpen}
|
||||||
title="Are you absolutely sure?"
|
title="Are you absolutely sure?"
|
||||||
description="This action cannot be undone. This will permanently delete your provider account and remove your data from the server."
|
description="This action cannot be undone. This will permanently delete your provider account and remove your data from the server."
|
||||||
>
|
>
|
||||||
<DeleteForm providerId={providerId} setIsOpen={setIsDeleteOpen} />
|
<DeleteForm providerId={providerId} setIsOpen={setIsDeleteOpen} />
|
||||||
</CustomAlertModal>
|
</Modal>
|
||||||
|
|
||||||
<div className="relative flex items-center justify-end gap-2">
|
<div className="relative flex items-center justify-end gap-2">
|
||||||
<Dropdown
|
<Dropdown
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
||||||
|
|
||||||
|
import {
|
||||||
|
Sheet,
|
||||||
|
SheetContent,
|
||||||
|
SheetDescription,
|
||||||
|
SheetHeader,
|
||||||
|
SheetTitle,
|
||||||
|
} from "@/components/ui/sheet";
|
||||||
|
import { ResourceProps } from "@/types";
|
||||||
|
|
||||||
|
import { ResourceDetail } from "./table/resource-detail";
|
||||||
|
|
||||||
|
interface ResourceDetailsSheetProps {
|
||||||
|
resource: ResourceProps;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ResourceDetailsSheet = ({
|
||||||
|
resource,
|
||||||
|
}: ResourceDetailsSheetProps) => {
|
||||||
|
const router = useRouter();
|
||||||
|
const pathname = usePathname();
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
|
||||||
|
const handleOpenChange = (open: boolean) => {
|
||||||
|
if (!open) {
|
||||||
|
const params = new URLSearchParams(searchParams.toString());
|
||||||
|
params.delete("resourceId");
|
||||||
|
router.push(`${pathname}?${params.toString()}`, { scroll: false });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Sheet open={true} onOpenChange={handleOpenChange}>
|
||||||
|
<SheetContent className="my-4 max-h-[calc(100vh-2rem)] max-w-[95vw] overflow-y-auto pt-10 md:my-8 md:max-h-[calc(100vh-4rem)] md:max-w-[55vw]">
|
||||||
|
<SheetHeader>
|
||||||
|
<SheetTitle className="sr-only">Resource Details</SheetTitle>
|
||||||
|
<SheetDescription className="sr-only">
|
||||||
|
View the resource details
|
||||||
|
</SheetDescription>
|
||||||
|
</SheetHeader>
|
||||||
|
<ResourceDetail resourceDetails={resource} />
|
||||||
|
</SheetContent>
|
||||||
|
</Sheet>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,50 +1,93 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import { ChevronDown } from "lucide-react";
|
||||||
|
import { useState } from "react";
|
||||||
|
|
||||||
|
import { AccountsSelector } from "@/app/(prowler)/_overview/_components/accounts-selector";
|
||||||
|
import { ProviderTypeSelector } from "@/app/(prowler)/_overview/_components/provider-type-selector";
|
||||||
|
import { ClearFiltersButton } from "@/components/filters/clear-filters-button";
|
||||||
|
import { CustomDatePicker } from "@/components/filters/custom-date-picker";
|
||||||
|
import { Button } from "@/components/shadcn";
|
||||||
|
import { ExpandableSection } from "@/components/ui/expandable-section";
|
||||||
import { DataTableFilterCustom } from "@/components/ui/table";
|
import { DataTableFilterCustom } from "@/components/ui/table";
|
||||||
import { getGroupLabel } from "@/lib/categories";
|
import { getGroupLabel } from "@/lib/categories";
|
||||||
import { FilterEntity } from "@/types";
|
import { ProviderProps } from "@/types/providers";
|
||||||
|
|
||||||
interface ResourcesFiltersProps {
|
interface ResourcesFiltersProps {
|
||||||
providerIds: string[];
|
providers: ProviderProps[];
|
||||||
providerDetails: { [id: string]: FilterEntity }[];
|
|
||||||
uniqueRegions: string[];
|
uniqueRegions: string[];
|
||||||
uniqueServices: string[];
|
uniqueServices: string[];
|
||||||
uniqueGroups: string[];
|
uniqueGroups: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ResourcesFilters = ({
|
export const ResourcesFilters = ({
|
||||||
providerIds,
|
providers,
|
||||||
providerDetails,
|
|
||||||
uniqueRegions,
|
uniqueRegions,
|
||||||
uniqueServices,
|
uniqueServices,
|
||||||
uniqueGroups,
|
uniqueGroups,
|
||||||
}: ResourcesFiltersProps) => {
|
}: ResourcesFiltersProps) => {
|
||||||
|
const [isExpanded, setIsExpanded] = useState(false);
|
||||||
|
|
||||||
|
// Custom filters for the expandable section
|
||||||
|
const customFilters = [
|
||||||
|
{
|
||||||
|
key: "region__in",
|
||||||
|
labelCheckboxGroup: "Region",
|
||||||
|
values: uniqueRegions,
|
||||||
|
index: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "service__in",
|
||||||
|
labelCheckboxGroup: "Service",
|
||||||
|
values: uniqueServices,
|
||||||
|
index: 2,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "groups__in",
|
||||||
|
labelCheckboxGroup: "Group",
|
||||||
|
values: uniqueGroups,
|
||||||
|
labelFormatter: getGroupLabel,
|
||||||
|
index: 3,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const hasCustomFilters = customFilters.length > 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DataTableFilterCustom
|
<div className="flex flex-col">
|
||||||
filters={[
|
{/* First row: Provider selectors + More Filters button + Clear Filters */}
|
||||||
{
|
<div className="flex flex-wrap items-center gap-4">
|
||||||
key: "provider__in",
|
<div className="min-w-[200px] flex-1 md:max-w-[280px]">
|
||||||
labelCheckboxGroup: "Provider",
|
<ProviderTypeSelector providers={providers} />
|
||||||
values: providerIds,
|
</div>
|
||||||
valueLabelMapping: providerDetails,
|
<div className="min-w-[200px] flex-1 md:max-w-[280px]">
|
||||||
},
|
<AccountsSelector providers={providers} />
|
||||||
{
|
</div>
|
||||||
key: "region__in",
|
{hasCustomFilters && (
|
||||||
labelCheckboxGroup: "Region",
|
<Button
|
||||||
values: uniqueRegions,
|
variant="outline"
|
||||||
},
|
size="lg"
|
||||||
{
|
onClick={() => setIsExpanded(!isExpanded)}
|
||||||
key: "service__in",
|
>
|
||||||
labelCheckboxGroup: "Service",
|
{isExpanded ? "Less Filters" : "More Filters"}
|
||||||
values: uniqueServices,
|
<ChevronDown
|
||||||
},
|
className={`size-4 transition-transform duration-300 ${isExpanded ? "rotate-180" : "rotate-0"}`}
|
||||||
{
|
/>
|
||||||
key: "groups__in",
|
</Button>
|
||||||
labelCheckboxGroup: "Resource Group",
|
)}
|
||||||
values: uniqueGroups,
|
<ClearFiltersButton showCount />
|
||||||
labelFormatter: getGroupLabel,
|
</div>
|
||||||
},
|
|
||||||
]}
|
{/* Expandable filters section */}
|
||||||
/>
|
{hasCustomFilters && (
|
||||||
|
<ExpandableSection isExpanded={isExpanded}>
|
||||||
|
<DataTableFilterCustom
|
||||||
|
filters={customFilters}
|
||||||
|
prependElement={<CustomDatePicker />}
|
||||||
|
hideClearButton
|
||||||
|
/>
|
||||||
|
</ExpandableSection>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,39 +1,130 @@
|
|||||||
import React from "react";
|
|
||||||
|
|
||||||
import { Card } from "@/components/shadcn/card/card";
|
|
||||||
import { Skeleton } from "@/components/shadcn/skeleton/skeleton";
|
import { Skeleton } from "@/components/shadcn/skeleton/skeleton";
|
||||||
|
|
||||||
export const SkeletonTableResources = () => {
|
const SkeletonTableRow = () => {
|
||||||
return (
|
return (
|
||||||
<Card variant="base" padding="md" className="flex flex-col gap-4">
|
<tr className="border-border-neutral-secondary border-b last:border-b-0">
|
||||||
{/* Table headers */}
|
{/* Name - clickable text with copy icon */}
|
||||||
<div className="hidden gap-4 md:flex">
|
<td className="px-3 py-4">
|
||||||
<Skeleton className="h-8 w-1/12" />
|
<div className="flex items-center gap-2">
|
||||||
<Skeleton className="h-8 w-2/12" />
|
<Skeleton className="h-4 w-32 rounded" />
|
||||||
<Skeleton className="h-8 w-2/12" />
|
<Skeleton className="size-4 rounded" />
|
||||||
<Skeleton className="h-8 w-2/12" />
|
</div>
|
||||||
<Skeleton className="h-8 w-2/12" />
|
</td>
|
||||||
<Skeleton className="h-8 w-1/12" />
|
{/* Provider Account - logo + alias + uid chip */}
|
||||||
<Skeleton className="h-8 w-1/12" />
|
<td className="px-3 py-4">
|
||||||
</div>
|
<div className="flex items-center gap-2">
|
||||||
|
<Skeleton className="size-9 rounded-xl" />
|
||||||
{/* Table body */}
|
<div className="flex flex-col gap-1">
|
||||||
<div className="flex flex-col gap-3">
|
<Skeleton className="h-3.5 w-20 rounded" />
|
||||||
{[...Array(3)].map((_, index) => (
|
<div className="bg-bg-neutral-tertiary flex h-6 w-24 items-center gap-1 rounded-xl px-1.5">
|
||||||
<div
|
<Skeleton className="h-3 w-16 rounded" />
|
||||||
key={index}
|
<Skeleton className="size-3.5 rounded" />
|
||||||
className="flex flex-col gap-4 md:flex-row md:items-center"
|
</div>
|
||||||
>
|
|
||||||
<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>
|
||||||
))}
|
</div>
|
||||||
|
</td>
|
||||||
|
{/* Failed Findings - badge */}
|
||||||
|
<td className="px-3 py-4">
|
||||||
|
<Skeleton className="h-6 w-10 rounded-full" />
|
||||||
|
</td>
|
||||||
|
{/* Group */}
|
||||||
|
<td className="px-3 py-4">
|
||||||
|
<Skeleton className="h-4 w-20 rounded" />
|
||||||
|
</td>
|
||||||
|
{/* Type */}
|
||||||
|
<td className="px-3 py-4">
|
||||||
|
<Skeleton className="h-4 w-24 rounded" />
|
||||||
|
</td>
|
||||||
|
{/* Region */}
|
||||||
|
<td className="px-3 py-4">
|
||||||
|
<Skeleton className="h-4 w-20 rounded" />
|
||||||
|
</td>
|
||||||
|
{/* Service */}
|
||||||
|
<td className="px-3 py-4">
|
||||||
|
<Skeleton className="h-4 w-16 rounded" />
|
||||||
|
</td>
|
||||||
|
{/* Actions */}
|
||||||
|
<td className="px-2 py-4">
|
||||||
|
<Skeleton className="size-6 rounded" />
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const SkeletonTableResources = () => {
|
||||||
|
const rows = 10;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rounded-large shadow-small border-border-neutral-secondary bg-bg-neutral-secondary flex w-full flex-col gap-4 overflow-hidden border p-4">
|
||||||
|
{/* Toolbar: Search + Total entries */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
{/* Search icon button */}
|
||||||
|
<Skeleton className="size-10 rounded-md" />
|
||||||
|
{/* Total entries */}
|
||||||
|
<Skeleton className="h-4 w-28 rounded" />
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
|
||||||
|
{/* Table */}
|
||||||
|
<table className="w-full">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-border-neutral-secondary border-b">
|
||||||
|
{/* Name */}
|
||||||
|
<th className="px-3 py-3 text-left">
|
||||||
|
<Skeleton className="h-4 w-12 rounded" />
|
||||||
|
</th>
|
||||||
|
{/* Provider Account */}
|
||||||
|
<th className="px-3 py-3 text-left">
|
||||||
|
<Skeleton className="h-4 w-28 rounded" />
|
||||||
|
</th>
|
||||||
|
{/* Failed Findings */}
|
||||||
|
<th className="px-3 py-3 text-left">
|
||||||
|
<Skeleton className="h-4 w-24 rounded" />
|
||||||
|
</th>
|
||||||
|
{/* Group */}
|
||||||
|
<th className="px-3 py-3 text-left">
|
||||||
|
<Skeleton className="h-4 w-12 rounded" />
|
||||||
|
</th>
|
||||||
|
{/* Type */}
|
||||||
|
<th className="px-3 py-3 text-left">
|
||||||
|
<Skeleton className="h-4 w-10 rounded" />
|
||||||
|
</th>
|
||||||
|
{/* Region */}
|
||||||
|
<th className="px-3 py-3 text-left">
|
||||||
|
<Skeleton className="h-4 w-14 rounded" />
|
||||||
|
</th>
|
||||||
|
{/* Service */}
|
||||||
|
<th className="px-3 py-3 text-left">
|
||||||
|
<Skeleton className="h-4 w-14 rounded" />
|
||||||
|
</th>
|
||||||
|
{/* Actions - empty header */}
|
||||||
|
<th className="w-10 py-3" />
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{Array.from({ length: rows }).map((_, i) => (
|
||||||
|
<SkeletonTableRow key={i} />
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
{/* Pagination */}
|
||||||
|
<div className="flex items-center justify-between pt-2">
|
||||||
|
{/* Rows per page */}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Skeleton className="h-4 w-24 rounded" />
|
||||||
|
<Skeleton className="h-9 w-16 rounded-md" />
|
||||||
|
</div>
|
||||||
|
{/* Page info + navigation */}
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<Skeleton className="h-4 w-24 rounded" />
|
||||||
|
<div className="flex gap-1">
|
||||||
|
<Skeleton className="size-9 rounded-md" />
|
||||||
|
<Skeleton className="size-9 rounded-md" />
|
||||||
|
<Skeleton className="size-9 rounded-md" />
|
||||||
|
<Skeleton className="size-9 rounded-md" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,13 +1,18 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { ColumnDef } from "@tanstack/react-table";
|
import { ColumnDef } from "@tanstack/react-table";
|
||||||
import { Database } from "lucide-react";
|
import { AlertTriangle, Eye, MoreVertical } from "lucide-react";
|
||||||
import { useSearchParams } from "next/navigation";
|
import { useSearchParams } from "next/navigation";
|
||||||
|
import { useState } from "react";
|
||||||
|
|
||||||
import { InfoIcon } from "@/components/icons";
|
import {
|
||||||
import { EntityInfo, SnippetChip } from "@/components/ui/entities";
|
ActionDropdown,
|
||||||
import { TriggerSheet } from "@/components/ui/sheet";
|
ActionDropdownItem,
|
||||||
|
} from "@/components/shadcn/dropdown";
|
||||||
|
import { CodeSnippet } from "@/components/ui/code-snippet/code-snippet";
|
||||||
|
import { EntityInfo } from "@/components/ui/entities";
|
||||||
import { DataTableColumnHeader } from "@/components/ui/table";
|
import { DataTableColumnHeader } from "@/components/ui/table";
|
||||||
|
import { getGroupLabel } from "@/lib/categories";
|
||||||
import { ProviderType, ResourceProps } from "@/types";
|
import { ProviderType, ResourceProps } from "@/types";
|
||||||
|
|
||||||
import { ResourceDetail } from "./resource-detail";
|
import { ResourceDetail } from "./resource-detail";
|
||||||
@@ -19,12 +24,6 @@ const getResourceData = (
|
|||||||
return row.original.attributes?.[field];
|
return row.original.attributes?.[field];
|
||||||
};
|
};
|
||||||
|
|
||||||
const getChipStyle = (count: number) => {
|
|
||||||
if (count === 0) return "bg-green-100 text-green-800";
|
|
||||||
if (count >= 10) return "bg-red-100 text-red-800";
|
|
||||||
if (count >= 1) return "bg-yellow-100 text-yellow-800";
|
|
||||||
};
|
|
||||||
|
|
||||||
const getProviderData = (
|
const getProviderData = (
|
||||||
row: { original: ResourceProps },
|
row: { original: ResourceProps },
|
||||||
field: keyof ResourceProps["relationships"]["provider"]["data"]["attributes"],
|
field: keyof ResourceProps["relationships"]["provider"]["data"]["attributes"],
|
||||||
@@ -35,61 +34,125 @@ const getProviderData = (
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const ResourceDetailsCell = ({ row }: { row: any }) => {
|
// Component for resource name that opens the detail drawer
|
||||||
|
const ResourceNameCell = ({ row }: { row: { original: ResourceProps } }) => {
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
const resourceId = searchParams.get("resourceId");
|
const resourceId = searchParams.get("resourceId");
|
||||||
const isOpen = resourceId === row.original.id;
|
const isOpen = resourceId === row.original.id;
|
||||||
|
const resourceName = row.original.attributes?.name;
|
||||||
|
const resourceUid = row.original.attributes?.uid;
|
||||||
|
const displayName =
|
||||||
|
typeof resourceName === "string" && resourceName.trim().length > 0
|
||||||
|
? resourceName
|
||||||
|
: "Unnamed resource";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex w-9 items-center justify-center">
|
<div className="flex items-center gap-2">
|
||||||
<TriggerSheet
|
<ResourceDetail
|
||||||
triggerComponent={
|
resourceDetails={row.original}
|
||||||
<InfoIcon className="text-button-primary" size={16} />
|
|
||||||
}
|
|
||||||
title="Resource Details"
|
|
||||||
description="View the Resource details"
|
|
||||||
defaultOpen={isOpen}
|
defaultOpen={isOpen}
|
||||||
>
|
trigger={
|
||||||
<ResourceDetail
|
<div className="max-w-[200px]">
|
||||||
resourceId={row.original.id}
|
<p className="text-text-neutral-primary hover:text-button-tertiary cursor-pointer text-left text-sm break-words whitespace-normal hover:underline">
|
||||||
initialResourceData={row.original}
|
{displayName}
|
||||||
/>
|
</p>
|
||||||
</TriggerSheet>
|
</div>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
{resourceUid && <CodeSnippet value={resourceUid} hideCode />}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Component for failed findings badge with warning style
|
||||||
|
const FailedFindingsBadge = ({ count }: { count: number }) => {
|
||||||
|
if (count === 0) {
|
||||||
|
return (
|
||||||
|
<span className="inline-flex h-6 items-center justify-center rounded-full bg-green-100 px-2 text-xs font-semibold text-green-800 dark:bg-green-900/30 dark:text-green-400">
|
||||||
|
0
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span className="inline-flex h-6 items-center gap-1 rounded-full bg-red-100 px-2 text-xs font-semibold text-red-800 dark:bg-red-900/30 dark:text-red-400">
|
||||||
|
<AlertTriangle className="h-3 w-3" />
|
||||||
|
{count}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Row actions dropdown
|
||||||
|
const ResourceRowActions = ({ row }: { row: { original: ResourceProps } }) => {
|
||||||
|
const [isDrawerOpen, setIsDrawerOpen] = useState(false);
|
||||||
|
const resourceName = row.original.attributes?.name || "Resource";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="flex items-center justify-end">
|
||||||
|
<ActionDropdown
|
||||||
|
trigger={
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label="Resource actions"
|
||||||
|
className="hover:bg-bg-neutral-tertiary rounded-md p-1 transition-colors"
|
||||||
|
>
|
||||||
|
<MoreVertical className="text-text-neutral-secondary size-5" />
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
ariaLabel="Resource actions"
|
||||||
|
>
|
||||||
|
<ActionDropdownItem
|
||||||
|
icon={<Eye className="size-5" />}
|
||||||
|
label="View details"
|
||||||
|
description={`View details for ${resourceName}`}
|
||||||
|
onSelect={() => setIsDrawerOpen(true)}
|
||||||
|
/>
|
||||||
|
</ActionDropdown>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ResourceDetail
|
||||||
|
resourceDetails={row.original}
|
||||||
|
open={isDrawerOpen}
|
||||||
|
onOpenChange={setIsDrawerOpen}
|
||||||
|
trigger={<span className="hidden" />}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Column definitions for resources table
|
||||||
export const ColumnResources: ColumnDef<ResourceProps>[] = [
|
export const ColumnResources: ColumnDef<ResourceProps>[] = [
|
||||||
{
|
// Name column
|
||||||
id: "moreInfo",
|
|
||||||
header: ({ column }) => (
|
|
||||||
<DataTableColumnHeader column={column} title="Details" />
|
|
||||||
),
|
|
||||||
cell: ({ row }) => <ResourceDetailsCell row={row} />,
|
|
||||||
enableSorting: false,
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
accessorKey: "resourceName",
|
accessorKey: "resourceName",
|
||||||
header: ({ column }) => (
|
header: ({ column }) => (
|
||||||
<DataTableColumnHeader column={column} title="Resource name" />
|
<DataTableColumnHeader column={column} title="Name" />
|
||||||
|
),
|
||||||
|
cell: ({ row }) => <ResourceNameCell row={row} />,
|
||||||
|
enableSorting: false,
|
||||||
|
},
|
||||||
|
// Provider Account column
|
||||||
|
{
|
||||||
|
accessorKey: "provider",
|
||||||
|
header: ({ column }) => (
|
||||||
|
<DataTableColumnHeader column={column} title="Provider Account" />
|
||||||
),
|
),
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
const resourceName = getResourceData(row, "name");
|
const provider = getProviderData(row, "provider");
|
||||||
const displayName =
|
const alias = getProviderData(row, "alias");
|
||||||
typeof resourceName === "string" && resourceName.trim().length > 0
|
const uid = getProviderData(row, "uid");
|
||||||
? resourceName
|
|
||||||
: "Unnamed resource";
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SnippetChip
|
<EntityInfo
|
||||||
value={displayName}
|
cloudProvider={provider as ProviderType}
|
||||||
className="max-w-[320px]"
|
entityAlias={alias && typeof alias === "string" ? alias : undefined}
|
||||||
icon={<Database size={16} />}
|
entityId={uid && typeof uid === "string" ? uid : undefined}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
enableSorting: false,
|
enableSorting: false,
|
||||||
},
|
},
|
||||||
|
// Failed Findings column
|
||||||
{
|
{
|
||||||
accessorKey: "failedFindings",
|
accessorKey: "failedFindings",
|
||||||
header: ({ column }) => (
|
header: ({ column }) => (
|
||||||
@@ -101,84 +164,94 @@ export const ColumnResources: ColumnDef<ResourceProps>[] = [
|
|||||||
"failed_findings_count",
|
"failed_findings_count",
|
||||||
) as number;
|
) as number;
|
||||||
|
|
||||||
|
return <FailedFindingsBadge count={failedFindingsCount ?? 0} />;
|
||||||
|
},
|
||||||
|
enableSorting: false,
|
||||||
|
},
|
||||||
|
// Group column
|
||||||
|
{
|
||||||
|
accessorKey: "groups",
|
||||||
|
header: ({ column }) => (
|
||||||
|
<DataTableColumnHeader column={column} title="Group" param="groups" />
|
||||||
|
),
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const groups = getResourceData(row, "groups") as string[] | null;
|
||||||
|
|
||||||
|
if (!groups || groups.length === 0) {
|
||||||
|
return <p className="text-text-neutral-primary text-sm">-</p>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const displayLabel = getGroupLabel(groups[0]);
|
||||||
|
const extraCount = groups.length - 1;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<span
|
<div className="flex items-center gap-1">
|
||||||
className={`ml-10 flex h-6 w-6 items-center justify-center rounded-full bg-yellow-100 text-xs font-semibold text-yellow-800 ${getChipStyle(failedFindingsCount)}`}
|
<p className="text-text-neutral-primary max-w-[120px] truncate text-sm">
|
||||||
>
|
{displayLabel}
|
||||||
{failedFindingsCount}
|
</p>
|
||||||
</span>
|
{extraCount > 0 && (
|
||||||
|
<span className="text-text-neutral-secondary text-xs">
|
||||||
|
+{extraCount}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
enableSorting: false,
|
enableSorting: false,
|
||||||
},
|
},
|
||||||
{
|
// Type column
|
||||||
accessorKey: "region",
|
|
||||||
header: ({ column }) => (
|
|
||||||
<DataTableColumnHeader column={column} title={"Region"} param="region" />
|
|
||||||
),
|
|
||||||
cell: ({ row }) => {
|
|
||||||
const region = getResourceData(row, "region");
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="w-[80px] text-xs">
|
|
||||||
{typeof region === "string" ? region : "Invalid region"}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
accessorKey: "type",
|
accessorKey: "type",
|
||||||
header: ({ column }) => (
|
header: ({ column }) => (
|
||||||
<DataTableColumnHeader column={column} title={"Type"} param="type" />
|
<DataTableColumnHeader column={column} title="Type" param="type" />
|
||||||
),
|
),
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
const type = getResourceData(row, "type");
|
const type = getResourceData(row, "type");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="max-w-[150px] text-xs break-words whitespace-nowrap">
|
<p className="text-text-neutral-primary max-w-[150px] truncate text-sm">
|
||||||
{typeof type === "string" ? type : "Invalid type"}
|
{typeof type === "string" ? type : "-"}
|
||||||
</div>
|
</p>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
// Region column
|
||||||
|
{
|
||||||
|
accessorKey: "region",
|
||||||
|
header: ({ column }) => (
|
||||||
|
<DataTableColumnHeader column={column} title="Region" param="region" />
|
||||||
|
),
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const region = getResourceData(row, "region");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<p className="text-text-neutral-primary max-w-[120px] truncate text-sm">
|
||||||
|
{typeof region === "string" ? region : "-"}
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
// Service column
|
||||||
{
|
{
|
||||||
accessorKey: "service",
|
accessorKey: "service",
|
||||||
header: ({ column }) => (
|
header: ({ column }) => (
|
||||||
<DataTableColumnHeader
|
<DataTableColumnHeader column={column} title="Service" param="service" />
|
||||||
column={column}
|
|
||||||
title={"Service"}
|
|
||||||
param="service"
|
|
||||||
/>
|
|
||||||
),
|
),
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
const service = getResourceData(row, "service");
|
const service = getResourceData(row, "service");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="max-w-96 truncate text-xs">
|
<p className="text-text-neutral-primary max-w-[150px] truncate text-sm">
|
||||||
{typeof service === "string" ? service : "Invalid region"}
|
{typeof service === "string" ? service : "-"}
|
||||||
</div>
|
</p>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
// Actions column
|
||||||
{
|
{
|
||||||
accessorKey: "provider",
|
id: "actions",
|
||||||
header: ({ column }) => (
|
header: () => <div className="w-10" />,
|
||||||
<DataTableColumnHeader column={column} title="Cloud Provider" />
|
cell: ({ row }) => <ResourceRowActions row={row} />,
|
||||||
),
|
|
||||||
cell: ({ row }) => {
|
|
||||||
const provider = getProviderData(row, "provider");
|
|
||||||
const alias = getProviderData(row, "alias");
|
|
||||||
const uid = getProviderData(row, "uid");
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<EntityInfo
|
|
||||||
cloudProvider={provider as ProviderType}
|
|
||||||
entityAlias={alias && typeof alias === "string" ? alias : undefined}
|
|
||||||
entityId={uid && typeof uid === "string" ? uid : undefined}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
enableSorting: false,
|
enableSorting: false,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
export * from "../skeleton/skeleton-table-resources";
|
export * from "../skeleton/skeleton-table-resources";
|
||||||
export * from "./column-resources";
|
export * from "./column-resources";
|
||||||
export * from "./resource-detail";
|
export * from "./resource-detail";
|
||||||
|
export * from "./resource-findings-columns";
|
||||||
|
export * from "./resources-table-with-selection";
|
||||||
|
|||||||
@@ -1,51 +1,53 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { Snippet } from "@heroui/snippet";
|
import { Row, RowSelectionState } from "@tanstack/react-table";
|
||||||
import { Spinner } from "@heroui/spinner";
|
import { Check, Copy, ExternalLink, Link, Loader2, X } from "lucide-react";
|
||||||
import { Tooltip } from "@heroui/tooltip";
|
import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
||||||
import { ExternalLink, InfoIcon } from "lucide-react";
|
import type { ReactNode } from "react";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
|
||||||
import { getFindingById } from "@/actions/findings";
|
import { getFindingById, getLatestFindings } from "@/actions/findings";
|
||||||
import { getResourceById } from "@/actions/resources";
|
import { getResourceById } from "@/actions/resources";
|
||||||
|
import { FloatingMuteButton } from "@/components/findings/floating-mute-button";
|
||||||
import { FindingDetail } from "@/components/findings/table/finding-detail";
|
import { FindingDetail } from "@/components/findings/table/finding-detail";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/shadcn";
|
import {
|
||||||
|
Drawer,
|
||||||
|
DrawerClose,
|
||||||
|
DrawerContent,
|
||||||
|
DrawerDescription,
|
||||||
|
DrawerHeader,
|
||||||
|
DrawerTitle,
|
||||||
|
DrawerTrigger,
|
||||||
|
Tabs,
|
||||||
|
TabsContent,
|
||||||
|
TabsList,
|
||||||
|
TabsTrigger,
|
||||||
|
Tooltip,
|
||||||
|
TooltipContent,
|
||||||
|
TooltipTrigger,
|
||||||
|
} from "@/components/shadcn";
|
||||||
import { BreadcrumbNavigation, CustomBreadcrumbItem } from "@/components/ui";
|
import { BreadcrumbNavigation, CustomBreadcrumbItem } from "@/components/ui";
|
||||||
|
import { CodeSnippet } from "@/components/ui/code-snippet/code-snippet";
|
||||||
import {
|
import {
|
||||||
DateWithTime,
|
DateWithTime,
|
||||||
getProviderLogo,
|
getProviderLogo,
|
||||||
InfoField,
|
InfoField,
|
||||||
} from "@/components/ui/entities";
|
} from "@/components/ui/entities";
|
||||||
import { SeverityBadge, StatusFindingBadge } from "@/components/ui/table";
|
import { DataTable } from "@/components/ui/table";
|
||||||
import { createDict } from "@/lib";
|
import { createDict } from "@/lib";
|
||||||
|
import { getGroupLabel } from "@/lib/categories";
|
||||||
import { buildGitFileUrl } from "@/lib/iac-utils";
|
import { buildGitFileUrl } from "@/lib/iac-utils";
|
||||||
import { FindingProps, ProviderType, ResourceProps } from "@/types";
|
import {
|
||||||
|
FindingProps,
|
||||||
|
MetaDataProps,
|
||||||
|
ProviderType,
|
||||||
|
ResourceProps,
|
||||||
|
} from "@/types";
|
||||||
|
|
||||||
const SEVERITY_ORDER = {
|
import {
|
||||||
critical: 0,
|
getResourceFindingsColumns,
|
||||||
high: 1,
|
ResourceFinding,
|
||||||
medium: 2,
|
} from "./resource-findings-columns";
|
||||||
low: 3,
|
|
||||||
informational: 4,
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
type SeverityLevel = keyof typeof SEVERITY_ORDER;
|
|
||||||
|
|
||||||
interface ResourceFinding {
|
|
||||||
type: "findings";
|
|
||||||
id: string;
|
|
||||||
attributes: {
|
|
||||||
status: "PASS" | "FAIL" | "MANUAL";
|
|
||||||
severity: SeverityLevel;
|
|
||||||
check_metadata?: {
|
|
||||||
checktitle?: string;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
interface FindingReference {
|
|
||||||
id: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const renderValue = (value: string | null | undefined) => {
|
const renderValue = (value: string | null | undefined) => {
|
||||||
return value && value.trim() !== "" ? value : "-";
|
return value && value.trim() !== "" ? value : "-";
|
||||||
@@ -97,133 +99,264 @@ const buildCustomBreadcrumbs = (
|
|||||||
return breadcrumbs;
|
return breadcrumbs;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
interface ResourceDetailProps {
|
||||||
|
resourceDetails: ResourceProps;
|
||||||
|
trigger?: ReactNode;
|
||||||
|
open?: boolean;
|
||||||
|
defaultOpen?: boolean;
|
||||||
|
onOpenChange?: (open: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
export const ResourceDetail = ({
|
export const ResourceDetail = ({
|
||||||
resourceId,
|
resourceDetails,
|
||||||
initialResourceData,
|
trigger,
|
||||||
}: {
|
open: controlledOpen,
|
||||||
resourceId: string;
|
defaultOpen = false,
|
||||||
initialResourceData: ResourceProps;
|
onOpenChange,
|
||||||
}) => {
|
}: ResourceDetailProps) => {
|
||||||
const [findingsData, setFindingsData] = useState<ResourceFinding[]>([]);
|
const [findingsData, setFindingsData] = useState<ResourceFinding[]>([]);
|
||||||
|
const [findingsMetadata, setFindingsMetadata] =
|
||||||
|
useState<MetaDataProps | null>(null);
|
||||||
const [resourceTags, setResourceTags] = useState<Record<string, string>>({});
|
const [resourceTags, setResourceTags] = useState<Record<string, string>>({});
|
||||||
const [findingsLoading, setFindingsLoading] = useState(true);
|
const [findingsLoading, setFindingsLoading] = useState(true);
|
||||||
|
const [hasInitiallyLoaded, setHasInitiallyLoaded] = useState(false);
|
||||||
|
const [findingsReloadNonce, setFindingsReloadNonce] = useState(0);
|
||||||
const [selectedFindingId, setSelectedFindingId] = useState<string | null>(
|
const [selectedFindingId, setSelectedFindingId] = useState<string | null>(
|
||||||
null,
|
null,
|
||||||
);
|
);
|
||||||
const [findingDetails, setFindingDetails] = useState<FindingProps | null>(
|
const [findingDetails, setFindingDetails] = useState<FindingProps | null>(
|
||||||
null,
|
null,
|
||||||
);
|
);
|
||||||
|
const [findingDetailLoading, setFindingDetailLoading] = useState(false);
|
||||||
|
const [rowSelection, setRowSelection] = useState<RowSelectionState>({});
|
||||||
|
const [activeTab, setActiveTab] = useState("overview");
|
||||||
|
const [metadataCopied, setMetadataCopied] = useState(false);
|
||||||
|
// Track internal open state for uncontrolled drawer (when using trigger)
|
||||||
|
const [internalOpen, setInternalOpen] = useState(defaultOpen);
|
||||||
|
// Drawer-local pagination and search state (not in URL to avoid page re-renders)
|
||||||
|
const [currentPage, setCurrentPage] = useState(1);
|
||||||
|
const [pageSize, setPageSize] = useState(10);
|
||||||
|
const [searchQuery, setSearchQuery] = useState("");
|
||||||
|
const findingFetchRef = useRef<AbortController | null>(null);
|
||||||
|
const router = useRouter();
|
||||||
|
const pathname = usePathname();
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
|
||||||
|
// Cleanup abort controller on unmount
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
findingFetchRef.current?.abort();
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Determine if drawer is actually open:
|
||||||
|
// - If controlled (open prop provided), use that
|
||||||
|
// - If uncontrolled (using trigger), use internal state
|
||||||
|
// - If no trigger (inline mode), always consider it "open"
|
||||||
|
const isDrawerOpen = !trigger || (controlledOpen ?? internalOpen);
|
||||||
|
|
||||||
|
// Handle open state changes for uncontrolled mode
|
||||||
|
const handleOpenChange = (newOpen: boolean) => {
|
||||||
|
setInternalOpen(newOpen);
|
||||||
|
onOpenChange?.(newOpen);
|
||||||
|
|
||||||
|
// Reset all drawer state when closing
|
||||||
|
if (!newOpen) {
|
||||||
|
findingFetchRef.current?.abort();
|
||||||
|
setActiveTab("overview");
|
||||||
|
setSelectedFindingId(null);
|
||||||
|
setFindingDetails(null);
|
||||||
|
setFindingDetailLoading(false);
|
||||||
|
setRowSelection({});
|
||||||
|
setCurrentPage(1);
|
||||||
|
setPageSize(10);
|
||||||
|
setSearchQuery("");
|
||||||
|
setHasInitiallyLoaded(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const resource = resourceDetails;
|
||||||
|
const resourceId = resource.id;
|
||||||
|
const attributes = resource.attributes;
|
||||||
|
const providerData = resource.relationships.provider.data.attributes;
|
||||||
|
|
||||||
|
const copyResourceUrl = () => {
|
||||||
|
const params = new URLSearchParams(searchParams.toString());
|
||||||
|
params.set("resourceId", resourceId);
|
||||||
|
const url = `${window.location.origin}${pathname}?${params.toString()}`;
|
||||||
|
navigator.clipboard.writeText(url);
|
||||||
|
};
|
||||||
|
|
||||||
|
const copyMetadata = async (metadata: Record<string, unknown>) => {
|
||||||
|
await navigator.clipboard.writeText(JSON.stringify(metadata, null, 2));
|
||||||
|
setMetadataCopied(true);
|
||||||
|
setTimeout(() => setMetadataCopied(false), 2000);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Load resource tags (separate from findings) - only when drawer is open
|
||||||
|
useEffect(() => {
|
||||||
|
const loadResourceTags = async () => {
|
||||||
|
try {
|
||||||
|
const resourceData = await getResourceById(resourceId, {
|
||||||
|
fields: ["tags"],
|
||||||
|
});
|
||||||
|
if (resourceData?.data) {
|
||||||
|
setResourceTags(resourceData.data.attributes.tags || {});
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Error loading resource tags:", err);
|
||||||
|
setResourceTags({});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (resourceId && isDrawerOpen) {
|
||||||
|
loadResourceTags();
|
||||||
|
}
|
||||||
|
}, [resourceId, isDrawerOpen]);
|
||||||
|
|
||||||
|
// Load findings with server-side pagination and search - only when drawer is open
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const loadFindings = async () => {
|
const loadFindings = async () => {
|
||||||
setFindingsLoading(true);
|
setFindingsLoading(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const resourceData = await getResourceById(resourceId, {
|
const findingsResponse = await getLatestFindings({
|
||||||
include: ["findings"],
|
page: currentPage,
|
||||||
fields: ["tags", "findings"],
|
pageSize,
|
||||||
|
query: searchQuery,
|
||||||
|
sort: "severity,-inserted_at",
|
||||||
|
filters: {
|
||||||
|
"filter[resource_uid]": attributes.uid,
|
||||||
|
"filter[status]": "FAIL",
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (resourceData?.data) {
|
if (findingsResponse?.data) {
|
||||||
// Get tags from the detailed resource data
|
setFindingsMetadata(findingsResponse.meta || null);
|
||||||
setResourceTags(resourceData.data.attributes.tags || {});
|
setFindingsData(findingsResponse.data as ResourceFinding[]);
|
||||||
|
|
||||||
// Create dictionary for findings and expand them
|
|
||||||
if (resourceData.data.relationships?.findings) {
|
|
||||||
const findingsDict = createDict("findings", resourceData);
|
|
||||||
const findings =
|
|
||||||
resourceData.data.relationships.findings.data?.map(
|
|
||||||
(finding: FindingReference) => findingsDict[finding.id],
|
|
||||||
) || [];
|
|
||||||
setFindingsData(findings as ResourceFinding[]);
|
|
||||||
} else {
|
|
||||||
setFindingsData([]);
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
setFindingsData([]);
|
setFindingsData([]);
|
||||||
setResourceTags({});
|
setFindingsMetadata(null);
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Error loading findings:", err);
|
console.error("Error loading findings:", err);
|
||||||
setFindingsData([]);
|
setFindingsData([]);
|
||||||
setResourceTags({});
|
setFindingsMetadata(null);
|
||||||
} finally {
|
} finally {
|
||||||
setFindingsLoading(false);
|
setFindingsLoading(false);
|
||||||
|
setHasInitiallyLoaded(true);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if (resourceId) {
|
if (attributes.uid && isDrawerOpen) {
|
||||||
loadFindings();
|
loadFindings();
|
||||||
}
|
}
|
||||||
}, [resourceId]);
|
}, [
|
||||||
|
attributes.uid,
|
||||||
|
currentPage,
|
||||||
|
pageSize,
|
||||||
|
searchQuery,
|
||||||
|
isDrawerOpen,
|
||||||
|
findingsReloadNonce,
|
||||||
|
]);
|
||||||
|
|
||||||
const navigateToFinding = async (findingId: string) => {
|
const navigateToFinding = async (findingId: string) => {
|
||||||
|
// Cancel any in-flight request
|
||||||
|
if (findingFetchRef.current) {
|
||||||
|
findingFetchRef.current.abort();
|
||||||
|
}
|
||||||
|
findingFetchRef.current = new AbortController();
|
||||||
|
|
||||||
setSelectedFindingId(findingId);
|
setSelectedFindingId(findingId);
|
||||||
|
setFindingDetailLoading(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const findingData = await getFindingById(
|
const findingData = await getFindingById(
|
||||||
findingId,
|
findingId,
|
||||||
"resources,scan.provider",
|
"resources,scan.provider",
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Check if request was aborted
|
||||||
|
if (findingFetchRef.current?.signal.aborted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (findingData?.data) {
|
if (findingData?.data) {
|
||||||
// Create dictionaries for resources, scans, and providers
|
|
||||||
const resourceDict = createDict("resources", findingData);
|
const resourceDict = createDict("resources", findingData);
|
||||||
const scanDict = createDict("scans", findingData);
|
const scanDict = createDict("scans", findingData);
|
||||||
const providerDict = createDict("providers", findingData);
|
const providerDict = createDict("providers", findingData);
|
||||||
|
|
||||||
// Expand the finding with its corresponding resource, scan, and provider
|
|
||||||
const finding = findingData.data;
|
const finding = findingData.data;
|
||||||
const scan = scanDict[finding.relationships?.scan?.data?.id];
|
const scan = scanDict[finding.relationships?.scan?.data?.id];
|
||||||
const resource =
|
const foundResource =
|
||||||
resourceDict[finding.relationships?.resources?.data?.[0]?.id];
|
resourceDict[finding.relationships?.resources?.data?.[0]?.id];
|
||||||
const provider = providerDict[scan?.relationships?.provider?.data?.id];
|
const provider = providerDict[scan?.relationships?.provider?.data?.id];
|
||||||
|
|
||||||
const expandedFinding = {
|
const expandedFinding = {
|
||||||
...finding,
|
...finding,
|
||||||
relationships: { scan, resource, provider },
|
relationships: { scan, resource: foundResource, provider },
|
||||||
};
|
};
|
||||||
|
|
||||||
setFindingDetails(expandedFinding);
|
setFindingDetails(expandedFinding);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
// Ignore abort errors
|
||||||
|
if (error instanceof Error && error.name === "AbortError") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
console.error("Error fetching finding:", error);
|
console.error("Error fetching finding:", error);
|
||||||
|
} finally {
|
||||||
|
// Only update loading state if this request wasn't aborted
|
||||||
|
if (!findingFetchRef.current?.signal.aborted) {
|
||||||
|
setFindingDetailLoading(false);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleBackToResource = () => {
|
const handleBackToResource = () => {
|
||||||
setSelectedFindingId(null);
|
setSelectedFindingId(null);
|
||||||
setFindingDetails(null);
|
setFindingDetails(null);
|
||||||
|
setFindingDetailLoading(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!initialResourceData) {
|
const handleMuteComplete = (_findingIds?: string[]) => {
|
||||||
return (
|
const ids =
|
||||||
<div className="flex min-h-96 flex-col items-center justify-center gap-4 rounded-lg p-8">
|
_findingIds && _findingIds.length > 0 ? _findingIds : selectedFindingIds;
|
||||||
<Spinner size="lg" />
|
|
||||||
<p className="dark:text-prowler-theme-pale/80 text-sm text-gray-600">
|
|
||||||
Loading resource details...
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const resource = initialResourceData;
|
setRowSelection({});
|
||||||
const attributes = resource.attributes;
|
if (ids.length > 0) setFindingsReloadNonce((v) => v + 1);
|
||||||
const providerData = resource.relationships.provider.data.attributes;
|
router.refresh();
|
||||||
|
};
|
||||||
|
|
||||||
// Filter only failed findings and sort by severity
|
// Findings are already filtered (FAIL only) and sorted by the server
|
||||||
const failedFindings = findingsData
|
const failedFindings = findingsData;
|
||||||
.filter(
|
|
||||||
(finding: ResourceFinding) => finding?.attributes?.status === "FAIL",
|
const selectableRowCount = failedFindings.filter(
|
||||||
)
|
(f) => !f.attributes.muted,
|
||||||
.sort((a: ResourceFinding, b: ResourceFinding) => {
|
).length;
|
||||||
const severityA = (a?.attributes?.severity?.toLowerCase() ||
|
|
||||||
"informational") as SeverityLevel;
|
// Reset selection when page changes
|
||||||
const severityB = (b?.attributes?.severity?.toLowerCase() ||
|
useEffect(() => {
|
||||||
"informational") as SeverityLevel;
|
setRowSelection({});
|
||||||
return (
|
}, [currentPage, pageSize]);
|
||||||
(SEVERITY_ORDER[severityA] ?? 999) - (SEVERITY_ORDER[severityB] ?? 999)
|
|
||||||
);
|
// Calculate total findings from metadata for tab title
|
||||||
});
|
const totalFindings = findingsMetadata?.pagination?.count || 0;
|
||||||
|
|
||||||
|
const getRowCanSelect = (row: Row<ResourceFinding>): boolean =>
|
||||||
|
!row.original.attributes.muted;
|
||||||
|
|
||||||
|
const selectedFindingIds = Object.keys(rowSelection)
|
||||||
|
.filter((key) => rowSelection[key])
|
||||||
|
.map((idx) => failedFindings[parseInt(idx)]?.id)
|
||||||
|
.filter(Boolean);
|
||||||
|
|
||||||
|
const columns = getResourceFindingsColumns(
|
||||||
|
rowSelection,
|
||||||
|
selectableRowCount,
|
||||||
|
navigateToFinding,
|
||||||
|
handleMuteComplete,
|
||||||
|
);
|
||||||
|
|
||||||
// Build Git URL for IaC resources
|
// Build Git URL for IaC resources
|
||||||
const gitUrl =
|
const gitUrl =
|
||||||
@@ -236,86 +369,134 @@ export const ResourceDetail = ({
|
|||||||
)
|
)
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
if (selectedFindingId) {
|
// Content when viewing a finding detail (breadcrumb navigation)
|
||||||
const findingTitle =
|
const findingTitle =
|
||||||
findingDetails?.attributes?.check_metadata?.checktitle ||
|
findingDetails?.attributes?.check_metadata?.checktitle || "Finding Detail";
|
||||||
"Finding Detail";
|
|
||||||
|
|
||||||
return (
|
const findingContent = (
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
<BreadcrumbNavigation
|
<BreadcrumbNavigation
|
||||||
mode="custom"
|
mode="custom"
|
||||||
customItems={buildCustomBreadcrumbs(
|
customItems={buildCustomBreadcrumbs(
|
||||||
attributes.name,
|
attributes.name,
|
||||||
findingTitle,
|
findingDetailLoading ? "Loading..." : findingTitle,
|
||||||
handleBackToResource,
|
handleBackToResource,
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{findingDetails && <FindingDetail findingDetails={findingDetails} />}
|
{findingDetailLoading ? (
|
||||||
</div>
|
<div className="flex items-center justify-center gap-2 py-8">
|
||||||
);
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
}
|
<p className="text-text-neutral-secondary text-sm">
|
||||||
|
Loading finding details...
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
findingDetails && <FindingDetail findingDetails={findingDetails} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
// Main resource content
|
||||||
<div className="flex flex-col gap-6 rounded-lg">
|
const resourceContent = (
|
||||||
{/* Resource Details section */}
|
<div className="flex min-w-0 flex-col gap-4 rounded-lg">
|
||||||
<Card variant="base" padding="lg">
|
{/* Header */}
|
||||||
<CardHeader className="flex flex-row items-center justify-between gap-2">
|
<div className="flex items-center gap-4">
|
||||||
<div className="flex flex-row items-center justify-start gap-2">
|
{/* Provider logo */}
|
||||||
<CardTitle>Resource Details</CardTitle>
|
<div className="shrink-0">
|
||||||
{providerData.provider === "iac" && gitUrl && (
|
{getProviderLogo(providerData.provider as ProviderType)}
|
||||||
<Tooltip content="Go to Resource in the Repository" size="sm">
|
</div>
|
||||||
<a
|
|
||||||
href={gitUrl}
|
{/* Details column */}
|
||||||
target="_blank"
|
<div className="flex min-w-0 flex-col gap-1">
|
||||||
rel="noopener noreferrer"
|
{/* Title with copy link and optional IaC link */}
|
||||||
className="text-bg-data-info mt-1 inline-flex cursor-pointer"
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
aria-label="Open resource in repository"
|
<h2 className="text-text-neutral-primary line-clamp-2 text-lg leading-tight font-medium">
|
||||||
|
{renderValue(attributes.name)}
|
||||||
|
</h2>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<button
|
||||||
|
onClick={copyResourceUrl}
|
||||||
|
className="text-bg-data-info inline-flex cursor-pointer transition-opacity hover:opacity-80"
|
||||||
|
aria-label="Copy resource link to clipboard"
|
||||||
>
|
>
|
||||||
<ExternalLink size={16} className="inline" />
|
<Link size={16} />
|
||||||
</a>
|
</button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>Copy resource link to clipboard</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
{providerData.provider === "iac" && gitUrl && (
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<a
|
||||||
|
href={gitUrl}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="text-bg-data-info inline-flex items-center gap-1 text-sm"
|
||||||
|
aria-label="Open resource in repository"
|
||||||
|
>
|
||||||
|
<ExternalLink size={16} />
|
||||||
|
View in Repository
|
||||||
|
</a>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
Go to Resource in the Repository
|
||||||
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{getProviderLogo(providerData.provider as ProviderType)}
|
|
||||||
</CardHeader>
|
{/* Last Updated */}
|
||||||
<CardContent className="flex flex-col gap-4">
|
<div className="text-text-neutral-tertiary text-sm">
|
||||||
|
<span className="text-text-neutral-secondary mr-1">
|
||||||
|
Last Updated:
|
||||||
|
</span>
|
||||||
|
<DateWithTime inline dateTime={attributes.updated_at || "-"} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tabs */}
|
||||||
|
<Tabs value={activeTab} onValueChange={setActiveTab} className="w-full">
|
||||||
|
<TabsList className="mb-4">
|
||||||
|
<TabsTrigger value="overview">Overview</TabsTrigger>
|
||||||
|
<TabsTrigger value="findings">
|
||||||
|
Findings {totalFindings > 0 && `(${totalFindings})`}
|
||||||
|
</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
|
||||||
|
{/* Overview Tab */}
|
||||||
|
<TabsContent value="overview" className="flex flex-col gap-4">
|
||||||
<InfoField label="Resource UID" variant="simple">
|
<InfoField label="Resource UID" variant="simple">
|
||||||
<Snippet
|
<CodeSnippet value={attributes.uid} className="max-w-full" />
|
||||||
className="border-border-neutral-tertiary bg-bg-neutral-tertiary rounded-lg border py-1"
|
|
||||||
hideSymbol
|
|
||||||
>
|
|
||||||
<span className="text-xs whitespace-pre-line">
|
|
||||||
{renderValue(attributes.uid)}
|
|
||||||
</span>
|
|
||||||
</Snippet>
|
|
||||||
</InfoField>
|
</InfoField>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||||
<InfoField label="Resource Name">
|
<InfoField label="Name">{renderValue(attributes.name)}</InfoField>
|
||||||
{renderValue(attributes.name)}
|
<InfoField label="Type">{renderValue(attributes.type)}</InfoField>
|
||||||
</InfoField>
|
|
||||||
<InfoField label="Resource Type">
|
|
||||||
{renderValue(attributes.type)}
|
|
||||||
</InfoField>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||||
|
<InfoField label="Group">
|
||||||
|
{attributes.groups && attributes.groups.length > 0
|
||||||
|
? attributes.groups.map(getGroupLabel).join(", ")
|
||||||
|
: "-"}
|
||||||
|
</InfoField>
|
||||||
<InfoField label="Service">
|
<InfoField label="Service">
|
||||||
{renderValue(attributes.service)}
|
{renderValue(attributes.service)}
|
||||||
</InfoField>
|
</InfoField>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||||
<InfoField label="Region">
|
<InfoField label="Region">
|
||||||
{renderValue(attributes.region)}
|
{renderValue(attributes.region)}
|
||||||
</InfoField>
|
</InfoField>
|
||||||
</div>
|
|
||||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
|
||||||
<InfoField label="Partition">
|
<InfoField label="Partition">
|
||||||
{renderValue(attributes.partition)}
|
{renderValue(attributes.partition)}
|
||||||
</InfoField>
|
</InfoField>
|
||||||
<InfoField label="Details">
|
|
||||||
{renderValue(attributes.details)}
|
|
||||||
</InfoField>
|
|
||||||
</div>
|
</div>
|
||||||
|
<InfoField label="Details" variant="simple">
|
||||||
|
{renderValue(attributes.details)}
|
||||||
|
</InfoField>
|
||||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||||
<InfoField label="Created At">
|
<InfoField label="Created At">
|
||||||
<DateWithTime inline dateTime={attributes.inserted_at} />
|
<DateWithTime inline dateTime={attributes.inserted_at} />
|
||||||
@@ -331,16 +512,19 @@ export const ResourceDetail = ({
|
|||||||
Object.entries(parsedMetadata).length > 0 ? (
|
Object.entries(parsedMetadata).length > 0 ? (
|
||||||
<InfoField label="Metadata" variant="simple">
|
<InfoField label="Metadata" variant="simple">
|
||||||
<div className="border-border-neutral-tertiary bg-bg-neutral-tertiary relative w-full rounded-lg border">
|
<div className="border-border-neutral-tertiary bg-bg-neutral-tertiary relative w-full rounded-lg border">
|
||||||
<Snippet
|
<button
|
||||||
className="absolute top-2 right-2 z-10 bg-transparent"
|
type="button"
|
||||||
classNames={{
|
onClick={() => copyMetadata(parsedMetadata)}
|
||||||
base: "bg-transparent p-0 min-w-0",
|
className="text-text-neutral-secondary hover:text-text-neutral-primary absolute top-2 right-2 z-10 cursor-pointer transition-colors"
|
||||||
pre: "hidden",
|
aria-label="Copy metadata to clipboard"
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{JSON.stringify(parsedMetadata, null, 2)}
|
{metadataCopied ? (
|
||||||
</Snippet>
|
<Check className="h-4 w-4" />
|
||||||
<pre className="minimal-scrollbar mr-10 max-h-[100px] overflow-auto p-3 text-xs break-words whitespace-pre-wrap">
|
) : (
|
||||||
|
<Copy className="h-4 w-4" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
<pre className="minimal-scrollbar mr-10 max-h-[200px] overflow-auto p-3 text-xs break-words whitespace-pre-wrap">
|
||||||
{JSON.stringify(parsedMetadata, null, 2)}
|
{JSON.stringify(parsedMetadata, null, 2)}
|
||||||
</pre>
|
</pre>
|
||||||
</div>
|
</div>
|
||||||
@@ -350,7 +534,7 @@ export const ResourceDetail = ({
|
|||||||
|
|
||||||
{resourceTags && Object.entries(resourceTags).length > 0 ? (
|
{resourceTags && Object.entries(resourceTags).length > 0 ? (
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
<h4 className="text-sm font-bold text-gray-500 dark:text-gray-400">
|
<h4 className="text-text-neutral-secondary text-sm font-bold">
|
||||||
Tags
|
Tags
|
||||||
</h4>
|
</h4>
|
||||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||||
@@ -362,79 +546,82 @@ export const ResourceDetail = ({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</CardContent>
|
</TabsContent>
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Failed findings associated with this resource section */}
|
{/* Findings Tab */}
|
||||||
<Card variant="base" padding="lg">
|
<TabsContent value="findings" className="flex flex-col gap-4">
|
||||||
<CardHeader>
|
{findingsLoading && !hasInitiallyLoaded ? (
|
||||||
<CardTitle>Failed findings associated with this resource</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
{findingsLoading ? (
|
|
||||||
<div className="flex items-center justify-center gap-2 py-8">
|
<div className="flex items-center justify-center gap-2 py-8">
|
||||||
<Spinner size="sm" />
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
<p className="dark:text-prowler-theme-pale/80 text-sm text-gray-600">
|
<p className="text-text-neutral-secondary text-sm">
|
||||||
Loading findings...
|
Loading findings...
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
) : failedFindings.length > 0 ? (
|
|
||||||
<div className="flex flex-col gap-4">
|
|
||||||
<p className="dark:text-prowler-theme-pale/80 text-sm text-gray-600">
|
|
||||||
Total failed findings: {failedFindings.length}
|
|
||||||
</p>
|
|
||||||
{failedFindings.map((finding: ResourceFinding, index: number) => {
|
|
||||||
const { attributes: findingAttrs, id } = finding;
|
|
||||||
|
|
||||||
// Handle cases where finding might not have all attributes
|
|
||||||
if (!findingAttrs) {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={index}
|
|
||||||
className="shadow-small dark:bg-prowler-blue-400 flex flex-col gap-2 rounded-lg px-4 py-2"
|
|
||||||
>
|
|
||||||
<p className="text-sm text-red-600">
|
|
||||||
Finding {id} - No attributes available
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const { severity, check_metadata, status } = findingAttrs;
|
|
||||||
const checktitle =
|
|
||||||
check_metadata?.checktitle || "Unknown check";
|
|
||||||
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={index}
|
|
||||||
onClick={() => navigateToFinding(id)}
|
|
||||||
className="shadow-small border-border-neutral-tertiary bg-bg-neutral-tertiary flex w-full cursor-pointer flex-col gap-2 rounded-lg px-4 py-2"
|
|
||||||
>
|
|
||||||
<div className="flex items-center justify-between gap-2">
|
|
||||||
<h3 className="dark:text-prowler-theme-pale/90 text-left text-sm font-medium text-gray-800">
|
|
||||||
{checktitle}
|
|
||||||
</h3>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<SeverityBadge severity={severity || "-"} />
|
|
||||||
<StatusFindingBadge status={status || "-"} />
|
|
||||||
<InfoIcon
|
|
||||||
className="text-button-primary cursor-pointer"
|
|
||||||
size={16}
|
|
||||||
onClick={() => navigateToFinding(id)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
) : (
|
) : (
|
||||||
<p className="dark:text-prowler-theme-pale/80 text-gray-600">
|
<>
|
||||||
No failed findings found for this resource.
|
<DataTable
|
||||||
</p>
|
columns={columns}
|
||||||
|
data={failedFindings}
|
||||||
|
metadata={findingsMetadata ?? undefined}
|
||||||
|
showSearch
|
||||||
|
disableScroll
|
||||||
|
enableRowSelection
|
||||||
|
rowSelection={rowSelection}
|
||||||
|
onRowSelectionChange={setRowSelection}
|
||||||
|
getRowCanSelect={getRowCanSelect}
|
||||||
|
controlledSearch={searchQuery}
|
||||||
|
onSearchChange={(value) => {
|
||||||
|
setSearchQuery(value);
|
||||||
|
setCurrentPage(1); // Reset to first page on search
|
||||||
|
}}
|
||||||
|
controlledPage={currentPage}
|
||||||
|
controlledPageSize={pageSize}
|
||||||
|
onPageChange={setCurrentPage}
|
||||||
|
onPageSizeChange={setPageSize}
|
||||||
|
isLoading={findingsLoading}
|
||||||
|
/>
|
||||||
|
{selectedFindingIds.length > 0 && (
|
||||||
|
<FloatingMuteButton
|
||||||
|
selectedCount={selectedFindingIds.length}
|
||||||
|
selectedFindingIds={selectedFindingIds}
|
||||||
|
onComplete={handleMuteComplete}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</TabsContent>
|
||||||
</Card>
|
</Tabs>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Determine which content to show
|
||||||
|
const content = selectedFindingId ? findingContent : resourceContent;
|
||||||
|
|
||||||
|
// If no trigger, render content directly (inline mode)
|
||||||
|
if (!trigger) {
|
||||||
|
return content;
|
||||||
|
}
|
||||||
|
|
||||||
|
// With trigger, wrap in Drawer
|
||||||
|
return (
|
||||||
|
<Drawer
|
||||||
|
direction="right"
|
||||||
|
open={controlledOpen ?? internalOpen}
|
||||||
|
defaultOpen={defaultOpen}
|
||||||
|
onOpenChange={handleOpenChange}
|
||||||
|
>
|
||||||
|
<DrawerTrigger asChild>{trigger}</DrawerTrigger>
|
||||||
|
<DrawerContent className="minimal-scrollbar 3xl:w-1/3 h-full w-full overflow-x-hidden overflow-y-auto p-6 outline-none md:w-1/2 md:max-w-none">
|
||||||
|
<DrawerHeader className="sr-only">
|
||||||
|
<DrawerTitle>Resource Details</DrawerTitle>
|
||||||
|
<DrawerDescription>View the resource details</DrawerDescription>
|
||||||
|
</DrawerHeader>
|
||||||
|
<DrawerClose className="ring-offset-background focus:ring-ring absolute top-4 right-4 rounded-sm opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-none">
|
||||||
|
<X className="size-4" />
|
||||||
|
<span className="sr-only">Close</span>
|
||||||
|
</DrawerClose>
|
||||||
|
{content}
|
||||||
|
</DrawerContent>
|
||||||
|
</Drawer>
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,146 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { ColumnDef, RowSelectionState } from "@tanstack/react-table";
|
||||||
|
|
||||||
|
import { DataTableRowActions } from "@/components/findings/table";
|
||||||
|
import {
|
||||||
|
DeltaType,
|
||||||
|
NotificationIndicator,
|
||||||
|
} from "@/components/findings/table/notification-indicator";
|
||||||
|
import { Checkbox } from "@/components/shadcn";
|
||||||
|
import { DateWithTime } from "@/components/ui/entities";
|
||||||
|
import {
|
||||||
|
DataTableColumnHeader,
|
||||||
|
Severity,
|
||||||
|
SeverityBadge,
|
||||||
|
StatusFindingBadge,
|
||||||
|
} from "@/components/ui/table";
|
||||||
|
|
||||||
|
export interface ResourceFinding {
|
||||||
|
type: "findings";
|
||||||
|
id: string;
|
||||||
|
attributes: {
|
||||||
|
status: "PASS" | "FAIL" | "MANUAL";
|
||||||
|
severity: Severity;
|
||||||
|
muted?: boolean;
|
||||||
|
muted_reason?: string;
|
||||||
|
delta?: DeltaType;
|
||||||
|
updated_at?: string;
|
||||||
|
check_metadata?: {
|
||||||
|
checktitle?: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getResourceFindingsColumns = (
|
||||||
|
rowSelection: RowSelectionState,
|
||||||
|
selectableRowCount: number,
|
||||||
|
onNavigate: (id: string) => void,
|
||||||
|
onMuteComplete?: (findingIds: string[]) => void,
|
||||||
|
): ColumnDef<ResourceFinding>[] => {
|
||||||
|
const selectedCount = Object.values(rowSelection).filter(Boolean).length;
|
||||||
|
const isAllSelected =
|
||||||
|
selectedCount > 0 && selectedCount === selectableRowCount;
|
||||||
|
const isSomeSelected =
|
||||||
|
selectedCount > 0 && selectedCount < selectableRowCount;
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
id: "notification",
|
||||||
|
header: () => null,
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<div className="flex items-center justify-center pr-4">
|
||||||
|
<NotificationIndicator
|
||||||
|
delta={row.original.attributes.delta}
|
||||||
|
isMuted={row.original.attributes.muted}
|
||||||
|
mutedReason={row.original.attributes.muted_reason}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
enableSorting: false,
|
||||||
|
enableHiding: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "select",
|
||||||
|
header: ({ table }) => (
|
||||||
|
<div className="ml-1 flex w-6 items-center justify-center pr-4">
|
||||||
|
<Checkbox
|
||||||
|
checked={
|
||||||
|
isAllSelected ? true : isSomeSelected ? "indeterminate" : false
|
||||||
|
}
|
||||||
|
onCheckedChange={(checked) =>
|
||||||
|
table.toggleAllPageRowsSelected(checked === true)
|
||||||
|
}
|
||||||
|
aria-label="Select all"
|
||||||
|
disabled={selectableRowCount === 0}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<div className="ml-1 flex w-6 items-center justify-center pr-4">
|
||||||
|
<Checkbox
|
||||||
|
checked={!!rowSelection[row.id]}
|
||||||
|
disabled={row.original.attributes.muted}
|
||||||
|
onCheckedChange={(checked) => row.toggleSelected(checked === true)}
|
||||||
|
aria-label="Select row"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
enableSorting: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "status",
|
||||||
|
header: ({ column }) => (
|
||||||
|
<DataTableColumnHeader column={column} title="Status" />
|
||||||
|
),
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<StatusFindingBadge status={row.original.attributes.status || "-"} />
|
||||||
|
),
|
||||||
|
enableSorting: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "finding",
|
||||||
|
header: ({ column }) => (
|
||||||
|
<DataTableColumnHeader column={column} title="Finding" />
|
||||||
|
),
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<button
|
||||||
|
onClick={() => onNavigate(row.original.id)}
|
||||||
|
className="text-text-neutral-primary hover:text-button-tertiary max-w-[300px] cursor-pointer truncate text-left text-sm hover:underline"
|
||||||
|
>
|
||||||
|
{row.original.attributes.check_metadata?.checktitle ||
|
||||||
|
"Unknown check"}
|
||||||
|
</button>
|
||||||
|
),
|
||||||
|
enableSorting: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "severity",
|
||||||
|
header: ({ column }) => (
|
||||||
|
<DataTableColumnHeader column={column} title="Severity" />
|
||||||
|
),
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<SeverityBadge severity={row.original.attributes.severity || "-"} />
|
||||||
|
),
|
||||||
|
enableSorting: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "updated_at",
|
||||||
|
header: ({ column }) => (
|
||||||
|
<DataTableColumnHeader column={column} title="Time" />
|
||||||
|
),
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<DateWithTime dateTime={row.original.attributes.updated_at || "-"} />
|
||||||
|
),
|
||||||
|
enableSorting: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "actions",
|
||||||
|
header: () => <div className="w-10" />,
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<DataTableRowActions row={row} onMuteComplete={onMuteComplete} />
|
||||||
|
),
|
||||||
|
enableSorting: false,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
};
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { DataTable } from "@/components/ui/table";
|
||||||
|
import { MetaDataProps, ResourceProps } from "@/types";
|
||||||
|
|
||||||
|
import { ColumnResources } from "./column-resources";
|
||||||
|
|
||||||
|
interface ResourcesTableWithSelectionProps {
|
||||||
|
data: ResourceProps[];
|
||||||
|
metadata?: MetaDataProps;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ResourcesTableWithSelection({
|
||||||
|
data,
|
||||||
|
metadata,
|
||||||
|
}: ResourcesTableWithSelectionProps) {
|
||||||
|
// Ensure data is always an array for safe operations
|
||||||
|
const safeData = data ?? [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DataTable
|
||||||
|
columns={ColumnResources}
|
||||||
|
data={safeData}
|
||||||
|
metadata={metadata}
|
||||||
|
showSearch
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -18,7 +18,7 @@ import { useState } from "react";
|
|||||||
|
|
||||||
import { VerticalDotsIcon } from "@/components/icons";
|
import { VerticalDotsIcon } from "@/components/icons";
|
||||||
import { Button } from "@/components/shadcn";
|
import { Button } from "@/components/shadcn";
|
||||||
import { CustomAlertModal } from "@/components/ui/custom/custom-alert-modal";
|
import { Modal } from "@/components/shadcn/modal";
|
||||||
|
|
||||||
import { DeleteRoleForm } from "../workflow/forms";
|
import { DeleteRoleForm } from "../workflow/forms";
|
||||||
interface DataTableRowActionsProps<RoleProps> {
|
interface DataTableRowActionsProps<RoleProps> {
|
||||||
@@ -34,14 +34,14 @@ export function DataTableRowActions<RoleProps>({
|
|||||||
const roleId = (row.original as { id: string }).id;
|
const roleId = (row.original as { id: string }).id;
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<CustomAlertModal
|
<Modal
|
||||||
isOpen={isDeleteOpen}
|
open={isDeleteOpen}
|
||||||
onOpenChange={setIsDeleteOpen}
|
onOpenChange={setIsDeleteOpen}
|
||||||
title="Are you absolutely sure?"
|
title="Are you absolutely sure?"
|
||||||
description="This action cannot be undone. This will permanently delete your role and remove your data from the server."
|
description="This action cannot be undone. This will permanently delete your role and remove your data from the server."
|
||||||
>
|
>
|
||||||
<DeleteRoleForm roleId={roleId} setIsOpen={setIsDeleteOpen} />
|
<DeleteRoleForm roleId={roleId} setIsOpen={setIsDeleteOpen} />
|
||||||
</CustomAlertModal>
|
</Modal>
|
||||||
<div className="relative flex items-center justify-end gap-2">
|
<div className="relative flex items-center justify-end gap-2">
|
||||||
<Dropdown
|
<Dropdown
|
||||||
className="border-border-neutral-secondary bg-bg-neutral-secondary border shadow-xl"
|
className="border-border-neutral-secondary bg-bg-neutral-secondary border shadow-xl"
|
||||||
|
|||||||
@@ -17,8 +17,8 @@ import { useState } from "react";
|
|||||||
|
|
||||||
import { VerticalDotsIcon } from "@/components/icons";
|
import { VerticalDotsIcon } from "@/components/icons";
|
||||||
import { Button } from "@/components/shadcn";
|
import { Button } from "@/components/shadcn";
|
||||||
|
import { Modal } from "@/components/shadcn/modal";
|
||||||
import { useToast } from "@/components/ui";
|
import { useToast } from "@/components/ui";
|
||||||
import { CustomAlertModal } from "@/components/ui/custom";
|
|
||||||
import { downloadScanZip } from "@/lib/helper";
|
import { downloadScanZip } from "@/lib/helper";
|
||||||
|
|
||||||
import { EditScanForm } from "../../forms";
|
import { EditScanForm } from "../../forms";
|
||||||
@@ -39,8 +39,8 @@ export function DataTableRowActions<ScanProps>({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<CustomAlertModal
|
<Modal
|
||||||
isOpen={isEditOpen}
|
open={isEditOpen}
|
||||||
onOpenChange={setIsEditOpen}
|
onOpenChange={setIsEditOpen}
|
||||||
title="Edit Scan Name"
|
title="Edit Scan Name"
|
||||||
>
|
>
|
||||||
@@ -49,7 +49,7 @@ export function DataTableRowActions<ScanProps>({
|
|||||||
scanName={scanName}
|
scanName={scanName}
|
||||||
setIsOpen={setIsEditOpen}
|
setIsOpen={setIsEditOpen}
|
||||||
/>
|
/>
|
||||||
</CustomAlertModal>
|
</Modal>
|
||||||
|
|
||||||
<div className="relative flex items-center justify-end gap-2">
|
<div className="relative flex items-center justify-end gap-2">
|
||||||
<Dropdown
|
<Dropdown
|
||||||
|
|||||||
@@ -0,0 +1,131 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { MoreHorizontal } from "lucide-react";
|
||||||
|
import { ComponentProps, ReactNode } from "react";
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuLabel,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from "./dropdown";
|
||||||
|
|
||||||
|
interface ActionDropdownProps {
|
||||||
|
/** The dropdown trigger element. Defaults to a vertical dots icon button */
|
||||||
|
trigger?: ReactNode;
|
||||||
|
/** Label shown at the top of the dropdown */
|
||||||
|
label?: string;
|
||||||
|
/** Alignment of the dropdown content */
|
||||||
|
align?: "start" | "center" | "end";
|
||||||
|
/** Additional className for the content */
|
||||||
|
className?: string;
|
||||||
|
/** Accessible label for the trigger */
|
||||||
|
ariaLabel?: string;
|
||||||
|
children: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ActionDropdown({
|
||||||
|
trigger,
|
||||||
|
label = "Actions",
|
||||||
|
align = "end",
|
||||||
|
className,
|
||||||
|
ariaLabel = "Open actions menu",
|
||||||
|
children,
|
||||||
|
}: ActionDropdownProps) {
|
||||||
|
return (
|
||||||
|
<DropdownMenu modal={false}>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
{trigger ?? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label={ariaLabel}
|
||||||
|
className="hover:bg-bg-neutral-tertiary rounded-md p-1 transition-colors"
|
||||||
|
>
|
||||||
|
<MoreHorizontal className="text-text-neutral-secondary size-5" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent
|
||||||
|
align={align}
|
||||||
|
className={cn(
|
||||||
|
"border-border-neutral-secondary bg-bg-neutral-secondary w-56",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{label && (
|
||||||
|
<>
|
||||||
|
<DropdownMenuLabel>{label}</DropdownMenuLabel>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{children}
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ActionDropdownItemProps
|
||||||
|
extends Omit<ComponentProps<typeof DropdownMenuItem>, "children"> {
|
||||||
|
/** Icon displayed before the label */
|
||||||
|
icon?: ReactNode;
|
||||||
|
/** Main label text */
|
||||||
|
label: ReactNode;
|
||||||
|
/** Optional description text below the label */
|
||||||
|
description?: string;
|
||||||
|
/** Whether the item is destructive (danger styling) */
|
||||||
|
destructive?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ActionDropdownItem({
|
||||||
|
icon,
|
||||||
|
label,
|
||||||
|
description,
|
||||||
|
destructive = false,
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: ActionDropdownItemProps) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuItem
|
||||||
|
className={cn(
|
||||||
|
"flex cursor-pointer items-center gap-2",
|
||||||
|
destructive && "text-destructive focus:text-destructive",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{icon && (
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"text-muted-foreground shrink-0 [&>svg]:size-5",
|
||||||
|
destructive && "text-destructive",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{icon}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span>{label}</span>
|
||||||
|
{description && (
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"text-muted-foreground text-xs",
|
||||||
|
destructive && "text-destructive/70",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{description}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-export commonly used components for convenience
|
||||||
|
export {
|
||||||
|
DropdownMenuLabel as ActionDropdownLabel,
|
||||||
|
DropdownMenuSeparator as ActionDropdownSeparator,
|
||||||
|
} from "./dropdown";
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
export {
|
||||||
|
ActionDropdown,
|
||||||
|
ActionDropdownItem,
|
||||||
|
ActionDropdownLabel,
|
||||||
|
ActionDropdownSeparator,
|
||||||
|
} from "./action-dropdown";
|
||||||
|
export {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuCheckboxItem,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuGroup,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuLabel,
|
||||||
|
DropdownMenuPortal,
|
||||||
|
DropdownMenuRadioGroup,
|
||||||
|
DropdownMenuRadioItem,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuShortcut,
|
||||||
|
DropdownMenuSub,
|
||||||
|
DropdownMenuSubContent,
|
||||||
|
DropdownMenuSubTrigger,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from "./dropdown";
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export { Modal } from "./modal";
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import { ReactNode } from "react";
|
||||||
|
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/shadcn/dialog";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
const SIZE_CLASSES = {
|
||||||
|
sm: "sm:max-w-sm",
|
||||||
|
md: "sm:max-w-md",
|
||||||
|
lg: "sm:max-w-lg",
|
||||||
|
xl: "sm:max-w-xl",
|
||||||
|
"2xl": "sm:max-w-2xl",
|
||||||
|
"3xl": "sm:max-w-3xl",
|
||||||
|
"4xl": "sm:max-w-4xl",
|
||||||
|
"5xl": "sm:max-w-5xl",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
type ModalSize = keyof typeof SIZE_CLASSES;
|
||||||
|
|
||||||
|
interface ModalProps {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
title?: string;
|
||||||
|
description?: string;
|
||||||
|
children: ReactNode;
|
||||||
|
size?: ModalSize;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Modal = ({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
children,
|
||||||
|
size = "xl",
|
||||||
|
className,
|
||||||
|
}: ModalProps) => {
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent
|
||||||
|
className={cn(
|
||||||
|
"border-border-neutral-secondary bg-bg-neutral-secondary",
|
||||||
|
SIZE_CLASSES[size],
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{title && (
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{title}</DialogTitle>
|
||||||
|
{description && (
|
||||||
|
<DialogDescription className="text-small text-gray-600 dark:text-gray-300">
|
||||||
|
{description}
|
||||||
|
</DialogDescription>
|
||||||
|
)}
|
||||||
|
</DialogHeader>
|
||||||
|
)}
|
||||||
|
{children}
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,13 +1,99 @@
|
|||||||
import { Snippet } from "@heroui/snippet";
|
"use client";
|
||||||
|
|
||||||
export const CodeSnippet = ({ value }: { value: string }) => (
|
import { Check, Copy } from "lucide-react";
|
||||||
<Snippet
|
import { ReactNode, useState } from "react";
|
||||||
className="bg-bg-neutral-tertiary text-text-neutral-primary border-border-neutral-tertiary w-full rounded-lg border py-1 text-xs"
|
|
||||||
hideSymbol
|
import {
|
||||||
classNames={{
|
Tooltip,
|
||||||
pre: "w-full truncate",
|
TooltipContent,
|
||||||
}}
|
TooltipTrigger,
|
||||||
>
|
} from "@/components/shadcn/tooltip";
|
||||||
{value}
|
import { cn } from "@/lib/utils";
|
||||||
</Snippet>
|
|
||||||
);
|
interface CodeSnippetProps {
|
||||||
|
value: string;
|
||||||
|
className?: string;
|
||||||
|
/** Hide the code text and show only the copy button */
|
||||||
|
hideCode?: boolean;
|
||||||
|
/** Hide the copy button */
|
||||||
|
hideCopyButton?: boolean;
|
||||||
|
/** Icon to display before the text */
|
||||||
|
icon?: ReactNode;
|
||||||
|
/** Function to format the displayed text (value is still copied as-is) */
|
||||||
|
formatter?: (value: string) => string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CodeSnippet = ({
|
||||||
|
value,
|
||||||
|
className,
|
||||||
|
hideCode = false,
|
||||||
|
hideCopyButton = false,
|
||||||
|
icon,
|
||||||
|
formatter,
|
||||||
|
}: CodeSnippetProps) => {
|
||||||
|
const [copied, setCopied] = useState(false);
|
||||||
|
|
||||||
|
const handleCopy = async () => {
|
||||||
|
await navigator.clipboard.writeText(value);
|
||||||
|
setCopied(true);
|
||||||
|
setTimeout(() => setCopied(false), 2000);
|
||||||
|
};
|
||||||
|
|
||||||
|
const displayValue = formatter ? formatter(value) : value;
|
||||||
|
|
||||||
|
const CopyButton = () => (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleCopy}
|
||||||
|
className="text-text-neutral-secondary hover:text-text-neutral-primary shrink-0 cursor-pointer transition-colors"
|
||||||
|
aria-label="Copy to clipboard"
|
||||||
|
>
|
||||||
|
{copied ? (
|
||||||
|
<Check className="h-3.5 w-3.5" />
|
||||||
|
) : (
|
||||||
|
<Copy className="h-3.5 w-3.5" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
|
||||||
|
// When hideCode is true, render only the copy button without container styling
|
||||||
|
if (hideCode) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleCopy}
|
||||||
|
className={cn(
|
||||||
|
"hover:bg-bg-neutral-tertiary text-text-neutral-secondary hover:text-text-neutral-primary shrink-0 cursor-pointer rounded-md p-1 transition-colors",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
aria-label="Copy to clipboard"
|
||||||
|
>
|
||||||
|
{copied ? (
|
||||||
|
<Check className="h-3.5 w-3.5" />
|
||||||
|
) : (
|
||||||
|
<Copy className="h-3.5 w-3.5" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"bg-bg-neutral-tertiary text-text-neutral-primary border-border-neutral-tertiary flex h-6 w-fit items-center gap-2 rounded-lg border px-2 py-1 text-xs",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{icon && (
|
||||||
|
<span className="text-text-neutral-secondary shrink-0">{icon}</span>
|
||||||
|
)}
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<code className="min-w-0 flex-1 truncate">{displayValue}</code>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side="top">{value}</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
{!hideCopyButton && <CopyButton />}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|||||||
@@ -1,50 +0,0 @@
|
|||||||
import { Modal, ModalBody, ModalContent, ModalHeader } from "@heroui/modal";
|
|
||||||
import React, { ReactNode } from "react";
|
|
||||||
|
|
||||||
interface CustomAlertModalProps {
|
|
||||||
isOpen: boolean;
|
|
||||||
onOpenChange: (isOpen: boolean) => void;
|
|
||||||
title?: string;
|
|
||||||
description?: string;
|
|
||||||
children: ReactNode;
|
|
||||||
size?: "sm" | "md" | "lg" | "xl" | "2xl" | "3xl" | "4xl" | "5xl";
|
|
||||||
}
|
|
||||||
|
|
||||||
export const CustomAlertModal: React.FC<CustomAlertModalProps> = ({
|
|
||||||
isOpen,
|
|
||||||
onOpenChange,
|
|
||||||
title,
|
|
||||||
description,
|
|
||||||
children,
|
|
||||||
size = "xl",
|
|
||||||
}) => {
|
|
||||||
return (
|
|
||||||
<Modal
|
|
||||||
isOpen={isOpen}
|
|
||||||
onOpenChange={onOpenChange}
|
|
||||||
size={size}
|
|
||||||
classNames={{
|
|
||||||
base: "border border-border-neutral-secondary bg-bg-neutral-secondary",
|
|
||||||
closeButton: "rounded-md",
|
|
||||||
}}
|
|
||||||
backdrop="blur"
|
|
||||||
placement="center"
|
|
||||||
>
|
|
||||||
<ModalContent className="py-4">
|
|
||||||
{(_onClose) => (
|
|
||||||
<>
|
|
||||||
<ModalHeader className="flex flex-col py-0">{title}</ModalHeader>
|
|
||||||
<ModalBody>
|
|
||||||
{description && (
|
|
||||||
<p className="text-small text-gray-600 dark:text-gray-300">
|
|
||||||
{description}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
{children}
|
|
||||||
</ModalBody>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</ModalContent>
|
|
||||||
</Modal>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,4 +1,3 @@
|
|||||||
export * from "./custom-alert-modal";
|
|
||||||
export * from "./custom-banner";
|
export * from "./custom-banner";
|
||||||
export * from "./custom-dropdown-selection";
|
export * from "./custom-dropdown-selection";
|
||||||
export * from "./custom-input";
|
export * from "./custom-input";
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { Tooltip } from "@heroui/tooltip";
|
import {
|
||||||
import { useEffect, useState } from "react";
|
Tooltip,
|
||||||
|
TooltipContent,
|
||||||
import { CopyIcon, DoneIcon } from "@/components/icons";
|
TooltipTrigger,
|
||||||
|
} from "@/components/shadcn/tooltip";
|
||||||
|
import { CodeSnippet } from "@/components/ui/code-snippet/code-snippet";
|
||||||
import type { ProviderType } from "@/types";
|
import type { ProviderType } from "@/types";
|
||||||
|
|
||||||
import { getProviderLogo } from "./get-provider-logo";
|
import { getProviderLogo } from "./get-provider-logo";
|
||||||
@@ -26,26 +28,6 @@ export const EntityInfo = ({
|
|||||||
maxWidth = "w-[120px]",
|
maxWidth = "w-[120px]",
|
||||||
showCopyAction = true,
|
showCopyAction = true,
|
||||||
}: EntityInfoProps) => {
|
}: EntityInfoProps) => {
|
||||||
const [copied, setCopied] = useState(false);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!copied) return undefined;
|
|
||||||
|
|
||||||
const timer = setTimeout(() => setCopied(false), 1400);
|
|
||||||
return () => clearTimeout(timer);
|
|
||||||
}, [copied]);
|
|
||||||
|
|
||||||
const handleCopyEntityId = async () => {
|
|
||||||
if (!entityId) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
await navigator.clipboard.writeText(entityId);
|
|
||||||
setCopied(true);
|
|
||||||
} catch (_error) {
|
|
||||||
setCopied(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const canCopy = Boolean(entityId && showCopyAction);
|
const canCopy = Boolean(entityId && showCopyAction);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -53,54 +35,46 @@ export const EntityInfo = ({
|
|||||||
<div className="relative shrink-0">
|
<div className="relative shrink-0">
|
||||||
{getProviderLogo(cloudProvider)}
|
{getProviderLogo(cloudProvider)}
|
||||||
{showConnectionStatus && (
|
{showConnectionStatus && (
|
||||||
<Tooltip
|
<Tooltip>
|
||||||
size="sm"
|
<TooltipTrigger asChild>
|
||||||
content={showConnectionStatus ? "Connected" : "Not Connected"}
|
<span className="absolute top-[-0.1rem] right-[-0.2rem] h-2 w-2 cursor-pointer rounded-full bg-green-500" />
|
||||||
>
|
</TooltipTrigger>
|
||||||
<span
|
<TooltipContent>Connected</TooltipContent>
|
||||||
className={`absolute top-[-0.1rem] right-[-0.2rem] h-2 w-2 cursor-pointer rounded-full ${
|
|
||||||
showConnectionStatus ? "bg-green-500" : "bg-red-500"
|
|
||||||
}`}
|
|
||||||
/>
|
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className={`flex ${maxWidth} flex-col gap-1`}>
|
<div className={`flex ${maxWidth} flex-col gap-1`}>
|
||||||
{entityAlias ? (
|
{entityAlias ? (
|
||||||
<Tooltip content={entityAlias} placement="top-start" size="sm">
|
<Tooltip>
|
||||||
<p className="text-text-neutral-primary truncate text-left text-xs font-medium">
|
<TooltipTrigger asChild>
|
||||||
{entityAlias}
|
<p className="text-text-neutral-primary truncate text-left text-xs font-medium">
|
||||||
</p>
|
{entityAlias}
|
||||||
|
</p>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side="top">{entityAlias}</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
) : (
|
) : (
|
||||||
<Tooltip content="No alias" placement="top-start" size="sm">
|
<Tooltip>
|
||||||
<p className="text-text-neutral-secondary truncate text-left text-xs">
|
<TooltipTrigger asChild>
|
||||||
-
|
<p className="text-text-neutral-secondary truncate text-left text-xs">
|
||||||
</p>
|
-
|
||||||
|
</p>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side="top">No alias</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
)}
|
)}
|
||||||
{entityId && (
|
{entityId && (
|
||||||
<div className="flex min-w-0 items-center gap-1">
|
<div className="bg-bg-neutral-tertiary border-border-neutral-tertiary flex w-full min-w-0 items-center gap-1 rounded-xl border px-1.5">
|
||||||
<Tooltip content={entityId} placement="top-start" size="sm">
|
<Tooltip>
|
||||||
<p className="text-text-neutral-secondary min-w-0 truncate text-left text-xs">
|
<TooltipTrigger asChild>
|
||||||
{entityId}
|
<p className="text-text-neutral-secondary min-w-0 flex-1 truncate text-left text-xs">
|
||||||
</p>
|
{entityId}
|
||||||
|
</p>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side="top">{entityId}</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
{canCopy && (
|
{canCopy && (
|
||||||
<Tooltip
|
<CodeSnippet value={entityId} hideCode className="shrink-0" />
|
||||||
content={copied ? "Copied" : "Copy to clipboard"}
|
|
||||||
placement="top"
|
|
||||||
size="sm"
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={handleCopyEntityId}
|
|
||||||
aria-label="Copiar ID de la entidad"
|
|
||||||
className="hover:bg-bg-neutral-tertiary focus-visible:ring-bg-data-info text-text-neutral-secondary hover:text-text-neutral-primary rounded-md p-1 transition-colors focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none"
|
|
||||||
>
|
|
||||||
{copied ? <DoneIcon size={14} /> : <CopyIcon size={14} />}
|
|
||||||
</button>
|
|
||||||
</Tooltip>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -34,7 +34,10 @@ export const MenuItem = ({
|
|||||||
highlight,
|
highlight,
|
||||||
}: MenuItemProps) => {
|
}: MenuItemProps) => {
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const isActive = active !== undefined ? active : pathname.startsWith(href);
|
// Extract only the pathname from href (without query parameters) for comparison
|
||||||
|
const hrefPathname = href.split("?")[0];
|
||||||
|
const isActive =
|
||||||
|
active !== undefined ? active : pathname.startsWith(hrefPathname);
|
||||||
|
|
||||||
// Show tooltip always for Prowler Hub, or when sidebar is collapsed
|
// Show tooltip always for Prowler Hub, or when sidebar is collapsed
|
||||||
const showTooltip = label === "Prowler Hub" ? !!tooltip : !isOpen;
|
const showTooltip = label === "Prowler Hub" ? !!tooltip : !isOpen;
|
||||||
@@ -55,8 +58,8 @@ export const MenuItem = ({
|
|||||||
<Icon size={18} />
|
<Icon size={18} />
|
||||||
</span>
|
</span>
|
||||||
{isOpen && (
|
{isOpen && (
|
||||||
<p className="max-w-[200px] truncate">
|
<p className="flex max-w-[200px] items-center truncate">
|
||||||
{label}
|
<span>{label}</span>
|
||||||
{highlight && (
|
{highlight && (
|
||||||
<span className="ml-2 rounded-sm bg-emerald-500 px-1.5 py-0.5 text-[10px] font-semibold text-white">
|
<span className="ml-2 rounded-sm bg-emerald-500 px-1.5 py-0.5 text-[10px] font-semibold text-white">
|
||||||
NEW
|
NEW
|
||||||
|
|||||||
@@ -24,6 +24,18 @@ import { MetaDataProps } from "@/types";
|
|||||||
interface DataTablePaginationProps {
|
interface DataTablePaginationProps {
|
||||||
metadata?: MetaDataProps;
|
metadata?: MetaDataProps;
|
||||||
disableScroll?: boolean;
|
disableScroll?: boolean;
|
||||||
|
/** Prefix for URL params to avoid conflicts (e.g., "findings" -> "findingsPage") */
|
||||||
|
paramPrefix?: string;
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Controlled mode: Use these props to manage pagination via React state
|
||||||
|
* instead of URL params. Useful for tables in drawers/modals to avoid
|
||||||
|
* triggering page re-renders when paginating.
|
||||||
|
*/
|
||||||
|
controlledPage?: number;
|
||||||
|
controlledPageSize?: number;
|
||||||
|
onPageChange?: (page: number) => void;
|
||||||
|
onPageSizeChange?: (pageSize: number) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const NAV_BUTTON_STYLES = {
|
const NAV_BUTTON_STYLES = {
|
||||||
@@ -35,18 +47,44 @@ const NAV_BUTTON_STYLES = {
|
|||||||
export function DataTablePagination({
|
export function DataTablePagination({
|
||||||
metadata,
|
metadata,
|
||||||
disableScroll = false,
|
disableScroll = false,
|
||||||
|
paramPrefix = "",
|
||||||
|
controlledPage,
|
||||||
|
controlledPageSize,
|
||||||
|
onPageChange,
|
||||||
|
onPageSizeChange,
|
||||||
}: DataTablePaginationProps) {
|
}: DataTablePaginationProps) {
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const initialPageSize = searchParams.get("pageSize") ?? "50";
|
|
||||||
|
// Determine if we're in controlled mode
|
||||||
|
const isControlled = controlledPage !== undefined && onPageChange;
|
||||||
|
|
||||||
|
// Determine param names based on prefix
|
||||||
|
const pageParam = paramPrefix ? `${paramPrefix}Page` : "page";
|
||||||
|
const pageSizeParam = paramPrefix ? `${paramPrefix}PageSize` : "pageSize";
|
||||||
|
|
||||||
|
const initialPageSize = isControlled
|
||||||
|
? String(controlledPageSize ?? 10)
|
||||||
|
: (searchParams.get(pageSizeParam) ?? "10");
|
||||||
|
|
||||||
const [selectedPageSize, setSelectedPageSize] = useState(initialPageSize);
|
const [selectedPageSize, setSelectedPageSize] = useState(initialPageSize);
|
||||||
|
|
||||||
if (!metadata) return null;
|
if (!metadata) return null;
|
||||||
|
|
||||||
const { currentPage, totalPages, totalEntries, itemsPerPageOptions } =
|
const {
|
||||||
getPaginationInfo(metadata);
|
currentPage: metaCurrentPage,
|
||||||
|
totalPages,
|
||||||
|
totalEntries,
|
||||||
|
itemsPerPageOptions,
|
||||||
|
} = getPaginationInfo(metadata);
|
||||||
|
|
||||||
|
// For controlled mode, use controlled values; for prefixed, read from URL; otherwise use metadata
|
||||||
|
const currentPage = isControlled
|
||||||
|
? controlledPage
|
||||||
|
: paramPrefix
|
||||||
|
? parseInt(searchParams.get(pageParam) || "1", 10)
|
||||||
|
: metaCurrentPage;
|
||||||
|
|
||||||
const createPageUrl = (pageNumber: number | string) => {
|
const createPageUrl = (pageNumber: number | string) => {
|
||||||
const params = new URLSearchParams(searchParams);
|
const params = new URLSearchParams(searchParams);
|
||||||
@@ -60,7 +98,7 @@ export function DataTablePagination({
|
|||||||
return `${pathname}?${params.toString()}`;
|
return `${pathname}?${params.toString()}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
params.set("page", pageNumber.toString());
|
params.set(pageParam, pageNumber.toString());
|
||||||
|
|
||||||
// Ensure that scanId, id and version are preserved
|
// Ensure that scanId, id and version are preserved
|
||||||
if (scanId) params.set("scanId", scanId);
|
if (scanId) params.set("scanId", scanId);
|
||||||
@@ -70,6 +108,20 @@ export function DataTablePagination({
|
|||||||
return `${pathname}?${params.toString()}`;
|
return `${pathname}?${params.toString()}`;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Handle page navigation for controlled mode
|
||||||
|
const handlePageChange = (pageNumber: number) => {
|
||||||
|
if (isControlled) {
|
||||||
|
onPageChange(pageNumber);
|
||||||
|
} else {
|
||||||
|
const url = createPageUrl(pageNumber);
|
||||||
|
if (disableScroll) {
|
||||||
|
router.push(url, { scroll: false });
|
||||||
|
} else {
|
||||||
|
router.push(url);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const isFirstPage = currentPage === 1;
|
const isFirstPage = currentPage === 1;
|
||||||
const isLastPage = currentPage === totalPages;
|
const isLastPage = currentPage === totalPages;
|
||||||
|
|
||||||
@@ -87,6 +139,12 @@ export function DataTablePagination({
|
|||||||
onValueChange={(value) => {
|
onValueChange={(value) => {
|
||||||
setSelectedPageSize(value);
|
setSelectedPageSize(value);
|
||||||
|
|
||||||
|
if (isControlled) {
|
||||||
|
onPageSizeChange?.(parseInt(value, 10));
|
||||||
|
onPageChange(1); // Reset to first page
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const params = new URLSearchParams(searchParams);
|
const params = new URLSearchParams(searchParams);
|
||||||
|
|
||||||
// Preserve all important parameters
|
// Preserve all important parameters
|
||||||
@@ -94,8 +152,8 @@ export function DataTablePagination({
|
|||||||
const id = searchParams.get("id");
|
const id = searchParams.get("id");
|
||||||
const version = searchParams.get("version");
|
const version = searchParams.get("version");
|
||||||
|
|
||||||
params.set("pageSize", value);
|
params.set(pageSizeParam, value);
|
||||||
params.set("page", "1");
|
params.set(pageParam, "1");
|
||||||
|
|
||||||
// Ensure that scanId, id and version are preserved
|
// Ensure that scanId, id and version are preserved
|
||||||
if (scanId) params.set("scanId", scanId);
|
if (scanId) params.set("scanId", scanId);
|
||||||
@@ -137,82 +195,145 @@ export function DataTablePagination({
|
|||||||
Page {currentPage} of {totalPages}
|
Page {currentPage} of {totalPages}
|
||||||
</span>
|
</span>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<Link
|
{isControlled ? (
|
||||||
aria-label="Go to first page"
|
<>
|
||||||
className={cn(
|
<button
|
||||||
NAV_BUTTON_STYLES.base,
|
type="button"
|
||||||
isFirstPage
|
aria-label="Go to first page"
|
||||||
? NAV_BUTTON_STYLES.disabled
|
className={cn(
|
||||||
: NAV_BUTTON_STYLES.enabled,
|
NAV_BUTTON_STYLES.base,
|
||||||
)}
|
isFirstPage
|
||||||
href={
|
? NAV_BUTTON_STYLES.disabled
|
||||||
isFirstPage
|
: NAV_BUTTON_STYLES.enabled,
|
||||||
? pathname + "?" + searchParams.toString()
|
)}
|
||||||
: createPageUrl(1)
|
disabled={isFirstPage}
|
||||||
}
|
onClick={() => handlePageChange(1)}
|
||||||
scroll={!disableScroll}
|
>
|
||||||
aria-disabled={isFirstPage}
|
<ChevronFirst className="size-6" aria-hidden="true" />
|
||||||
onClick={(e) => isFirstPage && e.preventDefault()}
|
</button>
|
||||||
>
|
<button
|
||||||
<ChevronFirst className="size-6" aria-hidden="true" />
|
type="button"
|
||||||
</Link>
|
aria-label="Go to previous page"
|
||||||
<Link
|
className={cn(
|
||||||
aria-label="Go to previous page"
|
NAV_BUTTON_STYLES.base,
|
||||||
className={cn(
|
isFirstPage
|
||||||
NAV_BUTTON_STYLES.base,
|
? NAV_BUTTON_STYLES.disabled
|
||||||
isFirstPage
|
: NAV_BUTTON_STYLES.enabled,
|
||||||
? NAV_BUTTON_STYLES.disabled
|
)}
|
||||||
: NAV_BUTTON_STYLES.enabled,
|
disabled={isFirstPage}
|
||||||
)}
|
onClick={() => handlePageChange(currentPage - 1)}
|
||||||
href={
|
>
|
||||||
isFirstPage
|
<ChevronLeft className="size-6" aria-hidden="true" />
|
||||||
? pathname + "?" + searchParams.toString()
|
</button>
|
||||||
: createPageUrl(currentPage - 1)
|
<button
|
||||||
}
|
type="button"
|
||||||
scroll={!disableScroll}
|
aria-label="Go to next page"
|
||||||
aria-disabled={isFirstPage}
|
className={cn(
|
||||||
onClick={(e) => isFirstPage && e.preventDefault()}
|
NAV_BUTTON_STYLES.base,
|
||||||
>
|
isLastPage
|
||||||
<ChevronLeft className="size-6" aria-hidden="true" />
|
? NAV_BUTTON_STYLES.disabled
|
||||||
</Link>
|
: NAV_BUTTON_STYLES.enabled,
|
||||||
<Link
|
)}
|
||||||
aria-label="Go to next page"
|
disabled={isLastPage}
|
||||||
className={cn(
|
onClick={() => handlePageChange(currentPage + 1)}
|
||||||
NAV_BUTTON_STYLES.base,
|
>
|
||||||
isLastPage
|
<ChevronRight className="size-6" aria-hidden="true" />
|
||||||
? NAV_BUTTON_STYLES.disabled
|
</button>
|
||||||
: NAV_BUTTON_STYLES.enabled,
|
<button
|
||||||
)}
|
type="button"
|
||||||
href={
|
aria-label="Go to last page"
|
||||||
isLastPage
|
className={cn(
|
||||||
? pathname + "?" + searchParams.toString()
|
NAV_BUTTON_STYLES.base,
|
||||||
: createPageUrl(currentPage + 1)
|
isLastPage
|
||||||
}
|
? NAV_BUTTON_STYLES.disabled
|
||||||
scroll={!disableScroll}
|
: NAV_BUTTON_STYLES.enabled,
|
||||||
aria-disabled={isLastPage}
|
)}
|
||||||
onClick={(e) => isLastPage && e.preventDefault()}
|
disabled={isLastPage}
|
||||||
>
|
onClick={() => handlePageChange(totalPages)}
|
||||||
<ChevronRight className="size-6" aria-hidden="true" />
|
>
|
||||||
</Link>
|
<ChevronLast className="size-6" aria-hidden="true" />
|
||||||
<Link
|
</button>
|
||||||
aria-label="Go to last page"
|
</>
|
||||||
className={cn(
|
) : (
|
||||||
NAV_BUTTON_STYLES.base,
|
<>
|
||||||
isLastPage
|
<Link
|
||||||
? NAV_BUTTON_STYLES.disabled
|
aria-label="Go to first page"
|
||||||
: NAV_BUTTON_STYLES.enabled,
|
className={cn(
|
||||||
)}
|
NAV_BUTTON_STYLES.base,
|
||||||
href={
|
isFirstPage
|
||||||
isLastPage
|
? NAV_BUTTON_STYLES.disabled
|
||||||
? pathname + "?" + searchParams.toString()
|
: NAV_BUTTON_STYLES.enabled,
|
||||||
: createPageUrl(totalPages)
|
)}
|
||||||
}
|
href={
|
||||||
scroll={!disableScroll}
|
isFirstPage
|
||||||
aria-disabled={isLastPage}
|
? pathname + "?" + searchParams.toString()
|
||||||
onClick={(e) => isLastPage && e.preventDefault()}
|
: createPageUrl(1)
|
||||||
>
|
}
|
||||||
<ChevronLast className="size-6" aria-hidden="true" />
|
scroll={!disableScroll}
|
||||||
</Link>
|
aria-disabled={isFirstPage}
|
||||||
|
onClick={(e) => isFirstPage && e.preventDefault()}
|
||||||
|
>
|
||||||
|
<ChevronFirst className="size-6" aria-hidden="true" />
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
aria-label="Go to previous page"
|
||||||
|
className={cn(
|
||||||
|
NAV_BUTTON_STYLES.base,
|
||||||
|
isFirstPage
|
||||||
|
? NAV_BUTTON_STYLES.disabled
|
||||||
|
: NAV_BUTTON_STYLES.enabled,
|
||||||
|
)}
|
||||||
|
href={
|
||||||
|
isFirstPage
|
||||||
|
? pathname + "?" + searchParams.toString()
|
||||||
|
: createPageUrl(currentPage - 1)
|
||||||
|
}
|
||||||
|
scroll={!disableScroll}
|
||||||
|
aria-disabled={isFirstPage}
|
||||||
|
onClick={(e) => isFirstPage && e.preventDefault()}
|
||||||
|
>
|
||||||
|
<ChevronLeft className="size-6" aria-hidden="true" />
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
aria-label="Go to next page"
|
||||||
|
className={cn(
|
||||||
|
NAV_BUTTON_STYLES.base,
|
||||||
|
isLastPage
|
||||||
|
? NAV_BUTTON_STYLES.disabled
|
||||||
|
: NAV_BUTTON_STYLES.enabled,
|
||||||
|
)}
|
||||||
|
href={
|
||||||
|
isLastPage
|
||||||
|
? pathname + "?" + searchParams.toString()
|
||||||
|
: createPageUrl(currentPage + 1)
|
||||||
|
}
|
||||||
|
scroll={!disableScroll}
|
||||||
|
aria-disabled={isLastPage}
|
||||||
|
onClick={(e) => isLastPage && e.preventDefault()}
|
||||||
|
>
|
||||||
|
<ChevronRight className="size-6" aria-hidden="true" />
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
aria-label="Go to last page"
|
||||||
|
className={cn(
|
||||||
|
NAV_BUTTON_STYLES.base,
|
||||||
|
isLastPage
|
||||||
|
? NAV_BUTTON_STYLES.disabled
|
||||||
|
: NAV_BUTTON_STYLES.enabled,
|
||||||
|
)}
|
||||||
|
href={
|
||||||
|
isLastPage
|
||||||
|
? pathname + "?" + searchParams.toString()
|
||||||
|
: createPageUrl(totalPages)
|
||||||
|
}
|
||||||
|
scroll={!disableScroll}
|
||||||
|
aria-disabled={isLastPage}
|
||||||
|
onClick={(e) => isLastPage && e.preventDefault()}
|
||||||
|
>
|
||||||
|
<ChevronLast className="size-6" aria-hidden="true" />
|
||||||
|
</Link>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -1,19 +1,40 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { LoaderCircleIcon, SearchIcon } from "lucide-react";
|
import { LoaderCircleIcon, SearchIcon } from "lucide-react";
|
||||||
import { useSearchParams } from "next/navigation";
|
import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
||||||
import { useEffect, useId, useRef, useState } from "react";
|
import { useEffect, useId, useRef, useState } from "react";
|
||||||
|
|
||||||
import { Input } from "@/components/shadcn/input/input";
|
import { Input } from "@/components/shadcn/input/input";
|
||||||
import { useUrlFilters } from "@/hooks/use-url-filters";
|
import { useUrlFilters } from "@/hooks/use-url-filters";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
const SEARCH_DEBOUNCE_MS = 300;
|
const SEARCH_DEBOUNCE_MS = 500;
|
||||||
|
|
||||||
export const DataTableSearch = () => {
|
interface DataTableSearchProps {
|
||||||
|
/** Prefix for URL params to avoid conflicts (e.g., "findings" -> "findingsSearch") */
|
||||||
|
paramPrefix?: string;
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Controlled mode: Use these props to manage search via React state
|
||||||
|
* instead of URL params. Useful for tables in drawers/modals to avoid
|
||||||
|
* triggering page re-renders when searching.
|
||||||
|
*/
|
||||||
|
controlledValue?: string;
|
||||||
|
onSearchChange?: (value: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DataTableSearch = ({
|
||||||
|
paramPrefix = "",
|
||||||
|
controlledValue,
|
||||||
|
onSearchChange,
|
||||||
|
}: DataTableSearchProps) => {
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
|
const pathname = usePathname();
|
||||||
|
const router = useRouter();
|
||||||
const { updateFilter } = useUrlFilters();
|
const { updateFilter } = useUrlFilters();
|
||||||
const [value, setValue] = useState("");
|
const [internalValue, setInternalValue] = useState("");
|
||||||
|
// In controlled mode, track display value separately for immediate feedback
|
||||||
|
const [displayValue, setDisplayValue] = useState(controlledValue ?? "");
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const [isExpanded, setIsExpanded] = useState(false);
|
const [isExpanded, setIsExpanded] = useState(false);
|
||||||
const [isFocused, setIsFocused] = useState(false);
|
const [isFocused, setIsFocused] = useState(false);
|
||||||
@@ -21,36 +42,87 @@ export const DataTableSearch = () => {
|
|||||||
const debounceTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
const debounceTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||||
const inputRef = useRef<HTMLInputElement>(null);
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
// Use controlled value if provided, otherwise internal state
|
||||||
|
const isControlled = controlledValue !== undefined && onSearchChange;
|
||||||
|
// For display: use displayValue in controlled mode (for responsive typing), internalValue otherwise
|
||||||
|
const value = isControlled ? displayValue : internalValue;
|
||||||
|
|
||||||
|
// Sync displayValue when controlledValue changes externally (e.g., clear filters)
|
||||||
|
useEffect(() => {
|
||||||
|
if (isControlled) {
|
||||||
|
setDisplayValue(controlledValue);
|
||||||
|
}
|
||||||
|
}, [controlledValue, isControlled]);
|
||||||
|
|
||||||
|
// Determine param names based on prefix
|
||||||
|
const searchParam = paramPrefix ? `${paramPrefix}Search` : "filter[search]";
|
||||||
|
const pageParam = paramPrefix ? `${paramPrefix}Page` : "page";
|
||||||
|
|
||||||
// Keep expanded if there's a value or input is focused
|
// Keep expanded if there's a value or input is focused
|
||||||
const shouldStayExpanded = value.length > 0 || isFocused;
|
const shouldStayExpanded = value.length > 0 || isFocused;
|
||||||
|
|
||||||
// Sync with URL on mount
|
// Sync with URL on mount (only for uncontrolled mode)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const searchFromUrl = searchParams.get("filter[search]") || "";
|
if (isControlled) return;
|
||||||
setValue(searchFromUrl);
|
const searchFromUrl = searchParams.get(searchParam) || "";
|
||||||
|
setInternalValue(searchFromUrl);
|
||||||
// If there's a search value, start expanded
|
// If there's a search value, start expanded
|
||||||
if (searchFromUrl) {
|
if (searchFromUrl) {
|
||||||
setIsExpanded(true);
|
setIsExpanded(true);
|
||||||
}
|
}
|
||||||
}, [searchParams]);
|
}, [searchParams, searchParam, isControlled]);
|
||||||
|
|
||||||
// Handle input change with debounce
|
// Handle input change with debounce
|
||||||
const handleChange = (newValue: string) => {
|
const handleChange = (newValue: string) => {
|
||||||
setValue(newValue);
|
// For controlled mode, update display immediately, debounce the callback
|
||||||
|
if (isControlled) {
|
||||||
|
// Update display value immediately for responsive typing
|
||||||
|
setDisplayValue(newValue);
|
||||||
|
|
||||||
|
if (debounceTimeoutRef.current) {
|
||||||
|
clearTimeout(debounceTimeoutRef.current);
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsLoading(true);
|
||||||
|
debounceTimeoutRef.current = setTimeout(() => {
|
||||||
|
onSearchChange(newValue);
|
||||||
|
setIsLoading(false);
|
||||||
|
}, SEARCH_DEBOUNCE_MS);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setInternalValue(newValue);
|
||||||
|
|
||||||
if (debounceTimeoutRef.current) {
|
if (debounceTimeoutRef.current) {
|
||||||
clearTimeout(debounceTimeoutRef.current);
|
clearTimeout(debounceTimeoutRef.current);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (newValue) {
|
// If using prefix, handle URL updates directly instead of useUrlFilters
|
||||||
|
if (paramPrefix) {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
debounceTimeoutRef.current = setTimeout(() => {
|
debounceTimeoutRef.current = setTimeout(() => {
|
||||||
updateFilter("search", newValue);
|
const params = new URLSearchParams(searchParams.toString());
|
||||||
|
if (newValue) {
|
||||||
|
params.set(searchParam, newValue);
|
||||||
|
} else {
|
||||||
|
params.delete(searchParam);
|
||||||
|
}
|
||||||
|
params.set(pageParam, "1"); // Reset to first page
|
||||||
|
router.push(`${pathname}?${params.toString()}`, { scroll: false });
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
}, SEARCH_DEBOUNCE_MS);
|
}, SEARCH_DEBOUNCE_MS);
|
||||||
} else {
|
} else {
|
||||||
setIsLoading(false);
|
// Original behavior for non-prefixed search
|
||||||
updateFilter("search", null);
|
if (newValue) {
|
||||||
|
setIsLoading(true);
|
||||||
|
debounceTimeoutRef.current = setTimeout(() => {
|
||||||
|
updateFilter("search", newValue);
|
||||||
|
setIsLoading(false);
|
||||||
|
}, SEARCH_DEBOUNCE_MS);
|
||||||
|
} else {
|
||||||
|
setIsLoading(false);
|
||||||
|
updateFilter("search", null);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import {
|
|||||||
flexRender,
|
flexRender,
|
||||||
getCoreRowModel,
|
getCoreRowModel,
|
||||||
getFilteredRowModel,
|
getFilteredRowModel,
|
||||||
getPaginationRowModel,
|
|
||||||
getSortedRowModel,
|
getSortedRowModel,
|
||||||
OnChangeFn,
|
OnChangeFn,
|
||||||
Row,
|
Row,
|
||||||
@@ -26,6 +25,8 @@ import {
|
|||||||
} from "@/components/ui/table";
|
} from "@/components/ui/table";
|
||||||
import { DataTablePagination } from "@/components/ui/table/data-table-pagination";
|
import { DataTablePagination } from "@/components/ui/table/data-table-pagination";
|
||||||
import { DataTableSearch } from "@/components/ui/table/data-table-search";
|
import { DataTableSearch } from "@/components/ui/table/data-table-search";
|
||||||
|
import { useFilterTransitionOptional } from "@/contexts";
|
||||||
|
import { cn } from "@/lib";
|
||||||
import { FilterOption, MetaDataProps } from "@/types";
|
import { FilterOption, MetaDataProps } from "@/types";
|
||||||
|
|
||||||
interface DataTableProviderProps<TData, TValue> {
|
interface DataTableProviderProps<TData, TValue> {
|
||||||
@@ -41,6 +42,39 @@ interface DataTableProviderProps<TData, TValue> {
|
|||||||
getRowCanSelect?: (row: Row<TData>) => boolean;
|
getRowCanSelect?: (row: Row<TData>) => boolean;
|
||||||
/** Show search bar in the table toolbar */
|
/** Show search bar in the table toolbar */
|
||||||
showSearch?: boolean;
|
showSearch?: boolean;
|
||||||
|
/** Prefix for URL params to avoid conflicts (e.g., "findings" -> "findingsPage") */
|
||||||
|
paramPrefix?: string;
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Controlled Mode Props
|
||||||
|
* ---------------------
|
||||||
|
* By default, DataTable uses URL params for pagination/search (via paramPrefix).
|
||||||
|
* This causes Next.js page re-renders on every interaction.
|
||||||
|
*
|
||||||
|
* For tables inside drawers/modals, use controlled mode instead:
|
||||||
|
* - Pass controlledPage, controlledPageSize, controlledSearch as state values
|
||||||
|
* - Pass onPageChange, onPageSizeChange, onSearchChange as state setters
|
||||||
|
* - This keeps state local, avoiding URL changes and unnecessary page re-renders
|
||||||
|
*
|
||||||
|
* Example:
|
||||||
|
* const [page, setPage] = useState(1);
|
||||||
|
* const [search, setSearch] = useState("");
|
||||||
|
* <DataTable
|
||||||
|
* controlledPage={page}
|
||||||
|
* onPageChange={setPage}
|
||||||
|
* controlledSearch={search}
|
||||||
|
* onSearchChange={setSearch}
|
||||||
|
* isLoading={isLoading}
|
||||||
|
* />
|
||||||
|
*/
|
||||||
|
controlledSearch?: string;
|
||||||
|
onSearchChange?: (value: string) => void;
|
||||||
|
controlledPage?: number;
|
||||||
|
controlledPageSize?: number;
|
||||||
|
onPageChange?: (page: number) => void;
|
||||||
|
onPageSizeChange?: (pageSize: number) => void;
|
||||||
|
/** Show loading state with opacity overlay (for controlled mode) */
|
||||||
|
isLoading?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function DataTable<TData, TValue>({
|
export function DataTable<TData, TValue>({
|
||||||
@@ -53,18 +87,29 @@ export function DataTable<TData, TValue>({
|
|||||||
onRowSelectionChange,
|
onRowSelectionChange,
|
||||||
getRowCanSelect,
|
getRowCanSelect,
|
||||||
showSearch = false,
|
showSearch = false,
|
||||||
|
paramPrefix = "",
|
||||||
|
controlledSearch,
|
||||||
|
onSearchChange,
|
||||||
|
controlledPage,
|
||||||
|
controlledPageSize,
|
||||||
|
onPageChange,
|
||||||
|
onPageSizeChange,
|
||||||
|
isLoading = false,
|
||||||
}: DataTableProviderProps<TData, TValue>) {
|
}: DataTableProviderProps<TData, TValue>) {
|
||||||
const [sorting, setSorting] = useState<SortingState>([]);
|
const [sorting, setSorting] = useState<SortingState>([]);
|
||||||
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
|
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
|
||||||
|
|
||||||
|
// Get transition state from context for loading indicator
|
||||||
|
const filterTransition = useFilterTransitionOptional();
|
||||||
|
// Use either context-based pending state or controlled isLoading prop
|
||||||
|
const isPending = (filterTransition?.isPending ?? false) || isLoading;
|
||||||
|
|
||||||
const table = useReactTable({
|
const table = useReactTable({
|
||||||
data,
|
data,
|
||||||
columns,
|
columns,
|
||||||
enableSorting: true,
|
enableSorting: true,
|
||||||
// Use getRowCanSelect function if provided, otherwise use boolean
|
|
||||||
enableRowSelection: getRowCanSelect ?? enableRowSelection,
|
enableRowSelection: getRowCanSelect ?? enableRowSelection,
|
||||||
getCoreRowModel: getCoreRowModel(),
|
getCoreRowModel: getCoreRowModel(),
|
||||||
getPaginationRowModel: getPaginationRowModel(),
|
|
||||||
onSortingChange: setSorting,
|
onSortingChange: setSorting,
|
||||||
getSortedRowModel: getSortedRowModel(),
|
getSortedRowModel: getSortedRowModel(),
|
||||||
onColumnFiltersChange: setColumnFilters,
|
onColumnFiltersChange: setColumnFilters,
|
||||||
@@ -86,13 +131,29 @@ export function DataTable<TData, TValue>({
|
|||||||
// Format total entries count
|
// Format total entries count
|
||||||
const totalEntries = metadata?.pagination?.count ?? 0;
|
const totalEntries = metadata?.pagination?.count ?? 0;
|
||||||
const formattedTotal = totalEntries.toLocaleString();
|
const formattedTotal = totalEntries.toLocaleString();
|
||||||
|
const showToolbar = showSearch || metadata;
|
||||||
|
|
||||||
|
const rows = table.getRowModel().rows;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="minimal-scrollbar rounded-large shadow-small border-border-neutral-secondary bg-bg-neutral-secondary relative z-0 flex w-full flex-col justify-between gap-4 overflow-auto border p-4">
|
<div
|
||||||
|
className={cn(
|
||||||
|
"minimal-scrollbar rounded-large shadow-small border-border-neutral-secondary bg-bg-neutral-secondary relative z-0 flex w-full flex-col justify-between gap-4 overflow-auto border p-4 transition-opacity duration-200",
|
||||||
|
isPending && "pointer-events-none opacity-60",
|
||||||
|
)}
|
||||||
|
>
|
||||||
{/* Table Toolbar */}
|
{/* Table Toolbar */}
|
||||||
{(showSearch || metadata) && (
|
{showToolbar && (
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>{showSearch && <DataTableSearch />}</div>
|
<div>
|
||||||
|
{showSearch && (
|
||||||
|
<DataTableSearch
|
||||||
|
paramPrefix={paramPrefix}
|
||||||
|
controlledValue={controlledSearch}
|
||||||
|
onSearchChange={onSearchChange}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
{metadata && (
|
{metadata && (
|
||||||
<span className="text-text-neutral-secondary text-sm">
|
<span className="text-text-neutral-secondary text-sm">
|
||||||
{formattedTotal} Total Entries
|
{formattedTotal} Total Entries
|
||||||
@@ -120,8 +181,8 @@ export function DataTable<TData, TValue>({
|
|||||||
))}
|
))}
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{table.getRowModel().rows?.length ? (
|
{rows?.length ? (
|
||||||
table.getRowModel().rows.map((row) => (
|
rows.map((row) => (
|
||||||
<TableRow
|
<TableRow
|
||||||
key={row.id}
|
key={row.id}
|
||||||
data-state={row.getIsSelected() && "selected"}
|
data-state={row.getIsSelected() && "selected"}
|
||||||
@@ -146,6 +207,11 @@ export function DataTable<TData, TValue>({
|
|||||||
<DataTablePagination
|
<DataTablePagination
|
||||||
metadata={metadata}
|
metadata={metadata}
|
||||||
disableScroll={disableScroll}
|
disableScroll={disableScroll}
|
||||||
|
paramPrefix={paramPrefix}
|
||||||
|
controlledPage={controlledPage}
|
||||||
|
controlledPageSize={controlledPageSize}
|
||||||
|
onPageChange={onPageChange}
|
||||||
|
onPageSizeChange={onPageSizeChange}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -3,8 +3,8 @@
|
|||||||
import { Snippet } from "@heroui/snippet";
|
import { Snippet } from "@heroui/snippet";
|
||||||
|
|
||||||
import { Button } from "@/components/shadcn";
|
import { Button } from "@/components/shadcn";
|
||||||
|
import { Modal } from "@/components/shadcn/modal";
|
||||||
import { Alert, AlertDescription } from "@/components/ui/alert/Alert";
|
import { Alert, AlertDescription } from "@/components/ui/alert/Alert";
|
||||||
import { CustomAlertModal } from "@/components/ui/custom/custom-alert-modal";
|
|
||||||
|
|
||||||
interface ApiKeySuccessModalProps {
|
interface ApiKeySuccessModalProps {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
@@ -18,8 +18,8 @@ export const ApiKeySuccessModal = ({
|
|||||||
apiKey,
|
apiKey,
|
||||||
}: ApiKeySuccessModalProps) => {
|
}: ApiKeySuccessModalProps) => {
|
||||||
return (
|
return (
|
||||||
<CustomAlertModal
|
<Modal
|
||||||
isOpen={isOpen}
|
open={isOpen}
|
||||||
onOpenChange={(open) => !open && onClose()}
|
onOpenChange={(open) => !open && onClose()}
|
||||||
title="API Key Created Successfully"
|
title="API Key Created Successfully"
|
||||||
>
|
>
|
||||||
@@ -56,6 +56,6 @@ export const ApiKeySuccessModal = ({
|
|||||||
Acknowledged
|
Acknowledged
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</CustomAlertModal>
|
</Modal>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -5,9 +5,9 @@ import { useForm } from "react-hook-form";
|
|||||||
import * as z from "zod";
|
import * as z from "zod";
|
||||||
|
|
||||||
import { createApiKey } from "@/actions/api-keys/api-keys";
|
import { createApiKey } from "@/actions/api-keys/api-keys";
|
||||||
|
import { Modal } from "@/components/shadcn/modal";
|
||||||
import { useToast } from "@/components/ui";
|
import { useToast } from "@/components/ui";
|
||||||
import { CustomInput } from "@/components/ui/custom";
|
import { CustomInput } from "@/components/ui/custom";
|
||||||
import { CustomAlertModal } from "@/components/ui/custom/custom-alert-modal";
|
|
||||||
import { CustomLink } from "@/components/ui/custom/custom-link";
|
import { CustomLink } from "@/components/ui/custom/custom-link";
|
||||||
import { Form, FormButtons } from "@/components/ui/form";
|
import { Form, FormButtons } from "@/components/ui/form";
|
||||||
|
|
||||||
@@ -86,8 +86,8 @@ export const CreateApiKeyModal = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<CustomAlertModal
|
<Modal
|
||||||
isOpen={isOpen}
|
open={isOpen}
|
||||||
onOpenChange={(open) => !open && handleClose()}
|
onOpenChange={(open) => !open && handleClose()}
|
||||||
title="Create API Key"
|
title="Create API Key"
|
||||||
size="lg"
|
size="lg"
|
||||||
@@ -139,6 +139,6 @@ export const CreateApiKeyModal = ({
|
|||||||
/>
|
/>
|
||||||
</form>
|
</form>
|
||||||
</Form>
|
</Form>
|
||||||
</CustomAlertModal>
|
</Modal>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -6,9 +6,9 @@ import { useForm } from "react-hook-form";
|
|||||||
import * as z from "zod";
|
import * as z from "zod";
|
||||||
|
|
||||||
import { updateApiKey } from "@/actions/api-keys/api-keys";
|
import { updateApiKey } from "@/actions/api-keys/api-keys";
|
||||||
|
import { Modal } from "@/components/shadcn/modal";
|
||||||
import { useToast } from "@/components/ui";
|
import { useToast } from "@/components/ui";
|
||||||
import { CustomInput } from "@/components/ui/custom";
|
import { CustomInput } from "@/components/ui/custom";
|
||||||
import { CustomAlertModal } from "@/components/ui/custom/custom-alert-modal";
|
|
||||||
import { Form, FormButtons } from "@/components/ui/form";
|
import { Form, FormButtons } from "@/components/ui/form";
|
||||||
|
|
||||||
import { EnrichedApiKey } from "./api-keys/types";
|
import { EnrichedApiKey } from "./api-keys/types";
|
||||||
@@ -92,8 +92,8 @@ export const EditApiKeyNameModal = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<CustomAlertModal
|
<Modal
|
||||||
isOpen={isOpen}
|
open={isOpen}
|
||||||
onOpenChange={(open) => !open && handleClose()}
|
onOpenChange={(open) => !open && handleClose()}
|
||||||
title="Edit API Key Name"
|
title="Edit API Key Name"
|
||||||
size="lg"
|
size="lg"
|
||||||
@@ -129,6 +129,6 @@ export const EditApiKeyNameModal = ({
|
|||||||
/>
|
/>
|
||||||
</form>
|
</form>
|
||||||
</Form>
|
</Form>
|
||||||
</CustomAlertModal>
|
</Modal>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { Chip } from "@heroui/chip";
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
|
||||||
import { Button, Card } from "@/components/shadcn";
|
import { Button, Card } from "@/components/shadcn";
|
||||||
import { CustomAlertModal } from "@/components/ui/custom";
|
import { Modal } from "@/components/shadcn/modal";
|
||||||
import { DateWithTime, InfoField } from "@/components/ui/entities";
|
import { DateWithTime, InfoField } from "@/components/ui/entities";
|
||||||
import { MembershipDetailData } from "@/types/users";
|
import { MembershipDetailData } from "@/types/users";
|
||||||
|
|
||||||
@@ -25,17 +25,13 @@ export const MembershipItem = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<CustomAlertModal
|
<Modal open={isEditOpen} onOpenChange={setIsEditOpen} title="">
|
||||||
isOpen={isEditOpen}
|
|
||||||
onOpenChange={setIsEditOpen}
|
|
||||||
title=""
|
|
||||||
>
|
|
||||||
<EditTenantForm
|
<EditTenantForm
|
||||||
tenantId={tenantId}
|
tenantId={tenantId}
|
||||||
tenantName={tenantName}
|
tenantName={tenantName}
|
||||||
setIsOpen={setIsEditOpen}
|
setIsOpen={setIsEditOpen}
|
||||||
/>
|
/>
|
||||||
</CustomAlertModal>
|
</Modal>
|
||||||
<Card variant="inner" className="min-w-[320px] p-2">
|
<Card variant="inner" className="min-w-[320px] p-2">
|
||||||
<div className="flex w-full items-center gap-4">
|
<div className="flex w-full items-center gap-4">
|
||||||
<Chip size="sm" variant="flat" color="secondary">
|
<Chip size="sm" variant="flat" color="secondary">
|
||||||
|
|||||||
@@ -4,12 +4,12 @@ import { Snippet } from "@heroui/snippet";
|
|||||||
import { Trash2Icon } from "lucide-react";
|
import { Trash2Icon } from "lucide-react";
|
||||||
|
|
||||||
import { revokeApiKey } from "@/actions/api-keys/api-keys";
|
import { revokeApiKey } from "@/actions/api-keys/api-keys";
|
||||||
|
import { Modal } from "@/components/shadcn/modal";
|
||||||
import {
|
import {
|
||||||
Alert,
|
Alert,
|
||||||
AlertDescription,
|
AlertDescription,
|
||||||
AlertTitle,
|
AlertTitle,
|
||||||
} from "@/components/ui/alert/Alert";
|
} from "@/components/ui/alert/Alert";
|
||||||
import { CustomAlertModal } from "@/components/ui/custom/custom-alert-modal";
|
|
||||||
import { ModalButtons } from "@/components/ui/custom/custom-modal-buttons";
|
import { ModalButtons } from "@/components/ui/custom/custom-modal-buttons";
|
||||||
|
|
||||||
import { FALLBACK_VALUES } from "./api-keys/constants";
|
import { FALLBACK_VALUES } from "./api-keys/constants";
|
||||||
@@ -49,8 +49,8 @@ export const RevokeApiKeyModal = ({
|
|||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<CustomAlertModal
|
<Modal
|
||||||
isOpen={isOpen}
|
open={isOpen}
|
||||||
onOpenChange={(open) => !open && handleClose()}
|
onOpenChange={(open) => !open && handleClose()}
|
||||||
title="Revoke API Key"
|
title="Revoke API Key"
|
||||||
size="lg"
|
size="lg"
|
||||||
@@ -95,6 +95,6 @@ export const RevokeApiKeyModal = ({
|
|||||||
submitColor="danger"
|
submitColor="danger"
|
||||||
submitIcon={<Trash2Icon size={24} />}
|
submitIcon={<Trash2Icon size={24} />}
|
||||||
/>
|
/>
|
||||||
</CustomAlertModal>
|
</Modal>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ import { useState } from "react";
|
|||||||
|
|
||||||
import { VerticalDotsIcon } from "@/components/icons";
|
import { VerticalDotsIcon } from "@/components/icons";
|
||||||
import { Button } from "@/components/shadcn";
|
import { Button } from "@/components/shadcn";
|
||||||
import { CustomAlertModal } from "@/components/ui/custom";
|
import { Modal } from "@/components/shadcn/modal";
|
||||||
|
|
||||||
import { DeleteForm, EditForm } from "../forms";
|
import { DeleteForm, EditForm } from "../forms";
|
||||||
|
|
||||||
@@ -41,8 +41,8 @@ export function DataTableRowActions<UserProps>({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<CustomAlertModal
|
<Modal
|
||||||
isOpen={isEditOpen}
|
open={isEditOpen}
|
||||||
onOpenChange={setIsEditOpen}
|
onOpenChange={setIsEditOpen}
|
||||||
title="Edit user details"
|
title="Edit user details"
|
||||||
>
|
>
|
||||||
@@ -55,15 +55,15 @@ export function DataTableRowActions<UserProps>({
|
|||||||
roles={roles || []}
|
roles={roles || []}
|
||||||
setIsOpen={setIsEditOpen}
|
setIsOpen={setIsEditOpen}
|
||||||
/>
|
/>
|
||||||
</CustomAlertModal>
|
</Modal>
|
||||||
<CustomAlertModal
|
<Modal
|
||||||
isOpen={isDeleteOpen}
|
open={isDeleteOpen}
|
||||||
onOpenChange={setIsDeleteOpen}
|
onOpenChange={setIsDeleteOpen}
|
||||||
title="Are you absolutely sure?"
|
title="Are you absolutely sure?"
|
||||||
description="This action cannot be undone. This will permanently delete your user account and remove your data from the server."
|
description="This action cannot be undone. This will permanently delete your user account and remove your data from the server."
|
||||||
>
|
>
|
||||||
<DeleteForm userId={userId} setIsOpen={setIsDeleteOpen} />
|
<DeleteForm userId={userId} setIsOpen={setIsDeleteOpen} />
|
||||||
</CustomAlertModal>
|
</Modal>
|
||||||
|
|
||||||
<div className="relative flex items-center justify-end gap-2">
|
<div className="relative flex items-center justify-end gap-2">
|
||||||
<Dropdown
|
<Dropdown
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import {
|
||||||
|
createContext,
|
||||||
|
ReactNode,
|
||||||
|
TransitionStartFunction,
|
||||||
|
useContext,
|
||||||
|
useTransition,
|
||||||
|
} from "react";
|
||||||
|
|
||||||
|
interface FilterTransitionContextType {
|
||||||
|
isPending: boolean;
|
||||||
|
startTransition: TransitionStartFunction;
|
||||||
|
}
|
||||||
|
|
||||||
|
const FilterTransitionContext = createContext<
|
||||||
|
FilterTransitionContextType | undefined
|
||||||
|
>(undefined);
|
||||||
|
|
||||||
|
export const useFilterTransition = () => {
|
||||||
|
const context = useContext(FilterTransitionContext);
|
||||||
|
if (!context) {
|
||||||
|
throw new Error(
|
||||||
|
"useFilterTransition must be used within a FilterTransitionProvider",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return context;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Optional hook that returns undefined if not within a provider.
|
||||||
|
* Useful for components that may or may not be within a provider.
|
||||||
|
*/
|
||||||
|
export const useFilterTransitionOptional = () => {
|
||||||
|
return useContext(FilterTransitionContext);
|
||||||
|
};
|
||||||
|
|
||||||
|
interface FilterTransitionProviderProps {
|
||||||
|
children: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const FilterTransitionProvider = ({
|
||||||
|
children,
|
||||||
|
}: FilterTransitionProviderProps) => {
|
||||||
|
const [isPending, startTransition] = useTransition();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FilterTransitionContext.Provider value={{ isPending, startTransition }}>
|
||||||
|
{children}
|
||||||
|
</FilterTransitionContext.Provider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convenience wrapper that provides filter transition context.
|
||||||
|
* Use this in pages to enable coordinated loading states across
|
||||||
|
* all filter components and the DataTable.
|
||||||
|
*/
|
||||||
|
export const FilterTransitionWrapper = ({
|
||||||
|
children,
|
||||||
|
}: FilterTransitionProviderProps) => {
|
||||||
|
return <FilterTransitionProvider>{children}</FilterTransitionProvider>;
|
||||||
|
};
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
export {
|
||||||
|
FilterTransitionProvider,
|
||||||
|
FilterTransitionWrapper,
|
||||||
|
useFilterTransition,
|
||||||
|
useFilterTransitionOptional,
|
||||||
|
} from "./filter-transition-context";
|
||||||
@@ -1,17 +1,33 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
||||||
import { useCallback } from "react";
|
import { useCallback, useTransition } from "react";
|
||||||
|
|
||||||
|
import { useFilterTransitionOptional } from "@/contexts";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Custom hook to handle URL filters and automatically reset
|
* Custom hook to handle URL filters and automatically reset
|
||||||
* pagination when filters change.
|
* pagination when filters change.
|
||||||
|
*
|
||||||
|
* Uses useTransition to prevent full page reloads when filters change,
|
||||||
|
* keeping the current UI visible while the new data loads.
|
||||||
|
*
|
||||||
|
* When used within a FilterTransitionProvider, the transition state is shared
|
||||||
|
* across all components using this hook, enabling coordinated loading indicators.
|
||||||
*/
|
*/
|
||||||
export const useUrlFilters = () => {
|
export const useUrlFilters = () => {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
|
|
||||||
|
// Use shared context if available, otherwise fall back to local transition
|
||||||
|
const sharedTransition = useFilterTransitionOptional();
|
||||||
|
const [localIsPending, localStartTransition] = useTransition();
|
||||||
|
|
||||||
|
const isPending = sharedTransition?.isPending ?? localIsPending;
|
||||||
|
const startTransition =
|
||||||
|
sharedTransition?.startTransition ?? localStartTransition;
|
||||||
|
|
||||||
const updateFilter = useCallback(
|
const updateFilter = useCallback(
|
||||||
(key: string, value: string | string[] | null) => {
|
(key: string, value: string | string[] | null) => {
|
||||||
const params = new URLSearchParams(searchParams.toString());
|
const params = new URLSearchParams(searchParams.toString());
|
||||||
@@ -41,9 +57,11 @@ export const useUrlFilters = () => {
|
|||||||
params.set(filterKey, nextValue);
|
params.set(filterKey, nextValue);
|
||||||
}
|
}
|
||||||
|
|
||||||
router.push(`${pathname}?${params.toString()}`, { scroll: false });
|
startTransition(() => {
|
||||||
|
router.push(`${pathname}?${params.toString()}`, { scroll: false });
|
||||||
|
});
|
||||||
},
|
},
|
||||||
[router, searchParams, pathname],
|
[router, searchParams, pathname, startTransition],
|
||||||
);
|
);
|
||||||
|
|
||||||
const clearFilter = useCallback(
|
const clearFilter = useCallback(
|
||||||
@@ -58,9 +76,11 @@ export const useUrlFilters = () => {
|
|||||||
params.set("page", "1");
|
params.set("page", "1");
|
||||||
}
|
}
|
||||||
|
|
||||||
router.push(`${pathname}?${params.toString()}`, { scroll: false });
|
startTransition(() => {
|
||||||
|
router.push(`${pathname}?${params.toString()}`, { scroll: false });
|
||||||
|
});
|
||||||
},
|
},
|
||||||
[router, searchParams, pathname],
|
[router, searchParams, pathname, startTransition],
|
||||||
);
|
);
|
||||||
|
|
||||||
const clearAllFilters = useCallback(() => {
|
const clearAllFilters = useCallback(() => {
|
||||||
@@ -73,8 +93,10 @@ export const useUrlFilters = () => {
|
|||||||
|
|
||||||
params.delete("page");
|
params.delete("page");
|
||||||
|
|
||||||
router.push(`${pathname}?${params.toString()}`, { scroll: false });
|
startTransition(() => {
|
||||||
}, [router, searchParams, pathname]);
|
router.push(`${pathname}?${params.toString()}`, { scroll: false });
|
||||||
|
});
|
||||||
|
}, [router, searchParams, pathname, startTransition]);
|
||||||
|
|
||||||
const hasFilters = useCallback(() => {
|
const hasFilters = useCallback(() => {
|
||||||
const params = new URLSearchParams(searchParams.toString());
|
const params = new URLSearchParams(searchParams.toString());
|
||||||
@@ -88,5 +110,6 @@ export const useUrlFilters = () => {
|
|||||||
clearFilter,
|
clearFilter,
|
||||||
clearAllFilters,
|
clearAllFilters,
|
||||||
hasFilters,
|
hasFilters,
|
||||||
|
isPending,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -78,8 +78,7 @@ test.describe("Session Error Messages", () => {
|
|||||||
await context.clearCookies();
|
await context.clearCookies();
|
||||||
|
|
||||||
// Try to navigate to a different protected route
|
// Try to navigate to a different protected route
|
||||||
// Use gotoFresh to ensure middleware runs without cached state
|
await providersPage.goto();
|
||||||
await providersPage.gotoFresh("/providers");
|
|
||||||
|
|
||||||
// Should be redirected to login with callbackUrl
|
// Should be redirected to login with callbackUrl
|
||||||
await signInPage.verifyRedirectWithCallback("/providers");
|
await signInPage.verifyRedirectWithCallback("/providers");
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ export interface ResourceProps {
|
|||||||
service: string;
|
service: string;
|
||||||
tags: Record<string, string>;
|
tags: Record<string, string>;
|
||||||
type: string;
|
type: string;
|
||||||
|
groups: string[] | null;
|
||||||
failed_findings_count: number;
|
failed_findings_count: number;
|
||||||
details: string | null;
|
details: string | null;
|
||||||
partition: string | null;
|
partition: string | null;
|
||||||
|
|||||||
Reference in New Issue
Block a user