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
|
||||
|
||||
- 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)
|
||||
- `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)
|
||||
|
||||
@@ -453,6 +453,8 @@ class ResourceTagFilter(FilterSet):
|
||||
|
||||
|
||||
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_value = CharFilter(method="filter_tag_value")
|
||||
tag = CharFilter(method="filter_tag")
|
||||
@@ -540,6 +542,8 @@ class ResourceFilter(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_value = CharFilter(method="filter_tag_value")
|
||||
tag = CharFilter(method="filter_tag")
|
||||
|
||||
@@ -8186,6 +8186,21 @@ paths:
|
||||
description: Multiple values may be separated by commas.
|
||||
explode: false
|
||||
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
|
||||
name: filter[provider_type]
|
||||
schema:
|
||||
@@ -8646,6 +8661,21 @@ paths:
|
||||
description: Multiple values may be separated by commas.
|
||||
explode: false
|
||||
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
|
||||
name: filter[provider_type]
|
||||
schema:
|
||||
@@ -8942,6 +8972,21 @@ paths:
|
||||
description: Multiple values may be separated by commas.
|
||||
explode: false
|
||||
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
|
||||
name: filter[provider_type]
|
||||
schema:
|
||||
@@ -9244,6 +9289,21 @@ paths:
|
||||
description: Multiple values may be separated by commas.
|
||||
explode: false
|
||||
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
|
||||
name: filter[provider_type]
|
||||
schema:
|
||||
|
||||
@@ -4356,6 +4356,108 @@ class TestResourceViewSet:
|
||||
assert attributes["types"] == [latest_scan_resource.type]
|
||||
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
|
||||
def test_events_non_aws_provider(self, authenticated_client, providers_fixture):
|
||||
"""Test events endpoint rejects non-AWS providers."""
|
||||
|
||||
@@ -6,6 +6,7 @@ All notable changes to the **Prowler UI** are documented in this file.
|
||||
|
||||
### 🔄 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)
|
||||
- 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,
|
||||
}: TimeRangeSelectorProps) => {
|
||||
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]) => (
|
||||
<button
|
||||
key={key}
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
SkeletonTableFindings,
|
||||
} from "@/components/findings/table";
|
||||
import { ContentLayout } from "@/components/ui";
|
||||
import { FilterTransitionWrapper } from "@/contexts";
|
||||
import {
|
||||
createDict,
|
||||
createScanDetailsMapping,
|
||||
@@ -36,8 +37,7 @@ export default async function Findings({
|
||||
searchParams: Promise<SearchParamsProps>;
|
||||
}) {
|
||||
const resolvedSearchParams = await searchParams;
|
||||
const { searchParamsKey, encodedSort } =
|
||||
extractSortAndKey(resolvedSearchParams);
|
||||
const { encodedSort } = extractSortAndKey(resolvedSearchParams);
|
||||
const { filters, query } = extractFiltersAndQuery(resolvedSearchParams);
|
||||
|
||||
// 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 scan = scanId ? scanDict[scanId] : undefined;
|
||||
const providerId = scan?.relationships?.provider?.data?.id;
|
||||
|
||||
const resource = resourceId ? resourceDict[resourceId] : undefined;
|
||||
const provider = providerId ? providerDict[providerId] : undefined;
|
||||
|
||||
@@ -148,6 +147,7 @@ export default async function Findings({
|
||||
|
||||
return (
|
||||
<ContentLayout title="Findings" icon="lucide:tag">
|
||||
<FilterTransitionWrapper>
|
||||
<div className="mb-6">
|
||||
<FindingsFilters
|
||||
providers={providersData?.data || []}
|
||||
@@ -163,9 +163,10 @@ export default async function Findings({
|
||||
uniqueGroups={uniqueGroups}
|
||||
/>
|
||||
</div>
|
||||
<Suspense key={searchParamsKey} fallback={<SkeletonTableFindings />}>
|
||||
<Suspense fallback={<SkeletonTableFindings />}>
|
||||
<SSRDataTable searchParams={resolvedSearchParams} />
|
||||
</Suspense>
|
||||
</FilterTransitionWrapper>
|
||||
{processedFinding && <FindingDetailsSheet finding={processedFinding} />}
|
||||
</ContentLayout>
|
||||
);
|
||||
|
||||
@@ -14,8 +14,8 @@ import {
|
||||
import { DeleteLLMProviderForm } from "@/components/lighthouse/forms/delete-llm-provider-form";
|
||||
import { WorkflowConnectLLM } from "@/components/lighthouse/workflow";
|
||||
import { Button } from "@/components/shadcn";
|
||||
import { Modal } from "@/components/shadcn/modal";
|
||||
import { NavigationHeader } from "@/components/ui";
|
||||
import { CustomAlertModal } from "@/components/ui/custom";
|
||||
import type { LighthouseProvider } from "@/types/lighthouse";
|
||||
|
||||
interface ConnectLLMLayoutProps {
|
||||
@@ -63,8 +63,8 @@ export default function ConnectLLMLayout({ children }: ConnectLLMLayoutProps) {
|
||||
|
||||
return (
|
||||
<>
|
||||
<CustomAlertModal
|
||||
isOpen={isDeleteOpen}
|
||||
<Modal
|
||||
open={isDeleteOpen}
|
||||
onOpenChange={setIsDeleteOpen}
|
||||
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."
|
||||
@@ -73,7 +73,7 @@ export default function ConnectLLMLayout({ children }: ConnectLLMLayoutProps) {
|
||||
providerType={provider}
|
||||
setIsOpen={setIsDeleteOpen}
|
||||
/>
|
||||
</CustomAlertModal>
|
||||
</Modal>
|
||||
|
||||
<NavigationHeader
|
||||
title={isEditMode ? "Configure LLM Provider" : "Connect LLM Provider"}
|
||||
|
||||
@@ -11,8 +11,8 @@ import {
|
||||
updateMutedFindingsConfig,
|
||||
} from "@/actions/processors";
|
||||
import { Button, Card, Skeleton } from "@/components/shadcn";
|
||||
import { Modal } from "@/components/shadcn/modal";
|
||||
import { useToast } from "@/components/ui";
|
||||
import { CustomAlertModal } from "@/components/ui/custom";
|
||||
import { CustomLink } from "@/components/ui/custom/custom-link";
|
||||
import { fontMono } from "@/config/fonts";
|
||||
import {
|
||||
@@ -152,8 +152,8 @@ export function AdvancedMutelistForm() {
|
||||
return (
|
||||
<>
|
||||
{/* Delete Confirmation Modal */}
|
||||
<CustomAlertModal
|
||||
isOpen={showDeleteConfirmation}
|
||||
<Modal
|
||||
open={showDeleteConfirmation}
|
||||
onOpenChange={setShowDeleteConfirmation}
|
||||
title="Delete Mutelist Configuration"
|
||||
size="md"
|
||||
@@ -185,7 +185,7 @@ export function AdvancedMutelistForm() {
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CustomAlertModal>
|
||||
</Modal>
|
||||
|
||||
<Card variant="base" className="p-6">
|
||||
<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 { MuteRuleData } from "@/actions/mute-rules/types";
|
||||
import { Button } from "@/components/shadcn";
|
||||
import { Modal } from "@/components/shadcn/modal";
|
||||
import { useToast } from "@/components/ui";
|
||||
import { CustomAlertModal } from "@/components/ui/custom";
|
||||
import { DataTable } from "@/components/ui/table";
|
||||
import { MetaDataProps } from "@/types";
|
||||
|
||||
@@ -82,8 +82,8 @@ export function MuteRulesTableClient({
|
||||
|
||||
{/* Edit Modal */}
|
||||
{selectedMuteRule && (
|
||||
<CustomAlertModal
|
||||
isOpen={editModal.isOpen}
|
||||
<Modal
|
||||
open={editModal.isOpen}
|
||||
onOpenChange={editModal.onOpenChange}
|
||||
title="Edit Mute Rule"
|
||||
size="lg"
|
||||
@@ -93,13 +93,13 @@ export function MuteRulesTableClient({
|
||||
onSuccess={handleEditSuccess}
|
||||
onCancel={editModal.onClose}
|
||||
/>
|
||||
</CustomAlertModal>
|
||||
</Modal>
|
||||
)}
|
||||
|
||||
{/* Delete Confirmation Modal */}
|
||||
{selectedMuteRule && (
|
||||
<CustomAlertModal
|
||||
isOpen={deleteModal.isOpen}
|
||||
<Modal
|
||||
open={deleteModal.isOpen}
|
||||
onOpenChange={deleteModal.onOpenChange}
|
||||
title="Delete Mute Rule"
|
||||
size="md"
|
||||
@@ -138,7 +138,7 @@ export function MuteRulesTableClient({
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</CustomAlertModal>
|
||||
</Modal>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -5,14 +5,15 @@ import {
|
||||
getLatestMetadataInfo,
|
||||
getLatestResources,
|
||||
getMetadataInfo,
|
||||
getResourceById,
|
||||
getResources,
|
||||
} from "@/actions/resources";
|
||||
import { FilterControls } from "@/components/filters";
|
||||
import { ResourceDetailsSheet } from "@/components/resources/resource-details-sheet";
|
||||
import { ResourcesFilters } from "@/components/resources/resources-filters";
|
||||
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 { DataTable } from "@/components/ui/table";
|
||||
import { FilterTransitionWrapper } from "@/contexts";
|
||||
import {
|
||||
createDict,
|
||||
extractFiltersAndQuery,
|
||||
@@ -20,10 +21,6 @@ import {
|
||||
hasDateOrScanFilter,
|
||||
replaceFieldKey,
|
||||
} from "@/lib";
|
||||
import {
|
||||
createProviderDetailsMappingById,
|
||||
extractProviderIds,
|
||||
} from "@/lib/provider-helpers";
|
||||
import { ResourceProps, SearchParamsProps } from "@/types";
|
||||
|
||||
export default async function Resources({
|
||||
@@ -32,49 +29,73 @@ export default async function Resources({
|
||||
searchParams: Promise<SearchParamsProps>;
|
||||
}) {
|
||||
const resolvedSearchParams = await searchParams;
|
||||
const { searchParamsKey, encodedSort } =
|
||||
extractSortAndKey(resolvedSearchParams);
|
||||
const { encodedSort } = extractSortAndKey(resolvedSearchParams);
|
||||
const { filters, query } = extractFiltersAndQuery(resolvedSearchParams);
|
||||
const outputFilters = replaceFieldKey(filters, "inserted_at", "updated_at");
|
||||
|
||||
// Check if the searchParams contain any date or scan filter
|
||||
const hasDateOrScan = hasDateOrScanFilter(resolvedSearchParams);
|
||||
|
||||
const [metadataInfoData, providersData] = await Promise.all([
|
||||
// Check if there's a specific resource ID to fetch
|
||||
const resourceId = resolvedSearchParams.resourceId?.toString();
|
||||
|
||||
const [metadataInfoData, providersData, resourceByIdData] = await Promise.all(
|
||||
[
|
||||
(hasDateOrScan ? getMetadataInfo : getLatestMetadataInfo)({
|
||||
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
|
||||
const uniqueRegions = metadataInfoData?.data?.attributes?.regions || [];
|
||||
const uniqueServices = metadataInfoData?.data?.attributes?.services || [];
|
||||
const uniqueGroups = metadataInfoData?.data?.attributes?.groups || [];
|
||||
|
||||
// Extract provider IDs and details
|
||||
const providerIds = providersData ? extractProviderIds(providersData) : [];
|
||||
const providerDetails = providersData
|
||||
? createProviderDetailsMappingById(providerIds, providersData)
|
||||
: [];
|
||||
|
||||
return (
|
||||
<ContentLayout title="Resources" icon="lucide:warehouse">
|
||||
<FilterControls search date />
|
||||
<div className="flex flex-col gap-6">
|
||||
<FilterTransitionWrapper>
|
||||
<div className="mb-6">
|
||||
<ResourcesFilters
|
||||
providerIds={providerIds}
|
||||
providerDetails={providerDetails}
|
||||
providers={providersData?.data || []}
|
||||
uniqueRegions={uniqueRegions}
|
||||
uniqueServices={uniqueServices}
|
||||
uniqueGroups={uniqueGroups}
|
||||
/>
|
||||
<Suspense key={searchParamsKey} fallback={<SkeletonTableResources />}>
|
||||
</div>
|
||||
<Suspense fallback={<SkeletonTableResources />}>
|
||||
<SSRDataTable searchParams={resolvedSearchParams} />
|
||||
</Suspense>
|
||||
</div>
|
||||
</FilterTransitionWrapper>
|
||||
{processedResource && (
|
||||
<ResourceDetailsSheet resource={processedResource} />
|
||||
)}
|
||||
</ContentLayout>
|
||||
);
|
||||
}
|
||||
@@ -112,6 +133,7 @@ const SSRDataTable = async ({
|
||||
"region",
|
||||
"service",
|
||||
"type",
|
||||
"groups",
|
||||
"provider",
|
||||
"inserted_at",
|
||||
"updated_at",
|
||||
@@ -150,9 +172,7 @@ const SSRDataTable = async ({
|
||||
<p>{resourcesData.errors[0].detail}</p>
|
||||
</div>
|
||||
)}
|
||||
<DataTable
|
||||
key={`resources-${Date.now()}`}
|
||||
columns={ColumnResources}
|
||||
<ResourcesTableWithSelection
|
||||
data={expandedResources || []}
|
||||
metadata={resourcesData?.meta}
|
||||
/>
|
||||
|
||||
+10
-1
@@ -1,4 +1,5 @@
|
||||
import { jwtDecode, type JwtPayload } from "jwt-decode";
|
||||
import { NextResponse } from "next/server";
|
||||
import NextAuth, {
|
||||
type DefaultSession,
|
||||
type NextAuthConfig,
|
||||
@@ -277,6 +278,7 @@ export const authConfig = {
|
||||
callbacks: {
|
||||
authorized({ auth, request: { nextUrl } }) {
|
||||
const isLoggedIn = !!auth?.user;
|
||||
const sessionError = auth?.error;
|
||||
const isSignUpPage = nextUrl.pathname === "/sign-up";
|
||||
const isSignInPage = nextUrl.pathname === "/sign-in";
|
||||
|
||||
@@ -284,8 +286,15 @@ export const authConfig = {
|
||||
if (isSignUpPage || isSignInPage) return true;
|
||||
|
||||
// For all other routes, require authentication
|
||||
// Return NextResponse.redirect to preserve callbackUrl for post-login redirect
|
||||
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;
|
||||
|
||||
@@ -32,6 +32,7 @@ export const SignInForm = ({
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const { toast } = useToast();
|
||||
const callbackUrl = searchParams.get("callbackUrl") || "/";
|
||||
|
||||
useEffect(() => {
|
||||
const samlError = searchParams.get("sso_saml_failed");
|
||||
@@ -122,7 +123,7 @@ export const SignInForm = ({
|
||||
});
|
||||
|
||||
if (result?.message === "Success") {
|
||||
router.push("/");
|
||||
router.push(callbackUrl);
|
||||
} else if (result?.errors && "credentials" in result.errors) {
|
||||
const message = result.errors.credentials ?? "Invalid email or password";
|
||||
|
||||
|
||||
@@ -4,15 +4,16 @@ import { Input, Textarea } from "@heroui/input";
|
||||
import {
|
||||
Dispatch,
|
||||
SetStateAction,
|
||||
useActionState,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
useTransition,
|
||||
} from "react";
|
||||
|
||||
import { createMuteRule } from "@/actions/mute-rules";
|
||||
import { MuteRuleActionState } from "@/actions/mute-rules/types";
|
||||
import { Modal } from "@/components/shadcn/modal";
|
||||
import { useToast } from "@/components/ui";
|
||||
import { CustomAlertModal } from "@/components/ui/custom";
|
||||
import { FormButtons } from "@/components/ui/form";
|
||||
|
||||
interface MuteFindingsModalProps {
|
||||
@@ -29,6 +30,8 @@ export function MuteFindingsModal({
|
||||
onComplete,
|
||||
}: MuteFindingsModalProps) {
|
||||
const { toast } = useToast();
|
||||
const [state, setState] = useState<MuteRuleActionState | null>(null);
|
||||
const [isPending, startTransition] = useTransition();
|
||||
|
||||
// Use refs to avoid stale closures in useEffect
|
||||
const onCompleteRef = useRef(onComplete);
|
||||
@@ -37,18 +40,12 @@ export function MuteFindingsModal({
|
||||
const onOpenChangeRef = useRef(onOpenChange);
|
||||
onOpenChangeRef.current = onOpenChange;
|
||||
|
||||
const [state, formAction, isPending] = useActionState<
|
||||
MuteRuleActionState,
|
||||
FormData
|
||||
>(createMuteRule, null);
|
||||
|
||||
useEffect(() => {
|
||||
if (state?.success) {
|
||||
toast({
|
||||
title: "Success",
|
||||
description: state.success,
|
||||
});
|
||||
// Call onComplete BEFORE closing the modal to ensure router.refresh() executes
|
||||
onCompleteRef.current?.();
|
||||
onOpenChangeRef.current(false);
|
||||
} else if (state?.errors?.general) {
|
||||
@@ -65,13 +62,26 @@ export function MuteFindingsModal({
|
||||
};
|
||||
|
||||
return (
|
||||
<CustomAlertModal
|
||||
isOpen={isOpen}
|
||||
<Modal
|
||||
open={isOpen}
|
||||
onOpenChange={onOpenChange}
|
||||
title="Mute Findings"
|
||||
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
|
||||
type="hidden"
|
||||
name="finding_ids"
|
||||
@@ -125,6 +135,6 @@ export function MuteFindingsModal({
|
||||
isDisabled={isPending}
|
||||
/>
|
||||
</form>
|
||||
</CustomAlertModal>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -15,8 +15,8 @@ import {
|
||||
sendFindingToJira,
|
||||
} from "@/actions/integrations/jira-dispatch";
|
||||
import { JiraIcon } from "@/components/icons/services/IconServices";
|
||||
import { Modal } from "@/components/shadcn/modal";
|
||||
import { useToast } from "@/components/ui";
|
||||
import { CustomAlertModal } from "@/components/ui/custom";
|
||||
import { CustomBanner } from "@/components/ui/custom/custom-banner";
|
||||
import {
|
||||
Form,
|
||||
@@ -215,8 +215,8 @@ export const SendToJiraModal = ({
|
||||
// }, [issueTypes, searchIssueTypeValue]);
|
||||
|
||||
return (
|
||||
<CustomAlertModal
|
||||
isOpen={isOpen}
|
||||
<Modal
|
||||
open={isOpen}
|
||||
onOpenChange={onOpenChange}
|
||||
title="Send Finding to Jira"
|
||||
description={
|
||||
@@ -389,6 +389,6 @@ export const SendToJiraModal = ({
|
||||
)}
|
||||
</form>
|
||||
</Form>
|
||||
</CustomAlertModal>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -7,7 +7,8 @@ import { useSearchParams } from "next/navigation";
|
||||
import { FindingDetail } from "@/components/findings/table";
|
||||
import { DataTableRowActions } from "@/components/findings/table";
|
||||
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 {
|
||||
DataTableColumnHeader,
|
||||
SeverityBadge,
|
||||
@@ -186,7 +187,7 @@ export function getColumnFindings(
|
||||
const resourceName = getResourceData(row, "name");
|
||||
|
||||
return (
|
||||
<SnippetChip
|
||||
<CodeSnippet
|
||||
value={resourceName as string}
|
||||
formatter={(value: string) => `...${value.slice(-10)}`}
|
||||
icon={<Database size={16} />}
|
||||
|
||||
@@ -1,12 +1,5 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
Dropdown,
|
||||
DropdownItem,
|
||||
DropdownMenu,
|
||||
DropdownSection,
|
||||
DropdownTrigger,
|
||||
} from "@heroui/dropdown";
|
||||
import { Row } from "@tanstack/react-table";
|
||||
import { VolumeOff, VolumeX } from "lucide-react";
|
||||
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 { VerticalDotsIcon } from "@/components/icons";
|
||||
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";
|
||||
|
||||
interface DataTableRowActionsProps {
|
||||
row: Row<FindingProps>;
|
||||
export interface FindingRowData {
|
||||
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 finding = row.original;
|
||||
const [isJiraModalOpen, setIsJiraModalOpen] = useState(false);
|
||||
@@ -67,11 +77,31 @@ export function DataTableRowActions({ row }: DataTableRowActionsProps) {
|
||||
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 = () => {
|
||||
// Always clear selection when a finding is muted because:
|
||||
// 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
|
||||
clearSelection();
|
||||
if (onMuteComplete) {
|
||||
onMuteComplete(getMuteIds());
|
||||
return;
|
||||
}
|
||||
|
||||
router.refresh();
|
||||
};
|
||||
|
||||
@@ -92,61 +122,43 @@ export function DataTableRowActions({ row }: DataTableRowActionsProps) {
|
||||
/>
|
||||
|
||||
<div className="flex items-center justify-end">
|
||||
<Dropdown
|
||||
className="border-border-neutral-secondary bg-bg-neutral-secondary border shadow-xl"
|
||||
placement="bottom"
|
||||
<ActionDropdown
|
||||
trigger={
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Finding actions"
|
||||
className="hover:bg-bg-neutral-tertiary rounded-md p-1 transition-colors"
|
||||
>
|
||||
<DropdownTrigger aria-label="Finding actions">
|
||||
<VerticalDotsIcon
|
||||
size={20}
|
||||
className="text-text-neutral-secondary"
|
||||
/>
|
||||
</DropdownTrigger>
|
||||
<DropdownMenu
|
||||
closeOnSelect
|
||||
aria-label="Finding actions"
|
||||
color="default"
|
||||
variant="flat"
|
||||
</button>
|
||||
}
|
||||
ariaLabel="Finding actions"
|
||||
>
|
||||
<DropdownSection title="Actions">
|
||||
<DropdownItem
|
||||
key="mute"
|
||||
description={getMuteDescription()}
|
||||
textValue="Mute"
|
||||
isDisabled={isMuted}
|
||||
startContent={
|
||||
<ActionDropdownItem
|
||||
icon={
|
||||
isMuted ? (
|
||||
<VolumeOff className="text-default-300 pointer-events-none size-5 shrink-0" />
|
||||
<VolumeOff className="size-5" />
|
||||
) : (
|
||||
<VolumeX className="text-default-500 pointer-events-none size-5 shrink-0" />
|
||||
<VolumeX className="size-5" />
|
||||
)
|
||||
}
|
||||
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"
|
||||
label={getMuteLabel()}
|
||||
description={getMuteDescription()}
|
||||
disabled={isMuted}
|
||||
onSelect={() => {
|
||||
setIsMuteModalOpen(true);
|
||||
}}
|
||||
/>
|
||||
}
|
||||
onPress={() => setIsJiraModalOpen(true)}
|
||||
>
|
||||
Send to Jira
|
||||
</DropdownItem>
|
||||
</DropdownSection>
|
||||
</DropdownMenu>
|
||||
</Dropdown>
|
||||
<ActionDropdownItem
|
||||
icon={<JiraIcon size={20} />}
|
||||
label="Send to Jira"
|
||||
description="Create a Jira issue for this finding"
|
||||
onSelect={() => setIsJiraModalOpen(true)}
|
||||
/>
|
||||
</ActionDropdown>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -181,13 +181,13 @@ export const FindingDetail = ({
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
<InfoField label="Check ID" variant="simple">
|
||||
<CodeSnippet value={attributes.check_id} />
|
||||
<CodeSnippet value={attributes.check_id} className="max-w-full" />
|
||||
</InfoField>
|
||||
<InfoField label="Finding ID" variant="simple">
|
||||
<CodeSnippet value={findingDetails.id} />
|
||||
<CodeSnippet value={findingDetails.id} className="max-w-full" />
|
||||
</InfoField>
|
||||
<InfoField label="Finding UID" variant="simple">
|
||||
<CodeSnippet value={attributes.uid} />
|
||||
<CodeSnippet value={attributes.uid} className="max-w-full" />
|
||||
</InfoField>
|
||||
<InfoField label="First seen" variant="simple">
|
||||
<DateWithTime inline dateTime={attributes.first_seen_at || "-"} />
|
||||
|
||||
@@ -16,8 +16,8 @@ import {
|
||||
IntegrationSkeleton,
|
||||
} from "@/components/integrations/shared";
|
||||
import { Button } from "@/components/shadcn";
|
||||
import { Modal } from "@/components/shadcn/modal";
|
||||
import { useToast } from "@/components/ui";
|
||||
import { CustomAlertModal } from "@/components/ui/custom";
|
||||
import { DataTablePagination } from "@/components/ui/table/data-table-pagination";
|
||||
import { triggerTestConnectionWithDelay } from "@/lib/integrations/test-connection-helper";
|
||||
import { MetaDataProps } from "@/types";
|
||||
@@ -209,8 +209,8 @@ export const JiraIntegrationsManager = ({
|
||||
|
||||
return (
|
||||
<>
|
||||
<CustomAlertModal
|
||||
isOpen={isDeleteOpen}
|
||||
<Modal
|
||||
open={isDeleteOpen}
|
||||
onOpenChange={setIsDeleteOpen}
|
||||
title="Delete 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"}
|
||||
</Button>
|
||||
</div>
|
||||
</CustomAlertModal>
|
||||
</Modal>
|
||||
|
||||
<CustomAlertModal
|
||||
isOpen={isModalOpen}
|
||||
<Modal
|
||||
open={isModalOpen}
|
||||
onOpenChange={setIsModalOpen}
|
||||
title={
|
||||
editingIntegration
|
||||
@@ -259,7 +259,7 @@ export const JiraIntegrationsManager = ({
|
||||
onSuccess={handleFormSuccess}
|
||||
onCancel={handleModalClose}
|
||||
/>
|
||||
</CustomAlertModal>
|
||||
</Modal>
|
||||
|
||||
<div className="flex flex-col gap-6">
|
||||
{/* Header with Add Button */}
|
||||
|
||||
@@ -16,8 +16,8 @@ import {
|
||||
IntegrationSkeleton,
|
||||
} from "@/components/integrations/shared";
|
||||
import { Button } from "@/components/shadcn";
|
||||
import { Modal } from "@/components/shadcn/modal";
|
||||
import { useToast } from "@/components/ui";
|
||||
import { CustomAlertModal } from "@/components/ui/custom";
|
||||
import { DataTablePagination } from "@/components/ui/table/data-table-pagination";
|
||||
import { triggerTestConnectionWithDelay } from "@/lib/integrations/test-connection-helper";
|
||||
import { MetaDataProps } from "@/types";
|
||||
@@ -209,8 +209,8 @@ export const S3IntegrationsManager = ({
|
||||
|
||||
return (
|
||||
<>
|
||||
<CustomAlertModal
|
||||
isOpen={isDeleteOpen}
|
||||
<Modal
|
||||
open={isDeleteOpen}
|
||||
onOpenChange={setIsDeleteOpen}
|
||||
title="Delete 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"}
|
||||
</Button>
|
||||
</div>
|
||||
</CustomAlertModal>
|
||||
</Modal>
|
||||
|
||||
<CustomAlertModal
|
||||
isOpen={isModalOpen}
|
||||
<Modal
|
||||
open={isModalOpen}
|
||||
onOpenChange={setIsModalOpen}
|
||||
title={
|
||||
editMode === "configuration"
|
||||
@@ -265,7 +265,7 @@ export const S3IntegrationsManager = ({
|
||||
onCancel={handleModalClose}
|
||||
editMode={editMode}
|
||||
/>
|
||||
</CustomAlertModal>
|
||||
</Modal>
|
||||
|
||||
<div className="flex flex-col gap-6">
|
||||
{/* Header with Add Button */}
|
||||
|
||||
@@ -5,8 +5,8 @@ import { useState } from "react";
|
||||
|
||||
import { deleteSamlConfig } from "@/actions/integrations";
|
||||
import { Button } from "@/components/shadcn";
|
||||
import { Modal } from "@/components/shadcn/modal";
|
||||
import { useToast } from "@/components/ui";
|
||||
import { CustomAlertModal } from "@/components/ui/custom";
|
||||
import { CustomLink } from "@/components/ui/custom/custom-link";
|
||||
|
||||
import { Card, CardContent, CardHeader } from "../../shadcn";
|
||||
@@ -53,8 +53,8 @@ export const SamlIntegrationCard = ({ samlConfig }: { samlConfig?: any }) => {
|
||||
return (
|
||||
<>
|
||||
{/* Configure SAML Modal */}
|
||||
<CustomAlertModal
|
||||
isOpen={isSamlModalOpen}
|
||||
<Modal
|
||||
open={isSamlModalOpen}
|
||||
onOpenChange={setIsSamlModalOpen}
|
||||
title="Configure SAML SSO"
|
||||
>
|
||||
@@ -62,11 +62,11 @@ export const SamlIntegrationCard = ({ samlConfig }: { samlConfig?: any }) => {
|
||||
setIsOpen={setIsSamlModalOpen}
|
||||
samlConfig={samlConfig}
|
||||
/>
|
||||
</CustomAlertModal>
|
||||
</Modal>
|
||||
|
||||
{/* Delete Confirmation Modal */}
|
||||
<CustomAlertModal
|
||||
isOpen={isDeleteModalOpen}
|
||||
<Modal
|
||||
open={isDeleteModalOpen}
|
||||
onOpenChange={setIsDeleteModalOpen}
|
||||
title="Remove SAML Configuration"
|
||||
size="md"
|
||||
@@ -98,7 +98,7 @@ export const SamlIntegrationCard = ({ samlConfig }: { samlConfig?: any }) => {
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CustomAlertModal>
|
||||
</Modal>
|
||||
|
||||
<Card variant="base" padding="lg">
|
||||
<CardHeader>
|
||||
|
||||
@@ -17,8 +17,8 @@ import {
|
||||
IntegrationSkeleton,
|
||||
} from "@/components/integrations/shared";
|
||||
import { Button } from "@/components/shadcn";
|
||||
import { Modal } from "@/components/shadcn/modal";
|
||||
import { useToast } from "@/components/ui";
|
||||
import { CustomAlertModal } from "@/components/ui/custom";
|
||||
import { DataTablePagination } from "@/components/ui/table/data-table-pagination";
|
||||
import { triggerTestConnectionWithDelay } from "@/lib/integrations/test-connection-helper";
|
||||
import { MetaDataProps } from "@/types";
|
||||
@@ -259,8 +259,8 @@ export const SecurityHubIntegrationsManager = ({
|
||||
|
||||
return (
|
||||
<>
|
||||
<CustomAlertModal
|
||||
isOpen={isDeleteOpen}
|
||||
<Modal
|
||||
open={isDeleteOpen}
|
||||
onOpenChange={setIsDeleteOpen}
|
||||
title="Delete 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"}
|
||||
</Button>
|
||||
</div>
|
||||
</CustomAlertModal>
|
||||
</Modal>
|
||||
|
||||
<CustomAlertModal
|
||||
isOpen={isModalOpen}
|
||||
<Modal
|
||||
open={isModalOpen}
|
||||
onOpenChange={setIsModalOpen}
|
||||
title={
|
||||
editMode === "configuration"
|
||||
@@ -316,7 +316,7 @@ export const SecurityHubIntegrationsManager = ({
|
||||
onCancel={handleModalClose}
|
||||
editMode={editMode}
|
||||
/>
|
||||
</CustomAlertModal>
|
||||
</Modal>
|
||||
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex items-center justify-between">
|
||||
|
||||
@@ -19,7 +19,7 @@ import { useState } from "react";
|
||||
|
||||
import { VerticalDotsIcon } from "@/components/icons";
|
||||
import { Button } from "@/components/shadcn";
|
||||
import { CustomAlertModal } from "@/components/ui/custom";
|
||||
import { Modal } from "@/components/shadcn/modal";
|
||||
|
||||
import { DeleteForm, EditForm } from "../forms";
|
||||
|
||||
@@ -44,8 +44,8 @@ export function DataTableRowActions<InvitationProps>({
|
||||
|
||||
return (
|
||||
<>
|
||||
<CustomAlertModal
|
||||
isOpen={isEditOpen}
|
||||
<Modal
|
||||
open={isEditOpen}
|
||||
onOpenChange={setIsEditOpen}
|
||||
title="Edit invitation details"
|
||||
>
|
||||
@@ -56,15 +56,15 @@ export function DataTableRowActions<InvitationProps>({
|
||||
roles={roles || []}
|
||||
setIsOpen={setIsEditOpen}
|
||||
/>
|
||||
</CustomAlertModal>
|
||||
<CustomAlertModal
|
||||
isOpen={isDeleteOpen}
|
||||
</Modal>
|
||||
<Modal
|
||||
open={isDeleteOpen}
|
||||
onOpenChange={setIsDeleteOpen}
|
||||
title="Are you absolutely sure?"
|
||||
description="This action cannot be undone. This will permanently revoke your invitation."
|
||||
>
|
||||
<DeleteForm invitationId={invitationId} setIsOpen={setIsDeleteOpen} />
|
||||
</CustomAlertModal>
|
||||
</Modal>
|
||||
|
||||
<div className="relative flex items-center justify-end gap-2">
|
||||
<Dropdown
|
||||
|
||||
@@ -18,7 +18,7 @@ import { useState } from "react";
|
||||
|
||||
import { VerticalDotsIcon } from "@/components/icons";
|
||||
import { Button } from "@/components/shadcn";
|
||||
import { CustomAlertModal } from "@/components/ui/custom";
|
||||
import { Modal } from "@/components/shadcn/modal";
|
||||
|
||||
import { DeleteGroupForm } from "../forms";
|
||||
|
||||
@@ -37,14 +37,14 @@ export function DataTableRowActions<ProviderProps>({
|
||||
|
||||
return (
|
||||
<>
|
||||
<CustomAlertModal
|
||||
isOpen={isDeleteOpen}
|
||||
<Modal
|
||||
open={isDeleteOpen}
|
||||
onOpenChange={setIsDeleteOpen}
|
||||
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."
|
||||
>
|
||||
<DeleteGroupForm groupId={groupId} setIsOpen={setIsDeleteOpen} />
|
||||
</CustomAlertModal>
|
||||
</Modal>
|
||||
|
||||
<div className="relative flex items-center justify-end gap-2">
|
||||
<Dropdown
|
||||
|
||||
@@ -52,7 +52,7 @@ export const ProviderInfo = ({
|
||||
return (
|
||||
<div className="flex items-center text-sm">
|
||||
<div className="flex items-center gap-4">
|
||||
{getProviderLogo(provider)}
|
||||
<div className="shrink-0">{getProviderLogo(provider)}</div>
|
||||
{getIcon()}
|
||||
<span className="font-medium">{providerAlias || providerUID}</span>
|
||||
</div>
|
||||
|
||||
@@ -20,7 +20,7 @@ import { useState } from "react";
|
||||
import { checkConnectionProvider } from "@/actions/providers/providers";
|
||||
import { VerticalDotsIcon } from "@/components/icons";
|
||||
import { Button } from "@/components/shadcn";
|
||||
import { CustomAlertModal } from "@/components/ui/custom";
|
||||
import { Modal } from "@/components/shadcn/modal";
|
||||
|
||||
import { EditForm } from "../forms";
|
||||
import { DeleteForm } from "../forms/delete-form";
|
||||
@@ -61,8 +61,8 @@ export function DataTableRowActions<ProviderProps>({
|
||||
|
||||
return (
|
||||
<>
|
||||
<CustomAlertModal
|
||||
isOpen={isEditOpen}
|
||||
<Modal
|
||||
open={isEditOpen}
|
||||
onOpenChange={setIsEditOpen}
|
||||
title="Edit Provider Alias"
|
||||
>
|
||||
@@ -71,15 +71,15 @@ export function DataTableRowActions<ProviderProps>({
|
||||
providerAlias={providerAlias}
|
||||
setIsOpen={setIsEditOpen}
|
||||
/>
|
||||
</CustomAlertModal>
|
||||
<CustomAlertModal
|
||||
isOpen={isDeleteOpen}
|
||||
</Modal>
|
||||
<Modal
|
||||
open={isDeleteOpen}
|
||||
onOpenChange={setIsDeleteOpen}
|
||||
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."
|
||||
>
|
||||
<DeleteForm providerId={providerId} setIsOpen={setIsDeleteOpen} />
|
||||
</CustomAlertModal>
|
||||
</Modal>
|
||||
|
||||
<div className="relative flex items-center justify-end gap-2">
|
||||
<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";
|
||||
|
||||
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 { getGroupLabel } from "@/lib/categories";
|
||||
import { FilterEntity } from "@/types";
|
||||
import { ProviderProps } from "@/types/providers";
|
||||
|
||||
interface ResourcesFiltersProps {
|
||||
providerIds: string[];
|
||||
providerDetails: { [id: string]: FilterEntity }[];
|
||||
providers: ProviderProps[];
|
||||
uniqueRegions: string[];
|
||||
uniqueServices: string[];
|
||||
uniqueGroups: string[];
|
||||
}
|
||||
|
||||
export const ResourcesFilters = ({
|
||||
providerIds,
|
||||
providerDetails,
|
||||
providers,
|
||||
uniqueRegions,
|
||||
uniqueServices,
|
||||
uniqueGroups,
|
||||
}: ResourcesFiltersProps) => {
|
||||
return (
|
||||
<DataTableFilterCustom
|
||||
filters={[
|
||||
{
|
||||
key: "provider__in",
|
||||
labelCheckboxGroup: "Provider",
|
||||
values: providerIds,
|
||||
valueLabelMapping: providerDetails,
|
||||
},
|
||||
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: "Resource Group",
|
||||
labelCheckboxGroup: "Group",
|
||||
values: uniqueGroups,
|
||||
labelFormatter: getGroupLabel,
|
||||
index: 3,
|
||||
},
|
||||
]}
|
||||
];
|
||||
|
||||
const hasCustomFilters = customFilters.length > 0;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col">
|
||||
{/* First row: Provider selectors + More Filters button + Clear Filters */}
|
||||
<div className="flex flex-wrap items-center gap-4">
|
||||
<div className="min-w-[200px] flex-1 md:max-w-[280px]">
|
||||
<ProviderTypeSelector providers={providers} />
|
||||
</div>
|
||||
<div className="min-w-[200px] flex-1 md:max-w-[280px]">
|
||||
<AccountsSelector providers={providers} />
|
||||
</div>
|
||||
{hasCustomFilters && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="lg"
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
>
|
||||
{isExpanded ? "Less Filters" : "More Filters"}
|
||||
<ChevronDown
|
||||
className={`size-4 transition-transform duration-300 ${isExpanded ? "rotate-180" : "rotate-0"}`}
|
||||
/>
|
||||
</Button>
|
||||
)}
|
||||
<ClearFiltersButton showCount />
|
||||
</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";
|
||||
|
||||
export const SkeletonTableResources = () => {
|
||||
const SkeletonTableRow = () => {
|
||||
return (
|
||||
<Card variant="base" padding="md" className="flex flex-col gap-4">
|
||||
{/* Table headers */}
|
||||
<div className="hidden gap-4 md:flex">
|
||||
<Skeleton className="h-8 w-1/12" />
|
||||
<Skeleton className="h-8 w-2/12" />
|
||||
<Skeleton className="h-8 w-2/12" />
|
||||
<Skeleton className="h-8 w-2/12" />
|
||||
<Skeleton className="h-8 w-2/12" />
|
||||
<Skeleton className="h-8 w-1/12" />
|
||||
<Skeleton className="h-8 w-1/12" />
|
||||
<tr className="border-border-neutral-secondary border-b last:border-b-0">
|
||||
{/* Name - clickable text with copy icon */}
|
||||
<td className="px-3 py-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Skeleton className="h-4 w-32 rounded" />
|
||||
<Skeleton className="size-4 rounded" />
|
||||
</div>
|
||||
</td>
|
||||
{/* Provider Account - logo + alias + uid chip */}
|
||||
<td className="px-3 py-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Skeleton className="size-9 rounded-xl" />
|
||||
<div className="flex flex-col gap-1">
|
||||
<Skeleton className="h-3.5 w-20 rounded" />
|
||||
<div className="bg-bg-neutral-tertiary flex h-6 w-24 items-center gap-1 rounded-xl px-1.5">
|
||||
<Skeleton className="h-3 w-16 rounded" />
|
||||
<Skeleton className="size-3.5 rounded" />
|
||||
</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>
|
||||
|
||||
{/* 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>
|
||||
|
||||
{/* Table body */}
|
||||
<div className="flex flex-col gap-3">
|
||||
{[...Array(3)].map((_, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="flex flex-col gap-4 md:flex-row md:items-center"
|
||||
>
|
||||
<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>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,13 +1,18 @@
|
||||
"use client";
|
||||
|
||||
import { ColumnDef } from "@tanstack/react-table";
|
||||
import { Database } from "lucide-react";
|
||||
import { AlertTriangle, Eye, MoreVertical } from "lucide-react";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
|
||||
import { InfoIcon } from "@/components/icons";
|
||||
import { EntityInfo, SnippetChip } from "@/components/ui/entities";
|
||||
import { TriggerSheet } from "@/components/ui/sheet";
|
||||
import {
|
||||
ActionDropdown,
|
||||
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 { getGroupLabel } from "@/lib/categories";
|
||||
import { ProviderType, ResourceProps } from "@/types";
|
||||
|
||||
import { ResourceDetail } from "./resource-detail";
|
||||
@@ -19,12 +24,6 @@ const getResourceData = (
|
||||
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 = (
|
||||
row: { original: ResourceProps },
|
||||
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 resourceId = searchParams.get("resourceId");
|
||||
const isOpen = resourceId === row.original.id;
|
||||
|
||||
return (
|
||||
<div className="flex w-9 items-center justify-center">
|
||||
<TriggerSheet
|
||||
triggerComponent={
|
||||
<InfoIcon className="text-button-primary" size={16} />
|
||||
}
|
||||
title="Resource Details"
|
||||
description="View the Resource details"
|
||||
defaultOpen={isOpen}
|
||||
>
|
||||
<ResourceDetail
|
||||
resourceId={row.original.id}
|
||||
initialResourceData={row.original}
|
||||
/>
|
||||
</TriggerSheet>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const ColumnResources: ColumnDef<ResourceProps>[] = [
|
||||
{
|
||||
id: "moreInfo",
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title="Details" />
|
||||
),
|
||||
cell: ({ row }) => <ResourceDetailsCell row={row} />,
|
||||
enableSorting: false,
|
||||
},
|
||||
{
|
||||
accessorKey: "resourceName",
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title="Resource name" />
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const resourceName = getResourceData(row, "name");
|
||||
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 (
|
||||
<SnippetChip
|
||||
value={displayName}
|
||||
className="max-w-[320px]"
|
||||
icon={<Database size={16} />}
|
||||
<div className="flex items-center gap-2">
|
||||
<ResourceDetail
|
||||
resourceDetails={row.original}
|
||||
defaultOpen={isOpen}
|
||||
trigger={
|
||||
<div className="max-w-[200px]">
|
||||
<p className="text-text-neutral-primary hover:text-button-tertiary cursor-pointer text-left text-sm break-words whitespace-normal hover:underline">
|
||||
{displayName}
|
||||
</p>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
{resourceUid && <CodeSnippet value={resourceUid} hideCode />}
|
||||
</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>[] = [
|
||||
// Name column
|
||||
{
|
||||
accessorKey: "resourceName",
|
||||
header: ({ column }) => (
|
||||
<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 }) => {
|
||||
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,
|
||||
},
|
||||
// Failed Findings column
|
||||
{
|
||||
accessorKey: "failedFindings",
|
||||
header: ({ column }) => (
|
||||
@@ -101,84 +164,94 @@ export const ColumnResources: ColumnDef<ResourceProps>[] = [
|
||||
"failed_findings_count",
|
||||
) 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 (
|
||||
<span
|
||||
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)}`}
|
||||
>
|
||||
{failedFindingsCount}
|
||||
<div className="flex items-center gap-1">
|
||||
<p className="text-text-neutral-primary max-w-[120px] truncate text-sm">
|
||||
{displayLabel}
|
||||
</p>
|
||||
{extraCount > 0 && (
|
||||
<span className="text-text-neutral-secondary text-xs">
|
||||
+{extraCount}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
enableSorting: false,
|
||||
},
|
||||
{
|
||||
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>
|
||||
);
|
||||
},
|
||||
},
|
||||
// Type column
|
||||
{
|
||||
accessorKey: "type",
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title={"Type"} param="type" />
|
||||
<DataTableColumnHeader column={column} title="Type" param="type" />
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const type = getResourceData(row, "type");
|
||||
|
||||
return (
|
||||
<div className="max-w-[150px] text-xs break-words whitespace-nowrap">
|
||||
{typeof type === "string" ? type : "Invalid type"}
|
||||
</div>
|
||||
<p className="text-text-neutral-primary max-w-[150px] truncate text-sm">
|
||||
{typeof type === "string" ? type : "-"}
|
||||
</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",
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader
|
||||
column={column}
|
||||
title={"Service"}
|
||||
param="service"
|
||||
/>
|
||||
<DataTableColumnHeader column={column} title="Service" param="service" />
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const service = getResourceData(row, "service");
|
||||
|
||||
return (
|
||||
<div className="max-w-96 truncate text-xs">
|
||||
{typeof service === "string" ? service : "Invalid region"}
|
||||
</div>
|
||||
<p className="text-text-neutral-primary max-w-[150px] truncate text-sm">
|
||||
{typeof service === "string" ? service : "-"}
|
||||
</p>
|
||||
);
|
||||
},
|
||||
},
|
||||
// Actions column
|
||||
{
|
||||
accessorKey: "provider",
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title="Cloud Provider" />
|
||||
),
|
||||
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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
},
|
||||
id: "actions",
|
||||
header: () => <div className="w-10" />,
|
||||
cell: ({ row }) => <ResourceRowActions row={row} />,
|
||||
enableSorting: false,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
export * from "../skeleton/skeleton-table-resources";
|
||||
export * from "./column-resources";
|
||||
export * from "./resource-detail";
|
||||
export * from "./resource-findings-columns";
|
||||
export * from "./resources-table-with-selection";
|
||||
|
||||
@@ -1,51 +1,53 @@
|
||||
"use client";
|
||||
|
||||
import { Snippet } from "@heroui/snippet";
|
||||
import { Spinner } from "@heroui/spinner";
|
||||
import { Tooltip } from "@heroui/tooltip";
|
||||
import { ExternalLink, InfoIcon } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Row, RowSelectionState } from "@tanstack/react-table";
|
||||
import { Check, Copy, ExternalLink, Link, Loader2, X } from "lucide-react";
|
||||
import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
||||
import type { ReactNode } 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 { FloatingMuteButton } from "@/components/findings/floating-mute-button";
|
||||
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 { CodeSnippet } from "@/components/ui/code-snippet/code-snippet";
|
||||
import {
|
||||
DateWithTime,
|
||||
getProviderLogo,
|
||||
InfoField,
|
||||
} from "@/components/ui/entities";
|
||||
import { SeverityBadge, StatusFindingBadge } from "@/components/ui/table";
|
||||
import { DataTable } from "@/components/ui/table";
|
||||
import { createDict } from "@/lib";
|
||||
import { getGroupLabel } from "@/lib/categories";
|
||||
import { buildGitFileUrl } from "@/lib/iac-utils";
|
||||
import { FindingProps, ProviderType, ResourceProps } from "@/types";
|
||||
import {
|
||||
FindingProps,
|
||||
MetaDataProps,
|
||||
ProviderType,
|
||||
ResourceProps,
|
||||
} from "@/types";
|
||||
|
||||
const SEVERITY_ORDER = {
|
||||
critical: 0,
|
||||
high: 1,
|
||||
medium: 2,
|
||||
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;
|
||||
}
|
||||
import {
|
||||
getResourceFindingsColumns,
|
||||
ResourceFinding,
|
||||
} from "./resource-findings-columns";
|
||||
|
||||
const renderValue = (value: string | null | undefined) => {
|
||||
return value && value.trim() !== "" ? value : "-";
|
||||
@@ -97,133 +99,264 @@ const buildCustomBreadcrumbs = (
|
||||
return breadcrumbs;
|
||||
};
|
||||
|
||||
interface ResourceDetailProps {
|
||||
resourceDetails: ResourceProps;
|
||||
trigger?: ReactNode;
|
||||
open?: boolean;
|
||||
defaultOpen?: boolean;
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
}
|
||||
|
||||
export const ResourceDetail = ({
|
||||
resourceId,
|
||||
initialResourceData,
|
||||
}: {
|
||||
resourceId: string;
|
||||
initialResourceData: ResourceProps;
|
||||
}) => {
|
||||
resourceDetails,
|
||||
trigger,
|
||||
open: controlledOpen,
|
||||
defaultOpen = false,
|
||||
onOpenChange,
|
||||
}: ResourceDetailProps) => {
|
||||
const [findingsData, setFindingsData] = useState<ResourceFinding[]>([]);
|
||||
const [findingsMetadata, setFindingsMetadata] =
|
||||
useState<MetaDataProps | null>(null);
|
||||
const [resourceTags, setResourceTags] = useState<Record<string, string>>({});
|
||||
const [findingsLoading, setFindingsLoading] = useState(true);
|
||||
const [hasInitiallyLoaded, setHasInitiallyLoaded] = useState(false);
|
||||
const [findingsReloadNonce, setFindingsReloadNonce] = useState(0);
|
||||
const [selectedFindingId, setSelectedFindingId] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
const [findingDetails, setFindingDetails] = useState<FindingProps | 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(() => {
|
||||
const loadFindings = async () => {
|
||||
setFindingsLoading(true);
|
||||
|
||||
try {
|
||||
const resourceData = await getResourceById(resourceId, {
|
||||
include: ["findings"],
|
||||
fields: ["tags", "findings"],
|
||||
const findingsResponse = await getLatestFindings({
|
||||
page: currentPage,
|
||||
pageSize,
|
||||
query: searchQuery,
|
||||
sort: "severity,-inserted_at",
|
||||
filters: {
|
||||
"filter[resource_uid]": attributes.uid,
|
||||
"filter[status]": "FAIL",
|
||||
},
|
||||
});
|
||||
|
||||
if (resourceData?.data) {
|
||||
// Get tags from the detailed resource data
|
||||
setResourceTags(resourceData.data.attributes.tags || {});
|
||||
|
||||
// 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[]);
|
||||
if (findingsResponse?.data) {
|
||||
setFindingsMetadata(findingsResponse.meta || null);
|
||||
setFindingsData(findingsResponse.data as ResourceFinding[]);
|
||||
} else {
|
||||
setFindingsData([]);
|
||||
}
|
||||
} else {
|
||||
setFindingsData([]);
|
||||
setResourceTags({});
|
||||
setFindingsMetadata(null);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Error loading findings:", err);
|
||||
setFindingsData([]);
|
||||
setResourceTags({});
|
||||
setFindingsMetadata(null);
|
||||
} finally {
|
||||
setFindingsLoading(false);
|
||||
setHasInitiallyLoaded(true);
|
||||
}
|
||||
};
|
||||
|
||||
if (resourceId) {
|
||||
if (attributes.uid && isDrawerOpen) {
|
||||
loadFindings();
|
||||
}
|
||||
}, [resourceId]);
|
||||
}, [
|
||||
attributes.uid,
|
||||
currentPage,
|
||||
pageSize,
|
||||
searchQuery,
|
||||
isDrawerOpen,
|
||||
findingsReloadNonce,
|
||||
]);
|
||||
|
||||
const navigateToFinding = async (findingId: string) => {
|
||||
// Cancel any in-flight request
|
||||
if (findingFetchRef.current) {
|
||||
findingFetchRef.current.abort();
|
||||
}
|
||||
findingFetchRef.current = new AbortController();
|
||||
|
||||
setSelectedFindingId(findingId);
|
||||
setFindingDetailLoading(true);
|
||||
|
||||
try {
|
||||
const findingData = await getFindingById(
|
||||
findingId,
|
||||
"resources,scan.provider",
|
||||
);
|
||||
|
||||
// Check if request was aborted
|
||||
if (findingFetchRef.current?.signal.aborted) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (findingData?.data) {
|
||||
// Create dictionaries for resources, scans, and providers
|
||||
const resourceDict = createDict("resources", findingData);
|
||||
const scanDict = createDict("scans", findingData);
|
||||
const providerDict = createDict("providers", findingData);
|
||||
|
||||
// Expand the finding with its corresponding resource, scan, and provider
|
||||
const finding = findingData.data;
|
||||
const scan = scanDict[finding.relationships?.scan?.data?.id];
|
||||
const resource =
|
||||
const foundResource =
|
||||
resourceDict[finding.relationships?.resources?.data?.[0]?.id];
|
||||
const provider = providerDict[scan?.relationships?.provider?.data?.id];
|
||||
|
||||
const expandedFinding = {
|
||||
...finding,
|
||||
relationships: { scan, resource, provider },
|
||||
relationships: { scan, resource: foundResource, provider },
|
||||
};
|
||||
|
||||
setFindingDetails(expandedFinding);
|
||||
}
|
||||
} catch (error) {
|
||||
// Ignore abort errors
|
||||
if (error instanceof Error && error.name === "AbortError") {
|
||||
return;
|
||||
}
|
||||
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 = () => {
|
||||
setSelectedFindingId(null);
|
||||
setFindingDetails(null);
|
||||
setFindingDetailLoading(false);
|
||||
};
|
||||
|
||||
if (!initialResourceData) {
|
||||
return (
|
||||
<div className="flex min-h-96 flex-col items-center justify-center gap-4 rounded-lg p-8">
|
||||
<Spinner size="lg" />
|
||||
<p className="dark:text-prowler-theme-pale/80 text-sm text-gray-600">
|
||||
Loading resource details...
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const handleMuteComplete = (_findingIds?: string[]) => {
|
||||
const ids =
|
||||
_findingIds && _findingIds.length > 0 ? _findingIds : selectedFindingIds;
|
||||
|
||||
const resource = initialResourceData;
|
||||
const attributes = resource.attributes;
|
||||
const providerData = resource.relationships.provider.data.attributes;
|
||||
setRowSelection({});
|
||||
if (ids.length > 0) setFindingsReloadNonce((v) => v + 1);
|
||||
router.refresh();
|
||||
};
|
||||
|
||||
// Filter only failed findings and sort by severity
|
||||
const failedFindings = findingsData
|
||||
.filter(
|
||||
(finding: ResourceFinding) => finding?.attributes?.status === "FAIL",
|
||||
)
|
||||
.sort((a: ResourceFinding, b: ResourceFinding) => {
|
||||
const severityA = (a?.attributes?.severity?.toLowerCase() ||
|
||||
"informational") as SeverityLevel;
|
||||
const severityB = (b?.attributes?.severity?.toLowerCase() ||
|
||||
"informational") as SeverityLevel;
|
||||
return (
|
||||
(SEVERITY_ORDER[severityA] ?? 999) - (SEVERITY_ORDER[severityB] ?? 999)
|
||||
// Findings are already filtered (FAIL only) and sorted by the server
|
||||
const failedFindings = findingsData;
|
||||
|
||||
const selectableRowCount = failedFindings.filter(
|
||||
(f) => !f.attributes.muted,
|
||||
).length;
|
||||
|
||||
// Reset selection when page changes
|
||||
useEffect(() => {
|
||||
setRowSelection({});
|
||||
}, [currentPage, pageSize]);
|
||||
|
||||
// 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
|
||||
const gitUrl =
|
||||
@@ -236,86 +369,134 @@ export const ResourceDetail = ({
|
||||
)
|
||||
: null;
|
||||
|
||||
if (selectedFindingId) {
|
||||
// Content when viewing a finding detail (breadcrumb navigation)
|
||||
const findingTitle =
|
||||
findingDetails?.attributes?.check_metadata?.checktitle ||
|
||||
"Finding Detail";
|
||||
findingDetails?.attributes?.check_metadata?.checktitle || "Finding Detail";
|
||||
|
||||
return (
|
||||
const findingContent = (
|
||||
<div className="flex flex-col gap-4">
|
||||
<BreadcrumbNavigation
|
||||
mode="custom"
|
||||
customItems={buildCustomBreadcrumbs(
|
||||
attributes.name,
|
||||
findingTitle,
|
||||
findingDetailLoading ? "Loading..." : findingTitle,
|
||||
handleBackToResource,
|
||||
)}
|
||||
/>
|
||||
|
||||
{findingDetails && <FindingDetail findingDetails={findingDetails} />}
|
||||
{findingDetailLoading ? (
|
||||
<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 (
|
||||
<div className="flex flex-col gap-6 rounded-lg">
|
||||
{/* Resource Details section */}
|
||||
<Card variant="base" padding="lg">
|
||||
<CardHeader className="flex flex-row items-center justify-between gap-2">
|
||||
<div className="flex flex-row items-center justify-start gap-2">
|
||||
<CardTitle>Resource Details</CardTitle>
|
||||
// Main resource content
|
||||
const resourceContent = (
|
||||
<div className="flex min-w-0 flex-col gap-4 rounded-lg">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-4">
|
||||
{/* Provider logo */}
|
||||
<div className="shrink-0">
|
||||
{getProviderLogo(providerData.provider as ProviderType)}
|
||||
</div>
|
||||
|
||||
{/* Details column */}
|
||||
<div className="flex min-w-0 flex-col gap-1">
|
||||
{/* Title with copy link and optional IaC link */}
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<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"
|
||||
>
|
||||
<Link size={16} />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Copy resource link to clipboard</TooltipContent>
|
||||
</Tooltip>
|
||||
{providerData.provider === "iac" && gitUrl && (
|
||||
<Tooltip content="Go to Resource in the Repository" size="sm">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<a
|
||||
href={gitUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-bg-data-info mt-1 inline-flex cursor-pointer"
|
||||
className="text-bg-data-info inline-flex items-center gap-1 text-sm"
|
||||
aria-label="Open resource in repository"
|
||||
>
|
||||
<ExternalLink size={16} className="inline" />
|
||||
<ExternalLink size={16} />
|
||||
View in Repository
|
||||
</a>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
Go to Resource in the Repository
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
{getProviderLogo(providerData.provider as ProviderType)}
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-4">
|
||||
<InfoField label="Resource UID" variant="simple">
|
||||
<Snippet
|
||||
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)}
|
||||
|
||||
{/* Last Updated */}
|
||||
<div className="text-text-neutral-tertiary text-sm">
|
||||
<span className="text-text-neutral-secondary mr-1">
|
||||
Last Updated:
|
||||
</span>
|
||||
</Snippet>
|
||||
<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">
|
||||
<CodeSnippet value={attributes.uid} className="max-w-full" />
|
||||
</InfoField>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<InfoField label="Resource Name">
|
||||
{renderValue(attributes.name)}
|
||||
</InfoField>
|
||||
<InfoField label="Resource Type">
|
||||
{renderValue(attributes.type)}
|
||||
</InfoField>
|
||||
<InfoField label="Name">{renderValue(attributes.name)}</InfoField>
|
||||
<InfoField label="Type">{renderValue(attributes.type)}</InfoField>
|
||||
</div>
|
||||
<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">
|
||||
{renderValue(attributes.service)}
|
||||
</InfoField>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<InfoField label="Region">
|
||||
{renderValue(attributes.region)}
|
||||
</InfoField>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<InfoField label="Partition">
|
||||
{renderValue(attributes.partition)}
|
||||
</InfoField>
|
||||
<InfoField label="Details">
|
||||
</div>
|
||||
<InfoField label="Details" variant="simple">
|
||||
{renderValue(attributes.details)}
|
||||
</InfoField>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<InfoField label="Created At">
|
||||
<DateWithTime inline dateTime={attributes.inserted_at} />
|
||||
@@ -331,16 +512,19 @@ export const ResourceDetail = ({
|
||||
Object.entries(parsedMetadata).length > 0 ? (
|
||||
<InfoField label="Metadata" variant="simple">
|
||||
<div className="border-border-neutral-tertiary bg-bg-neutral-tertiary relative w-full rounded-lg border">
|
||||
<Snippet
|
||||
className="absolute top-2 right-2 z-10 bg-transparent"
|
||||
classNames={{
|
||||
base: "bg-transparent p-0 min-w-0",
|
||||
pre: "hidden",
|
||||
}}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => copyMetadata(parsedMetadata)}
|
||||
className="text-text-neutral-secondary hover:text-text-neutral-primary absolute top-2 right-2 z-10 cursor-pointer transition-colors"
|
||||
aria-label="Copy metadata to clipboard"
|
||||
>
|
||||
{JSON.stringify(parsedMetadata, null, 2)}
|
||||
</Snippet>
|
||||
<pre className="minimal-scrollbar mr-10 max-h-[100px] overflow-auto p-3 text-xs break-words whitespace-pre-wrap">
|
||||
{metadataCopied ? (
|
||||
<Check className="h-4 w-4" />
|
||||
) : (
|
||||
<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)}
|
||||
</pre>
|
||||
</div>
|
||||
@@ -350,7 +534,7 @@ export const ResourceDetail = ({
|
||||
|
||||
{resourceTags && Object.entries(resourceTags).length > 0 ? (
|
||||
<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
|
||||
</h4>
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
@@ -362,79 +546,82 @@ export const ResourceDetail = ({
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
{/* Failed findings associated with this resource section */}
|
||||
<Card variant="base" padding="lg">
|
||||
<CardHeader>
|
||||
<CardTitle>Failed findings associated with this resource</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{findingsLoading ? (
|
||||
{/* Findings Tab */}
|
||||
<TabsContent value="findings" className="flex flex-col gap-4">
|
||||
{findingsLoading && !hasInitiallyLoaded ? (
|
||||
<div className="flex items-center justify-center gap-2 py-8">
|
||||
<Spinner size="sm" />
|
||||
<p className="dark:text-prowler-theme-pale/80 text-sm text-gray-600">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
<p className="text-text-neutral-secondary text-sm">
|
||||
Loading findings...
|
||||
</p>
|
||||
</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>
|
||||
) : (
|
||||
<>
|
||||
<DataTable
|
||||
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}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
);
|
||||
|
||||
// Determine which content to show
|
||||
const content = selectedFindingId ? findingContent : resourceContent;
|
||||
|
||||
// If no trigger, render content directly (inline mode)
|
||||
if (!trigger) {
|
||||
return content;
|
||||
}
|
||||
|
||||
const { severity, check_metadata, status } = findingAttrs;
|
||||
const checktitle =
|
||||
check_metadata?.checktitle || "Unknown check";
|
||||
|
||||
// With trigger, wrap in Drawer
|
||||
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"
|
||||
<Drawer
|
||||
direction="right"
|
||||
open={controlledOpen ?? internalOpen}
|
||||
defaultOpen={defaultOpen}
|
||||
onOpenChange={handleOpenChange}
|
||||
>
|
||||
<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.
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
<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 { Button } from "@/components/shadcn";
|
||||
import { CustomAlertModal } from "@/components/ui/custom/custom-alert-modal";
|
||||
import { Modal } from "@/components/shadcn/modal";
|
||||
|
||||
import { DeleteRoleForm } from "../workflow/forms";
|
||||
interface DataTableRowActionsProps<RoleProps> {
|
||||
@@ -34,14 +34,14 @@ export function DataTableRowActions<RoleProps>({
|
||||
const roleId = (row.original as { id: string }).id;
|
||||
return (
|
||||
<>
|
||||
<CustomAlertModal
|
||||
isOpen={isDeleteOpen}
|
||||
<Modal
|
||||
open={isDeleteOpen}
|
||||
onOpenChange={setIsDeleteOpen}
|
||||
title="Are you absolutely sure?"
|
||||
description="This action cannot be undone. This will permanently delete your role and remove your data from the server."
|
||||
>
|
||||
<DeleteRoleForm roleId={roleId} setIsOpen={setIsDeleteOpen} />
|
||||
</CustomAlertModal>
|
||||
</Modal>
|
||||
<div className="relative flex items-center justify-end gap-2">
|
||||
<Dropdown
|
||||
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 { Button } from "@/components/shadcn";
|
||||
import { Modal } from "@/components/shadcn/modal";
|
||||
import { useToast } from "@/components/ui";
|
||||
import { CustomAlertModal } from "@/components/ui/custom";
|
||||
import { downloadScanZip } from "@/lib/helper";
|
||||
|
||||
import { EditScanForm } from "../../forms";
|
||||
@@ -39,8 +39,8 @@ export function DataTableRowActions<ScanProps>({
|
||||
|
||||
return (
|
||||
<>
|
||||
<CustomAlertModal
|
||||
isOpen={isEditOpen}
|
||||
<Modal
|
||||
open={isEditOpen}
|
||||
onOpenChange={setIsEditOpen}
|
||||
title="Edit Scan Name"
|
||||
>
|
||||
@@ -49,7 +49,7 @@ export function DataTableRowActions<ScanProps>({
|
||||
scanName={scanName}
|
||||
setIsOpen={setIsEditOpen}
|
||||
/>
|
||||
</CustomAlertModal>
|
||||
</Modal>
|
||||
|
||||
<div className="relative flex items-center justify-end gap-2">
|
||||
<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 }) => (
|
||||
<Snippet
|
||||
className="bg-bg-neutral-tertiary text-text-neutral-primary border-border-neutral-tertiary w-full rounded-lg border py-1 text-xs"
|
||||
hideSymbol
|
||||
classNames={{
|
||||
pre: "w-full truncate",
|
||||
}}
|
||||
import { Check, Copy } from "lucide-react";
|
||||
import { ReactNode, useState } from "react";
|
||||
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/shadcn/tooltip";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
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"
|
||||
>
|
||||
{value}
|
||||
</Snippet>
|
||||
{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-dropdown-selection";
|
||||
export * from "./custom-input";
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
"use client";
|
||||
|
||||
import { Tooltip } from "@heroui/tooltip";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { CopyIcon, DoneIcon } from "@/components/icons";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/shadcn/tooltip";
|
||||
import { CodeSnippet } from "@/components/ui/code-snippet/code-snippet";
|
||||
import type { ProviderType } from "@/types";
|
||||
|
||||
import { getProviderLogo } from "./get-provider-logo";
|
||||
@@ -26,26 +28,6 @@ export const EntityInfo = ({
|
||||
maxWidth = "w-[120px]",
|
||||
showCopyAction = true,
|
||||
}: 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);
|
||||
|
||||
return (
|
||||
@@ -53,54 +35,46 @@ export const EntityInfo = ({
|
||||
<div className="relative shrink-0">
|
||||
{getProviderLogo(cloudProvider)}
|
||||
{showConnectionStatus && (
|
||||
<Tooltip
|
||||
size="sm"
|
||||
content={showConnectionStatus ? "Connected" : "Not Connected"}
|
||||
>
|
||||
<span
|
||||
className={`absolute top-[-0.1rem] right-[-0.2rem] h-2 w-2 cursor-pointer rounded-full ${
|
||||
showConnectionStatus ? "bg-green-500" : "bg-red-500"
|
||||
}`}
|
||||
/>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="absolute top-[-0.1rem] right-[-0.2rem] h-2 w-2 cursor-pointer rounded-full bg-green-500" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Connected</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
<div className={`flex ${maxWidth} flex-col gap-1`}>
|
||||
{entityAlias ? (
|
||||
<Tooltip content={entityAlias} placement="top-start" size="sm">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<p className="text-text-neutral-primary truncate text-left text-xs font-medium">
|
||||
{entityAlias}
|
||||
</p>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">{entityAlias}</TooltipContent>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<Tooltip content="No alias" placement="top-start" size="sm">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<p className="text-text-neutral-secondary truncate text-left text-xs">
|
||||
-
|
||||
</p>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">No alias</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
{entityId && (
|
||||
<div className="flex min-w-0 items-center gap-1">
|
||||
<Tooltip content={entityId} placement="top-start" size="sm">
|
||||
<p className="text-text-neutral-secondary min-w-0 truncate text-left text-xs">
|
||||
<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>
|
||||
<TooltipTrigger asChild>
|
||||
<p className="text-text-neutral-secondary min-w-0 flex-1 truncate text-left text-xs">
|
||||
{entityId}
|
||||
</p>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">{entityId}</TooltipContent>
|
||||
</Tooltip>
|
||||
{canCopy && (
|
||||
<Tooltip
|
||||
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>
|
||||
<CodeSnippet value={entityId} hideCode className="shrink-0" />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -34,7 +34,10 @@ export const MenuItem = ({
|
||||
highlight,
|
||||
}: MenuItemProps) => {
|
||||
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
|
||||
const showTooltip = label === "Prowler Hub" ? !!tooltip : !isOpen;
|
||||
@@ -55,8 +58,8 @@ export const MenuItem = ({
|
||||
<Icon size={18} />
|
||||
</span>
|
||||
{isOpen && (
|
||||
<p className="max-w-[200px] truncate">
|
||||
{label}
|
||||
<p className="flex max-w-[200px] items-center truncate">
|
||||
<span>{label}</span>
|
||||
{highlight && (
|
||||
<span className="ml-2 rounded-sm bg-emerald-500 px-1.5 py-0.5 text-[10px] font-semibold text-white">
|
||||
NEW
|
||||
|
||||
@@ -24,6 +24,18 @@ import { MetaDataProps } from "@/types";
|
||||
interface DataTablePaginationProps {
|
||||
metadata?: MetaDataProps;
|
||||
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 = {
|
||||
@@ -35,18 +47,44 @@ const NAV_BUTTON_STYLES = {
|
||||
export function DataTablePagination({
|
||||
metadata,
|
||||
disableScroll = false,
|
||||
paramPrefix = "",
|
||||
controlledPage,
|
||||
controlledPageSize,
|
||||
onPageChange,
|
||||
onPageSizeChange,
|
||||
}: DataTablePaginationProps) {
|
||||
const pathname = usePathname();
|
||||
const searchParams = useSearchParams();
|
||||
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);
|
||||
|
||||
if (!metadata) return null;
|
||||
|
||||
const { currentPage, totalPages, totalEntries, itemsPerPageOptions } =
|
||||
getPaginationInfo(metadata);
|
||||
const {
|
||||
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 params = new URLSearchParams(searchParams);
|
||||
@@ -60,7 +98,7 @@ export function DataTablePagination({
|
||||
return `${pathname}?${params.toString()}`;
|
||||
}
|
||||
|
||||
params.set("page", pageNumber.toString());
|
||||
params.set(pageParam, pageNumber.toString());
|
||||
|
||||
// Ensure that scanId, id and version are preserved
|
||||
if (scanId) params.set("scanId", scanId);
|
||||
@@ -70,6 +108,20 @@ export function DataTablePagination({
|
||||
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 isLastPage = currentPage === totalPages;
|
||||
|
||||
@@ -87,6 +139,12 @@ export function DataTablePagination({
|
||||
onValueChange={(value) => {
|
||||
setSelectedPageSize(value);
|
||||
|
||||
if (isControlled) {
|
||||
onPageSizeChange?.(parseInt(value, 10));
|
||||
onPageChange(1); // Reset to first page
|
||||
return;
|
||||
}
|
||||
|
||||
const params = new URLSearchParams(searchParams);
|
||||
|
||||
// Preserve all important parameters
|
||||
@@ -94,8 +152,8 @@ export function DataTablePagination({
|
||||
const id = searchParams.get("id");
|
||||
const version = searchParams.get("version");
|
||||
|
||||
params.set("pageSize", value);
|
||||
params.set("page", "1");
|
||||
params.set(pageSizeParam, value);
|
||||
params.set(pageParam, "1");
|
||||
|
||||
// Ensure that scanId, id and version are preserved
|
||||
if (scanId) params.set("scanId", scanId);
|
||||
@@ -137,6 +195,67 @@ export function DataTablePagination({
|
||||
Page {currentPage} of {totalPages}
|
||||
</span>
|
||||
<div className="flex items-center gap-3">
|
||||
{isControlled ? (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Go to first page"
|
||||
className={cn(
|
||||
NAV_BUTTON_STYLES.base,
|
||||
isFirstPage
|
||||
? NAV_BUTTON_STYLES.disabled
|
||||
: NAV_BUTTON_STYLES.enabled,
|
||||
)}
|
||||
disabled={isFirstPage}
|
||||
onClick={() => handlePageChange(1)}
|
||||
>
|
||||
<ChevronFirst className="size-6" aria-hidden="true" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Go to previous page"
|
||||
className={cn(
|
||||
NAV_BUTTON_STYLES.base,
|
||||
isFirstPage
|
||||
? NAV_BUTTON_STYLES.disabled
|
||||
: NAV_BUTTON_STYLES.enabled,
|
||||
)}
|
||||
disabled={isFirstPage}
|
||||
onClick={() => handlePageChange(currentPage - 1)}
|
||||
>
|
||||
<ChevronLeft className="size-6" aria-hidden="true" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Go to next page"
|
||||
className={cn(
|
||||
NAV_BUTTON_STYLES.base,
|
||||
isLastPage
|
||||
? NAV_BUTTON_STYLES.disabled
|
||||
: NAV_BUTTON_STYLES.enabled,
|
||||
)}
|
||||
disabled={isLastPage}
|
||||
onClick={() => handlePageChange(currentPage + 1)}
|
||||
>
|
||||
<ChevronRight className="size-6" aria-hidden="true" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Go to last page"
|
||||
className={cn(
|
||||
NAV_BUTTON_STYLES.base,
|
||||
isLastPage
|
||||
? NAV_BUTTON_STYLES.disabled
|
||||
: NAV_BUTTON_STYLES.enabled,
|
||||
)}
|
||||
disabled={isLastPage}
|
||||
onClick={() => handlePageChange(totalPages)}
|
||||
>
|
||||
<ChevronLast className="size-6" aria-hidden="true" />
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Link
|
||||
aria-label="Go to first page"
|
||||
className={cn(
|
||||
@@ -213,6 +332,8 @@ export function DataTablePagination({
|
||||
>
|
||||
<ChevronLast className="size-6" aria-hidden="true" />
|
||||
</Link>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
|
||||
@@ -1,19 +1,40 @@
|
||||
"use client";
|
||||
|
||||
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 { Input } from "@/components/shadcn/input/input";
|
||||
import { useUrlFilters } from "@/hooks/use-url-filters";
|
||||
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 pathname = usePathname();
|
||||
const router = useRouter();
|
||||
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 [isExpanded, setIsExpanded] = useState(false);
|
||||
const [isFocused, setIsFocused] = useState(false);
|
||||
@@ -21,27 +42,77 @@ export const DataTableSearch = () => {
|
||||
const debounceTimeoutRef = useRef<NodeJS.Timeout | null>(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
|
||||
const shouldStayExpanded = value.length > 0 || isFocused;
|
||||
|
||||
// Sync with URL on mount
|
||||
// Sync with URL on mount (only for uncontrolled mode)
|
||||
useEffect(() => {
|
||||
const searchFromUrl = searchParams.get("filter[search]") || "";
|
||||
setValue(searchFromUrl);
|
||||
if (isControlled) return;
|
||||
const searchFromUrl = searchParams.get(searchParam) || "";
|
||||
setInternalValue(searchFromUrl);
|
||||
// If there's a search value, start expanded
|
||||
if (searchFromUrl) {
|
||||
setIsExpanded(true);
|
||||
}
|
||||
}, [searchParams]);
|
||||
}, [searchParams, searchParam, isControlled]);
|
||||
|
||||
// Handle input change with debounce
|
||||
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) {
|
||||
clearTimeout(debounceTimeoutRef.current);
|
||||
}
|
||||
|
||||
// If using prefix, handle URL updates directly instead of useUrlFilters
|
||||
if (paramPrefix) {
|
||||
setIsLoading(true);
|
||||
debounceTimeoutRef.current = setTimeout(() => {
|
||||
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);
|
||||
}, SEARCH_DEBOUNCE_MS);
|
||||
} else {
|
||||
// Original behavior for non-prefixed search
|
||||
if (newValue) {
|
||||
setIsLoading(true);
|
||||
debounceTimeoutRef.current = setTimeout(() => {
|
||||
@@ -52,6 +123,7 @@ export const DataTableSearch = () => {
|
||||
setIsLoading(false);
|
||||
updateFilter("search", null);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Cleanup timeout on unmount
|
||||
|
||||
@@ -6,7 +6,6 @@ import {
|
||||
flexRender,
|
||||
getCoreRowModel,
|
||||
getFilteredRowModel,
|
||||
getPaginationRowModel,
|
||||
getSortedRowModel,
|
||||
OnChangeFn,
|
||||
Row,
|
||||
@@ -26,6 +25,8 @@ import {
|
||||
} from "@/components/ui/table";
|
||||
import { DataTablePagination } from "@/components/ui/table/data-table-pagination";
|
||||
import { DataTableSearch } from "@/components/ui/table/data-table-search";
|
||||
import { useFilterTransitionOptional } from "@/contexts";
|
||||
import { cn } from "@/lib";
|
||||
import { FilterOption, MetaDataProps } from "@/types";
|
||||
|
||||
interface DataTableProviderProps<TData, TValue> {
|
||||
@@ -41,6 +42,39 @@ interface DataTableProviderProps<TData, TValue> {
|
||||
getRowCanSelect?: (row: Row<TData>) => boolean;
|
||||
/** Show search bar in the table toolbar */
|
||||
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>({
|
||||
@@ -53,18 +87,29 @@ export function DataTable<TData, TValue>({
|
||||
onRowSelectionChange,
|
||||
getRowCanSelect,
|
||||
showSearch = false,
|
||||
paramPrefix = "",
|
||||
controlledSearch,
|
||||
onSearchChange,
|
||||
controlledPage,
|
||||
controlledPageSize,
|
||||
onPageChange,
|
||||
onPageSizeChange,
|
||||
isLoading = false,
|
||||
}: DataTableProviderProps<TData, TValue>) {
|
||||
const [sorting, setSorting] = useState<SortingState>([]);
|
||||
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({
|
||||
data,
|
||||
columns,
|
||||
enableSorting: true,
|
||||
// Use getRowCanSelect function if provided, otherwise use boolean
|
||||
enableRowSelection: getRowCanSelect ?? enableRowSelection,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getPaginationRowModel: getPaginationRowModel(),
|
||||
onSortingChange: setSorting,
|
||||
getSortedRowModel: getSortedRowModel(),
|
||||
onColumnFiltersChange: setColumnFilters,
|
||||
@@ -86,13 +131,29 @@ export function DataTable<TData, TValue>({
|
||||
// Format total entries count
|
||||
const totalEntries = metadata?.pagination?.count ?? 0;
|
||||
const formattedTotal = totalEntries.toLocaleString();
|
||||
const showToolbar = showSearch || metadata;
|
||||
|
||||
const rows = table.getRowModel().rows;
|
||||
|
||||
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 */}
|
||||
{(showSearch || metadata) && (
|
||||
{showToolbar && (
|
||||
<div className="flex items-center justify-between">
|
||||
<div>{showSearch && <DataTableSearch />}</div>
|
||||
<div>
|
||||
{showSearch && (
|
||||
<DataTableSearch
|
||||
paramPrefix={paramPrefix}
|
||||
controlledValue={controlledSearch}
|
||||
onSearchChange={onSearchChange}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{metadata && (
|
||||
<span className="text-text-neutral-secondary text-sm">
|
||||
{formattedTotal} Total Entries
|
||||
@@ -120,8 +181,8 @@ export function DataTable<TData, TValue>({
|
||||
))}
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{table.getRowModel().rows?.length ? (
|
||||
table.getRowModel().rows.map((row) => (
|
||||
{rows?.length ? (
|
||||
rows.map((row) => (
|
||||
<TableRow
|
||||
key={row.id}
|
||||
data-state={row.getIsSelected() && "selected"}
|
||||
@@ -146,6 +207,11 @@ export function DataTable<TData, TValue>({
|
||||
<DataTablePagination
|
||||
metadata={metadata}
|
||||
disableScroll={disableScroll}
|
||||
paramPrefix={paramPrefix}
|
||||
controlledPage={controlledPage}
|
||||
controlledPageSize={controlledPageSize}
|
||||
onPageChange={onPageChange}
|
||||
onPageSizeChange={onPageSizeChange}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -3,8 +3,8 @@
|
||||
import { Snippet } from "@heroui/snippet";
|
||||
|
||||
import { Button } from "@/components/shadcn";
|
||||
import { Modal } from "@/components/shadcn/modal";
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert/Alert";
|
||||
import { CustomAlertModal } from "@/components/ui/custom/custom-alert-modal";
|
||||
|
||||
interface ApiKeySuccessModalProps {
|
||||
isOpen: boolean;
|
||||
@@ -18,8 +18,8 @@ export const ApiKeySuccessModal = ({
|
||||
apiKey,
|
||||
}: ApiKeySuccessModalProps) => {
|
||||
return (
|
||||
<CustomAlertModal
|
||||
isOpen={isOpen}
|
||||
<Modal
|
||||
open={isOpen}
|
||||
onOpenChange={(open) => !open && onClose()}
|
||||
title="API Key Created Successfully"
|
||||
>
|
||||
@@ -56,6 +56,6 @@ export const ApiKeySuccessModal = ({
|
||||
Acknowledged
|
||||
</Button>
|
||||
</div>
|
||||
</CustomAlertModal>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -5,9 +5,9 @@ import { useForm } from "react-hook-form";
|
||||
import * as z from "zod";
|
||||
|
||||
import { createApiKey } from "@/actions/api-keys/api-keys";
|
||||
import { Modal } from "@/components/shadcn/modal";
|
||||
import { useToast } from "@/components/ui";
|
||||
import { CustomInput } from "@/components/ui/custom";
|
||||
import { CustomAlertModal } from "@/components/ui/custom/custom-alert-modal";
|
||||
import { CustomLink } from "@/components/ui/custom/custom-link";
|
||||
import { Form, FormButtons } from "@/components/ui/form";
|
||||
|
||||
@@ -86,8 +86,8 @@ export const CreateApiKeyModal = ({
|
||||
};
|
||||
|
||||
return (
|
||||
<CustomAlertModal
|
||||
isOpen={isOpen}
|
||||
<Modal
|
||||
open={isOpen}
|
||||
onOpenChange={(open) => !open && handleClose()}
|
||||
title="Create API Key"
|
||||
size="lg"
|
||||
@@ -139,6 +139,6 @@ export const CreateApiKeyModal = ({
|
||||
/>
|
||||
</form>
|
||||
</Form>
|
||||
</CustomAlertModal>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -6,9 +6,9 @@ import { useForm } from "react-hook-form";
|
||||
import * as z from "zod";
|
||||
|
||||
import { updateApiKey } from "@/actions/api-keys/api-keys";
|
||||
import { Modal } from "@/components/shadcn/modal";
|
||||
import { useToast } from "@/components/ui";
|
||||
import { CustomInput } from "@/components/ui/custom";
|
||||
import { CustomAlertModal } from "@/components/ui/custom/custom-alert-modal";
|
||||
import { Form, FormButtons } from "@/components/ui/form";
|
||||
|
||||
import { EnrichedApiKey } from "./api-keys/types";
|
||||
@@ -92,8 +92,8 @@ export const EditApiKeyNameModal = ({
|
||||
};
|
||||
|
||||
return (
|
||||
<CustomAlertModal
|
||||
isOpen={isOpen}
|
||||
<Modal
|
||||
open={isOpen}
|
||||
onOpenChange={(open) => !open && handleClose()}
|
||||
title="Edit API Key Name"
|
||||
size="lg"
|
||||
@@ -129,6 +129,6 @@ export const EditApiKeyNameModal = ({
|
||||
/>
|
||||
</form>
|
||||
</Form>
|
||||
</CustomAlertModal>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -4,7 +4,7 @@ import { Chip } from "@heroui/chip";
|
||||
import { useState } from "react";
|
||||
|
||||
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 { MembershipDetailData } from "@/types/users";
|
||||
|
||||
@@ -25,17 +25,13 @@ export const MembershipItem = ({
|
||||
|
||||
return (
|
||||
<>
|
||||
<CustomAlertModal
|
||||
isOpen={isEditOpen}
|
||||
onOpenChange={setIsEditOpen}
|
||||
title=""
|
||||
>
|
||||
<Modal open={isEditOpen} onOpenChange={setIsEditOpen} title="">
|
||||
<EditTenantForm
|
||||
tenantId={tenantId}
|
||||
tenantName={tenantName}
|
||||
setIsOpen={setIsEditOpen}
|
||||
/>
|
||||
</CustomAlertModal>
|
||||
</Modal>
|
||||
<Card variant="inner" className="min-w-[320px] p-2">
|
||||
<div className="flex w-full items-center gap-4">
|
||||
<Chip size="sm" variant="flat" color="secondary">
|
||||
|
||||
@@ -4,12 +4,12 @@ import { Snippet } from "@heroui/snippet";
|
||||
import { Trash2Icon } from "lucide-react";
|
||||
|
||||
import { revokeApiKey } from "@/actions/api-keys/api-keys";
|
||||
import { Modal } from "@/components/shadcn/modal";
|
||||
import {
|
||||
Alert,
|
||||
AlertDescription,
|
||||
AlertTitle,
|
||||
} from "@/components/ui/alert/Alert";
|
||||
import { CustomAlertModal } from "@/components/ui/custom/custom-alert-modal";
|
||||
import { ModalButtons } from "@/components/ui/custom/custom-modal-buttons";
|
||||
|
||||
import { FALLBACK_VALUES } from "./api-keys/constants";
|
||||
@@ -49,8 +49,8 @@ export const RevokeApiKeyModal = ({
|
||||
});
|
||||
|
||||
return (
|
||||
<CustomAlertModal
|
||||
isOpen={isOpen}
|
||||
<Modal
|
||||
open={isOpen}
|
||||
onOpenChange={(open) => !open && handleClose()}
|
||||
title="Revoke API Key"
|
||||
size="lg"
|
||||
@@ -95,6 +95,6 @@ export const RevokeApiKeyModal = ({
|
||||
submitColor="danger"
|
||||
submitIcon={<Trash2Icon size={24} />}
|
||||
/>
|
||||
</CustomAlertModal>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -17,7 +17,7 @@ import { useState } from "react";
|
||||
|
||||
import { VerticalDotsIcon } from "@/components/icons";
|
||||
import { Button } from "@/components/shadcn";
|
||||
import { CustomAlertModal } from "@/components/ui/custom";
|
||||
import { Modal } from "@/components/shadcn/modal";
|
||||
|
||||
import { DeleteForm, EditForm } from "../forms";
|
||||
|
||||
@@ -41,8 +41,8 @@ export function DataTableRowActions<UserProps>({
|
||||
|
||||
return (
|
||||
<>
|
||||
<CustomAlertModal
|
||||
isOpen={isEditOpen}
|
||||
<Modal
|
||||
open={isEditOpen}
|
||||
onOpenChange={setIsEditOpen}
|
||||
title="Edit user details"
|
||||
>
|
||||
@@ -55,15 +55,15 @@ export function DataTableRowActions<UserProps>({
|
||||
roles={roles || []}
|
||||
setIsOpen={setIsEditOpen}
|
||||
/>
|
||||
</CustomAlertModal>
|
||||
<CustomAlertModal
|
||||
isOpen={isDeleteOpen}
|
||||
</Modal>
|
||||
<Modal
|
||||
open={isDeleteOpen}
|
||||
onOpenChange={setIsDeleteOpen}
|
||||
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."
|
||||
>
|
||||
<DeleteForm userId={userId} setIsOpen={setIsDeleteOpen} />
|
||||
</CustomAlertModal>
|
||||
</Modal>
|
||||
|
||||
<div className="relative flex items-center justify-end gap-2">
|
||||
<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";
|
||||
|
||||
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
|
||||
* 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 = () => {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
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(
|
||||
(key: string, value: string | string[] | null) => {
|
||||
const params = new URLSearchParams(searchParams.toString());
|
||||
@@ -41,9 +57,11 @@ export const useUrlFilters = () => {
|
||||
params.set(filterKey, nextValue);
|
||||
}
|
||||
|
||||
startTransition(() => {
|
||||
router.push(`${pathname}?${params.toString()}`, { scroll: false });
|
||||
});
|
||||
},
|
||||
[router, searchParams, pathname],
|
||||
[router, searchParams, pathname, startTransition],
|
||||
);
|
||||
|
||||
const clearFilter = useCallback(
|
||||
@@ -58,9 +76,11 @@ export const useUrlFilters = () => {
|
||||
params.set("page", "1");
|
||||
}
|
||||
|
||||
startTransition(() => {
|
||||
router.push(`${pathname}?${params.toString()}`, { scroll: false });
|
||||
});
|
||||
},
|
||||
[router, searchParams, pathname],
|
||||
[router, searchParams, pathname, startTransition],
|
||||
);
|
||||
|
||||
const clearAllFilters = useCallback(() => {
|
||||
@@ -73,8 +93,10 @@ export const useUrlFilters = () => {
|
||||
|
||||
params.delete("page");
|
||||
|
||||
startTransition(() => {
|
||||
router.push(`${pathname}?${params.toString()}`, { scroll: false });
|
||||
}, [router, searchParams, pathname]);
|
||||
});
|
||||
}, [router, searchParams, pathname, startTransition]);
|
||||
|
||||
const hasFilters = useCallback(() => {
|
||||
const params = new URLSearchParams(searchParams.toString());
|
||||
@@ -88,5 +110,6 @@ export const useUrlFilters = () => {
|
||||
clearFilter,
|
||||
clearAllFilters,
|
||||
hasFilters,
|
||||
isPending,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -78,8 +78,7 @@ test.describe("Session Error Messages", () => {
|
||||
await context.clearCookies();
|
||||
|
||||
// Try to navigate to a different protected route
|
||||
// Use gotoFresh to ensure middleware runs without cached state
|
||||
await providersPage.gotoFresh("/providers");
|
||||
await providersPage.goto();
|
||||
|
||||
// Should be redirected to login with callbackUrl
|
||||
await signInPage.verifyRedirectWithCallback("/providers");
|
||||
|
||||
@@ -10,6 +10,7 @@ export interface ResourceProps {
|
||||
service: string;
|
||||
tags: Record<string, string>;
|
||||
type: string;
|
||||
groups: string[] | null;
|
||||
failed_findings_count: number;
|
||||
details: string | null;
|
||||
partition: string | null;
|
||||
|
||||
Reference in New Issue
Block a user