refactor(ui): remove old route-based provider flow

This commit is contained in:
alejandrobailo
2026-02-19 22:55:54 +01:00
parent 518fd1bf2d
commit d9a142a445
15 changed files with 188 additions and 1144 deletions
@@ -1,75 +0,0 @@
import { getProvider } from "@/actions/providers/providers";
import {
AddViaCredentialsForm,
AddViaRoleForm,
} from "@/components/providers/workflow/forms";
import { SelectViaAlibabaCloud } from "@/components/providers/workflow/forms/select-credentials-type/alibabacloud";
import { SelectViaAWS } from "@/components/providers/workflow/forms/select-credentials-type/aws";
import {
AddViaServiceAccountForm,
SelectViaGCP,
} from "@/components/providers/workflow/forms/select-credentials-type/gcp";
import { SelectViaGitHub } from "@/components/providers/workflow/forms/select-credentials-type/github";
import { SelectViaM365 } from "@/components/providers/workflow/forms/select-credentials-type/m365";
import { getProviderFormType } from "@/lib/provider-helpers";
import { ProviderType } from "@/types/providers";
interface Props {
searchParams: Promise<{ type: ProviderType; id: string; via?: string }>;
}
export default async function AddCredentialsPage({ searchParams }: Props) {
const resolvedSearchParams = await searchParams;
const { type: providerType, via, id: providerId } = resolvedSearchParams;
const formType = getProviderFormType(providerType, via);
// Fetch provider data to get the UID (needed for OCI)
let providerUid: string | undefined;
if (providerId) {
const formData = new FormData();
formData.append("id", providerId);
const providerResponse = await getProvider(formData);
if (providerResponse?.data?.attributes?.uid) {
providerUid = providerResponse.data.attributes.uid;
}
}
switch (formType) {
case "selector":
if (providerType === "aws") return <SelectViaAWS initialVia={via} />;
if (providerType === "gcp") return <SelectViaGCP initialVia={via} />;
if (providerType === "github")
return <SelectViaGitHub initialVia={via} />;
if (providerType === "m365") return <SelectViaM365 initialVia={via} />;
if (providerType === "alibabacloud")
return <SelectViaAlibabaCloud initialVia={via} />;
return null;
case "credentials":
return (
<AddViaCredentialsForm
searchParams={resolvedSearchParams}
providerUid={providerUid}
/>
);
case "role":
return (
<AddViaRoleForm
searchParams={resolvedSearchParams}
providerUid={providerUid}
/>
);
case "service-account":
return (
<AddViaServiceAccountForm
searchParams={resolvedSearchParams}
providerUid={providerUid}
/>
);
default:
return null;
}
}
@@ -1,7 +0,0 @@
import React from "react";
import { ConnectAccountForm } from "@/components/providers/workflow/forms";
export default function ConnectAccountPage() {
return <ConnectAccountForm />;
}
@@ -1,31 +0,0 @@
import "@/styles/globals.css";
import React from "react";
import { WorkflowAddProvider } from "@/components/providers/workflow";
import { NavigationHeader } from "@/components/ui";
interface ProviderLayoutProps {
children: React.ReactNode;
}
export default function ProviderLayout({ children }: ProviderLayoutProps) {
return (
<>
<NavigationHeader
title="Connect a Cloud Provider"
icon="icon-park-outline:close-small"
href="/providers"
/>
<div className="mt-8" />
<div className="grid grid-cols-1 gap-8 lg:grid-cols-12">
<div className="order-1 my-auto hidden h-full lg:col-span-4 lg:col-start-2 lg:block">
<WorkflowAddProvider />
</div>
<div className="order-2 my-auto lg:col-span-5 lg:col-start-6">
{children}
</div>
</div>
</>
);
}
@@ -1,46 +0,0 @@
import { redirect } from "next/navigation";
import React, { Suspense } from "react";
import { getProvider } from "@/actions/providers";
import { SkeletonProviderWorkflow } from "@/components/providers/workflow";
import { TestConnectionForm } from "@/components/providers/workflow/forms";
interface Props {
searchParams: Promise<{ type: string; id: string; updated: string }>;
}
export default async function TestConnectionPage({ searchParams }: Props) {
const resolvedSearchParams = await searchParams;
const providerId = resolvedSearchParams.id;
if (!providerId) {
redirect("/providers/connect-account");
}
return (
<Suspense fallback={<SkeletonProviderWorkflow />}>
<SSRTestConnection searchParams={resolvedSearchParams} />
</Suspense>
);
}
async function SSRTestConnection({
searchParams,
}: {
searchParams: { type: string; id: string; updated: string };
}) {
const formData = new FormData();
formData.append("id", searchParams.id);
const providerData = await getProvider(formData);
if (providerData.errors) {
redirect("/providers/connect-account");
}
return (
<TestConnectionForm
searchParams={searchParams}
providerData={providerData}
/>
);
}
@@ -1,76 +0,0 @@
import { redirect } from "next/navigation";
import React from "react";
import { getProvider } from "@/actions/providers/providers";
import { CredentialsUpdateInfo } from "@/components/providers";
import {
UpdateViaCredentialsForm,
UpdateViaRoleForm,
} from "@/components/providers/workflow/forms";
import { UpdateViaServiceAccountForm } from "@/components/providers/workflow/forms/update-via-service-account-key-form";
import { getProviderFormType } from "@/lib/provider-helpers";
import { ProviderType } from "@/types/providers";
interface Props {
searchParams: Promise<{
type: ProviderType;
id: string;
via?: string;
secretId?: string;
}>;
}
export default async function UpdateCredentialsPage({ searchParams }: Props) {
const resolvedSearchParams = await searchParams;
const { type: providerType, via, id: providerId } = resolvedSearchParams;
if (!providerId) {
redirect("/providers");
}
const formType = getProviderFormType(providerType, via);
const formData = new FormData();
formData.append("id", providerId);
const providerResponse = await getProvider(formData);
if (providerResponse?.errors) {
redirect("/providers");
}
const providerUid = providerResponse?.data?.attributes?.uid;
switch (formType) {
case "selector":
return (
<CredentialsUpdateInfo providerType={providerType} initialVia={via} />
);
case "credentials":
return (
<UpdateViaCredentialsForm
searchParams={resolvedSearchParams}
providerUid={providerUid}
/>
);
case "role":
return (
<UpdateViaRoleForm
searchParams={resolvedSearchParams}
providerUid={providerUid}
/>
);
case "service-account":
return (
<UpdateViaServiceAccountForm
searchParams={resolvedSearchParams}
providerUid={providerUid}
/>
);
default:
return null;
}
}
@@ -5,7 +5,7 @@ import { Spacer } from "@heroui/spacer";
import { usePathname, useSearchParams } from "next/navigation";
import React from "react";
import { VerticalSteps } from "@/components/providers/workflow/vertical-steps";
import { cn } from "@/lib/utils";
import type { LighthouseProvider } from "@/types/lighthouse";
import { getProviderConfig } from "../llm-provider-registry";
@@ -75,12 +75,60 @@ export const WorkflowConnectLLM = () => {
value={currentStep + 1}
valueLabel={`${currentStep + 1} of ${steps.length}`}
/>
<VerticalSteps
hideProgressBars
currentStep={currentStep}
stepClassName="border border-border-neutral-primary aria-[current]:bg-bg-neutral-primary cursor-default"
steps={steps}
/>
<nav aria-label="Progress">
<ol className="flex flex-col gap-y-3">
{steps.map((step, index) => {
const isActive = index === currentStep;
const isComplete = index < currentStep;
return (
<li
key={step.title}
className="border-border-neutral-primary rounded-large border px-3 py-2.5"
>
<div className="flex items-center gap-4">
<div
className={cn(
"flex h-[34px] w-[34px] items-center justify-center rounded-full border text-sm font-semibold",
isComplete &&
"bg-button-primary border-button-primary text-white",
isActive &&
"border-button-primary text-button-primary bg-transparent",
!isActive &&
!isComplete &&
"text-default-500 border-border-neutral-primary bg-transparent",
)}
>
{index + 1}
</div>
<div className="flex-1 text-left">
<div
className={cn(
"text-medium font-medium",
isActive || isComplete
? "text-default-foreground"
: "text-default-500",
)}
>
{step.title}
</div>
<div
className={cn(
"text-small",
isActive || isComplete
? "text-default-600"
: "text-default-500",
)}
>
{step.description}
</div>
</div>
</div>
</li>
);
})}
</ol>
</nav>
<Spacer y={4} />
</section>
);
@@ -1,12 +1,16 @@
"use client";
import { ChevronLeft, ChevronRight } from "lucide-react";
import { useEffect } from "react";
import {
buildAccountLookup,
buildOrgTreeData,
} from "@/actions/organizations/organizations.adapter";
import { Badge, Button } from "@/components/shadcn";
import {
WIZARD_FOOTER_ACTION_TYPE,
WizardFooterConfig,
} from "@/components/providers/wizard/steps/footer-controls";
import { Badge } from "@/components/shadcn";
import { TreeView } from "@/components/shadcn/tree-view";
import { useOrgSetupStore } from "@/store/organizations/store";
@@ -15,11 +19,13 @@ import { OrgAccountTreeItem, TREE_ITEM_MODE } from "./org-account-tree-item";
interface OrgAccountSelectionProps {
onBack: () => void;
onNext: () => void;
onFooterChange: (config: WizardFooterConfig) => void;
}
export function OrgAccountSelection({
onBack,
onNext,
onFooterChange,
}: OrgAccountSelectionProps) {
const {
organizationName,
@@ -31,6 +37,21 @@ export function OrgAccountSelection({
setAccountAlias,
} = useOrgSetupStore();
const selectedCount = selectedAccountIds.length;
useEffect(() => {
onFooterChange({
showBack: true,
backLabel: "Back",
onBack,
showAction: true,
actionLabel: "Next",
actionDisabled: selectedCount === 0,
actionType: WIZARD_FOOTER_ACTION_TYPE.BUTTON,
onAction: onNext,
});
}, [onBack, onFooterChange, onNext, selectedCount]);
if (!discoveryResult) {
return (
<div className="text-muted-foreground py-8 text-center text-sm">
@@ -43,7 +64,6 @@ export function OrgAccountSelection({
const accountLookup = buildAccountLookup(discoveryResult);
const totalAccounts = discoveryResult.accounts.length;
const selectedCount = selectedAccountIds.length;
return (
<div className="flex flex-col gap-5">
@@ -82,18 +102,6 @@ export function OrgAccountSelection({
)}
/>
</div>
{/* Actions */}
<div className="flex justify-end gap-3">
<Button type="button" variant="ghost" onClick={onBack}>
<ChevronLeft className="mr-1 size-4" />
Back
</Button>
<Button type="button" onClick={onNext} disabled={selectedCount === 0}>
Next
<ChevronRight className="ml-1 size-4" />
</Button>
</div>
</div>
);
}
@@ -1,6 +1,6 @@
"use client";
import { AlertTriangle, Loader2, RefreshCw } from "lucide-react";
import { AlertTriangle, Loader2 } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import { applyDiscovery } from "@/actions/organizations/organizations";
@@ -10,7 +10,11 @@ import {
getOuIdsForSelectedAccounts,
} from "@/actions/organizations/organizations.adapter";
import { checkConnectionProvider } from "@/actions/providers/providers";
import { Badge, Button } from "@/components/shadcn";
import {
WIZARD_FOOTER_ACTION_TYPE,
WizardFooterConfig,
} from "@/components/providers/wizard/steps/footer-controls";
import { Badge } from "@/components/shadcn";
import { TreeView } from "@/components/shadcn/tree-view";
import { checkTaskStatus } from "@/lib";
import { useOrgSetupStore } from "@/store/organizations/store";
@@ -22,12 +26,14 @@ interface OrgConnectionTestProps {
onBack: () => void;
onNext: () => void;
onSkip: () => void;
onFooterChange: (config: WizardFooterConfig) => void;
}
export function OrgConnectionTest({
onBack,
onNext,
onSkip,
onFooterChange,
}: OrgConnectionTestProps) {
const {
organizationId,
@@ -51,6 +57,7 @@ export function OrgConnectionTest({
>(new Map());
const hasApplied = useRef(false);
const retryFailedActionRef = useRef<() => void>(() => {});
// On mount: apply discovery, then test connections
useEffect(() => {
@@ -168,22 +175,36 @@ export function OrgConnectionTest({
}
};
const handleRetryFailed = () => {
retryFailedActionRef.current = () => {
const failedProviderIds = createdProviderIds.filter(
(id) => connectionResults[id] === CONNECTION_TEST_STATUS.ERROR,
);
testAllConnections(failedProviderIds);
};
const hasErrors = Object.values(connectionResults).some(
(s) => s === CONNECTION_TEST_STATUS.ERROR,
);
useEffect(() => {
onFooterChange({
showBack: true,
backLabel: "Back",
backDisabled: isApplying || isTesting,
onBack,
showAction: hasErrors || isTesting,
actionLabel: isTesting ? "Testing..." : "Test connections",
actionDisabled: isApplying || isTesting || !hasErrors,
actionType: WIZARD_FOOTER_ACTION_TYPE.BUTTON,
onAction: hasErrors ? () => retryFailedActionRef.current() : undefined,
});
}, [hasErrors, isApplying, isTesting, onBack, onFooterChange]);
if (!discoveryResult) return null;
const treeData = buildOrgTreeData(discoveryResult);
const accountLookup = buildAccountLookup(discoveryResult);
const hasErrors = Object.values(connectionResults).some(
(s) => s === CONNECTION_TEST_STATUS.ERROR,
);
return (
<div className="flex flex-col gap-5">
{/* Header */}
@@ -244,12 +265,8 @@ export function OrgConnectionTest({
</div>
)}
{/* Actions */}
<div className="flex items-center justify-between">
<Button type="button" variant="ghost" onClick={onBack}>
Back
</Button>
{/* Optional skip */}
<div className="flex justify-end">
<div className="flex items-center gap-3">
<button
type="button"
@@ -258,20 +275,6 @@ export function OrgConnectionTest({
>
Skip Connection Validation
</button>
{hasErrors && !isTesting && (
<Button type="button" onClick={handleRetryFailed}>
<RefreshCw className="mr-2 size-4" />
Test Connections
</Button>
)}
{isTesting && (
<Button disabled>
<Loader2 className="mr-2 size-4 animate-spin" />
Testing...
</Button>
)}
</div>
</div>
</div>
@@ -2,18 +2,28 @@
import { CheckCircle2, Loader2 } from "lucide-react";
import { useRouter } from "next/navigation";
import { useState } from "react";
import { useEffect, useRef, useState } from "react";
import { scheduleDaily } from "@/actions/scans/scans";
import { Badge, Button } from "@/components/shadcn";
import {
WIZARD_FOOTER_ACTION_TYPE,
WizardFooterConfig,
} from "@/components/providers/wizard/steps/footer-controls";
import { Badge } from "@/components/shadcn";
import { useToast } from "@/components/ui";
import { useOrgSetupStore } from "@/store/organizations/store";
interface OrgLaunchScanProps {
onClose: () => void;
onBack: () => void;
onFooterChange: (config: WizardFooterConfig) => void;
}
export function OrgLaunchScan({ onClose }: OrgLaunchScanProps) {
export function OrgLaunchScan({
onClose,
onBack,
onFooterChange,
}: OrgLaunchScanProps) {
const router = useRouter();
const { toast } = useToast();
const {
@@ -24,12 +34,7 @@ export function OrgLaunchScan({ onClose }: OrgLaunchScanProps) {
} = useOrgSetupStore();
const [isLaunching, setIsLaunching] = useState(false);
const handleDone = () => {
reset();
onClose();
router.push("/providers");
};
const launchActionRef = useRef<() => void>(() => {});
const handleLaunchScan = async () => {
setIsLaunching(true);
@@ -57,6 +62,26 @@ export function OrgLaunchScan({ onClose }: OrgLaunchScanProps) {
});
};
launchActionRef.current = () => {
void handleLaunchScan();
};
useEffect(() => {
onFooterChange({
showBack: true,
backLabel: "Back",
backDisabled: isLaunching,
onBack,
showAction: true,
actionLabel: isLaunching ? "Launching..." : "Launch scan",
actionDisabled: isLaunching,
actionType: WIZARD_FOOTER_ACTION_TYPE.BUTTON,
onAction: () => {
launchActionRef.current();
},
});
}, [isLaunching, onBack, onFooterChange]);
return (
<div className="flex flex-col items-center gap-6 py-6">
{/* Org info */}
@@ -87,16 +112,12 @@ export function OrgLaunchScan({ onClose }: OrgLaunchScanProps) {
</div>
</div>
{/* Actions */}
<div className="flex gap-3">
<Button type="button" variant="outline" onClick={handleDone}>
Done
</Button>
<Button type="button" onClick={handleLaunchScan} disabled={isLaunching}>
{isLaunching && <Loader2 className="mr-2 size-4 animate-spin" />}
{isLaunching ? "Launching..." : "Launch Scan"}
</Button>
</div>
{isLaunching && (
<div className="text-muted-foreground flex items-center gap-2 text-sm">
<Loader2 className="size-4 animate-spin" />
Launching scans...
</div>
)}
</div>
);
}
@@ -2,7 +2,7 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { Loader2 } from "lucide-react";
import { useState } from "react";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { z } from "zod";
@@ -11,7 +11,10 @@ import {
createOrganizationSecret,
triggerDiscovery,
} from "@/actions/organizations/organizations";
import { Button } from "@/components/shadcn";
import {
WIZARD_FOOTER_ACTION_TYPE,
WizardFooterConfig,
} from "@/components/providers/wizard/steps/footer-controls";
import { Input } from "@/components/shadcn/input/input";
import { useOrgSetupStore } from "@/store/organizations/store";
@@ -39,19 +42,27 @@ type OrgSetupFormData = z.infer<typeof orgSetupSchema>;
interface OrgSetupFormProps {
onBack: () => void;
onNext: () => void;
onFooterChange: (config: WizardFooterConfig) => void;
}
export function OrgSetupForm({ onBack, onNext }: OrgSetupFormProps) {
export function OrgSetupForm({
onBack,
onNext,
onFooterChange,
}: OrgSetupFormProps) {
const [apiError, setApiError] = useState<string | null>(null);
const { setOrganization, setDiscovery } = useOrgSetupStore();
const formId = "org-wizard-setup-form";
const {
register,
handleSubmit,
formState: { errors, isSubmitting },
formState: { errors, isSubmitting, isValid },
setError,
} = useForm<OrgSetupFormData>({
resolver: zodResolver(orgSetupSchema),
mode: "onChange",
reValidateMode: "onChange",
defaultValues: {
organizationName: "",
awsOrgId: "",
@@ -60,6 +71,20 @@ export function OrgSetupForm({ onBack, onNext }: OrgSetupFormProps) {
},
});
useEffect(() => {
onFooterChange({
showBack: true,
backLabel: "Back",
backDisabled: isSubmitting,
onBack,
showAction: true,
actionLabel: "Next",
actionDisabled: isSubmitting || !isValid,
actionType: WIZARD_FOOTER_ACTION_TYPE.SUBMIT,
actionFormId: formId,
});
}, [formId, isSubmitting, isValid, onBack, onFooterChange]);
const onSubmit = async (data: OrgSetupFormData) => {
setApiError(null);
@@ -131,7 +156,11 @@ export function OrgSetupForm({ onBack, onNext }: OrgSetupFormProps) {
};
return (
<form onSubmit={handleSubmit(onSubmit)} className="flex flex-col gap-5">
<form
id={formId}
onSubmit={handleSubmit(onSubmit)}
className="flex flex-col gap-5"
>
<div className="flex flex-col gap-1">
<h3 className="text-lg font-semibold">Organization Details</h3>
<p className="text-muted-foreground text-sm">
@@ -211,20 +240,12 @@ export function OrgSetupForm({ onBack, onNext }: OrgSetupFormProps) {
</div>
</div>
<div className="flex justify-end gap-3">
<Button
type="button"
variant="ghost"
onClick={onBack}
disabled={isSubmitting}
>
Back
</Button>
<Button type="submit" disabled={isSubmitting}>
{isSubmitting && <Loader2 className="mr-2 size-4 animate-spin" />}
{isSubmitting ? "Setting up..." : "Next"}
</Button>
</div>
{isSubmitting && (
<div className="text-muted-foreground flex items-center justify-end gap-2 text-sm">
<Loader2 className="size-4 animate-spin" />
Setting up organization...
</div>
)}
</form>
);
}
@@ -1,157 +0,0 @@
"use client";
import { ExternalLink } from "lucide-react";
import { useState } from "react";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/components/shadcn/dialog";
import { useOrgSetupStore } from "@/store/organizations/store";
import { ORG_WIZARD_STEP, OrgWizardStep } from "@/types/organizations";
import { OrgAccountSelection } from "./org-account-selection";
import { OrgConnectionTest } from "./org-connection-test";
import { OrgDiscoveryLoader } from "./org-discovery-loader";
import { OrgLaunchScan } from "./org-launch-scan";
import { OrgSetupForm } from "./org-setup-form";
import { OrgWizardStepper } from "./org-wizard-stepper";
const VALIDATE_PHASE = {
DISCOVERY: "discovery",
SELECTION: "selection",
TESTING: "testing",
} as const;
type ValidatePhase = (typeof VALIDATE_PHASE)[keyof typeof VALIDATE_PHASE];
interface OrgWizardModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
}
export function OrgWizardModal({ open, onOpenChange }: OrgWizardModalProps) {
const [currentStep, setCurrentStep] = useState<OrgWizardStep>(
ORG_WIZARD_STEP.SETUP,
);
const [validatePhase, setValidatePhase] = useState<ValidatePhase>(
VALIDATE_PHASE.DISCOVERY,
);
const { connectionResults, reset } = useOrgSetupStore();
const hasConnectionErrors = Object.values(connectionResults).some(
(s) => s === "error",
);
const handleClose = () => {
reset();
setCurrentStep(ORG_WIZARD_STEP.SETUP);
setValidatePhase(VALIDATE_PHASE.DISCOVERY);
onOpenChange(false);
};
const handleSetupBack = () => {
handleClose();
};
const handleSetupNext = () => {
setCurrentStep(ORG_WIZARD_STEP.VALIDATE);
setValidatePhase(VALIDATE_PHASE.DISCOVERY);
};
const handleDiscoveryComplete = () => {
setValidatePhase(VALIDATE_PHASE.SELECTION);
};
const handleSelectionBack = () => {
// Go back to setup form
setCurrentStep(ORG_WIZARD_STEP.SETUP);
};
const handleSelectionNext = () => {
setValidatePhase(VALIDATE_PHASE.TESTING);
};
const handleTestingBack = () => {
setValidatePhase(VALIDATE_PHASE.SELECTION);
};
const handleTestingNext = () => {
setCurrentStep(ORG_WIZARD_STEP.LAUNCH);
};
const handleSkipValidation = () => {
setCurrentStep(ORG_WIZARD_STEP.LAUNCH);
};
return (
<Dialog open={open} onOpenChange={handleClose}>
<DialogContent className="max-w-4xl p-0">
<DialogHeader className="border-b px-6 py-4">
<div className="flex items-center justify-between">
<DialogTitle className="text-lg font-semibold">
Adding A Cloud Provider
</DialogTitle>
<a
href="https://docs.prowler.com"
target="_blank"
rel="noopener noreferrer"
className="text-muted-foreground hover:text-foreground flex items-center gap-1 text-xs"
>
Learn more
<ExternalLink className="size-3" />
</a>
</div>
</DialogHeader>
<div className="flex min-h-[500px]">
{/* Left: Stepper */}
<div className="w-72 shrink-0 border-r p-5">
<OrgWizardStepper
currentStep={currentStep}
hasConnectionErrors={hasConnectionErrors}
/>
</div>
{/* Right: Step content */}
<div className="flex-1 overflow-y-auto p-6">
{currentStep === ORG_WIZARD_STEP.SETUP && (
<OrgSetupForm onBack={handleSetupBack} onNext={handleSetupNext} />
)}
{currentStep === ORG_WIZARD_STEP.VALIDATE &&
validatePhase === VALIDATE_PHASE.DISCOVERY && (
<OrgDiscoveryLoader
onDiscoveryComplete={handleDiscoveryComplete}
/>
)}
{currentStep === ORG_WIZARD_STEP.VALIDATE &&
validatePhase === VALIDATE_PHASE.SELECTION && (
<OrgAccountSelection
onBack={handleSelectionBack}
onNext={handleSelectionNext}
/>
)}
{currentStep === ORG_WIZARD_STEP.VALIDATE &&
validatePhase === VALIDATE_PHASE.TESTING && (
<OrgConnectionTest
onBack={handleTestingBack}
onNext={handleTestingNext}
onSkip={handleSkipValidation}
/>
)}
{currentStep === ORG_WIZARD_STEP.LAUNCH && (
<OrgLaunchScan onClose={handleClose} />
)}
</div>
</div>
</DialogContent>
</Dialog>
);
}
@@ -1,196 +0,0 @@
"use client";
import {
AlertCircle,
CircleCheckBig,
FolderGit2,
KeyRound,
Rocket,
} from "lucide-react";
import { ProwlerShort } from "@/components/icons/prowler/ProwlerIcons";
import { cn } from "@/lib/utils";
import { IconComponent, IconSvgProps } from "@/types/components";
import { OrgWizardStep } from "@/types/organizations";
interface OrgWizardStepperProps {
currentStep: OrgWizardStep;
hasConnectionErrors: boolean;
}
interface StepConfig {
label: string;
description: string;
icon: IconComponent;
}
const STEPS: StepConfig[] = [
{
label: "Link a Cloud Provider",
description: "Enter the provider details you would like to add in Prowler.",
icon: FolderGit2,
},
{
label: "Authenticate Credentials",
description:
"Authorize a secure connection between Prowler and your provider.",
icon: KeyRound,
},
{
label: "Validate Connection",
description:
"Review provider resources and test the connection to Prowler.",
icon: Rocket,
},
{
label: "Launch Scan",
description: "Scan newly connected resources.",
icon: ProwlerShort,
},
];
/**
* Maps modal internal step (0=Setup, 1=Validate, 2=Launch) to the
* visual stepper index (0-3). Step 0 in the stepper is always complete.
*/
function getVisualStepIndex(modalStep: OrgWizardStep): number {
return modalStep + 1;
}
export function OrgWizardStepper({
currentStep,
hasConnectionErrors,
}: OrgWizardStepperProps) {
const activeVisualStep = getVisualStepIndex(currentStep);
return (
<nav aria-label="Wizard progress" className="flex flex-col gap-0">
{STEPS.map((step, index) => {
const isComplete = index < activeVisualStep;
const isActive = index === activeVisualStep;
const isInactive = index > activeVisualStep;
const isErrorStep = index === 2 && hasConnectionErrors && isActive;
return (
<div key={step.label} className="flex items-start gap-3">
{/* Step indicator + connector line */}
<div className="flex flex-col items-center">
<StepCircle
isComplete={isComplete}
isActive={isActive}
isError={isErrorStep}
icon={step.icon}
/>
{index < STEPS.length - 1 && (
<StepConnector isComplete={isComplete} />
)}
</div>
{/* Label + description */}
<div className="flex flex-col gap-1 pt-[10px]">
<span
className={cn(
"text-lg leading-7 font-normal",
isActive && "text-[#f4f4f5]",
isComplete && "text-[#f4f4f5]",
isInactive && "text-[#f4f4f5]/60",
isErrorStep && "text-destructive",
)}
>
{step.label}
</span>
<p className="text-xs leading-5 text-[#d4d4d8]">
{step.description}
</p>
</div>
</div>
);
})}
</nav>
);
}
interface StepCircleProps {
isComplete: boolean;
isActive: boolean;
isError: boolean;
icon: IconComponent;
}
function StepCircle({
isComplete,
isActive,
isError,
icon: Icon,
}: StepCircleProps) {
if (isError) {
return (
<div className="border-destructive flex size-[44px] shrink-0 items-center justify-center rounded-full border bg-[#121110]">
<AlertCircle className="text-destructive size-6" />
</div>
);
}
if (isComplete) {
return (
<div className="bg-button-primary-press flex size-[44px] shrink-0 items-center justify-center rounded-full">
<CircleCheckBig className="text-bg-neutral-primary size-6" />
</div>
);
}
if (isActive) {
return (
<div className="border-border-input-primary-pressed flex size-[44px] shrink-0 items-center justify-center rounded-full border bg-[#121110]">
<StepIcon icon={Icon} className="text-border-input-primary-pressed" />
</div>
);
}
return (
<div className="flex size-[44px] shrink-0 items-center justify-center rounded-full border border-[#202020] bg-[#121110]">
<StepIcon icon={Icon} className="text-[#525252]" />
</div>
);
}
function StepConnector({ isComplete }: { isComplete: boolean }) {
if (isComplete) {
return <div className="bg-border-input-primary-pressed h-14 w-px" />;
}
return (
<div
className="h-14 w-px"
style={{
backgroundImage:
"repeating-linear-gradient(to bottom, var(--color-bg-data-muted) 0px, var(--color-bg-data-muted) 4px, transparent 4px, transparent 8px)",
}}
/>
);
}
/**
* Renders either a Lucide icon (className-based) or a custom SVG icon
* (size-prop-based like ProwlerShort) with consistent sizing.
*/
function StepIcon({
icon: Icon,
className,
}: {
icon: IconComponent;
className: string;
}) {
if (isCustomSvgIcon(Icon)) {
return <Icon size={24} className={className} />;
}
return <Icon className={cn("size-6", className)} />;
}
/**
* Type guard: Lucide icons have a `displayName` property set by lucide-react.
* Custom SVG icons (React.FC<IconSvgProps>) accept a `size` number prop.
*/
function isCustomSvgIcon(icon: IconComponent): icon is React.FC<IconSvgProps> {
return !("displayName" in icon && typeof icon.displayName === "string");
}
@@ -1,4 +1,3 @@
export * from "./credentials-role-helper";
export * from "./provider-title-docs";
export * from "./skeleton-provider-workflow";
export * from "./workflow-add-provider";
@@ -1,294 +0,0 @@
"use client";
import { cn } from "@heroui/theme";
import { useControlledState } from "@react-stately/utils";
import { domAnimation, LazyMotion, m } from "framer-motion";
import type { ComponentProps } from "react";
import React from "react";
export type VerticalStepProps = {
className?: string;
description?: React.ReactNode;
title?: React.ReactNode;
};
export interface VerticalStepsProps
extends React.HTMLAttributes<HTMLButtonElement> {
/**
* An array of steps.
*
* @default []
*/
steps?: VerticalStepProps[];
/**
* The color of the steps.
*
* @default "primary"
*/
color?:
| "primary"
| "secondary"
| "success"
| "warning"
| "danger"
| "default";
/**
* The current step index.
*/
currentStep?: number;
/**
* The default step index.
*
* @default 0
*/
defaultStep?: number;
/**
* Whether to hide the progress bars.
*
* @default false
*/
hideProgressBars?: boolean;
/**
* The custom class for the steps wrapper.
*/
className?: string;
/**
* The custom class for the step.
*/
stepClassName?: string;
/**
* Callback function when the step index changes.
*/
onStepChange?: (stepIndex: number) => void;
}
function CheckIcon(props: ComponentProps<"svg">) {
return (
<svg
{...props}
fill="none"
stroke="currentColor"
strokeWidth={2}
viewBox="0 0 24 24"
>
<m.path
animate={{ pathLength: 1 }}
d="M5 13l4 4L19 7"
initial={{ pathLength: 0 }}
strokeLinecap="round"
strokeLinejoin="round"
transition={{
delay: 0.2,
type: "tween",
ease: "easeOut",
duration: 0.3,
}}
/>
</svg>
);
}
export const VerticalSteps = React.forwardRef<
HTMLButtonElement,
VerticalStepsProps
>(
(
{
color = "primary",
steps = [],
defaultStep = 0,
onStepChange,
currentStep: currentStepProp,
hideProgressBars = false,
stepClassName,
className,
...props
},
ref,
) => {
const [currentStep, setCurrentStep] = useControlledState(
currentStepProp,
defaultStep,
onStepChange,
);
const colors = React.useMemo(() => {
let userColor;
let fgColor;
const colorsVars = [
"[--active-fg-color:var(--step-fg-color)]",
"[--active-border-color:var(--step-color)]",
"[--active-color:var(--step-color)]",
"[--complete-background-color:var(--step-color)]",
"[--complete-border-color:var(--step-color)]",
"[--inactive-border-color:hsl(var(--heroui-default-300))]",
"[--inactive-color:hsl(var(--heroui-default-300))]",
];
switch (color) {
case "primary":
userColor = "[--step-color:hsl(var(--heroui-primary))]";
fgColor = "[--step-fg-color:hsl(var(--heroui-primary-foreground))]";
break;
case "secondary":
userColor = "[--step-color:hsl(var(--heroui-secondary))]";
fgColor = "[--step-fg-color:hsl(var(--heroui-secondary-foreground))]";
break;
case "success":
userColor = "[--step-color:hsl(var(--heroui-success))]";
fgColor = "[--step-fg-color:hsl(var(--heroui-success-foreground))]";
break;
case "warning":
userColor = "[--step-color:hsl(var(--heroui-warning))]";
fgColor = "[--step-fg-color:hsl(var(--heroui-warning-foreground))]";
break;
case "danger":
userColor = "[--step-color:hsl(var(--heroui-error))]";
fgColor = "[--step-fg-color:hsl(var(--heroui-error-foreground))]";
break;
case "default":
userColor = "[--step-color:hsl(var(--heroui-default))]";
fgColor = "[--step-fg-color:hsl(var(--heroui-default-foreground))]";
break;
default:
userColor = "[--step-color:hsl(var(--heroui-primary))]";
fgColor = "[--step-fg-color:hsl(var(--heroui-primary-foreground))]";
break;
}
if (!className?.includes("--step-fg-color")) colorsVars.unshift(fgColor);
if (!className?.includes("--step-color")) colorsVars.unshift(userColor);
if (!className?.includes("--inactive-bar-color"))
colorsVars.push(
"[--inactive-bar-color:hsl(var(--heroui-default-300))]",
);
return colorsVars;
}, [color, className]);
return (
<nav aria-label="Progress" className="max-w-fit">
<ol className={cn("flex flex-col gap-y-3", colors, className)}>
{steps?.map((step, stepIdx) => {
const status =
currentStep === stepIdx
? "active"
: currentStep < stepIdx
? "inactive"
: "complete";
return (
<li key={stepIdx} className="relative">
<div className="flex w-full max-w-full items-center">
<button
key={stepIdx}
ref={ref}
aria-current={status === "active" ? "step" : undefined}
className={cn(
"group rounded-large flex w-full cursor-pointer items-center justify-center gap-4 px-3 py-2.5",
stepClassName,
)}
onClick={() => setCurrentStep(stepIdx)}
{...props}
>
<div className="flex h-full items-center">
<LazyMotion features={domAnimation}>
<div className="relative">
<m.div
animate={status}
className={cn(
"border-medium text-large text-default-foreground relative flex h-[34px] w-[34px] items-center justify-center rounded-full font-semibold",
{
"shadow-lg": status === "complete",
},
)}
data-status={status}
initial={false}
transition={{ duration: 0.25 }}
variants={{
inactive: {
backgroundColor: "transparent",
borderColor: "var(--inactive-border-color)",
color: "var(--inactive-color)",
},
active: {
backgroundColor: "transparent",
borderColor: "var(--bg-button-primary)",
color: "var(--bg-button-primary)",
},
complete: {
backgroundColor: "var(--bg-button-primary)",
borderColor: "var(--bg-button-primary)",
},
}}
>
<div className="flex items-center justify-center">
{status === "complete" ? (
<CheckIcon className="h-6 w-6 text-(--active-fg-color)" />
) : (
<span>{stepIdx + 1}</span>
)}
</div>
</m.div>
</div>
</LazyMotion>
</div>
<div className="flex-1 text-left">
<div>
<div
className={cn(
"text-medium text-default-foreground font-medium transition-[color,opacity] duration-300 group-active:opacity-70",
{
"text-default-500": status === "inactive",
},
)}
>
{step.title}
</div>
<div
className={cn(
"text-tiny text-default-600 lg:text-small transition-[color,opacity] duration-300 group-active:opacity-70",
{
"text-default-500": status === "inactive",
},
)}
>
{step.description}
</div>
</div>
</div>
</button>
</div>
{stepIdx < steps.length - 1 && !hideProgressBars && (
<div
aria-hidden="true"
className={cn(
"pointer-events-none absolute top-[calc(64px*var(--idx)+1)] left-3 flex h-1/2 -translate-y-1/3 items-center px-4",
)}
style={{
// @ts-expect-error
"--idx": stepIdx,
}}
>
<div
className={cn(
"relative h-full w-0.5 bg-(--inactive-bar-color) transition-colors duration-300",
"after:absolute after:block after:h-0 after:w-full after:bg-(--active-border-color) after:transition-[height] after:duration-300 after:content-['']",
{
"after:h-full": stepIdx < currentStep,
},
)}
/>
</div>
)}
</li>
);
})}
</ol>
</nav>
);
},
);
VerticalSteps.displayName = "VerticalSteps";
@@ -1,174 +0,0 @@
"use client";
import {
CircleCheckBig,
FolderGit2,
KeyRound,
Rocket,
} from "lucide-react";
import { usePathname } from "next/navigation";
import { ProwlerShort } from "@/components/icons/prowler/ProwlerIcons";
import { cn } from "@/lib/utils";
import { IconComponent, IconSvgProps } from "@/types/components";
interface StepConfig {
label: string;
description: string;
icon: IconComponent;
}
const STEPS: StepConfig[] = [
{
label: "Link a Cloud Provider",
description:
"Enter the provider details you would like to add in Prowler.",
icon: FolderGit2,
},
{
label: "Authenticate Credentials",
description:
"Authorize a secure connection between Prowler and your provider.",
icon: KeyRound,
},
{
label: "Validate Connection",
description:
"Review provider resources and test the connection to Prowler.",
icon: Rocket,
},
{
label: "Launch Scan",
description: "Scan newly connected resources.",
icon: ProwlerShort,
},
];
const ROUTE_TO_STEP: Record<string, number> = {
"/providers/connect-account": 0,
"/providers/add-credentials": 1,
"/providers/test-connection": 2,
"/providers/update-credentials": 1,
};
export const WorkflowAddProvider = () => {
const pathname = usePathname();
const currentStep = ROUTE_TO_STEP[pathname] ?? 0;
return (
<nav aria-label="Progress" className="flex max-w-sm flex-col gap-0">
{STEPS.map((step, index) => {
const isComplete = index < currentStep;
const isActive = index === currentStep;
const isInactive = index > currentStep;
return (
<div key={step.label} className="flex items-start gap-3">
{/* Step indicator + connector line */}
<div className="flex flex-col items-center">
<StepCircle
isComplete={isComplete}
isActive={isActive}
icon={step.icon}
/>
{index < STEPS.length - 1 && (
<StepConnector isComplete={isComplete} />
)}
</div>
{/* Label + description */}
<div className="flex flex-col gap-1 pt-[10px]">
<span
className={cn(
"text-lg leading-7 font-normal",
isActive && "text-[#f4f4f5]",
isComplete && "text-[#f4f4f5]",
isInactive && "text-[#f4f4f5]/60",
)}
>
{step.label}
</span>
<p className="text-xs leading-5 text-[#d4d4d8]">
{step.description}
</p>
</div>
</div>
);
})}
</nav>
);
};
interface StepCircleProps {
isComplete: boolean;
isActive: boolean;
icon: IconComponent;
}
function StepCircle({ isComplete, isActive, icon: Icon }: StepCircleProps) {
if (isComplete) {
return (
<div className="bg-button-primary-press flex size-[44px] shrink-0 items-center justify-center rounded-full">
<CircleCheckBig className="text-bg-neutral-primary size-6" />
</div>
);
}
if (isActive) {
return (
<div className="border-border-input-primary-pressed flex size-[44px] shrink-0 items-center justify-center rounded-full border bg-[#121110]">
<StepIcon
icon={Icon}
className="text-border-input-primary-pressed"
/>
</div>
);
}
return (
<div className="flex size-[44px] shrink-0 items-center justify-center rounded-full border border-[#202020] bg-[#121110]">
<StepIcon icon={Icon} className="text-[#525252]" />
</div>
);
}
function StepConnector({ isComplete }: { isComplete: boolean }) {
if (isComplete) {
return <div className="bg-border-input-primary-pressed h-14 w-px" />;
}
return (
<div
className="h-14 w-px"
style={{
backgroundImage:
"repeating-linear-gradient(to bottom, var(--color-bg-data-muted) 0px, var(--color-bg-data-muted) 4px, transparent 4px, transparent 8px)",
}}
/>
);
}
/**
* Renders either a Lucide icon (className-based) or a custom SVG icon
* (size-prop-based like ProwlerShort) with consistent sizing.
*/
function StepIcon({
icon: Icon,
className,
}: {
icon: IconComponent;
className: string;
}) {
if (isCustomSvgIcon(Icon)) {
return <Icon size={24} className={className} />;
}
return <Icon className={cn("size-6", className)} />;
}
/**
* Type guard: Lucide icons have a `displayName` property set by lucide-react.
* Custom SVG icons (React.FC<IconSvgProps>) accept a `size` number prop.
*/
function isCustomSvgIcon(icon: IconComponent): icon is React.FC<IconSvgProps> {
return !("displayName" in icon && typeof icon.displayName === "string");
}