mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-07-23 20:42:02 +00:00
refactor(ui): remove old route-based provider flow
This commit is contained in:
@@ -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");
|
||||
}
|
||||
Reference in New Issue
Block a user