fix(ui): stabilize provider wizard modal and DataTable rendering (#10194)

This commit is contained in:
Alejandro Bailo
2026-02-27 14:35:13 +01:00
committed by GitHub
parent fff80a920b
commit 80e84d1da4
14 changed files with 512 additions and 154 deletions

View File

@@ -0,0 +1,15 @@
import { readFileSync } from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { describe, expect, it } from "vitest";
describe("providers page", () => {
it("does not use unstable Date.now keys for the providers DataTable", () => {
const currentDir = path.dirname(fileURLToPath(import.meta.url));
const pagePath = path.join(currentDir, "page.tsx");
const source = readFileSync(pagePath, "utf8");
expect(source).not.toContain("key={`providers-${Date.now()}`}");
});
});

View File

@@ -104,7 +104,6 @@ const ProvidersTable = async ({
<div className="grid grid-cols-12 gap-4"> <div className="grid grid-cols-12 gap-4">
<div className="col-span-12"> <div className="col-span-12">
<DataTable <DataTable
key={`providers-${Date.now()}`}
columns={ColumnProviders} columns={ColumnProviders}
data={enrichedProviders || []} data={enrichedProviders || []}
metadata={providersData?.meta} metadata={providersData?.meta}

View File

@@ -11,16 +11,6 @@ import {
import { useProviderWizardController } from "./use-provider-wizard-controller"; import { useProviderWizardController } from "./use-provider-wizard-controller";
const { pushMock } = vi.hoisted(() => ({
pushMock: vi.fn(),
}));
vi.mock("next/navigation", () => ({
useRouter: () => ({
push: pushMock,
}),
}));
vi.mock("next-auth/react", () => ({ vi.mock("next-auth/react", () => ({
useSession: () => ({ useSession: () => ({
data: null, data: null,
@@ -30,9 +20,9 @@ vi.mock("next-auth/react", () => ({
describe("useProviderWizardController", () => { describe("useProviderWizardController", () => {
beforeEach(() => { beforeEach(() => {
vi.useRealTimers();
sessionStorage.clear(); sessionStorage.clear();
localStorage.clear(); localStorage.clear();
pushMock.mockReset();
useProviderWizardStore.getState().reset(); useProviderWizardStore.getState().reset();
useOrgSetupStore.getState().reset(); useOrgSetupStore.getState().reset();
}); });
@@ -131,7 +121,7 @@ describe("useProviderWizardController", () => {
expect(onOpenChange).not.toHaveBeenCalled(); expect(onOpenChange).not.toHaveBeenCalled();
}); });
it("closes and navigates when launch footer action is triggered", () => { it("does not override launch footer config in the controller", () => {
// Given // Given
const onOpenChange = vi.fn(); const onOpenChange = vi.fn();
const { result } = renderHook(() => const { result } = renderHook(() =>
@@ -146,15 +136,10 @@ describe("useProviderWizardController", () => {
result.current.setCurrentStep(PROVIDER_WIZARD_STEP.LAUNCH); result.current.setCurrentStep(PROVIDER_WIZARD_STEP.LAUNCH);
}); });
const { resolvedFooterConfig } = result.current;
act(() => {
resolvedFooterConfig.onAction?.();
});
// Then // Then
expect(pushMock).toHaveBeenCalledWith("/scans"); expect(result.current.resolvedFooterConfig.showAction).toBe(false);
expect(onOpenChange).toHaveBeenCalledWith(false); expect(result.current.resolvedFooterConfig.showBack).toBe(false);
expect(result.current.currentStep).toBe(PROVIDER_WIZARD_STEP.CONNECT); expect(onOpenChange).not.toHaveBeenCalled();
}); });
it("does not reset organizations step when org store updates while modal is open", () => { it("does not reset organizations step when org store updates while modal is open", () => {
@@ -188,4 +173,69 @@ describe("useProviderWizardController", () => {
expect(result.current.wizardVariant).toBe("organizations"); expect(result.current.wizardVariant).toBe("organizations");
expect(result.current.orgCurrentStep).toBe(ORG_WIZARD_STEP.VALIDATE); expect(result.current.orgCurrentStep).toBe(ORG_WIZARD_STEP.VALIDATE);
}); });
it("does not rehydrate wizard state when initial data changes while modal remains open", async () => {
// Given
const onOpenChange = vi.fn();
const { result, rerender } = renderHook(
({
open,
initialData,
}: {
open: boolean;
initialData?: {
providerId: string;
providerType: "gcp";
providerUid: string;
providerAlias: string;
secretId: string | null;
mode: "add" | "update";
};
}) =>
useProviderWizardController({
open,
onOpenChange,
initialData,
}),
{
initialProps: {
open: true,
initialData: {
providerId: "provider-1",
providerType: "gcp",
providerUid: "project-123",
providerAlias: "gcp-main",
secretId: null,
mode: PROVIDER_WIZARD_MODE.ADD,
},
},
},
);
await waitFor(() => {
expect(result.current.currentStep).toBe(PROVIDER_WIZARD_STEP.CREDENTIALS);
});
act(() => {
useProviderWizardStore.getState().setVia("service-account");
result.current.setCurrentStep(PROVIDER_WIZARD_STEP.TEST);
});
// When: provider data refreshes while modal is still open
rerender({
open: true,
initialData: {
providerId: "provider-1",
providerType: "gcp",
providerUid: "project-123",
providerAlias: "gcp-main",
secretId: "secret-1",
mode: PROVIDER_WIZARD_MODE.UPDATE,
},
});
// Then: keep user progress in the current flow
expect(result.current.currentStep).toBe(PROVIDER_WIZARD_STEP.TEST);
expect(useProviderWizardStore.getState().via).toBe("service-account");
});
}); });

View File

@@ -1,7 +1,6 @@
"use client"; "use client";
import { useRouter } from "next/navigation"; import { useEffect, useRef, useState } from "react";
import { useEffect, useState } from "react";
import { DOCS_URLS, getProviderHelpText } from "@/lib/external-urls"; import { DOCS_URLS, getProviderHelpText } from "@/lib/external-urls";
import { useOrgSetupStore } from "@/store/organizations/store"; import { useOrgSetupStore } from "@/store/organizations/store";
@@ -63,7 +62,7 @@ export function useProviderWizardController({
const initialSecretId = initialData?.secretId ?? null; const initialSecretId = initialData?.secretId ?? null;
const initialVia = initialData?.via ?? null; const initialVia = initialData?.via ?? null;
const initialMode = initialData?.mode ?? null; const initialMode = initialData?.mode ?? null;
const router = useRouter(); const hasHydratedForCurrentOpenRef = useRef(false);
const [wizardVariant, setWizardVariant] = useState<WizardVariant>( const [wizardVariant, setWizardVariant] = useState<WizardVariant>(
WIZARD_VARIANT.PROVIDER, WIZARD_VARIANT.PROVIDER,
); );
@@ -95,9 +94,15 @@ export function useProviderWizardController({
useEffect(() => { useEffect(() => {
if (!open) { if (!open) {
hasHydratedForCurrentOpenRef.current = false;
return; return;
} }
if (hasHydratedForCurrentOpenRef.current) {
return;
}
hasHydratedForCurrentOpenRef.current = true;
if (initialProviderId && initialProviderType && initialProviderUid) { if (initialProviderId && initialProviderType && initialProviderUid) {
setWizardVariant(WIZARD_VARIANT.PROVIDER); setWizardVariant(WIZARD_VARIANT.PROVIDER);
setProvider({ setProvider({
@@ -198,25 +203,7 @@ export function useProviderWizardController({
const docsLink = isProviderFlow const docsLink = isProviderFlow
? getProviderHelpText(providerTypeHint ?? providerType ?? "").link ? getProviderHelpText(providerTypeHint ?? providerType ?? "").link
: DOCS_URLS.AWS_ORGANIZATIONS; : DOCS_URLS.AWS_ORGANIZATIONS;
const resolvedFooterConfig: WizardFooterConfig = const resolvedFooterConfig: WizardFooterConfig = footerConfig;
isProviderFlow && currentStep === PROVIDER_WIZARD_STEP.LAUNCH
? {
showBack: true,
backLabel: "Back",
onBack: () => setCurrentStep(PROVIDER_WIZARD_STEP.TEST),
showSecondaryAction: false,
secondaryActionLabel: "",
secondaryActionVariant: "outline",
secondaryActionType: WIZARD_FOOTER_ACTION_TYPE.BUTTON,
showAction: true,
actionLabel: "Go to scans",
actionType: WIZARD_FOOTER_ACTION_TYPE.BUTTON,
onAction: () => {
handleClose();
router.push("/scans");
},
}
: footerConfig;
const modalTitle = getProviderWizardModalTitle(mode); const modalTitle = getProviderWizardModalTitle(mode);
return { return {

View File

@@ -13,7 +13,10 @@ import { ORG_SETUP_PHASE, ORG_WIZARD_STEP } from "@/types/organizations";
import { PROVIDER_WIZARD_STEP } from "@/types/provider-wizard"; import { PROVIDER_WIZARD_STEP } from "@/types/provider-wizard";
import { useProviderWizardController } from "./hooks/use-provider-wizard-controller"; import { useProviderWizardController } from "./hooks/use-provider-wizard-controller";
import { getOrganizationsStepperOffset } from "./provider-wizard-modal.utils"; import {
getOrganizationsStepperOffset,
getProviderWizardDocsDestination,
} from "./provider-wizard-modal.utils";
import { ConnectStep } from "./steps/connect-step"; import { ConnectStep } from "./steps/connect-step";
import { CredentialsStep } from "./steps/credentials-step"; import { CredentialsStep } from "./steps/credentials-step";
import { WIZARD_FOOTER_ACTION_TYPE } from "./steps/footer-controls"; import { WIZARD_FOOTER_ACTION_TYPE } from "./steps/footer-controls";
@@ -62,6 +65,7 @@ export function ProviderWizardModal({
enabled: open, enabled: open,
refreshToken: scrollHintRefreshToken, refreshToken: scrollHintRefreshToken,
}); });
const docsDestination = getProviderWizardDocsDestination(docsLink);
return ( return (
<Modal <Modal
@@ -80,7 +84,7 @@ export function ProviderWizardModal({
<Button variant="link" size="link-sm" className="h-auto p-0" asChild> <Button variant="link" size="link-sm" className="h-auto p-0" asChild>
<a href={docsLink} target="_blank" rel="noopener noreferrer"> <a href={docsLink} target="_blank" rel="noopener noreferrer">
<ExternalLink className="size-3.5 shrink-0" /> <ExternalLink className="size-3.5 shrink-0" />
<span>Prowler Docs</span> <span>{`Prowler Docs (${docsDestination})`}</span>
</a> </a>
</Button> </Button>
</div> </div>
@@ -136,7 +140,11 @@ export function ProviderWizardModal({
)} )}
{isProviderFlow && currentStep === PROVIDER_WIZARD_STEP.LAUNCH && ( {isProviderFlow && currentStep === PROVIDER_WIZARD_STEP.LAUNCH && (
<LaunchStep /> <LaunchStep
onBack={() => setCurrentStep(PROVIDER_WIZARD_STEP.TEST)}
onClose={handleClose}
onFooterChange={setFooterConfig}
/>
)} )}
{!isProviderFlow && orgCurrentStep === ORG_WIZARD_STEP.SETUP && ( {!isProviderFlow && orgCurrentStep === ORG_WIZARD_STEP.SETUP && (

View File

@@ -5,6 +5,7 @@ import { PROVIDER_WIZARD_MODE } from "@/types/provider-wizard";
import { import {
getOrganizationsStepperOffset, getOrganizationsStepperOffset,
getProviderWizardDocsDestination,
getProviderWizardModalTitle, getProviderWizardModalTitle,
} from "./provider-wizard-modal.utils"; } from "./provider-wizard-modal.utils";
@@ -50,3 +51,21 @@ describe("getProviderWizardModalTitle", () => {
expect(title).toBe("Update Provider Credentials"); expect(title).toBe("Update Provider Credentials");
}); });
}); });
describe("getProviderWizardDocsDestination", () => {
it("returns a compact provider label for short provider docs links", () => {
const destination = getProviderWizardDocsDestination(
"https://goto.prowler.com/provider-aws",
);
expect(destination).toBe("aws");
});
it("returns a compact destination label for long docs links", () => {
const destination = getProviderWizardDocsDestination(
"https://docs.prowler.com/user-guide/tutorials/prowler-cloud-aws-organizations",
);
expect(destination).toBe("aws-organizations");
});
});

View File

@@ -27,3 +27,21 @@ export function getProviderWizardModalTitle(mode: ProviderWizardMode) {
return "Adding A Cloud Provider"; return "Adding A Cloud Provider";
} }
export function getProviderWizardDocsDestination(docsLink: string) {
try {
const parsed = new URL(docsLink);
const pathSegments = parsed.pathname
.split("/")
.filter((segment) => segment.length > 0);
const lastSegment = pathSegments.at(-1);
if (!lastSegment) {
return parsed.hostname;
}
return lastSegment.replace(/^provider-/, "").replace(/^prowler-cloud-/, "");
} catch {
return docsLink;
}
}

View File

@@ -0,0 +1,85 @@
import { act, render, waitFor } from "@testing-library/react";
import type { ComponentProps } from "react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { useProviderWizardStore } from "@/store/provider-wizard/store";
import { LaunchStep } from "./launch-step";
const { scheduleDailyMock, scanOnDemandMock, toastMock } = vi.hoisted(() => ({
scheduleDailyMock: vi.fn(),
scanOnDemandMock: vi.fn(),
toastMock: vi.fn(),
}));
vi.mock("@/actions/scans", () => ({
scheduleDaily: scheduleDailyMock,
scanOnDemand: scanOnDemandMock,
}));
vi.mock("@/components/ui", () => ({
ToastAction: ({ children, ...props }: ComponentProps<"button">) => (
<button {...props}>{children}</button>
),
useToast: () => ({
toast: toastMock,
}),
}));
describe("LaunchStep", () => {
beforeEach(() => {
sessionStorage.clear();
localStorage.clear();
scheduleDailyMock.mockReset();
scanOnDemandMock.mockReset();
toastMock.mockReset();
useProviderWizardStore.getState().reset();
});
it("launches a daily scan and shows toast", async () => {
// Given
const onClose = vi.fn();
const onFooterChange = vi.fn();
useProviderWizardStore.setState({
providerId: "provider-1",
providerType: "gcp",
providerUid: "project-123",
mode: "add",
});
scheduleDailyMock.mockResolvedValue({ data: { id: "scan-1" } });
render(
<LaunchStep
onBack={vi.fn()}
onClose={onClose}
onFooterChange={onFooterChange}
/>,
);
await waitFor(() => {
expect(onFooterChange).toHaveBeenCalled();
});
// When
const initialFooterConfig = onFooterChange.mock.calls.at(-1)?.[0];
await act(async () => {
initialFooterConfig.onAction?.();
});
// Then
await waitFor(() => {
expect(scheduleDailyMock).toHaveBeenCalledTimes(1);
});
const sentFormData = scheduleDailyMock.mock.calls[0]?.[0] as FormData;
expect(sentFormData.get("providerId")).toBe("provider-1");
expect(onClose).toHaveBeenCalledTimes(1);
expect(scanOnDemandMock).not.toHaveBeenCalled();
expect(toastMock).toHaveBeenCalledWith(
expect.objectContaining({
title: "Scan Launched",
}),
);
});
});

View File

@@ -1,15 +1,162 @@
"use client"; "use client";
import { CheckCircle2 } from "lucide-react"; import Link from "next/link";
import { useEffect, useRef, useState } from "react";
import { scanOnDemand, scheduleDaily } from "@/actions/scans";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/shadcn/select/select";
import { TreeSpinner } from "@/components/shadcn/tree-view/tree-spinner";
import { TreeStatusIcon } from "@/components/shadcn/tree-view/tree-status-icon";
import { ToastAction, useToast } from "@/components/ui";
import { useProviderWizardStore } from "@/store/provider-wizard/store";
import { TREE_ITEM_STATUS } from "@/types/tree";
import {
WIZARD_FOOTER_ACTION_TYPE,
WizardFooterConfig,
} from "./footer-controls";
const SCAN_SCHEDULE = {
DAILY: "daily",
SINGLE: "single",
} as const;
type ScanScheduleOption = (typeof SCAN_SCHEDULE)[keyof typeof SCAN_SCHEDULE];
interface LaunchStepProps {
onBack: () => void;
onClose: () => void;
onFooterChange: (config: WizardFooterConfig) => void;
}
export function LaunchStep({
onBack,
onClose,
onFooterChange,
}: LaunchStepProps) {
const { toast } = useToast();
const { providerId } = useProviderWizardStore();
const [isLaunching, setIsLaunching] = useState(false);
const [scheduleOption, setScheduleOption] = useState<ScanScheduleOption>(
SCAN_SCHEDULE.DAILY,
);
const launchActionRef = useRef<() => void>(() => {});
const handleLaunchScan = async () => {
if (!providerId) {
return;
}
setIsLaunching(true);
const formData = new FormData();
formData.set("providerId", providerId);
const result =
scheduleOption === SCAN_SCHEDULE.DAILY
? await scheduleDaily(formData)
: await scanOnDemand(formData);
if (result?.error) {
setIsLaunching(false);
toast({
variant: "destructive",
title: "Unable to launch scan",
description: String(result.error),
});
return;
}
setIsLaunching(false);
onClose();
toast({
title: "Scan Launched",
description:
scheduleOption === SCAN_SCHEDULE.DAILY
? "Daily scan scheduled successfully."
: "Single scan launched successfully.",
action: (
<ToastAction altText="Go to scans" asChild>
<Link href="/scans">Go to scans</Link>
</ToastAction>
),
});
};
launchActionRef.current = () => {
void handleLaunchScan();
};
useEffect(() => {
onFooterChange({
showBack: true,
backLabel: "Back",
backDisabled: isLaunching,
onBack,
showAction: true,
actionLabel: isLaunching ? "Launching scans..." : "Launch scan",
actionDisabled: isLaunching || !providerId,
actionType: WIZARD_FOOTER_ACTION_TYPE.BUTTON,
onAction: () => {
launchActionRef.current();
},
});
}, [isLaunching, onBack, onFooterChange, providerId]);
if (isLaunching) {
return (
<div className="flex min-h-[320px] items-center justify-center">
<div className="flex items-center gap-3 py-2">
<TreeSpinner className="size-6" />
<p className="text-sm font-medium">Launching scans...</p>
</div>
</div>
);
}
export function LaunchStep() {
return ( return (
<div className="flex min-h-[320px] flex-col items-center justify-center gap-4 text-center"> <div className="flex min-h-0 flex-1 flex-col gap-6">
<CheckCircle2 className="text-success size-12" /> <div className="flex items-center gap-3">
<h3 className="text-xl font-semibold">Provider connected successfully</h3> <TreeStatusIcon status={TREE_ITEM_STATUS.SUCCESS} className="size-6" />
<p className="text-muted-foreground text-sm"> <h3 className="text-sm font-semibold">Connection validated!</h3>
Continue with the action button to go to scans. </div>
<p className="text-text-neutral-secondary text-sm">
Choose how you want to launch scans for this provider.
</p> </p>
{!providerId && (
<p className="text-text-error-primary text-sm">
Provider data is missing. Go back and test the connection again.
</p>
)}
<div className="flex flex-col gap-4">
<p className="text-text-neutral-secondary text-sm">Scan schedule</p>
<Select
value={scheduleOption}
onValueChange={(value) =>
setScheduleOption(value as ScanScheduleOption)
}
disabled={isLaunching || !providerId}
>
<SelectTrigger className="w-full max-w-[376px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value={SCAN_SCHEDULE.DAILY}>
Scan Daily (every 24 hours)
</SelectItem>
<SelectItem value={SCAN_SCHEDULE.SINGLE}>
Run a single scan (no recurring schedule)
</SelectItem>
</SelectContent>
</Select>
</div>
</div> </div>
); );
} }

View File

@@ -1,4 +1,5 @@
import { render, screen, waitFor } from "@testing-library/react"; import { render, screen, waitFor } from "@testing-library/react";
import { useEffect } from "react";
import { beforeEach, describe, expect, it, vi } from "vitest"; import { beforeEach, describe, expect, it, vi } from "vitest";
import { useProviderWizardStore } from "@/store/provider-wizard/store"; import { useProviderWizardStore } from "@/store/provider-wizard/store";
@@ -6,8 +7,9 @@ import { PROVIDER_WIZARD_MODE } from "@/types/provider-wizard";
import { TestConnectionStep } from "./test-connection-step"; import { TestConnectionStep } from "./test-connection-step";
const { getProviderMock } = vi.hoisted(() => ({ const { getProviderMock, loadingFromFormMock } = vi.hoisted(() => ({
getProviderMock: vi.fn(), getProviderMock: vi.fn(),
loadingFromFormMock: { current: false },
})); }));
vi.mock("@/actions/providers", () => ({ vi.mock("@/actions/providers", () => ({
@@ -15,7 +17,19 @@ vi.mock("@/actions/providers", () => ({
})); }));
vi.mock("../../workflow/forms/test-connection-form", () => ({ vi.mock("../../workflow/forms/test-connection-form", () => ({
TestConnectionForm: () => <div data-testid="test-connection-form" />, TestConnectionForm: ({
onLoadingChange,
}: {
onLoadingChange?: (isLoading: boolean) => void;
}) => {
useEffect(() => {
if (loadingFromFormMock.current) {
onLoadingChange?.(true);
}
}, [onLoadingChange]);
return <div data-testid="test-connection-form" />;
},
})); }));
describe("TestConnectionStep", () => { describe("TestConnectionStep", () => {
@@ -23,6 +37,7 @@ describe("TestConnectionStep", () => {
sessionStorage.clear(); sessionStorage.clear();
localStorage.clear(); localStorage.clear();
getProviderMock.mockReset(); getProviderMock.mockReset();
loadingFromFormMock.current = false;
useProviderWizardStore.getState().reset(); useProviderWizardStore.getState().reset();
}); });
@@ -64,4 +79,54 @@ describe("TestConnectionStep", () => {
}); });
expect(useProviderWizardStore.getState().secretId).toBe("secret-1"); expect(useProviderWizardStore.getState().secretId).toBe("secret-1");
}); });
it("updates footer action label to checking while connection test is in progress", async () => {
// Given
loadingFromFormMock.current = true;
useProviderWizardStore.setState({
providerId: "provider-1",
providerType: "gcp",
mode: PROVIDER_WIZARD_MODE.ADD,
});
getProviderMock.mockResolvedValue({
data: {
id: "provider-1",
attributes: {
uid: "project-123",
provider: "gcp",
alias: "Main",
connection: { connected: false, last_checked_at: null },
scanner_args: {},
},
relationships: {
secret: { data: { type: "provider-secrets", id: "secret-1" } },
},
},
});
const onFooterChange = vi.fn();
// When
render(
<TestConnectionStep
onSuccess={vi.fn()}
onResetCredentials={vi.fn()}
onFooterChange={onFooterChange}
/>,
);
// Then
await waitFor(() => {
expect(onFooterChange).toHaveBeenCalled();
});
await waitFor(() => {
const footerConfigs = onFooterChange.mock.calls.map((call) => call[0]);
const hasCheckingState = footerConfigs.some(
(config) =>
config.actionLabel === "Checking connection..." &&
config.actionDisabled === true,
);
expect(hasCheckingState).toBe(true);
});
});
}); });

View File

@@ -92,10 +92,9 @@ export function TestConnectionStep({
backDisabled: isFormLoading, backDisabled: isFormLoading,
onBack: onResetCredentials, onBack: onResetCredentials,
showAction: canSubmit, showAction: canSubmit,
actionLabel: actionLabel: isFormLoading
mode === PROVIDER_WIZARD_MODE.UPDATE ? "Checking connection..."
? "Check connection" : "Check connection",
: "Launch scan",
actionDisabled: isFormLoading, actionDisabled: isFormLoading,
actionType: WIZARD_FOOTER_ACTION_TYPE.SUBMIT, actionType: WIZARD_FOOTER_ACTION_TYPE.SUBMIT,
actionFormId: formId, actionFormId: formId,

View File

@@ -1,6 +1,5 @@
"use client"; "use client";
import { Checkbox } from "@heroui/checkbox";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import { Icon } from "@iconify/react"; import { Icon } from "@iconify/react";
import { Loader2 } from "lucide-react"; import { Loader2 } from "lucide-react";
@@ -14,9 +13,8 @@ import {
checkConnectionProvider, checkConnectionProvider,
deleteCredentials, deleteCredentials,
} from "@/actions/providers"; } from "@/actions/providers";
import { scanOnDemand, scheduleDaily } from "@/actions/scans";
import { getTask } from "@/actions/task/tasks"; import { getTask } from "@/actions/task/tasks";
import { CheckIcon, RocketIcon } from "@/components/icons"; import { CheckIcon } from "@/components/icons";
import { Button } from "@/components/shadcn"; import { Button } from "@/components/shadcn";
import { useToast } from "@/components/ui"; import { useToast } from "@/components/ui";
import { Form } from "@/components/ui/form"; import { Form } from "@/components/ui/form";
@@ -83,7 +81,6 @@ export const TestConnectionForm = ({
error: string | null; error: string | null;
} | null>(null); } | null>(null);
const [isResettingCredentials, setIsResettingCredentials] = useState(false); const [isResettingCredentials, setIsResettingCredentials] = useState(false);
const [isRedirecting, setIsRedirecting] = useState(false);
const formSchema = testConnectionFormSchema; const formSchema = testConnectionFormSchema;
@@ -91,7 +88,6 @@ export const TestConnectionForm = ({
resolver: zodResolver(formSchema), resolver: zodResolver(formSchema),
defaultValues: { defaultValues: {
providerId, providerId,
runOnce: false,
}, },
}); });
@@ -149,44 +145,12 @@ export const TestConnectionForm = ({
} }
if (connected && !isUpdated) { if (connected && !isUpdated) {
try { if (onSuccess) {
// Check if the runOnce checkbox is checked onSuccess();
const runOnce = form.watch("runOnce"); return;
let data;
if (runOnce) {
data = await scanOnDemand(formData);
} else {
data = await scheduleDaily(formData);
}
if (data.error) {
setApiErrorMessage(data.error);
form.setError("providerId", {
type: "server",
message: data.error,
});
toast({
variant: "destructive",
title: "Oops! Something went wrong",
description: data.error,
});
} else {
if (onSuccess) {
onSuccess();
return;
}
setIsRedirecting(true);
router.push("/scans");
}
} catch (_error) {
form.setError("providerId", {
type: "server",
message: "An unexpected error occurred. Please try again.",
});
} }
return router.push("/providers");
} else { } else {
setConnectionStatus({ setConnectionStatus({
connected: false, connected: false,
@@ -235,25 +199,6 @@ export const TestConnectionForm = ({
} }
}; };
if (isRedirecting) {
return (
<div className="flex flex-col items-center justify-center gap-6 py-12">
<div className="relative">
<div className="bg-primary/20 h-24 w-24 animate-pulse rounded-full" />
<div className="border-primary absolute inset-0 h-24 w-24 animate-spin rounded-full border-4 border-t-transparent" />
</div>
<div className="text-center">
<p className="text-primary text-xl font-medium">
Scan initiated successfully
</p>
<p className="text-small mt-2 font-bold text-gray-500">
Redirecting to scans job details...
</p>
</div>
</div>
);
}
return ( return (
<Form {...form}> <Form {...form}>
<form <form
@@ -262,14 +207,10 @@ export const TestConnectionForm = ({
className="flex flex-col gap-4" className="flex flex-col gap-4"
> >
<div className="text-left"> <div className="text-left">
<div className="mb-2 text-xl font-medium"> <div className="mb-2 text-xl font-medium">Check connection</div>
{!isUpdated
? "Check connection and launch scan"
: "Check connection"}
</div>
<p className="text-small text-default-500 py-2"> <p className="text-small text-default-500 py-2">
{!isUpdated {!isUpdated
? "After a successful connection, a scan will automatically run every 24 hours. To run a single scan instead, select the checkbox below." ? "After a successful connection, continue to the launch step to configure and start your scan."
: "A successful connection will redirect you to the providers page."} : "A successful connection will redirect you to the providers page."}
</p> </p>
</div> </div>
@@ -309,20 +250,6 @@ export const TestConnectionForm = ({
providerUID={providerData.data.attributes.uid} providerUID={providerData.data.attributes.uid}
/> />
{!isUpdated && !connectionStatus?.error && (
<Checkbox
{...form.register("runOnce")}
isSelected={!!form.watch("runOnce")}
classNames={{
label: "text-small",
wrapper: "checkbox-update",
}}
color="default"
>
Run a single scan (no recurring schedule).
</Checkbox>
)}
{isUpdated && !connectionStatus?.error && ( {isUpdated && !connectionStatus?.error && (
<p className="text-small text-default-500 py-2"> <p className="text-small text-default-500 py-2">
Check the new credentials and test the connection. Check the new credentials and test the connection.
@@ -372,13 +299,13 @@ export const TestConnectionForm = ({
{isLoading ? ( {isLoading ? (
<Loader2 className="animate-spin" /> <Loader2 className="animate-spin" />
) : ( ) : (
!isUpdated && <RocketIcon size={24} /> <CheckIcon size={24} />
)} )}
{isLoading {isLoading
? "Loading" ? "Checking"
: isUpdated : isUpdated
? "Check connection" ? "Check connection"
: "Launch scan"} : "Continue"}
</Button> </Button>
)} )}
</div> </div>

View File

@@ -756,6 +756,9 @@ export class ProvidersPage extends BasePage {
}); });
if (await button.isVisible().catch(() => false)) { if (await button.isVisible().catch(() => false)) {
await button.click(); await button.click();
if (actionName === "Check connection") {
await this.handleCheckConnectionCompletion();
}
if (actionName === "Launch scan") { if (actionName === "Launch scan") {
await this.handleLaunchScanCompletion(); await this.handleLaunchScanCompletion();
} }
@@ -768,9 +771,9 @@ export class ProvidersPage extends BasePage {
); );
} }
private async handleLaunchScanCompletion(): Promise<void> { private async handleCheckConnectionCompletion(): Promise<void> {
const goToScansButton = this.page.getByRole("button", { const launchScanButton = this.page.getByRole("button", {
name: "Go to scans", name: "Launch scan",
exact: true, exact: true,
}); });
const connectionError = this.page.locator( const connectionError = this.page.locator(
@@ -779,9 +782,9 @@ export class ProvidersPage extends BasePage {
try { try {
await Promise.race([ await Promise.race([
this.page.waitForURL(/\/scans/, { timeout: 20000 }), launchScanButton.waitFor({ state: "visible", timeout: 30000 }),
goToScansButton.waitFor({ state: "visible", timeout: 20000 }), this.wizardModal.waitFor({ state: "hidden", timeout: 30000 }),
connectionError.waitFor({ state: "visible", timeout: 20000 }), connectionError.waitFor({ state: "visible", timeout: 30000 }),
]); ]);
} catch { } catch {
// Continue and inspect visible state below. // Continue and inspect visible state below.
@@ -794,14 +797,47 @@ export class ProvidersPage extends BasePage {
); );
} }
if (this.page.url().includes("/scans")) { if (await launchScanButton.isVisible().catch(() => false)) {
return; await launchScanButton.click();
await this.handleLaunchScanCompletion();
}
}
private async handleLaunchScanCompletion(): Promise<void> {
const connectionError = this.page.locator(
"div.border-border-error p.text-text-error-primary",
);
const launchErrorToast = this.page.getByRole("alert").filter({
hasText: /Unable to launch scan/i,
});
try {
await Promise.race([
this.wizardModal.waitFor({ state: "hidden", timeout: 30000 }),
connectionError.waitFor({ state: "visible", timeout: 30000 }),
launchErrorToast.waitFor({ state: "visible", timeout: 30000 }),
]);
} catch {
// Continue and inspect visible state below.
} }
if (await goToScansButton.isVisible().catch(() => false)) { if (await connectionError.isVisible().catch(() => false)) {
await goToScansButton.click(); const errorText = await connectionError.textContent();
await this.page.waitForURL(/\/scans/, { timeout: 30000 }); throw new Error(
`Test connection failed with error: ${errorText?.trim() || "Unknown error"}`,
);
} }
if (await launchErrorToast.isVisible().catch(() => false)) {
const errorText = await launchErrorToast.textContent();
throw new Error(
`Launch scan failed with error: ${errorText?.trim() || "Unknown error"}`,
);
}
await expect(this.wizardModal).not.toBeVisible({ timeout: 30000 });
await this.page.waitForURL(/\/providers/, { timeout: 30000 });
await expect(this.providersTable).toBeVisible({ timeout: 30000 });
} }
async selectCredentialsType(type: AWSCredentialType): Promise<void> { async selectCredentialsType(type: AWSCredentialType): Promise<void> {

View File

@@ -38,6 +38,9 @@ export class ScansPage extends BasePage {
async verifyPageLoaded(): Promise<void> { async verifyPageLoaded(): Promise<void> {
// Verify the scans page is loaded // Verify the scans page is loaded
if (!this.page.url().includes("/scans")) {
await this.goto();
}
await expect(this.page).toHaveTitle(/Prowler/); await expect(this.page).toHaveTitle(/Prowler/);
await expect(this.scanTable).toBeVisible(); await expect(this.scanTable).toBeVisible();