mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-03-22 03:08:23 +00:00
feat(ui): replace route-based provider flow with modal wizard (#10156)
This commit is contained in:
@@ -1,78 +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 { SelectViaCloudflare } from "@/components/providers/workflow/forms/select-credentials-type/cloudflare";
|
||||
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} />;
|
||||
if (providerType === "cloudflare")
|
||||
return <SelectViaCloudflare 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,32 +0,0 @@
|
||||
import "@/styles/globals.css";
|
||||
|
||||
import { Spacer } from "@heroui/spacer";
|
||||
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"
|
||||
/>
|
||||
<Spacer y={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;
|
||||
}
|
||||
}
|
||||
@@ -1,18 +1,22 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useState } from "react";
|
||||
|
||||
import { ProviderWizardModal } from "@/components/providers/wizard";
|
||||
import { Button } from "@/components/shadcn";
|
||||
|
||||
import { AddIcon } from "../icons";
|
||||
|
||||
export const AddProviderButton = () => {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<Button asChild>
|
||||
<Link href="/providers/connect-account">
|
||||
<>
|
||||
<Button onClick={() => setOpen(true)}>
|
||||
Add Cloud Provider
|
||||
<AddIcon size={20} />
|
||||
</Link>
|
||||
</Button>
|
||||
</Button>
|
||||
<ProviderWizardModal open={open} onOpenChange={setOpen} />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
"use client";
|
||||
|
||||
import { Ban, Box, Boxes } from "lucide-react";
|
||||
|
||||
import { RadioCard } from "@/components/providers/radio-card";
|
||||
|
||||
interface AwsMethodSelectorProps {
|
||||
onSelectSingle: () => void;
|
||||
onSelectOrganizations: () => void;
|
||||
}
|
||||
|
||||
export function AwsMethodSelector({
|
||||
onSelectSingle,
|
||||
onSelectOrganizations,
|
||||
}: AwsMethodSelectorProps) {
|
||||
const isCloudEnv = process.env.NEXT_PUBLIC_IS_CLOUD_ENV === "true";
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Select a method to add your accounts to Prowler.
|
||||
</p>
|
||||
|
||||
<RadioCard
|
||||
icon={Box}
|
||||
title="Add A Single AWS Cloud Account"
|
||||
onClick={onSelectSingle}
|
||||
/>
|
||||
|
||||
<RadioCard
|
||||
icon={isCloudEnv ? Boxes : Ban}
|
||||
title="Add Multiple Accounts With AWS Organizations"
|
||||
onClick={onSelectOrganizations}
|
||||
disabled={!isCloudEnv}
|
||||
>
|
||||
{!isCloudEnv && <CtaBadge />}
|
||||
</RadioCard>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CtaBadge() {
|
||||
return (
|
||||
<a
|
||||
href="https://prowler.com/pricing"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex h-[52px] shrink-0 items-center justify-center rounded-lg px-4 py-3 transition-opacity hover:opacity-90"
|
||||
style={{
|
||||
backgroundImage:
|
||||
"linear-gradient(112deg, rgb(46, 229, 155) 3.5%, rgb(98, 223, 240) 98.8%)",
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-primary-foreground text-sm leading-6 font-bold">
|
||||
Available in Prowler Cloud
|
||||
</span>
|
||||
</div>
|
||||
</a>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
|
||||
import {
|
||||
WIZARD_FOOTER_ACTION_TYPE,
|
||||
WizardFooterConfig,
|
||||
} from "@/components/providers/wizard/steps/footer-controls";
|
||||
|
||||
interface OrgAccountSelectionProps {
|
||||
onBack: () => void;
|
||||
onNext: () => void;
|
||||
onSkip: () => void;
|
||||
onFooterChange: (config: WizardFooterConfig) => void;
|
||||
}
|
||||
|
||||
export function OrgAccountSelection({
|
||||
onBack,
|
||||
onNext,
|
||||
onSkip,
|
||||
onFooterChange,
|
||||
}: OrgAccountSelectionProps) {
|
||||
useEffect(() => {
|
||||
onFooterChange({
|
||||
showBack: true,
|
||||
backLabel: "Back",
|
||||
onBack,
|
||||
showSecondaryAction: true,
|
||||
secondaryActionLabel: "Skip",
|
||||
secondaryActionType: WIZARD_FOOTER_ACTION_TYPE.BUTTON,
|
||||
onSecondaryAction: onSkip,
|
||||
showAction: true,
|
||||
actionLabel: "Continue",
|
||||
actionType: WIZARD_FOOTER_ACTION_TYPE.BUTTON,
|
||||
onAction: onNext,
|
||||
});
|
||||
}, [onBack, onFooterChange, onNext, onSkip]);
|
||||
|
||||
return (
|
||||
<div className="flex min-h-0 flex-1 flex-col justify-center gap-2 py-6">
|
||||
<h3 className="text-base font-semibold">Account selection</h3>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Account discovery and selection are introduced in the next chained PR.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
41
ui/components/providers/organizations/org-launch-scan.tsx
Normal file
41
ui/components/providers/organizations/org-launch-scan.tsx
Normal file
@@ -0,0 +1,41 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
|
||||
import {
|
||||
WIZARD_FOOTER_ACTION_TYPE,
|
||||
WizardFooterConfig,
|
||||
} from "@/components/providers/wizard/steps/footer-controls";
|
||||
|
||||
interface OrgLaunchScanProps {
|
||||
onClose: () => void;
|
||||
onBack: () => void;
|
||||
onFooterChange: (config: WizardFooterConfig) => void;
|
||||
}
|
||||
|
||||
export function OrgLaunchScan({
|
||||
onClose,
|
||||
onBack,
|
||||
onFooterChange,
|
||||
}: OrgLaunchScanProps) {
|
||||
useEffect(() => {
|
||||
onFooterChange({
|
||||
showBack: true,
|
||||
backLabel: "Back",
|
||||
onBack,
|
||||
showAction: true,
|
||||
actionLabel: "Close",
|
||||
actionType: WIZARD_FOOTER_ACTION_TYPE.BUTTON,
|
||||
onAction: onClose,
|
||||
});
|
||||
}, [onBack, onClose, onFooterChange]);
|
||||
|
||||
return (
|
||||
<div className="flex min-h-0 flex-1 flex-col justify-center gap-2 py-6">
|
||||
<h3 className="text-base font-semibold">Launch scan</h3>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Organizations scan launch flow is completed in the next chained PR.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
51
ui/components/providers/organizations/org-setup-form.tsx
Normal file
51
ui/components/providers/organizations/org-setup-form.tsx
Normal file
@@ -0,0 +1,51 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
|
||||
import {
|
||||
WIZARD_FOOTER_ACTION_TYPE,
|
||||
WizardFooterConfig,
|
||||
} from "@/components/providers/wizard/steps/footer-controls";
|
||||
import { ORG_SETUP_PHASE, OrgSetupPhase } from "@/types/organizations";
|
||||
|
||||
interface OrgSetupFormProps {
|
||||
onBack: () => void;
|
||||
onNext: () => void;
|
||||
onFooterChange: (config: WizardFooterConfig) => void;
|
||||
onPhaseChange: (phase: OrgSetupPhase) => void;
|
||||
initialPhase?: OrgSetupPhase;
|
||||
}
|
||||
|
||||
export function OrgSetupForm({
|
||||
onBack,
|
||||
onNext,
|
||||
onFooterChange,
|
||||
onPhaseChange,
|
||||
initialPhase = ORG_SETUP_PHASE.DETAILS,
|
||||
}: OrgSetupFormProps) {
|
||||
useEffect(() => {
|
||||
onPhaseChange(initialPhase);
|
||||
}, [initialPhase, onPhaseChange]);
|
||||
|
||||
useEffect(() => {
|
||||
onFooterChange({
|
||||
showBack: true,
|
||||
backLabel: "Back",
|
||||
onBack,
|
||||
showAction: true,
|
||||
actionLabel: "Continue",
|
||||
actionType: WIZARD_FOOTER_ACTION_TYPE.BUTTON,
|
||||
onAction: onNext,
|
||||
});
|
||||
}, [onBack, onFooterChange, onNext]);
|
||||
|
||||
return (
|
||||
<div className="flex min-h-0 flex-1 flex-col justify-center gap-2 py-6">
|
||||
<h3 className="text-base font-semibold">AWS Organizations</h3>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
The full AWS Organizations setup step is included in the next chained
|
||||
PR.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -114,7 +114,7 @@ export const RadioGroupProvider: FC<RadioGroupProviderProps> = ({
|
||||
name="providerType"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<div className="flex h-[calc(100vh-200px)] flex-col px-4">
|
||||
<div className="flex flex-col px-4">
|
||||
<div className="relative z-10 shrink-0 pb-4">
|
||||
<SearchInput
|
||||
aria-label="Search providers"
|
||||
@@ -125,19 +125,11 @@ export const RadioGroupProvider: FC<RadioGroupProviderProps> = ({
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="minimal-scrollbar relative flex-1 overflow-y-auto pr-3">
|
||||
<div className="relative">
|
||||
<div
|
||||
role="listbox"
|
||||
aria-label="Select a provider"
|
||||
className="flex flex-col gap-3"
|
||||
style={{
|
||||
maskImage:
|
||||
"linear-gradient(to bottom, transparent, black 24px)",
|
||||
WebkitMaskImage:
|
||||
"linear-gradient(to bottom, transparent, black 24px)",
|
||||
paddingTop: "24px",
|
||||
marginTop: "-24px",
|
||||
}}
|
||||
>
|
||||
{filteredProviders.length > 0 ? (
|
||||
filteredProviders.map((provider) => {
|
||||
@@ -152,19 +144,26 @@ export const RadioGroupProvider: FC<RadioGroupProviderProps> = ({
|
||||
aria-selected={isSelected}
|
||||
onClick={() => field.onChange(provider.value)}
|
||||
className={cn(
|
||||
"flex w-full cursor-pointer items-center gap-3 rounded-lg border p-4 text-left transition-all",
|
||||
"hover:border-button-primary",
|
||||
"focus-visible:border-button-primary focus-visible:ring-button-primary focus:outline-none focus-visible:ring-1",
|
||||
"flex min-h-[72px] w-full items-center gap-4 rounded-lg border px-3 py-2.5 text-left transition-colors",
|
||||
"focus-visible:border-primary focus-visible:outline-none",
|
||||
isSelected
|
||||
? "border-button-primary bg-bg-neutral-tertiary"
|
||||
: "border-border-neutral-secondary bg-bg-neutral-secondary",
|
||||
? "border-primary bg-bg-neutral-tertiary"
|
||||
: "border-border-neutral-primary bg-bg-neutral-tertiary hover:border-primary",
|
||||
isInvalid && "border-bg-fail",
|
||||
)}
|
||||
>
|
||||
<BadgeComponent size={26} />
|
||||
<span className="text-text-neutral-primary text-sm font-medium">
|
||||
{provider.label}
|
||||
</span>
|
||||
<div className="border-border-neutral-primary bg-bg-input-primary flex size-[18px] shrink-0 items-center justify-center rounded-full border shadow-xs">
|
||||
{isSelected && (
|
||||
<div className="bg-primary size-2.5 rounded-full" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex min-w-0 flex-1 items-center gap-1.5">
|
||||
<BadgeComponent size={26} />
|
||||
<span className="text-foreground text-sm leading-6">
|
||||
{provider.label}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})
|
||||
|
||||
@@ -2,11 +2,11 @@
|
||||
|
||||
import { Row } from "@tanstack/react-table";
|
||||
import { Pencil, PlugZap, Trash2 } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
|
||||
import { checkConnectionProvider } from "@/actions/providers/providers";
|
||||
import { VerticalDotsIcon } from "@/components/icons";
|
||||
import { ProviderWizardModal } from "@/components/providers/wizard";
|
||||
import { Button } from "@/components/shadcn";
|
||||
import {
|
||||
ActionDropdown,
|
||||
@@ -14,26 +14,27 @@ import {
|
||||
ActionDropdownItem,
|
||||
} from "@/components/shadcn/dropdown";
|
||||
import { Modal } from "@/components/shadcn/modal";
|
||||
import { PROVIDER_WIZARD_MODE } from "@/types/provider-wizard";
|
||||
import { ProviderProps } from "@/types/providers";
|
||||
|
||||
import { EditForm } from "../forms";
|
||||
import { DeleteForm } from "../forms/delete-form";
|
||||
|
||||
interface DataTableRowActionsProps<ProviderProps> {
|
||||
interface DataTableRowActionsProps {
|
||||
row: Row<ProviderProps>;
|
||||
}
|
||||
|
||||
export function DataTableRowActions<ProviderProps>({
|
||||
row,
|
||||
}: DataTableRowActionsProps<ProviderProps>) {
|
||||
const router = useRouter();
|
||||
export function DataTableRowActions({ row }: DataTableRowActionsProps) {
|
||||
const [isEditOpen, setIsEditOpen] = useState(false);
|
||||
const [isDeleteOpen, setIsDeleteOpen] = useState(false);
|
||||
const [isWizardOpen, setIsWizardOpen] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const providerId = (row.original as { id: string }).id;
|
||||
const providerType = (row.original as any).attributes?.provider;
|
||||
const providerAlias = (row.original as any).attributes?.alias;
|
||||
const providerSecretId =
|
||||
(row.original as any).relationships?.secret?.data?.id || null;
|
||||
const provider = row.original;
|
||||
const providerId = provider.id;
|
||||
const providerType = provider.attributes.provider;
|
||||
const providerUid = provider.attributes.uid;
|
||||
const providerAlias = provider.attributes.alias ?? null;
|
||||
const providerSecretId = provider.relationships.secret.data?.id ?? null;
|
||||
|
||||
const handleTestConnection = async () => {
|
||||
setLoading(true);
|
||||
@@ -43,7 +44,7 @@ export function DataTableRowActions<ProviderProps>({
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
const hasSecret = (row.original as any).relationships?.secret?.data;
|
||||
const hasSecret = Boolean(provider.relationships.secret.data);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -66,6 +67,20 @@ export function DataTableRowActions<ProviderProps>({
|
||||
>
|
||||
<DeleteForm providerId={providerId} setIsOpen={setIsDeleteOpen} />
|
||||
</Modal>
|
||||
<ProviderWizardModal
|
||||
open={isWizardOpen}
|
||||
onOpenChange={setIsWizardOpen}
|
||||
initialData={{
|
||||
providerId,
|
||||
providerType,
|
||||
providerUid,
|
||||
providerAlias,
|
||||
secretId: providerSecretId,
|
||||
mode: providerSecretId
|
||||
? PROVIDER_WIZARD_MODE.UPDATE
|
||||
: PROVIDER_WIZARD_MODE.ADD,
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className="relative flex items-center justify-end gap-2">
|
||||
<ActionDropdown
|
||||
@@ -78,11 +93,7 @@ export function DataTableRowActions<ProviderProps>({
|
||||
<ActionDropdownItem
|
||||
icon={<Pencil />}
|
||||
label={hasSecret ? "Update Credentials" : "Add Credentials"}
|
||||
onSelect={() =>
|
||||
router.push(
|
||||
`/providers/${hasSecret ? "update" : "add"}-credentials?type=${providerType}&id=${providerId}${providerSecretId ? `&secretId=${providerSecretId}` : ""}`,
|
||||
)
|
||||
}
|
||||
onSelect={() => setIsWizardOpen(true)}
|
||||
/>
|
||||
<ActionDropdownItem
|
||||
icon={<PlugZap />}
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
import { act, renderHook, waitFor } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { useOrgSetupStore } from "@/store/organizations/store";
|
||||
import { useProviderWizardStore } from "@/store/provider-wizard/store";
|
||||
import { ORG_WIZARD_STEP } from "@/types/organizations";
|
||||
import {
|
||||
PROVIDER_WIZARD_MODE,
|
||||
PROVIDER_WIZARD_STEP,
|
||||
} from "@/types/provider-wizard";
|
||||
|
||||
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", () => ({
|
||||
useSession: () => ({
|
||||
data: null,
|
||||
status: "unauthenticated",
|
||||
}),
|
||||
}));
|
||||
|
||||
describe("useProviderWizardController", () => {
|
||||
beforeEach(() => {
|
||||
sessionStorage.clear();
|
||||
localStorage.clear();
|
||||
pushMock.mockReset();
|
||||
useProviderWizardStore.getState().reset();
|
||||
useOrgSetupStore.getState().reset();
|
||||
});
|
||||
|
||||
it("hydrates update mode when initial data is provided", async () => {
|
||||
// Given
|
||||
const onOpenChange = vi.fn();
|
||||
|
||||
// When
|
||||
const { result } = renderHook(() =>
|
||||
useProviderWizardController({
|
||||
open: true,
|
||||
onOpenChange,
|
||||
initialData: {
|
||||
providerId: "provider-1",
|
||||
providerType: "aws",
|
||||
providerUid: "111111111111",
|
||||
providerAlias: "production",
|
||||
secretId: "secret-1",
|
||||
mode: PROVIDER_WIZARD_MODE.UPDATE,
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
// Then
|
||||
await waitFor(() => {
|
||||
expect(result.current.currentStep).toBe(PROVIDER_WIZARD_STEP.CREDENTIALS);
|
||||
});
|
||||
expect(result.current.modalTitle).toBe("Update Provider Credentials");
|
||||
expect(result.current.isProviderFlow).toBe(true);
|
||||
expect(result.current.docsLink).toBe(
|
||||
"https://goto.prowler.com/provider-aws",
|
||||
);
|
||||
|
||||
const state = useProviderWizardStore.getState();
|
||||
expect(state.providerId).toBe("provider-1");
|
||||
expect(state.providerType).toBe("aws");
|
||||
expect(state.providerUid).toBe("111111111111");
|
||||
expect(state.providerAlias).toBe("production");
|
||||
expect(state.secretId).toBe("secret-1");
|
||||
expect(state.mode).toBe(PROVIDER_WIZARD_MODE.UPDATE);
|
||||
});
|
||||
|
||||
it("switches into and out of organizations flow", () => {
|
||||
// Given
|
||||
const onOpenChange = vi.fn();
|
||||
const { result } = renderHook(() =>
|
||||
useProviderWizardController({
|
||||
open: true,
|
||||
onOpenChange,
|
||||
}),
|
||||
);
|
||||
|
||||
// When
|
||||
act(() => {
|
||||
result.current.openOrganizationsFlow();
|
||||
});
|
||||
|
||||
// Then
|
||||
expect(result.current.wizardVariant).toBe("organizations");
|
||||
expect(result.current.isProviderFlow).toBe(false);
|
||||
expect(result.current.orgCurrentStep).toBe(ORG_WIZARD_STEP.SETUP);
|
||||
|
||||
// When
|
||||
act(() => {
|
||||
result.current.backToProviderFlow();
|
||||
});
|
||||
|
||||
// Then
|
||||
expect(result.current.wizardVariant).toBe("provider");
|
||||
expect(result.current.isProviderFlow).toBe(true);
|
||||
expect(result.current.currentStep).toBe(PROVIDER_WIZARD_STEP.CONNECT);
|
||||
});
|
||||
|
||||
it("moves to launch step after a successful connection test in add mode", () => {
|
||||
// Given
|
||||
const onOpenChange = vi.fn();
|
||||
const { result } = renderHook(() =>
|
||||
useProviderWizardController({
|
||||
open: true,
|
||||
onOpenChange,
|
||||
}),
|
||||
);
|
||||
|
||||
// When
|
||||
act(() => {
|
||||
result.current.setCurrentStep(PROVIDER_WIZARD_STEP.TEST);
|
||||
result.current.handleTestSuccess();
|
||||
});
|
||||
|
||||
// Then
|
||||
expect(result.current.currentStep).toBe(PROVIDER_WIZARD_STEP.LAUNCH);
|
||||
expect(onOpenChange).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("closes and navigates when launch footer action is triggered", () => {
|
||||
// Given
|
||||
const onOpenChange = vi.fn();
|
||||
const { result } = renderHook(() =>
|
||||
useProviderWizardController({
|
||||
open: true,
|
||||
onOpenChange,
|
||||
}),
|
||||
);
|
||||
|
||||
// When
|
||||
act(() => {
|
||||
result.current.setCurrentStep(PROVIDER_WIZARD_STEP.LAUNCH);
|
||||
});
|
||||
|
||||
const { resolvedFooterConfig } = result.current;
|
||||
act(() => {
|
||||
resolvedFooterConfig.onAction?.();
|
||||
});
|
||||
|
||||
// Then
|
||||
expect(pushMock).toHaveBeenCalledWith("/scans");
|
||||
expect(onOpenChange).toHaveBeenCalledWith(false);
|
||||
expect(result.current.currentStep).toBe(PROVIDER_WIZARD_STEP.CONNECT);
|
||||
});
|
||||
|
||||
it("does not reset organizations step when org store updates while modal is open", () => {
|
||||
// Given
|
||||
const onOpenChange = vi.fn();
|
||||
const { result } = renderHook(() =>
|
||||
useProviderWizardController({
|
||||
open: true,
|
||||
onOpenChange,
|
||||
}),
|
||||
);
|
||||
|
||||
act(() => {
|
||||
result.current.openOrganizationsFlow();
|
||||
result.current.setOrgCurrentStep(ORG_WIZARD_STEP.VALIDATE);
|
||||
});
|
||||
|
||||
// When
|
||||
act(() => {
|
||||
useOrgSetupStore
|
||||
.getState()
|
||||
.setOrganization("org-1", "My Org", "o-abc123def4");
|
||||
useOrgSetupStore.getState().setDiscovery("disc-1", {
|
||||
roots: [],
|
||||
organizational_units: [],
|
||||
accounts: [],
|
||||
});
|
||||
});
|
||||
|
||||
// Then
|
||||
expect(result.current.wizardVariant).toBe("organizations");
|
||||
expect(result.current.orgCurrentStep).toBe(ORG_WIZARD_STEP.VALIDATE);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,244 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { getProviderHelpText } from "@/lib/external-urls";
|
||||
import { useOrgSetupStore } from "@/store/organizations/store";
|
||||
import { useProviderWizardStore } from "@/store/provider-wizard/store";
|
||||
import {
|
||||
ORG_SETUP_PHASE,
|
||||
ORG_WIZARD_STEP,
|
||||
OrgSetupPhase,
|
||||
OrgWizardStep,
|
||||
} from "@/types/organizations";
|
||||
import {
|
||||
PROVIDER_WIZARD_MODE,
|
||||
PROVIDER_WIZARD_STEP,
|
||||
ProviderWizardStep,
|
||||
} from "@/types/provider-wizard";
|
||||
import { ProviderType } from "@/types/providers";
|
||||
|
||||
import { getProviderWizardModalTitle } from "../provider-wizard-modal.utils";
|
||||
import {
|
||||
WIZARD_FOOTER_ACTION_TYPE,
|
||||
WizardFooterConfig,
|
||||
} from "../steps/footer-controls";
|
||||
import type { ProviderWizardInitialData } from "../types";
|
||||
|
||||
const WIZARD_VARIANT = {
|
||||
PROVIDER: "provider",
|
||||
ORGANIZATIONS: "organizations",
|
||||
} as const;
|
||||
|
||||
type WizardVariant = (typeof WIZARD_VARIANT)[keyof typeof WIZARD_VARIANT];
|
||||
|
||||
const EMPTY_FOOTER_CONFIG: WizardFooterConfig = {
|
||||
showBack: false,
|
||||
backLabel: "Back",
|
||||
showSecondaryAction: false,
|
||||
secondaryActionLabel: "",
|
||||
secondaryActionVariant: "outline",
|
||||
secondaryActionType: WIZARD_FOOTER_ACTION_TYPE.BUTTON,
|
||||
showAction: false,
|
||||
actionLabel: "Next",
|
||||
actionType: WIZARD_FOOTER_ACTION_TYPE.BUTTON,
|
||||
};
|
||||
|
||||
interface UseProviderWizardControllerProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
initialData?: ProviderWizardInitialData;
|
||||
}
|
||||
|
||||
export function useProviderWizardController({
|
||||
open,
|
||||
onOpenChange,
|
||||
initialData,
|
||||
}: UseProviderWizardControllerProps) {
|
||||
const initialProviderId = initialData?.providerId ?? null;
|
||||
const initialProviderType = initialData?.providerType ?? null;
|
||||
const initialProviderUid = initialData?.providerUid ?? null;
|
||||
const initialProviderAlias = initialData?.providerAlias ?? null;
|
||||
const initialSecretId = initialData?.secretId ?? null;
|
||||
const initialVia = initialData?.via ?? null;
|
||||
const initialMode = initialData?.mode ?? null;
|
||||
const router = useRouter();
|
||||
const [wizardVariant, setWizardVariant] = useState<WizardVariant>(
|
||||
WIZARD_VARIANT.PROVIDER,
|
||||
);
|
||||
const [currentStep, setCurrentStep] = useState<ProviderWizardStep>(
|
||||
PROVIDER_WIZARD_STEP.CONNECT,
|
||||
);
|
||||
const [orgCurrentStep, setOrgCurrentStep] = useState<OrgWizardStep>(
|
||||
ORG_WIZARD_STEP.SETUP,
|
||||
);
|
||||
const [footerConfig, setFooterConfig] =
|
||||
useState<WizardFooterConfig>(EMPTY_FOOTER_CONFIG);
|
||||
const [providerTypeHint, setProviderTypeHint] = useState<ProviderType | null>(
|
||||
null,
|
||||
);
|
||||
const [orgSetupPhase, setOrgSetupPhase] = useState<OrgSetupPhase>(
|
||||
ORG_SETUP_PHASE.DETAILS,
|
||||
);
|
||||
|
||||
const {
|
||||
reset: resetProviderWizard,
|
||||
setProvider,
|
||||
setVia,
|
||||
setSecretId,
|
||||
setMode,
|
||||
mode,
|
||||
providerType,
|
||||
} = useProviderWizardStore();
|
||||
const { reset: resetOrgWizard } = useOrgSetupStore();
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (initialProviderId && initialProviderType && initialProviderUid) {
|
||||
setWizardVariant(WIZARD_VARIANT.PROVIDER);
|
||||
setProvider({
|
||||
id: initialProviderId,
|
||||
type: initialProviderType,
|
||||
uid: initialProviderUid,
|
||||
alias: initialProviderAlias,
|
||||
});
|
||||
setVia(initialVia);
|
||||
setSecretId(initialSecretId);
|
||||
setMode(
|
||||
initialMode ||
|
||||
(initialSecretId
|
||||
? PROVIDER_WIZARD_MODE.UPDATE
|
||||
: PROVIDER_WIZARD_MODE.ADD),
|
||||
);
|
||||
setCurrentStep(PROVIDER_WIZARD_STEP.CREDENTIALS);
|
||||
setOrgCurrentStep(ORG_WIZARD_STEP.SETUP);
|
||||
setFooterConfig(EMPTY_FOOTER_CONFIG);
|
||||
setProviderTypeHint(initialProviderType);
|
||||
setOrgSetupPhase(ORG_SETUP_PHASE.DETAILS);
|
||||
return;
|
||||
}
|
||||
|
||||
resetProviderWizard();
|
||||
resetOrgWizard();
|
||||
setWizardVariant(WIZARD_VARIANT.PROVIDER);
|
||||
setCurrentStep(PROVIDER_WIZARD_STEP.CONNECT);
|
||||
setOrgCurrentStep(ORG_WIZARD_STEP.SETUP);
|
||||
setFooterConfig(EMPTY_FOOTER_CONFIG);
|
||||
setProviderTypeHint(null);
|
||||
setOrgSetupPhase(ORG_SETUP_PHASE.DETAILS);
|
||||
}, [
|
||||
initialMode,
|
||||
initialProviderAlias,
|
||||
initialProviderId,
|
||||
initialProviderType,
|
||||
initialProviderUid,
|
||||
initialSecretId,
|
||||
initialVia,
|
||||
open,
|
||||
resetOrgWizard,
|
||||
resetProviderWizard,
|
||||
setMode,
|
||||
setProvider,
|
||||
setSecretId,
|
||||
setVia,
|
||||
]);
|
||||
|
||||
const handleClose = () => {
|
||||
resetProviderWizard();
|
||||
resetOrgWizard();
|
||||
setWizardVariant(WIZARD_VARIANT.PROVIDER);
|
||||
setCurrentStep(PROVIDER_WIZARD_STEP.CONNECT);
|
||||
setOrgCurrentStep(ORG_WIZARD_STEP.SETUP);
|
||||
setFooterConfig(EMPTY_FOOTER_CONFIG);
|
||||
setProviderTypeHint(null);
|
||||
setOrgSetupPhase(ORG_SETUP_PHASE.DETAILS);
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
const handleDialogOpenChange = (nextOpen: boolean) => {
|
||||
if (nextOpen) {
|
||||
onOpenChange(true);
|
||||
return;
|
||||
}
|
||||
handleClose();
|
||||
};
|
||||
|
||||
const handleTestSuccess = () => {
|
||||
if (mode === PROVIDER_WIZARD_MODE.UPDATE) {
|
||||
handleClose();
|
||||
return;
|
||||
}
|
||||
|
||||
setCurrentStep(PROVIDER_WIZARD_STEP.LAUNCH);
|
||||
};
|
||||
|
||||
const openOrganizationsFlow = () => {
|
||||
resetOrgWizard();
|
||||
setWizardVariant(WIZARD_VARIANT.ORGANIZATIONS);
|
||||
setOrgCurrentStep(ORG_WIZARD_STEP.SETUP);
|
||||
setFooterConfig(EMPTY_FOOTER_CONFIG);
|
||||
setProviderTypeHint(null);
|
||||
setOrgSetupPhase(ORG_SETUP_PHASE.DETAILS);
|
||||
};
|
||||
|
||||
const backToProviderFlow = () => {
|
||||
resetOrgWizard();
|
||||
setWizardVariant(WIZARD_VARIANT.PROVIDER);
|
||||
setCurrentStep(PROVIDER_WIZARD_STEP.CONNECT);
|
||||
setFooterConfig(EMPTY_FOOTER_CONFIG);
|
||||
setProviderTypeHint(null);
|
||||
setOrgSetupPhase(ORG_SETUP_PHASE.DETAILS);
|
||||
};
|
||||
|
||||
const isProviderFlow = wizardVariant === WIZARD_VARIANT.PROVIDER;
|
||||
const docsLink = getProviderHelpText(
|
||||
isProviderFlow ? (providerTypeHint ?? providerType ?? "") : "aws",
|
||||
).link;
|
||||
const resolvedFooterConfig: WizardFooterConfig =
|
||||
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);
|
||||
|
||||
return {
|
||||
currentStep,
|
||||
docsLink,
|
||||
footerConfig,
|
||||
handleClose,
|
||||
handleDialogOpenChange,
|
||||
handleTestSuccess,
|
||||
isProviderFlow,
|
||||
modalTitle,
|
||||
openOrganizationsFlow,
|
||||
orgCurrentStep,
|
||||
orgSetupPhase,
|
||||
providerTypeHint,
|
||||
resolvedFooterConfig,
|
||||
setCurrentStep,
|
||||
setFooterConfig,
|
||||
setOrgCurrentStep,
|
||||
setOrgSetupPhase,
|
||||
setProviderTypeHint,
|
||||
backToProviderFlow,
|
||||
wizardVariant,
|
||||
};
|
||||
}
|
||||
3
ui/components/providers/wizard/index.ts
Normal file
3
ui/components/providers/wizard/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export * from "./provider-wizard-modal";
|
||||
export * from "./steps";
|
||||
export * from "./wizard-stepper";
|
||||
273
ui/components/providers/wizard/provider-wizard-modal.tsx
Normal file
273
ui/components/providers/wizard/provider-wizard-modal.tsx
Normal file
@@ -0,0 +1,273 @@
|
||||
"use client";
|
||||
|
||||
import { ExternalLink, Info } from "lucide-react";
|
||||
|
||||
import { OrgAccountSelection } from "@/components/providers/organizations/org-account-selection";
|
||||
import { OrgLaunchScan } from "@/components/providers/organizations/org-launch-scan";
|
||||
import { OrgSetupForm } from "@/components/providers/organizations/org-setup-form";
|
||||
import { Button } from "@/components/shadcn/button/button";
|
||||
import { DialogHeader, DialogTitle } from "@/components/shadcn/dialog";
|
||||
import { Modal } from "@/components/shadcn/modal";
|
||||
import { useScrollHint } from "@/hooks/use-scroll-hint";
|
||||
import { ORG_SETUP_PHASE, ORG_WIZARD_STEP } from "@/types/organizations";
|
||||
import { PROVIDER_WIZARD_STEP } from "@/types/provider-wizard";
|
||||
|
||||
import { useProviderWizardController } from "./hooks/use-provider-wizard-controller";
|
||||
import { getOrganizationsStepperOffset } from "./provider-wizard-modal.utils";
|
||||
import { ConnectStep } from "./steps/connect-step";
|
||||
import { CredentialsStep } from "./steps/credentials-step";
|
||||
import { WIZARD_FOOTER_ACTION_TYPE } from "./steps/footer-controls";
|
||||
import { LaunchStep } from "./steps/launch-step";
|
||||
import { TestConnectionStep } from "./steps/test-connection-step";
|
||||
import type { ProviderWizardInitialData } from "./types";
|
||||
import { WizardStepper } from "./wizard-stepper";
|
||||
|
||||
interface ProviderWizardModalProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
initialData?: ProviderWizardInitialData;
|
||||
}
|
||||
|
||||
export function ProviderWizardModal({
|
||||
open,
|
||||
onOpenChange,
|
||||
initialData,
|
||||
}: ProviderWizardModalProps) {
|
||||
const {
|
||||
backToProviderFlow,
|
||||
currentStep,
|
||||
docsLink,
|
||||
handleClose,
|
||||
handleDialogOpenChange,
|
||||
handleTestSuccess,
|
||||
isProviderFlow,
|
||||
modalTitle,
|
||||
openOrganizationsFlow,
|
||||
orgCurrentStep,
|
||||
orgSetupPhase,
|
||||
resolvedFooterConfig,
|
||||
setCurrentStep,
|
||||
setFooterConfig,
|
||||
setOrgCurrentStep,
|
||||
setOrgSetupPhase,
|
||||
setProviderTypeHint,
|
||||
wizardVariant,
|
||||
} = useProviderWizardController({
|
||||
open,
|
||||
onOpenChange,
|
||||
initialData,
|
||||
});
|
||||
const scrollHintRefreshToken = `${wizardVariant}-${currentStep}-${orgCurrentStep}-${orgSetupPhase}`;
|
||||
const { containerRef, showScrollHint, handleScroll } = useScrollHint({
|
||||
enabled: open,
|
||||
refreshToken: scrollHintRefreshToken,
|
||||
});
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
onOpenChange={handleDialogOpenChange}
|
||||
size="4xl"
|
||||
className="flex !h-[90vh] !max-h-[90vh] !min-h-[90vh] !w-[calc(100vw-24px)] !max-w-[1192px] flex-col overflow-hidden p-4 sm:!w-[calc(100vw-40px)] sm:p-6 lg:!w-[calc(100vw-64px)] lg:p-8"
|
||||
>
|
||||
<DialogHeader className="gap-2 p-0">
|
||||
<DialogTitle className="text-lg font-semibold">
|
||||
{modalTitle}
|
||||
</DialogTitle>
|
||||
<div className="text-muted-foreground flex flex-wrap items-center gap-2 text-sm">
|
||||
<Info className="size-4 shrink-0" />
|
||||
<span>For assistance connecting a Cloud Provider visit</span>
|
||||
<Button variant="link" size="link-sm" className="h-auto p-0" asChild>
|
||||
<a href={docsLink} target="_blank" rel="noopener noreferrer">
|
||||
<ExternalLink className="size-3.5 shrink-0" />
|
||||
<span>Prowler Docs</span>
|
||||
</a>
|
||||
</Button>
|
||||
</div>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="mt-6 flex min-h-0 flex-1 flex-col overflow-hidden lg:mt-8 lg:flex-row">
|
||||
<div className="mb-4 box-border w-full shrink-0 lg:mb-0 lg:w-[328px]">
|
||||
{isProviderFlow ? (
|
||||
<WizardStepper currentStep={currentStep} />
|
||||
) : (
|
||||
<WizardStepper
|
||||
currentStep={orgCurrentStep}
|
||||
stepOffset={getOrganizationsStepperOffset(
|
||||
orgCurrentStep,
|
||||
orgSetupPhase,
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div aria-hidden className="hidden w-[100px] min-w-0 shrink lg:block" />
|
||||
|
||||
<div className="relative flex-1 overflow-hidden">
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="minimal-scrollbar h-full w-full overflow-y-scroll [scrollbar-gutter:stable] lg:ml-auto lg:max-w-[620px] xl:max-w-[700px]"
|
||||
onScroll={handleScroll}
|
||||
>
|
||||
{isProviderFlow && currentStep === PROVIDER_WIZARD_STEP.CONNECT && (
|
||||
<ConnectStep
|
||||
onNext={() => setCurrentStep(PROVIDER_WIZARD_STEP.CREDENTIALS)}
|
||||
onSelectOrganizations={openOrganizationsFlow}
|
||||
onFooterChange={setFooterConfig}
|
||||
onProviderTypeChange={setProviderTypeHint}
|
||||
/>
|
||||
)}
|
||||
|
||||
{isProviderFlow &&
|
||||
currentStep === PROVIDER_WIZARD_STEP.CREDENTIALS && (
|
||||
<CredentialsStep
|
||||
onNext={() => setCurrentStep(PROVIDER_WIZARD_STEP.TEST)}
|
||||
onBack={() => setCurrentStep(PROVIDER_WIZARD_STEP.CONNECT)}
|
||||
onFooterChange={setFooterConfig}
|
||||
/>
|
||||
)}
|
||||
|
||||
{isProviderFlow && currentStep === PROVIDER_WIZARD_STEP.TEST && (
|
||||
<TestConnectionStep
|
||||
onSuccess={handleTestSuccess}
|
||||
onResetCredentials={() =>
|
||||
setCurrentStep(PROVIDER_WIZARD_STEP.CREDENTIALS)
|
||||
}
|
||||
onFooterChange={setFooterConfig}
|
||||
/>
|
||||
)}
|
||||
|
||||
{isProviderFlow && currentStep === PROVIDER_WIZARD_STEP.LAUNCH && (
|
||||
<LaunchStep />
|
||||
)}
|
||||
|
||||
{!isProviderFlow && orgCurrentStep === ORG_WIZARD_STEP.SETUP && (
|
||||
<OrgSetupForm
|
||||
onBack={backToProviderFlow}
|
||||
onNext={() => {
|
||||
setOrgCurrentStep(ORG_WIZARD_STEP.VALIDATE);
|
||||
}}
|
||||
onFooterChange={setFooterConfig}
|
||||
onPhaseChange={setOrgSetupPhase}
|
||||
initialPhase={orgSetupPhase}
|
||||
/>
|
||||
)}
|
||||
|
||||
{!isProviderFlow && orgCurrentStep === ORG_WIZARD_STEP.VALIDATE && (
|
||||
<OrgAccountSelection
|
||||
onBack={() => {
|
||||
setOrgCurrentStep(ORG_WIZARD_STEP.SETUP);
|
||||
setOrgSetupPhase(ORG_SETUP_PHASE.ACCESS);
|
||||
}}
|
||||
onNext={() => {
|
||||
setOrgCurrentStep(ORG_WIZARD_STEP.LAUNCH);
|
||||
}}
|
||||
onSkip={() => {
|
||||
setOrgCurrentStep(ORG_WIZARD_STEP.LAUNCH);
|
||||
}}
|
||||
onFooterChange={setFooterConfig}
|
||||
/>
|
||||
)}
|
||||
|
||||
{!isProviderFlow && orgCurrentStep === ORG_WIZARD_STEP.LAUNCH && (
|
||||
<OrgLaunchScan
|
||||
onClose={handleClose}
|
||||
onBack={() => {
|
||||
setOrgCurrentStep(ORG_WIZARD_STEP.VALIDATE);
|
||||
}}
|
||||
onFooterChange={setFooterConfig}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{showScrollHint && (
|
||||
<div className="pointer-events-none absolute right-0 bottom-0 left-0 z-10">
|
||||
<div className="from-bg-neutral-secondary h-12 bg-gradient-to-t to-transparent" />
|
||||
<div className="absolute inset-x-0 bottom-2 flex justify-center">
|
||||
<span className="bg-bg-neutral-secondary/85 text-text-neutral-tertiary rounded-full px-3 py-1 text-xs backdrop-blur-sm">
|
||||
Scroll to see more
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{(resolvedFooterConfig.showBack ||
|
||||
resolvedFooterConfig.showSecondaryAction ||
|
||||
resolvedFooterConfig.showAction) && (
|
||||
<div className="mt-8 pt-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
{resolvedFooterConfig.showBack && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="xl"
|
||||
disabled={resolvedFooterConfig.backDisabled}
|
||||
onClick={resolvedFooterConfig.onBack}
|
||||
>
|
||||
{resolvedFooterConfig.backLabel}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-6">
|
||||
{resolvedFooterConfig.showSecondaryAction && (
|
||||
<Button
|
||||
size={
|
||||
resolvedFooterConfig.secondaryActionVariant === "link"
|
||||
? "link-sm"
|
||||
: "xl"
|
||||
}
|
||||
className={
|
||||
resolvedFooterConfig.secondaryActionVariant === "link"
|
||||
? "h-auto p-0"
|
||||
: undefined
|
||||
}
|
||||
variant={resolvedFooterConfig.secondaryActionVariant}
|
||||
type={
|
||||
resolvedFooterConfig.secondaryActionType ===
|
||||
WIZARD_FOOTER_ACTION_TYPE.SUBMIT
|
||||
? "submit"
|
||||
: "button"
|
||||
}
|
||||
form={resolvedFooterConfig.secondaryActionFormId}
|
||||
disabled={resolvedFooterConfig.secondaryActionDisabled}
|
||||
onClick={
|
||||
resolvedFooterConfig.secondaryActionType ===
|
||||
WIZARD_FOOTER_ACTION_TYPE.BUTTON
|
||||
? resolvedFooterConfig.onSecondaryAction
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{resolvedFooterConfig.secondaryActionLabel}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{resolvedFooterConfig.showAction && (
|
||||
<Button
|
||||
size="xl"
|
||||
type={
|
||||
resolvedFooterConfig.actionType ===
|
||||
WIZARD_FOOTER_ACTION_TYPE.SUBMIT
|
||||
? "submit"
|
||||
: "button"
|
||||
}
|
||||
form={resolvedFooterConfig.actionFormId}
|
||||
disabled={resolvedFooterConfig.actionDisabled}
|
||||
onClick={
|
||||
resolvedFooterConfig.actionType ===
|
||||
WIZARD_FOOTER_ACTION_TYPE.BUTTON
|
||||
? resolvedFooterConfig.onAction
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{resolvedFooterConfig.actionLabel}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { ORG_SETUP_PHASE, ORG_WIZARD_STEP } from "@/types/organizations";
|
||||
import { PROVIDER_WIZARD_MODE } from "@/types/provider-wizard";
|
||||
|
||||
import {
|
||||
getOrganizationsStepperOffset,
|
||||
getProviderWizardModalTitle,
|
||||
} from "./provider-wizard-modal.utils";
|
||||
|
||||
describe("getOrganizationsStepperOffset", () => {
|
||||
it("keeps step 1 active during organization details", () => {
|
||||
const offset = getOrganizationsStepperOffset(
|
||||
ORG_WIZARD_STEP.SETUP,
|
||||
ORG_SETUP_PHASE.DETAILS,
|
||||
);
|
||||
|
||||
expect(offset).toBe(0);
|
||||
});
|
||||
|
||||
it("moves to step 2 during credentials phase", () => {
|
||||
const offset = getOrganizationsStepperOffset(
|
||||
ORG_WIZARD_STEP.SETUP,
|
||||
ORG_SETUP_PHASE.ACCESS,
|
||||
);
|
||||
|
||||
expect(offset).toBe(1);
|
||||
});
|
||||
|
||||
it("uses step 2+ offset for later wizard steps", () => {
|
||||
const offset = getOrganizationsStepperOffset(
|
||||
ORG_WIZARD_STEP.VALIDATE,
|
||||
ORG_SETUP_PHASE.DETAILS,
|
||||
);
|
||||
|
||||
expect(offset).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getProviderWizardModalTitle", () => {
|
||||
it("returns add title for add mode", () => {
|
||||
const title = getProviderWizardModalTitle(PROVIDER_WIZARD_MODE.ADD);
|
||||
|
||||
expect(title).toBe("Adding A Cloud Provider");
|
||||
});
|
||||
|
||||
it("returns update title for update mode", () => {
|
||||
const title = getProviderWizardModalTitle(PROVIDER_WIZARD_MODE.UPDATE);
|
||||
|
||||
expect(title).toBe("Update Provider Credentials");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,29 @@
|
||||
import {
|
||||
ORG_SETUP_PHASE,
|
||||
ORG_WIZARD_STEP,
|
||||
OrgSetupPhase,
|
||||
OrgWizardStep,
|
||||
} from "@/types/organizations";
|
||||
import {
|
||||
PROVIDER_WIZARD_MODE,
|
||||
ProviderWizardMode,
|
||||
} from "@/types/provider-wizard";
|
||||
|
||||
export function getOrganizationsStepperOffset(
|
||||
currentStep: OrgWizardStep,
|
||||
setupPhase: OrgSetupPhase,
|
||||
) {
|
||||
if (currentStep === ORG_WIZARD_STEP.SETUP) {
|
||||
return setupPhase === ORG_SETUP_PHASE.ACCESS ? 1 : 0;
|
||||
}
|
||||
|
||||
return 1;
|
||||
}
|
||||
|
||||
export function getProviderWizardModalTitle(mode: ProviderWizardMode) {
|
||||
if (mode === PROVIDER_WIZARD_MODE.UPDATE) {
|
||||
return "Update Provider Credentials";
|
||||
}
|
||||
|
||||
return "Adding A Cloud Provider";
|
||||
}
|
||||
84
ui/components/providers/wizard/steps/connect-step.tsx
Normal file
84
ui/components/providers/wizard/steps/connect-step.tsx
Normal file
@@ -0,0 +1,84 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
import {
|
||||
ConnectAccountForm,
|
||||
ConnectAccountSuccessData,
|
||||
} from "@/components/providers/workflow/forms";
|
||||
import { useProviderWizardStore } from "@/store/provider-wizard/store";
|
||||
import { PROVIDER_WIZARD_MODE } from "@/types/provider-wizard";
|
||||
import { ProviderType } from "@/types/providers";
|
||||
|
||||
import {
|
||||
WIZARD_FOOTER_ACTION_TYPE,
|
||||
WizardFooterConfig,
|
||||
} from "./footer-controls";
|
||||
|
||||
interface ConnectStepProps {
|
||||
onNext: () => void;
|
||||
onSelectOrganizations: () => void;
|
||||
onFooterChange: (config: WizardFooterConfig) => void;
|
||||
onProviderTypeChange: (providerType: ProviderType | null) => void;
|
||||
}
|
||||
|
||||
export function ConnectStep({
|
||||
onNext,
|
||||
onSelectOrganizations,
|
||||
onFooterChange,
|
||||
onProviderTypeChange,
|
||||
}: ConnectStepProps) {
|
||||
const { setProvider, setVia, setSecretId, setMode } =
|
||||
useProviderWizardStore();
|
||||
const backHandlerRef = useRef<(() => void) | null>(null);
|
||||
const [uiState, setUiState] = useState({
|
||||
showBack: false,
|
||||
showAction: false,
|
||||
actionLabel: "Next",
|
||||
actionDisabled: true,
|
||||
isLoading: false,
|
||||
});
|
||||
|
||||
const formId = "provider-wizard-connect-form";
|
||||
|
||||
const handleSuccess = (data: ConnectAccountSuccessData) => {
|
||||
setProvider({
|
||||
id: data.id,
|
||||
type: data.providerType,
|
||||
uid: data.uid,
|
||||
alias: data.alias,
|
||||
});
|
||||
setVia(null);
|
||||
setSecretId(null);
|
||||
setMode(PROVIDER_WIZARD_MODE.ADD);
|
||||
onNext();
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
onFooterChange({
|
||||
showBack: uiState.showBack,
|
||||
backLabel: "Back",
|
||||
backDisabled: uiState.isLoading,
|
||||
onBack: () => backHandlerRef.current?.(),
|
||||
showAction: uiState.showAction,
|
||||
actionLabel: uiState.actionLabel,
|
||||
actionDisabled: uiState.actionDisabled || uiState.isLoading,
|
||||
actionType: WIZARD_FOOTER_ACTION_TYPE.SUBMIT,
|
||||
actionFormId: formId,
|
||||
});
|
||||
}, [onFooterChange, uiState]);
|
||||
|
||||
return (
|
||||
<ConnectAccountForm
|
||||
formId={formId}
|
||||
hideNavigation
|
||||
onSuccess={handleSuccess}
|
||||
onSelectOrganizations={onSelectOrganizations}
|
||||
onProviderTypeChange={onProviderTypeChange}
|
||||
onUiStateChange={setUiState}
|
||||
onBackHandlerChange={(handler) => {
|
||||
backHandlerRef.current = handler;
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
263
ui/components/providers/wizard/steps/credentials-step.tsx
Normal file
263
ui/components/providers/wizard/steps/credentials-step.tsx
Normal file
@@ -0,0 +1,263 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { getProviderFormType } from "@/lib/provider-helpers";
|
||||
import { useProviderWizardStore } from "@/store/provider-wizard/store";
|
||||
import { PROVIDER_WIZARD_MODE } from "@/types/provider-wizard";
|
||||
import { ProviderType } from "@/types/providers";
|
||||
|
||||
import {
|
||||
AddViaCredentialsForm,
|
||||
AddViaRoleForm,
|
||||
UpdateViaCredentialsForm,
|
||||
UpdateViaRoleForm,
|
||||
} from "../../workflow/forms";
|
||||
import { SelectViaAlibabaCloud } from "../../workflow/forms/select-credentials-type/alibabacloud";
|
||||
import { SelectViaAWS } from "../../workflow/forms/select-credentials-type/aws";
|
||||
import { SelectViaCloudflare } from "../../workflow/forms/select-credentials-type/cloudflare";
|
||||
import {
|
||||
AddViaServiceAccountForm,
|
||||
SelectViaGCP,
|
||||
} from "../../workflow/forms/select-credentials-type/gcp";
|
||||
import { SelectViaGitHub } from "../../workflow/forms/select-credentials-type/github";
|
||||
import { SelectViaM365 } from "../../workflow/forms/select-credentials-type/m365";
|
||||
import { UpdateViaServiceAccountForm } from "../../workflow/forms/update-via-service-account-key-form";
|
||||
import {
|
||||
WIZARD_FOOTER_ACTION_TYPE,
|
||||
WizardFooterConfig,
|
||||
} from "./footer-controls";
|
||||
|
||||
interface CredentialsStepProps {
|
||||
onNext: () => void;
|
||||
onBack: () => void;
|
||||
onFooterChange: (config: WizardFooterConfig) => void;
|
||||
}
|
||||
|
||||
export function CredentialsStep({
|
||||
onNext,
|
||||
onBack,
|
||||
onFooterChange,
|
||||
}: CredentialsStepProps) {
|
||||
const { providerId, providerType, providerUid, via, secretId, mode, setVia } =
|
||||
useProviderWizardStore();
|
||||
const [isFormLoading, setIsFormLoading] = useState(false);
|
||||
const [isFormValid, setIsFormValid] = useState(false);
|
||||
|
||||
const formId = "provider-wizard-credentials-form";
|
||||
const hasProviderContext = Boolean(providerType && providerId);
|
||||
const formType =
|
||||
providerType && providerId
|
||||
? getProviderFormType(providerType, via || undefined)
|
||||
: null;
|
||||
const shouldUseUpdateForms =
|
||||
mode === PROVIDER_WIZARD_MODE.UPDATE && Boolean(secretId);
|
||||
|
||||
const handleBack = () => {
|
||||
if (via) {
|
||||
setVia(null);
|
||||
return;
|
||||
}
|
||||
onBack();
|
||||
};
|
||||
|
||||
const handleViaChange = (value: string) => {
|
||||
setVia(value);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
setIsFormValid(false);
|
||||
}, [formType, via]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!hasProviderContext) {
|
||||
onFooterChange({
|
||||
showBack: true,
|
||||
backLabel: "Back",
|
||||
onBack,
|
||||
showAction: false,
|
||||
actionLabel: "Authenticate",
|
||||
actionType: WIZARD_FOOTER_ACTION_TYPE.BUTTON,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const isSelector = formType === "selector";
|
||||
|
||||
onFooterChange({
|
||||
showBack: true,
|
||||
backLabel: "Back",
|
||||
backDisabled: isFormLoading,
|
||||
onBack: () => {
|
||||
if (via) {
|
||||
setVia(null);
|
||||
return;
|
||||
}
|
||||
onBack();
|
||||
},
|
||||
showAction: !isSelector,
|
||||
actionLabel: "Authenticate",
|
||||
actionDisabled: isFormLoading || !isFormValid,
|
||||
actionType: WIZARD_FOOTER_ACTION_TYPE.SUBMIT,
|
||||
actionFormId: formId,
|
||||
});
|
||||
}, [
|
||||
hasProviderContext,
|
||||
formType,
|
||||
formId,
|
||||
isFormLoading,
|
||||
isFormValid,
|
||||
onBack,
|
||||
onFooterChange,
|
||||
setVia,
|
||||
via,
|
||||
]);
|
||||
|
||||
if (!providerType || !providerId) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center py-8">
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Provider details are missing. Go back and select a provider.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (formType === "selector") {
|
||||
if (providerType === "aws") {
|
||||
return (
|
||||
<SelectViaAWS
|
||||
initialVia={via || undefined}
|
||||
onViaChange={handleViaChange}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (providerType === "gcp") {
|
||||
return (
|
||||
<SelectViaGCP
|
||||
initialVia={via || undefined}
|
||||
onViaChange={handleViaChange}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (providerType === "github") {
|
||||
return (
|
||||
<SelectViaGitHub
|
||||
initialVia={via || undefined}
|
||||
onViaChange={handleViaChange}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (providerType === "m365") {
|
||||
return (
|
||||
<SelectViaM365
|
||||
initialVia={via || undefined}
|
||||
onViaChange={handleViaChange}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (providerType === "alibabacloud") {
|
||||
return (
|
||||
<SelectViaAlibabaCloud
|
||||
initialVia={via || undefined}
|
||||
onViaChange={handleViaChange}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (providerType === "cloudflare") {
|
||||
return (
|
||||
<SelectViaCloudflare
|
||||
initialVia={via || undefined}
|
||||
onViaChange={handleViaChange}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
const commonFormProps = {
|
||||
via,
|
||||
onSuccess: onNext,
|
||||
onBack: handleBack,
|
||||
providerUid: providerUid || undefined,
|
||||
formId,
|
||||
hideActions: true,
|
||||
onLoadingChange: setIsFormLoading,
|
||||
onValidityChange: setIsFormValid,
|
||||
validationMode: "onChange" as const,
|
||||
};
|
||||
|
||||
if (formType === "credentials") {
|
||||
if (shouldUseUpdateForms) {
|
||||
return (
|
||||
<UpdateViaCredentialsForm
|
||||
searchParams={{
|
||||
type: providerType,
|
||||
id: providerId,
|
||||
secretId: secretId || undefined,
|
||||
}}
|
||||
{...commonFormProps}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<AddViaCredentialsForm
|
||||
searchParams={{ type: providerType, id: providerId }}
|
||||
{...commonFormProps}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (formType === "role") {
|
||||
if (shouldUseUpdateForms) {
|
||||
return (
|
||||
<UpdateViaRoleForm
|
||||
searchParams={{
|
||||
type: providerType,
|
||||
id: providerId,
|
||||
secretId: secretId || undefined,
|
||||
}}
|
||||
{...commonFormProps}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<AddViaRoleForm
|
||||
searchParams={{ type: providerType, id: providerId }}
|
||||
{...commonFormProps}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (formType === "service-account") {
|
||||
if (shouldUseUpdateForms) {
|
||||
return (
|
||||
<UpdateViaServiceAccountForm
|
||||
searchParams={{
|
||||
type: providerType,
|
||||
id: providerId,
|
||||
secretId: secretId || undefined,
|
||||
}}
|
||||
{...commonFormProps}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<AddViaServiceAccountForm
|
||||
searchParams={{ type: providerType as ProviderType, id: providerId }}
|
||||
{...commonFormProps}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4 py-6">
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Select a credential type to continue.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
29
ui/components/providers/wizard/steps/footer-controls.ts
Normal file
29
ui/components/providers/wizard/steps/footer-controls.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
export const WIZARD_FOOTER_ACTION_TYPE = {
|
||||
BUTTON: "button",
|
||||
SUBMIT: "submit",
|
||||
} as const;
|
||||
|
||||
export type WizardFooterActionType =
|
||||
(typeof WIZARD_FOOTER_ACTION_TYPE)[keyof typeof WIZARD_FOOTER_ACTION_TYPE];
|
||||
|
||||
export type WizardFooterSecondaryActionVariant = "outline" | "link";
|
||||
|
||||
export interface WizardFooterConfig {
|
||||
showBack: boolean;
|
||||
backLabel: string;
|
||||
backDisabled?: boolean;
|
||||
onBack?: () => void;
|
||||
showSecondaryAction?: boolean;
|
||||
secondaryActionLabel?: string;
|
||||
secondaryActionDisabled?: boolean;
|
||||
secondaryActionVariant?: WizardFooterSecondaryActionVariant;
|
||||
secondaryActionType?: WizardFooterActionType;
|
||||
secondaryActionFormId?: string;
|
||||
onSecondaryAction?: () => void;
|
||||
showAction: boolean;
|
||||
actionLabel: string;
|
||||
actionDisabled?: boolean;
|
||||
actionType: WizardFooterActionType;
|
||||
actionFormId?: string;
|
||||
onAction?: () => void;
|
||||
}
|
||||
5
ui/components/providers/wizard/steps/index.ts
Normal file
5
ui/components/providers/wizard/steps/index.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export * from "./connect-step";
|
||||
export * from "./credentials-step";
|
||||
export * from "./footer-controls";
|
||||
export * from "./launch-step";
|
||||
export * from "./test-connection-step";
|
||||
15
ui/components/providers/wizard/steps/launch-step.tsx
Normal file
15
ui/components/providers/wizard/steps/launch-step.tsx
Normal file
@@ -0,0 +1,15 @@
|
||||
"use client";
|
||||
|
||||
import { CheckCircle2 } from "lucide-react";
|
||||
|
||||
export function LaunchStep() {
|
||||
return (
|
||||
<div className="flex min-h-[320px] flex-col items-center justify-center gap-4 text-center">
|
||||
<CheckCircle2 className="text-success size-12" />
|
||||
<h3 className="text-xl font-semibold">Provider connected successfully</h3>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Continue with the action button to go to scans.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
141
ui/components/providers/wizard/steps/test-connection-step.tsx
Normal file
141
ui/components/providers/wizard/steps/test-connection-step.tsx
Normal file
@@ -0,0 +1,141 @@
|
||||
"use client";
|
||||
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { getProvider } from "@/actions/providers";
|
||||
import { useProviderWizardStore } from "@/store/provider-wizard/store";
|
||||
import { PROVIDER_WIZARD_MODE } from "@/types/provider-wizard";
|
||||
|
||||
import {
|
||||
TestConnectionForm,
|
||||
TestConnectionProviderData,
|
||||
} from "../../workflow/forms/test-connection-form";
|
||||
import {
|
||||
WIZARD_FOOTER_ACTION_TYPE,
|
||||
WizardFooterConfig,
|
||||
} from "./footer-controls";
|
||||
|
||||
interface TestConnectionStepProps {
|
||||
onSuccess: () => void;
|
||||
onResetCredentials: () => void;
|
||||
onFooterChange: (config: WizardFooterConfig) => void;
|
||||
}
|
||||
|
||||
export function TestConnectionStep({
|
||||
onSuccess,
|
||||
onResetCredentials,
|
||||
onFooterChange,
|
||||
}: TestConnectionStepProps) {
|
||||
const { providerId, providerType, mode } = useProviderWizardStore();
|
||||
const [providerData, setProviderData] =
|
||||
useState<TestConnectionProviderData | null>(null);
|
||||
const [isLoadingProvider, setIsLoadingProvider] = useState(true);
|
||||
const [isFormLoading, setIsFormLoading] = useState(false);
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||
|
||||
const formId = "provider-wizard-test-connection-form";
|
||||
|
||||
useEffect(() => {
|
||||
let isMounted = true;
|
||||
|
||||
async function loadProvider() {
|
||||
if (!providerId || !providerType) {
|
||||
setErrorMessage("Provider information is missing.");
|
||||
setIsLoadingProvider(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoadingProvider(true);
|
||||
setErrorMessage(null);
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append("id", providerId);
|
||||
|
||||
const response = await getProvider(formData);
|
||||
|
||||
if (!isMounted) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (response?.errors?.length) {
|
||||
setErrorMessage(
|
||||
response.errors[0]?.detail || "Failed to load provider.",
|
||||
);
|
||||
setProviderData(null);
|
||||
setIsLoadingProvider(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setProviderData(response as TestConnectionProviderData);
|
||||
setIsLoadingProvider(false);
|
||||
}
|
||||
|
||||
loadProvider();
|
||||
|
||||
return () => {
|
||||
isMounted = false;
|
||||
};
|
||||
}, [providerId, providerType]);
|
||||
|
||||
useEffect(() => {
|
||||
const canSubmit = !isLoadingProvider && !errorMessage && !!providerData;
|
||||
|
||||
onFooterChange({
|
||||
showBack: true,
|
||||
backLabel: "Back",
|
||||
backDisabled: isFormLoading,
|
||||
onBack: onResetCredentials,
|
||||
showAction: canSubmit,
|
||||
actionLabel:
|
||||
mode === PROVIDER_WIZARD_MODE.UPDATE
|
||||
? "Check connection"
|
||||
: "Launch scan",
|
||||
actionDisabled: isFormLoading,
|
||||
actionType: WIZARD_FOOTER_ACTION_TYPE.SUBMIT,
|
||||
actionFormId: formId,
|
||||
});
|
||||
}, [
|
||||
errorMessage,
|
||||
isFormLoading,
|
||||
isLoadingProvider,
|
||||
mode,
|
||||
onFooterChange,
|
||||
onResetCredentials,
|
||||
providerData,
|
||||
]);
|
||||
|
||||
if (isLoadingProvider) {
|
||||
return (
|
||||
<div className="flex min-h-[320px] items-center justify-center">
|
||||
<Loader2 className="text-muted-foreground size-6 animate-spin" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (errorMessage || !providerData || !providerId || !providerType) {
|
||||
return (
|
||||
<div className="flex min-h-[320px] flex-col items-center justify-center gap-4 text-center">
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{errorMessage || "Unable to load provider details."}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<TestConnectionForm
|
||||
formId={formId}
|
||||
hideActions
|
||||
onLoadingChange={setIsFormLoading}
|
||||
searchParams={{
|
||||
type: providerType,
|
||||
id: providerId,
|
||||
updated: mode === PROVIDER_WIZARD_MODE.UPDATE ? "true" : "false",
|
||||
}}
|
||||
providerData={providerData}
|
||||
onSuccess={onSuccess}
|
||||
onResetCredentials={onResetCredentials}
|
||||
/>
|
||||
);
|
||||
}
|
||||
12
ui/components/providers/wizard/types.ts
Normal file
12
ui/components/providers/wizard/types.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { ProviderWizardMode } from "@/types/provider-wizard";
|
||||
import { ProviderType } from "@/types/providers";
|
||||
|
||||
export interface ProviderWizardInitialData {
|
||||
providerId: string;
|
||||
providerType: ProviderType;
|
||||
providerUid: string;
|
||||
providerAlias: string | null;
|
||||
secretId?: string | null;
|
||||
via?: string | null;
|
||||
mode?: ProviderWizardMode;
|
||||
}
|
||||
160
ui/components/providers/wizard/wizard-stepper.tsx
Normal file
160
ui/components/providers/wizard/wizard-stepper.tsx
Normal file
@@ -0,0 +1,160 @@
|
||||
"use client";
|
||||
|
||||
import { CircleCheckBig, FolderGit2, KeyRound, Rocket } from "lucide-react";
|
||||
import { ReactElement } from "react";
|
||||
|
||||
import { ProwlerShort } from "@/components/icons/prowler/ProwlerIcons";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { IconComponent, IconSvgProps } from "@/types/components";
|
||||
|
||||
interface WizardStepperProps {
|
||||
currentStep: number;
|
||||
stepOffset?: number;
|
||||
}
|
||||
|
||||
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,
|
||||
},
|
||||
];
|
||||
|
||||
export function WizardStepper({
|
||||
currentStep,
|
||||
stepOffset = 0,
|
||||
}: WizardStepperProps) {
|
||||
const activeVisualStep = Math.max(
|
||||
0,
|
||||
Math.min(currentStep + stepOffset, STEPS.length - 1),
|
||||
);
|
||||
|
||||
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;
|
||||
|
||||
return (
|
||||
<div key={step.label} className="flex items-start gap-3">
|
||||
<div className="flex flex-col items-center">
|
||||
<StepCircle
|
||||
isComplete={isComplete}
|
||||
isActive={isActive}
|
||||
icon={step.icon}
|
||||
/>
|
||||
{index < STEPS.length - 1 && (
|
||||
<StepConnector isComplete={isComplete} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1 pt-[10px]">
|
||||
<span
|
||||
className={cn(
|
||||
"text-lg leading-7 font-normal",
|
||||
isActive && "text-text-neutral-primary",
|
||||
isComplete && "text-text-neutral-primary",
|
||||
isInactive && "text-text-neutral-tertiary",
|
||||
)}
|
||||
>
|
||||
{step.label}
|
||||
</span>
|
||||
<p className="text-text-neutral-secondary text-xs leading-5">
|
||||
{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 bg-bg-neutral-secondary flex size-[44px] shrink-0 items-center justify-center rounded-full border">
|
||||
<StepIcon icon={Icon} className="text-border-input-primary-pressed" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="border-border-neutral-secondary bg-bg-neutral-secondary flex size-[44px] shrink-0 items-center justify-center rounded-full border">
|
||||
<StepIcon icon={Icon} className="text-text-neutral-tertiary" />
|
||||
</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)",
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
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)} />;
|
||||
}
|
||||
|
||||
function isCustomSvgIcon(
|
||||
icon: IconComponent,
|
||||
): icon is (props: IconSvgProps) => ReactElement {
|
||||
return !("displayName" in icon && typeof icon.displayName === "string");
|
||||
}
|
||||
@@ -8,9 +8,23 @@ import { BaseCredentialsForm } from "./base-credentials-form";
|
||||
export const AddViaCredentialsForm = ({
|
||||
searchParams,
|
||||
providerUid,
|
||||
via,
|
||||
onSuccess,
|
||||
onBack,
|
||||
formId,
|
||||
hideActions,
|
||||
onLoadingChange,
|
||||
onValidityChange,
|
||||
}: {
|
||||
searchParams: { type: string; id: string };
|
||||
providerUid?: string;
|
||||
via?: string | null;
|
||||
onSuccess?: () => void;
|
||||
onBack?: () => void;
|
||||
formId?: string;
|
||||
hideActions?: boolean;
|
||||
onLoadingChange?: (isLoading: boolean) => void;
|
||||
onValidityChange?: (isValid: boolean) => void;
|
||||
}) => {
|
||||
const providerType = searchParams.type as ProviderType;
|
||||
const providerId = searchParams.id;
|
||||
@@ -19,7 +33,7 @@ export const AddViaCredentialsForm = ({
|
||||
return await addCredentialsProvider(formData);
|
||||
};
|
||||
|
||||
const successNavigationUrl = `/providers/test-connection?type=${providerType}&id=${providerId}`;
|
||||
const successNavigationUrl = "/providers";
|
||||
|
||||
return (
|
||||
<BaseCredentialsForm
|
||||
@@ -28,6 +42,13 @@ export const AddViaCredentialsForm = ({
|
||||
providerUid={providerUid}
|
||||
onSubmit={handleAddCredentials}
|
||||
successNavigationUrl={successNavigationUrl}
|
||||
via={via}
|
||||
onSuccess={onSuccess}
|
||||
onBack={onBack}
|
||||
formId={formId}
|
||||
hideActions={hideActions}
|
||||
onLoadingChange={onLoadingChange}
|
||||
onValidityChange={onValidityChange}
|
||||
submitButtonText="Next"
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -8,9 +8,23 @@ import { BaseCredentialsForm } from "./base-credentials-form";
|
||||
export const AddViaRoleForm = ({
|
||||
searchParams,
|
||||
providerUid,
|
||||
via,
|
||||
onSuccess,
|
||||
onBack,
|
||||
formId,
|
||||
hideActions,
|
||||
onLoadingChange,
|
||||
onValidityChange,
|
||||
}: {
|
||||
searchParams: { type: string; id: string };
|
||||
providerUid?: string;
|
||||
via?: string | null;
|
||||
onSuccess?: () => void;
|
||||
onBack?: () => void;
|
||||
formId?: string;
|
||||
hideActions?: boolean;
|
||||
onLoadingChange?: (isLoading: boolean) => void;
|
||||
onValidityChange?: (isValid: boolean) => void;
|
||||
}) => {
|
||||
const providerType = searchParams.type as ProviderType;
|
||||
const providerId = searchParams.id;
|
||||
@@ -19,7 +33,7 @@ export const AddViaRoleForm = ({
|
||||
return await addCredentialsProvider(formData);
|
||||
};
|
||||
|
||||
const successNavigationUrl = `/providers/test-connection?type=${providerType}&id=${providerId}`;
|
||||
const successNavigationUrl = "/providers";
|
||||
|
||||
return (
|
||||
<BaseCredentialsForm
|
||||
@@ -28,6 +42,13 @@ export const AddViaRoleForm = ({
|
||||
providerUid={providerUid}
|
||||
onSubmit={handleAddCredentials}
|
||||
successNavigationUrl={successNavigationUrl}
|
||||
via={via}
|
||||
onSuccess={onSuccess}
|
||||
onBack={onBack}
|
||||
formId={formId}
|
||||
hideActions={hideActions}
|
||||
onLoadingChange={onLoadingChange}
|
||||
onValidityChange={onValidityChange}
|
||||
submitButtonText="Next"
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { Divider } from "@heroui/divider";
|
||||
import { ChevronLeftIcon, ChevronRightIcon, Loader2 } from "lucide-react";
|
||||
import { useEffect } from "react";
|
||||
import { Control, UseFormSetValue } from "react-hook-form";
|
||||
|
||||
import { Button } from "@/components/shadcn";
|
||||
@@ -62,8 +63,16 @@ type BaseCredentialsFormProps = {
|
||||
providerUid?: string;
|
||||
onSubmit: (formData: FormData) => Promise<ApiResponse>;
|
||||
successNavigationUrl: string;
|
||||
via?: string | null;
|
||||
onSuccess?: () => void;
|
||||
onBack?: () => void;
|
||||
formId?: string;
|
||||
hideActions?: boolean;
|
||||
onLoadingChange?: (isLoading: boolean) => void;
|
||||
onValidityChange?: (isValid: boolean) => void;
|
||||
submitButtonText?: string;
|
||||
showBackButton?: boolean;
|
||||
validationMode?: "onSubmit" | "onChange";
|
||||
};
|
||||
|
||||
export const BaseCredentialsForm = ({
|
||||
@@ -72,15 +81,24 @@ export const BaseCredentialsForm = ({
|
||||
providerUid,
|
||||
onSubmit,
|
||||
successNavigationUrl,
|
||||
via,
|
||||
onSuccess,
|
||||
onBack,
|
||||
formId,
|
||||
hideActions = false,
|
||||
onLoadingChange,
|
||||
onValidityChange,
|
||||
submitButtonText = "Next",
|
||||
showBackButton = true,
|
||||
validationMode,
|
||||
}: BaseCredentialsFormProps) => {
|
||||
const {
|
||||
form,
|
||||
isLoading,
|
||||
isValid,
|
||||
handleSubmit,
|
||||
handleBackStep,
|
||||
searchParamsObj,
|
||||
effectiveVia,
|
||||
externalId,
|
||||
} = useCredentialsForm({
|
||||
providerType,
|
||||
@@ -88,13 +106,26 @@ export const BaseCredentialsForm = ({
|
||||
providerUid,
|
||||
onSubmit,
|
||||
successNavigationUrl,
|
||||
via,
|
||||
onSuccess,
|
||||
onBack,
|
||||
validationMode,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
onLoadingChange?.(isLoading);
|
||||
}, [isLoading, onLoadingChange]);
|
||||
|
||||
useEffect(() => {
|
||||
onValidityChange?.(isValid);
|
||||
}, [isValid, onValidityChange]);
|
||||
|
||||
const templateLinks = getAWSCredentialsTemplateLinks(externalId);
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form
|
||||
id={formId}
|
||||
onSubmit={form.handleSubmit(handleSubmit)}
|
||||
className="flex flex-col gap-4"
|
||||
>
|
||||
@@ -120,7 +151,7 @@ export const BaseCredentialsForm = ({
|
||||
|
||||
<Divider />
|
||||
|
||||
{providerType === "aws" && searchParamsObj.get("via") === "role" && (
|
||||
{providerType === "aws" && effectiveVia === "role" && (
|
||||
<AWSRoleCredentialsForm
|
||||
control={form.control as unknown as Control<AWSCredentialsRole>}
|
||||
setValue={
|
||||
@@ -130,7 +161,7 @@ export const BaseCredentialsForm = ({
|
||||
templateLinks={templateLinks}
|
||||
/>
|
||||
)}
|
||||
{providerType === "aws" && searchParamsObj.get("via") !== "role" && (
|
||||
{providerType === "aws" && effectiveVia !== "role" && (
|
||||
<AWSStaticCredentialsForm
|
||||
control={form.control as unknown as Control<AWSCredentials>}
|
||||
/>
|
||||
@@ -140,36 +171,30 @@ export const BaseCredentialsForm = ({
|
||||
control={form.control as unknown as Control<AzureCredentials>}
|
||||
/>
|
||||
)}
|
||||
{providerType === "m365" &&
|
||||
searchParamsObj.get("via") === "app_client_secret" && (
|
||||
<M365ClientSecretCredentialsForm
|
||||
control={
|
||||
form.control as unknown as Control<M365ClientSecretCredentials>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{providerType === "m365" &&
|
||||
searchParamsObj.get("via") === "app_certificate" && (
|
||||
<M365CertificateCredentialsForm
|
||||
control={
|
||||
form.control as unknown as Control<M365CertificateCredentials>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{providerType === "gcp" &&
|
||||
searchParamsObj.get("via") === "service-account" && (
|
||||
<GCPServiceAccountKeyForm
|
||||
control={form.control as unknown as Control<GCPServiceAccountKey>}
|
||||
/>
|
||||
)}
|
||||
{providerType === "gcp" &&
|
||||
searchParamsObj.get("via") !== "service-account" && (
|
||||
<GCPDefaultCredentialsForm
|
||||
control={
|
||||
form.control as unknown as Control<GCPDefaultCredentials>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{providerType === "m365" && effectiveVia === "app_client_secret" && (
|
||||
<M365ClientSecretCredentialsForm
|
||||
control={
|
||||
form.control as unknown as Control<M365ClientSecretCredentials>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{providerType === "m365" && effectiveVia === "app_certificate" && (
|
||||
<M365CertificateCredentialsForm
|
||||
control={
|
||||
form.control as unknown as Control<M365CertificateCredentials>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{providerType === "gcp" && effectiveVia === "service-account" && (
|
||||
<GCPServiceAccountKeyForm
|
||||
control={form.control as unknown as Control<GCPServiceAccountKey>}
|
||||
/>
|
||||
)}
|
||||
{providerType === "gcp" && effectiveVia !== "service-account" && (
|
||||
<GCPDefaultCredentialsForm
|
||||
control={form.control as unknown as Control<GCPDefaultCredentials>}
|
||||
/>
|
||||
)}
|
||||
{providerType === "kubernetes" && (
|
||||
<KubernetesCredentialsForm
|
||||
control={form.control as unknown as Control<KubernetesCredentials>}
|
||||
@@ -178,7 +203,7 @@ export const BaseCredentialsForm = ({
|
||||
{providerType === "github" && (
|
||||
<GitHubCredentialsForm
|
||||
control={form.control}
|
||||
credentialsType={searchParamsObj.get("via") || undefined}
|
||||
credentialsType={effectiveVia || undefined}
|
||||
/>
|
||||
)}
|
||||
{providerType === "iac" && (
|
||||
@@ -198,71 +223,69 @@ export const BaseCredentialsForm = ({
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{providerType === "alibabacloud" &&
|
||||
searchParamsObj.get("via") === "role" && (
|
||||
<AlibabaCloudRoleCredentialsForm
|
||||
control={
|
||||
form.control as unknown as Control<AlibabaCloudCredentialsRole>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{providerType === "alibabacloud" &&
|
||||
searchParamsObj.get("via") !== "role" && (
|
||||
<AlibabaCloudStaticCredentialsForm
|
||||
control={
|
||||
form.control as unknown as Control<AlibabaCloudCredentials>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{providerType === "cloudflare" &&
|
||||
searchParamsObj.get("via") === "api_token" && (
|
||||
<CloudflareApiTokenCredentialsForm
|
||||
control={
|
||||
form.control as unknown as Control<CloudflareTokenCredentials>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{providerType === "cloudflare" &&
|
||||
searchParamsObj.get("via") === "api_key" && (
|
||||
<CloudflareApiKeyCredentialsForm
|
||||
control={
|
||||
form.control as unknown as Control<CloudflareApiKeyCredentials>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{providerType === "alibabacloud" && effectiveVia === "role" && (
|
||||
<AlibabaCloudRoleCredentialsForm
|
||||
control={
|
||||
form.control as unknown as Control<AlibabaCloudCredentialsRole>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{providerType === "alibabacloud" && effectiveVia !== "role" && (
|
||||
<AlibabaCloudStaticCredentialsForm
|
||||
control={
|
||||
form.control as unknown as Control<AlibabaCloudCredentials>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{providerType === "cloudflare" && effectiveVia === "api_token" && (
|
||||
<CloudflareApiTokenCredentialsForm
|
||||
control={
|
||||
form.control as unknown as Control<CloudflareTokenCredentials>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{providerType === "cloudflare" && effectiveVia === "api_key" && (
|
||||
<CloudflareApiKeyCredentialsForm
|
||||
control={
|
||||
form.control as unknown as Control<CloudflareApiKeyCredentials>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{providerType === "openstack" && (
|
||||
<OpenStackCredentialsForm
|
||||
control={form.control as unknown as Control<OpenStackCredentials>}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="flex w-full justify-end gap-4">
|
||||
{showBackButton && requiresBackButton(searchParamsObj.get("via")) && (
|
||||
{!hideActions && (
|
||||
<div className="flex w-full justify-end gap-4">
|
||||
{showBackButton && requiresBackButton(effectiveVia) && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="lg"
|
||||
onClick={handleBackStep}
|
||||
disabled={isLoading}
|
||||
>
|
||||
{!isLoading && <ChevronLeftIcon size={24} />}
|
||||
Back
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
type="submit"
|
||||
variant="default"
|
||||
size="lg"
|
||||
onClick={handleBackStep}
|
||||
disabled={isLoading}
|
||||
>
|
||||
{!isLoading && <ChevronLeftIcon size={24} />}
|
||||
Back
|
||||
{isLoading ? (
|
||||
<Loader2 className="animate-spin" />
|
||||
) : (
|
||||
<ChevronRightIcon size={24} />
|
||||
)}
|
||||
{isLoading ? "Loading" : submitButtonText}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
type="submit"
|
||||
variant="default"
|
||||
size="lg"
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading ? (
|
||||
<Loader2 className="animate-spin" />
|
||||
) : (
|
||||
<ChevronRightIcon size={24} />
|
||||
)}
|
||||
{isLoading ? "Loading" : submitButtonText}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</form>
|
||||
</Form>
|
||||
);
|
||||
|
||||
@@ -3,11 +3,12 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { ChevronLeftIcon, ChevronRightIcon, Loader2 } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { Dispatch, SetStateAction, useEffect, useState } from "react";
|
||||
import { useForm, UseFormReturn } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
|
||||
import { addProvider } from "@/actions/providers/providers";
|
||||
import { AwsMethodSelector } from "@/components/providers/organizations/aws-method-selector";
|
||||
import { ProviderTitleDocs } from "@/components/providers/workflow/provider-title-docs";
|
||||
import { Button } from "@/components/shadcn";
|
||||
import { useToast } from "@/components/ui";
|
||||
@@ -19,6 +20,29 @@ import { RadioGroupProvider } from "../../radio-group-provider";
|
||||
|
||||
export type FormValues = z.infer<typeof addProviderFormSchema>;
|
||||
|
||||
export interface ConnectAccountSuccessData {
|
||||
id: string;
|
||||
providerType: ProviderType;
|
||||
uid: string;
|
||||
alias: string | null;
|
||||
}
|
||||
|
||||
interface ConnectAccountFormProps {
|
||||
onSuccess?: (data: ConnectAccountSuccessData) => void;
|
||||
onSelectOrganizations?: () => void;
|
||||
onProviderTypeChange?: (providerType: ProviderType | null) => void;
|
||||
formId?: string;
|
||||
hideNavigation?: boolean;
|
||||
onUiStateChange?: (state: {
|
||||
showBack: boolean;
|
||||
showAction: boolean;
|
||||
actionLabel: string;
|
||||
actionDisabled: boolean;
|
||||
isLoading: boolean;
|
||||
}) => void;
|
||||
onBackHandlerChange?: (handler: () => void) => void;
|
||||
}
|
||||
|
||||
// Helper function for labels and placeholders
|
||||
const getProviderFieldDetails = (providerType?: ProviderType) => {
|
||||
switch (providerType) {
|
||||
@@ -90,9 +114,50 @@ const getProviderFieldDetails = (providerType?: ProviderType) => {
|
||||
}
|
||||
};
|
||||
|
||||
export const ConnectAccountForm = () => {
|
||||
function applyBackStep({
|
||||
prevStep,
|
||||
awsMethod,
|
||||
form,
|
||||
setPrevStep,
|
||||
setAwsMethod,
|
||||
}: {
|
||||
prevStep: number;
|
||||
awsMethod: "single" | null;
|
||||
form: Pick<UseFormReturn<FormValues>, "setValue">;
|
||||
setPrevStep: Dispatch<SetStateAction<number>>;
|
||||
setAwsMethod: Dispatch<SetStateAction<"single" | null>>;
|
||||
}) {
|
||||
// If in UID form after choosing single, go back to method selector
|
||||
if (prevStep === 2 && awsMethod === "single") {
|
||||
setAwsMethod(null);
|
||||
form.setValue("providerUid", "");
|
||||
form.setValue("providerAlias", "");
|
||||
return;
|
||||
}
|
||||
|
||||
setPrevStep((prev) => prev - 1);
|
||||
// Deselect the providerType if the user is going back to the first step
|
||||
if (prevStep === 2) {
|
||||
form.setValue("providerType", undefined as unknown as ProviderType);
|
||||
setAwsMethod(null);
|
||||
}
|
||||
// Reset the providerUid and providerAlias fields when going back
|
||||
form.setValue("providerUid", "");
|
||||
form.setValue("providerAlias", "");
|
||||
}
|
||||
|
||||
export const ConnectAccountForm = ({
|
||||
onSuccess,
|
||||
onSelectOrganizations,
|
||||
onProviderTypeChange,
|
||||
formId,
|
||||
hideNavigation = false,
|
||||
onUiStateChange,
|
||||
onBackHandlerChange,
|
||||
}: ConnectAccountFormProps) => {
|
||||
const { toast } = useToast();
|
||||
const [prevStep, setPrevStep] = useState(1);
|
||||
const [awsMethod, setAwsMethod] = useState<"single" | null>(null);
|
||||
const router = useRouter();
|
||||
|
||||
const formSchema = addProviderFormSchema;
|
||||
@@ -104,9 +169,12 @@ export const ConnectAccountForm = () => {
|
||||
providerUid: "",
|
||||
providerAlias: "",
|
||||
},
|
||||
mode: "onChange",
|
||||
reValidateMode: "onChange",
|
||||
});
|
||||
|
||||
const providerType = form.watch("providerType");
|
||||
const providerUid = form.watch("providerUid");
|
||||
const providerFieldDetails = getProviderFieldDetails(providerType);
|
||||
|
||||
const isLoading = form.formState.isSubmitting;
|
||||
@@ -161,10 +229,20 @@ export const ConnectAccountForm = () => {
|
||||
// Go to the next step after successful submission
|
||||
const {
|
||||
id,
|
||||
attributes: { provider: providerType },
|
||||
attributes: { provider: createdProviderType, uid, alias },
|
||||
} = data.data;
|
||||
|
||||
router.push(`/providers/add-credentials?type=${providerType}&id=${id}`);
|
||||
if (onSuccess) {
|
||||
onSuccess({
|
||||
id,
|
||||
providerType: createdProviderType,
|
||||
uid: uid || values.providerUid,
|
||||
alias: alias ?? values.providerAlias ?? null,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
router.push("/providers");
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
console.error("Error during submission:", error);
|
||||
@@ -180,14 +258,13 @@ export const ConnectAccountForm = () => {
|
||||
};
|
||||
|
||||
const handleBackStep = () => {
|
||||
setPrevStep((prev) => prev - 1);
|
||||
//Deselect the providerType if the user is going back to the first step
|
||||
if (prevStep === 2) {
|
||||
form.setValue("providerType", undefined as unknown as ProviderType);
|
||||
}
|
||||
// Reset the providerUid and providerAlias fields when going back
|
||||
form.setValue("providerUid", "");
|
||||
form.setValue("providerAlias", "");
|
||||
applyBackStep({
|
||||
prevStep,
|
||||
awsMethod,
|
||||
form,
|
||||
setPrevStep,
|
||||
setAwsMethod,
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
@@ -196,9 +273,51 @@ export const ConnectAccountForm = () => {
|
||||
}
|
||||
}, [providerType]);
|
||||
|
||||
useEffect(() => {
|
||||
onProviderTypeChange?.(providerType ?? null);
|
||||
}, [onProviderTypeChange, providerType]);
|
||||
|
||||
useEffect(() => {
|
||||
onBackHandlerChange?.(() => {
|
||||
applyBackStep({
|
||||
prevStep,
|
||||
awsMethod,
|
||||
form,
|
||||
setPrevStep,
|
||||
setAwsMethod,
|
||||
});
|
||||
});
|
||||
}, [onBackHandlerChange, prevStep, awsMethod, form]);
|
||||
|
||||
useEffect(() => {
|
||||
const canSubmit =
|
||||
prevStep === 2 &&
|
||||
(providerType !== "aws" || awsMethod === "single") &&
|
||||
providerUid.trim().length > 0 &&
|
||||
form.formState.isValid;
|
||||
|
||||
onUiStateChange?.({
|
||||
showBack: prevStep === 2,
|
||||
showAction:
|
||||
prevStep === 2 && (providerType !== "aws" || awsMethod === "single"),
|
||||
actionLabel: "Next",
|
||||
actionDisabled: !canSubmit || isLoading,
|
||||
isLoading,
|
||||
});
|
||||
}, [
|
||||
awsMethod,
|
||||
form.formState.isValid,
|
||||
isLoading,
|
||||
onUiStateChange,
|
||||
prevStep,
|
||||
providerType,
|
||||
providerUid,
|
||||
]);
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form
|
||||
id={formId}
|
||||
onSubmit={form.handleSubmit(onSubmitClient)}
|
||||
className="flex flex-col gap-4"
|
||||
>
|
||||
@@ -210,64 +329,77 @@ export const ConnectAccountForm = () => {
|
||||
errorMessage={form.formState.errors.providerType?.message}
|
||||
/>
|
||||
)}
|
||||
{/* Step 2: UID, alias, and credentials (if AWS) */}
|
||||
{prevStep === 2 && (
|
||||
{/* Step 2: AWS method selector (only for AWS, before choosing method) */}
|
||||
{prevStep === 2 && providerType === "aws" && awsMethod === null && (
|
||||
<>
|
||||
<ProviderTitleDocs providerType={providerType} />
|
||||
<CustomInput
|
||||
control={form.control}
|
||||
name="providerUid"
|
||||
type="text"
|
||||
label={providerFieldDetails.label}
|
||||
labelPlacement="inside"
|
||||
placeholder={providerFieldDetails.placeholder}
|
||||
variant="bordered"
|
||||
isRequired
|
||||
/>
|
||||
<CustomInput
|
||||
control={form.control}
|
||||
name="providerAlias"
|
||||
type="text"
|
||||
label="Provider alias (optional)"
|
||||
labelPlacement="inside"
|
||||
placeholder="Enter the provider alias"
|
||||
variant="bordered"
|
||||
isRequired={false}
|
||||
<AwsMethodSelector
|
||||
onSelectSingle={() => setAwsMethod("single")}
|
||||
onSelectOrganizations={() => {
|
||||
onSelectOrganizations?.();
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{/* Navigation buttons */}
|
||||
<div className="flex w-full justify-end gap-4">
|
||||
{/* Show "Back" button only in Step 2 */}
|
||||
{prevStep === 2 && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="lg"
|
||||
onClick={handleBackStep}
|
||||
disabled={isLoading}
|
||||
>
|
||||
{!isLoading && <ChevronLeftIcon size={24} />}
|
||||
Back
|
||||
</Button>
|
||||
{/* Step 2: UID, alias form (non-AWS or AWS single account) */}
|
||||
{prevStep === 2 &&
|
||||
(providerType !== "aws" || awsMethod === "single") && (
|
||||
<>
|
||||
<ProviderTitleDocs providerType={providerType} />
|
||||
<CustomInput
|
||||
control={form.control}
|
||||
name="providerUid"
|
||||
type="text"
|
||||
label={providerFieldDetails.label}
|
||||
labelPlacement="inside"
|
||||
placeholder={providerFieldDetails.placeholder}
|
||||
variant="bordered"
|
||||
isRequired
|
||||
/>
|
||||
<CustomInput
|
||||
control={form.control}
|
||||
name="providerAlias"
|
||||
type="text"
|
||||
label="Provider alias (optional)"
|
||||
labelPlacement="inside"
|
||||
placeholder="Enter the provider alias"
|
||||
variant="bordered"
|
||||
isRequired={false}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{/* Show "Next" button in Step 2 */}
|
||||
{prevStep === 2 && (
|
||||
<Button
|
||||
type="submit"
|
||||
variant="default"
|
||||
size="lg"
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading ? (
|
||||
<Loader2 className="animate-spin" />
|
||||
) : (
|
||||
<ChevronRightIcon size={24} />
|
||||
{!hideNavigation && (
|
||||
<div className="flex w-full justify-end gap-4">
|
||||
{prevStep === 2 && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="lg"
|
||||
onClick={handleBackStep}
|
||||
disabled={isLoading}
|
||||
>
|
||||
{!isLoading && <ChevronLeftIcon size={24} />}
|
||||
Back
|
||||
</Button>
|
||||
)}
|
||||
{prevStep === 2 &&
|
||||
(providerType !== "aws" || awsMethod === "single") && (
|
||||
<Button
|
||||
type="submit"
|
||||
variant="default"
|
||||
size="lg"
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading ? (
|
||||
<Loader2 className="animate-spin" />
|
||||
) : (
|
||||
<ChevronRightIcon size={24} />
|
||||
)}
|
||||
{isLoading ? "Loading" : "Next"}
|
||||
</Button>
|
||||
)}
|
||||
{isLoading ? "Loading" : "Next"}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</form>
|
||||
</Form>
|
||||
);
|
||||
|
||||
@@ -9,10 +9,12 @@ import { RadioGroupAlibabaCloudViaCredentialsTypeForm } from "./radio-group-alib
|
||||
|
||||
interface SelectViaAlibabaCloudProps {
|
||||
initialVia?: string;
|
||||
onViaChange?: (via: string) => void;
|
||||
}
|
||||
|
||||
export const SelectViaAlibabaCloud = ({
|
||||
initialVia,
|
||||
onViaChange,
|
||||
}: SelectViaAlibabaCloudProps) => {
|
||||
const router = useRouter();
|
||||
const form = useForm({
|
||||
@@ -22,6 +24,11 @@ export const SelectViaAlibabaCloud = ({
|
||||
});
|
||||
|
||||
const handleSelectionChange = (value: string) => {
|
||||
if (onViaChange) {
|
||||
onViaChange(value);
|
||||
return;
|
||||
}
|
||||
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.set("via", value);
|
||||
router.push(url.toString());
|
||||
|
||||
@@ -9,9 +9,13 @@ import { RadioGroupAWSViaCredentialsTypeForm } from "./radio-group-aws-via-crede
|
||||
|
||||
interface SelectViaAWSProps {
|
||||
initialVia?: string;
|
||||
onViaChange?: (via: string) => void;
|
||||
}
|
||||
|
||||
export const SelectViaAWS = ({ initialVia }: SelectViaAWSProps) => {
|
||||
export const SelectViaAWS = ({
|
||||
initialVia,
|
||||
onViaChange,
|
||||
}: SelectViaAWSProps) => {
|
||||
const router = useRouter();
|
||||
const form = useForm({
|
||||
defaultValues: {
|
||||
@@ -20,6 +24,11 @@ export const SelectViaAWS = ({ initialVia }: SelectViaAWSProps) => {
|
||||
});
|
||||
|
||||
const handleSelectionChange = (value: string) => {
|
||||
if (onViaChange) {
|
||||
onViaChange(value);
|
||||
return;
|
||||
}
|
||||
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.set("via", value);
|
||||
router.push(url.toString());
|
||||
|
||||
@@ -9,10 +9,12 @@ import { RadioGroupCloudflareViaCredentialsTypeForm } from "./radio-group-cloudf
|
||||
|
||||
interface SelectViaCloudflareProps {
|
||||
initialVia?: string;
|
||||
onViaChange?: (value: string) => void;
|
||||
}
|
||||
|
||||
export const SelectViaCloudflare = ({
|
||||
initialVia,
|
||||
onViaChange,
|
||||
}: SelectViaCloudflareProps) => {
|
||||
const router = useRouter();
|
||||
const form = useForm({
|
||||
@@ -22,6 +24,11 @@ export const SelectViaCloudflare = ({
|
||||
});
|
||||
|
||||
const handleSelectionChange = (value: string) => {
|
||||
if (onViaChange) {
|
||||
onViaChange(value);
|
||||
return;
|
||||
}
|
||||
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.set("via", value);
|
||||
router.push(url.toString());
|
||||
|
||||
@@ -8,9 +8,23 @@ import { BaseCredentialsForm } from "../../base-credentials-form";
|
||||
export const AddViaServiceAccountForm = ({
|
||||
searchParams,
|
||||
providerUid,
|
||||
via,
|
||||
onSuccess,
|
||||
onBack,
|
||||
formId,
|
||||
hideActions,
|
||||
onLoadingChange,
|
||||
onValidityChange,
|
||||
}: {
|
||||
searchParams: { type: ProviderType; id: string };
|
||||
providerUid?: string;
|
||||
via?: string | null;
|
||||
onSuccess?: () => void;
|
||||
onBack?: () => void;
|
||||
formId?: string;
|
||||
hideActions?: boolean;
|
||||
onLoadingChange?: (isLoading: boolean) => void;
|
||||
onValidityChange?: (isValid: boolean) => void;
|
||||
}) => {
|
||||
const providerType = searchParams.type;
|
||||
const providerId = searchParams.id;
|
||||
@@ -19,7 +33,7 @@ export const AddViaServiceAccountForm = ({
|
||||
return await addCredentialsProvider(formData);
|
||||
};
|
||||
|
||||
const successNavigationUrl = `/providers/test-connection?type=${providerType}&id=${providerId}`;
|
||||
const successNavigationUrl = "/providers";
|
||||
|
||||
return (
|
||||
<BaseCredentialsForm
|
||||
@@ -28,6 +42,13 @@ export const AddViaServiceAccountForm = ({
|
||||
providerUid={providerUid}
|
||||
onSubmit={handleAddCredentials}
|
||||
successNavigationUrl={successNavigationUrl}
|
||||
via={via}
|
||||
onSuccess={onSuccess}
|
||||
onBack={onBack}
|
||||
formId={formId}
|
||||
hideActions={hideActions}
|
||||
onLoadingChange={onLoadingChange}
|
||||
onValidityChange={onValidityChange}
|
||||
submitButtonText="Next"
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -9,9 +9,13 @@ import { RadioGroupGCPViaCredentialsTypeForm } from "./radio-group-gcp-via-crede
|
||||
|
||||
interface SelectViaGCPProps {
|
||||
initialVia?: string;
|
||||
onViaChange?: (via: string) => void;
|
||||
}
|
||||
|
||||
export const SelectViaGCP = ({ initialVia }: SelectViaGCPProps) => {
|
||||
export const SelectViaGCP = ({
|
||||
initialVia,
|
||||
onViaChange,
|
||||
}: SelectViaGCPProps) => {
|
||||
const router = useRouter();
|
||||
const form = useForm({
|
||||
defaultValues: {
|
||||
@@ -20,6 +24,11 @@ export const SelectViaGCP = ({ initialVia }: SelectViaGCPProps) => {
|
||||
});
|
||||
|
||||
const handleSelectionChange = (value: string) => {
|
||||
if (onViaChange) {
|
||||
onViaChange(value);
|
||||
return;
|
||||
}
|
||||
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.set("via", value);
|
||||
router.push(url.toString());
|
||||
|
||||
@@ -9,9 +9,13 @@ import { RadioGroupGitHubViaCredentialsTypeForm } from "./radio-group-github-via
|
||||
|
||||
interface SelectViaGitHubProps {
|
||||
initialVia?: string;
|
||||
onViaChange?: (via: string) => void;
|
||||
}
|
||||
|
||||
export const SelectViaGitHub = ({ initialVia }: SelectViaGitHubProps) => {
|
||||
export const SelectViaGitHub = ({
|
||||
initialVia,
|
||||
onViaChange,
|
||||
}: SelectViaGitHubProps) => {
|
||||
const router = useRouter();
|
||||
const form = useForm({
|
||||
defaultValues: {
|
||||
@@ -20,6 +24,11 @@ export const SelectViaGitHub = ({ initialVia }: SelectViaGitHubProps) => {
|
||||
});
|
||||
|
||||
const handleSelectionChange = (value: string) => {
|
||||
if (onViaChange) {
|
||||
onViaChange(value);
|
||||
return;
|
||||
}
|
||||
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.set("via", value);
|
||||
router.push(url.toString());
|
||||
|
||||
@@ -9,9 +9,13 @@ import { RadioGroupM365ViaCredentialsTypeForm } from "./radio-group-m365-via-cre
|
||||
|
||||
interface SelectViaM365Props {
|
||||
initialVia?: string;
|
||||
onViaChange?: (via: string) => void;
|
||||
}
|
||||
|
||||
export const SelectViaM365 = ({ initialVia }: SelectViaM365Props) => {
|
||||
export const SelectViaM365 = ({
|
||||
initialVia,
|
||||
onViaChange,
|
||||
}: SelectViaM365Props) => {
|
||||
const router = useRouter();
|
||||
const form = useForm({
|
||||
defaultValues: {
|
||||
@@ -20,6 +24,11 @@ export const SelectViaM365 = ({ initialVia }: SelectViaM365Props) => {
|
||||
});
|
||||
|
||||
const handleSelectionChange = (value: string) => {
|
||||
if (onViaChange) {
|
||||
onViaChange(value);
|
||||
return;
|
||||
}
|
||||
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.set("via", value);
|
||||
router.push(url.toString());
|
||||
|
||||
@@ -6,7 +6,7 @@ import { Icon } from "@iconify/react";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
|
||||
@@ -28,40 +28,53 @@ import { ProviderInfo } from "../..";
|
||||
|
||||
type FormValues = z.input<typeof testConnectionFormSchema>;
|
||||
|
||||
export const TestConnectionForm = ({
|
||||
searchParams,
|
||||
providerData,
|
||||
}: {
|
||||
searchParams: { type: string; id: string; updated: string };
|
||||
providerData: {
|
||||
data: {
|
||||
id: string;
|
||||
type: string;
|
||||
attributes: {
|
||||
uid: string;
|
||||
connection: {
|
||||
connected: boolean | null;
|
||||
last_checked_at: string | null;
|
||||
};
|
||||
provider: ProviderType;
|
||||
alias: string;
|
||||
scanner_args: Record<string, unknown>;
|
||||
export interface TestConnectionProviderData {
|
||||
data: {
|
||||
id: string;
|
||||
type: string;
|
||||
attributes: {
|
||||
uid: string;
|
||||
connection: {
|
||||
connected: boolean | null;
|
||||
last_checked_at: string | null;
|
||||
};
|
||||
relationships: {
|
||||
secret: {
|
||||
data: {
|
||||
type: string;
|
||||
id: string;
|
||||
} | null;
|
||||
};
|
||||
provider: ProviderType;
|
||||
alias: string;
|
||||
scanner_args: Record<string, unknown>;
|
||||
};
|
||||
relationships: {
|
||||
secret: {
|
||||
data: {
|
||||
type: string;
|
||||
id: string;
|
||||
} | null;
|
||||
};
|
||||
};
|
||||
};
|
||||
}) => {
|
||||
}
|
||||
|
||||
interface TestConnectionFormProps {
|
||||
searchParams: { type: string; id: string; updated: string };
|
||||
providerData: TestConnectionProviderData;
|
||||
onSuccess?: () => void;
|
||||
onResetCredentials?: () => void;
|
||||
formId?: string;
|
||||
hideActions?: boolean;
|
||||
onLoadingChange?: (isLoading: boolean) => void;
|
||||
}
|
||||
|
||||
export const TestConnectionForm = ({
|
||||
searchParams,
|
||||
providerData,
|
||||
onSuccess,
|
||||
onResetCredentials: onResetCredentialsCallback,
|
||||
formId,
|
||||
hideActions = false,
|
||||
onLoadingChange,
|
||||
}: TestConnectionFormProps) => {
|
||||
const { toast } = useToast();
|
||||
const router = useRouter();
|
||||
|
||||
const providerType = searchParams.type;
|
||||
const providerId = searchParams.id;
|
||||
|
||||
const [apiErrorMessage, setApiErrorMessage] = useState<string | null>(null);
|
||||
@@ -85,6 +98,10 @@ export const TestConnectionForm = ({
|
||||
const isLoading = form.formState.isSubmitting;
|
||||
const isUpdated = searchParams?.updated === "true";
|
||||
|
||||
useEffect(() => {
|
||||
onLoadingChange?.(isLoading || isResettingCredentials);
|
||||
}, [isLoading, isResettingCredentials, onLoadingChange]);
|
||||
|
||||
const onSubmitClient = async (values: FormValues) => {
|
||||
const formData = new FormData();
|
||||
formData.append("providerId", values.providerId);
|
||||
@@ -123,7 +140,13 @@ export const TestConnectionForm = ({
|
||||
error: connected ? null : error || "Unknown error",
|
||||
});
|
||||
|
||||
if (connected && isUpdated) return router.push("/providers");
|
||||
if (connected && isUpdated) {
|
||||
if (onSuccess) {
|
||||
onSuccess();
|
||||
return;
|
||||
}
|
||||
return router.push("/providers");
|
||||
}
|
||||
|
||||
if (connected && !isUpdated) {
|
||||
try {
|
||||
@@ -150,6 +173,11 @@ export const TestConnectionForm = ({
|
||||
description: data.error,
|
||||
});
|
||||
} else {
|
||||
if (onSuccess) {
|
||||
onSuccess();
|
||||
return;
|
||||
}
|
||||
|
||||
setIsRedirecting(true);
|
||||
router.push("/scans");
|
||||
}
|
||||
@@ -174,7 +202,7 @@ export const TestConnectionForm = ({
|
||||
}
|
||||
};
|
||||
|
||||
const onResetCredentials = async () => {
|
||||
const handleResetCredentials = async () => {
|
||||
setIsResettingCredentials(true);
|
||||
|
||||
// Check if provider the provider has no credentials
|
||||
@@ -183,20 +211,23 @@ export const TestConnectionForm = ({
|
||||
const hasNoCredentials = !providerSecretId;
|
||||
|
||||
if (hasNoCredentials) {
|
||||
// If no credentials, redirect to add credentials page
|
||||
router.push(
|
||||
`/providers/add-credentials?type=${providerType}&id=${providerId}`,
|
||||
);
|
||||
if (onResetCredentialsCallback) {
|
||||
onResetCredentialsCallback();
|
||||
} else {
|
||||
router.push("/providers");
|
||||
}
|
||||
setIsResettingCredentials(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// If provider has credentials, delete them first
|
||||
try {
|
||||
await deleteCredentials(providerSecretId);
|
||||
// After successful deletion, redirect to add credentials page
|
||||
router.push(
|
||||
`/providers/add-credentials?type=${providerType}&id=${providerId}`,
|
||||
);
|
||||
if (onResetCredentialsCallback) {
|
||||
onResetCredentialsCallback();
|
||||
} else {
|
||||
router.push("/providers");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to delete credentials:", error);
|
||||
} finally {
|
||||
@@ -226,6 +257,7 @@ export const TestConnectionForm = ({
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form
|
||||
id={formId}
|
||||
onSubmit={form.handleSubmit(onSubmitClient)}
|
||||
className="flex flex-col gap-4"
|
||||
>
|
||||
@@ -299,52 +331,58 @@ export const TestConnectionForm = ({
|
||||
|
||||
<input type="hidden" name="providerId" value={providerId} />
|
||||
|
||||
<div className="flex w-full justify-end sm:gap-6">
|
||||
{apiErrorMessage ? (
|
||||
<Button variant="outline" size="lg" asChild>
|
||||
<Link href="/providers">Back to providers</Link>
|
||||
</Button>
|
||||
) : connectionStatus?.error ? (
|
||||
<Button
|
||||
onClick={isUpdated ? () => router.back() : onResetCredentials}
|
||||
type="button"
|
||||
variant="secondary"
|
||||
size="lg"
|
||||
disabled={isResettingCredentials}
|
||||
>
|
||||
{isResettingCredentials ? (
|
||||
<Loader2 className="animate-spin" />
|
||||
) : (
|
||||
<CheckIcon size={24} />
|
||||
)}
|
||||
{isResettingCredentials
|
||||
? "Loading"
|
||||
: isUpdated
|
||||
? "Update credentials"
|
||||
: "Reset credentials"}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
type={
|
||||
isUpdated && connectionStatus?.connected ? "button" : "submit"
|
||||
}
|
||||
variant="default"
|
||||
size="lg"
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading ? (
|
||||
<Loader2 className="animate-spin" />
|
||||
) : (
|
||||
!isUpdated && <RocketIcon size={24} />
|
||||
)}
|
||||
{isLoading
|
||||
? "Loading"
|
||||
: isUpdated
|
||||
? "Check connection"
|
||||
: "Launch scan"}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
{!hideActions && (
|
||||
<div className="flex w-full justify-end sm:gap-6">
|
||||
{apiErrorMessage ? (
|
||||
<Button variant="outline" size="lg" asChild>
|
||||
<Link href="/providers">Back to providers</Link>
|
||||
</Button>
|
||||
) : connectionStatus?.error ? (
|
||||
<Button
|
||||
onClick={
|
||||
isUpdated
|
||||
? onResetCredentialsCallback || (() => router.back())
|
||||
: handleResetCredentials
|
||||
}
|
||||
type="button"
|
||||
variant="secondary"
|
||||
size="lg"
|
||||
disabled={isResettingCredentials}
|
||||
>
|
||||
{isResettingCredentials ? (
|
||||
<Loader2 className="animate-spin" />
|
||||
) : (
|
||||
<CheckIcon size={24} />
|
||||
)}
|
||||
{isResettingCredentials
|
||||
? "Loading"
|
||||
: isUpdated
|
||||
? "Update credentials"
|
||||
: "Reset credentials"}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
type={
|
||||
isUpdated && connectionStatus?.connected ? "button" : "submit"
|
||||
}
|
||||
variant="default"
|
||||
size="lg"
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading ? (
|
||||
<Loader2 className="animate-spin" />
|
||||
) : (
|
||||
!isUpdated && <RocketIcon size={24} />
|
||||
)}
|
||||
{isLoading
|
||||
? "Loading"
|
||||
: isUpdated
|
||||
? "Check connection"
|
||||
: "Launch scan"}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</form>
|
||||
</Form>
|
||||
);
|
||||
|
||||
@@ -8,9 +8,23 @@ import { BaseCredentialsForm } from "./base-credentials-form";
|
||||
export const UpdateViaCredentialsForm = ({
|
||||
searchParams,
|
||||
providerUid,
|
||||
via,
|
||||
onSuccess,
|
||||
onBack,
|
||||
formId,
|
||||
hideActions,
|
||||
onLoadingChange,
|
||||
onValidityChange,
|
||||
}: {
|
||||
searchParams: { type: string; id: string; secretId?: string };
|
||||
providerUid?: string;
|
||||
via?: string | null;
|
||||
onSuccess?: () => void;
|
||||
onBack?: () => void;
|
||||
formId?: string;
|
||||
hideActions?: boolean;
|
||||
onLoadingChange?: (isLoading: boolean) => void;
|
||||
onValidityChange?: (isValid: boolean) => void;
|
||||
}) => {
|
||||
const providerType = searchParams.type as ProviderType;
|
||||
const providerId = searchParams.id;
|
||||
@@ -20,7 +34,7 @@ export const UpdateViaCredentialsForm = ({
|
||||
return await updateCredentialsProvider(providerSecretId, formData);
|
||||
};
|
||||
|
||||
const successNavigationUrl = `/providers/test-connection?type=${providerType}&id=${providerId}&updated=true`;
|
||||
const successNavigationUrl = "/providers";
|
||||
|
||||
return (
|
||||
<BaseCredentialsForm
|
||||
@@ -29,6 +43,13 @@ export const UpdateViaCredentialsForm = ({
|
||||
providerUid={providerUid}
|
||||
onSubmit={handleUpdateCredentials}
|
||||
successNavigationUrl={successNavigationUrl}
|
||||
via={via}
|
||||
onSuccess={onSuccess}
|
||||
onBack={onBack}
|
||||
formId={formId}
|
||||
hideActions={hideActions}
|
||||
onLoadingChange={onLoadingChange}
|
||||
onValidityChange={onValidityChange}
|
||||
submitButtonText="Next"
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -8,9 +8,23 @@ import { BaseCredentialsForm } from "./base-credentials-form";
|
||||
export const UpdateViaRoleForm = ({
|
||||
searchParams,
|
||||
providerUid,
|
||||
via,
|
||||
onSuccess,
|
||||
onBack,
|
||||
formId,
|
||||
hideActions,
|
||||
onLoadingChange,
|
||||
onValidityChange,
|
||||
}: {
|
||||
searchParams: { type: string; id: string; secretId?: string };
|
||||
providerUid?: string;
|
||||
via?: string | null;
|
||||
onSuccess?: () => void;
|
||||
onBack?: () => void;
|
||||
formId?: string;
|
||||
hideActions?: boolean;
|
||||
onLoadingChange?: (isLoading: boolean) => void;
|
||||
onValidityChange?: (isValid: boolean) => void;
|
||||
}) => {
|
||||
const providerType = searchParams.type as ProviderType;
|
||||
const providerId = searchParams.id;
|
||||
@@ -20,7 +34,7 @@ export const UpdateViaRoleForm = ({
|
||||
return await updateCredentialsProvider(providerSecretId, formData);
|
||||
};
|
||||
|
||||
const successNavigationUrl = `/providers/test-connection?type=${providerType}&id=${providerId}&updated=true`;
|
||||
const successNavigationUrl = "/providers";
|
||||
|
||||
return (
|
||||
<BaseCredentialsForm
|
||||
@@ -29,6 +43,13 @@ export const UpdateViaRoleForm = ({
|
||||
providerUid={providerUid}
|
||||
onSubmit={handleUpdateCredentials}
|
||||
successNavigationUrl={successNavigationUrl}
|
||||
via={via}
|
||||
onSuccess={onSuccess}
|
||||
onBack={onBack}
|
||||
formId={formId}
|
||||
hideActions={hideActions}
|
||||
onLoadingChange={onLoadingChange}
|
||||
onValidityChange={onValidityChange}
|
||||
submitButtonText="Next"
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -8,9 +8,23 @@ import { BaseCredentialsForm } from "./base-credentials-form";
|
||||
export const UpdateViaServiceAccountForm = ({
|
||||
searchParams,
|
||||
providerUid,
|
||||
via,
|
||||
onSuccess,
|
||||
onBack,
|
||||
formId,
|
||||
hideActions,
|
||||
onLoadingChange,
|
||||
onValidityChange,
|
||||
}: {
|
||||
searchParams: { type: string; id: string; secretId?: string };
|
||||
providerUid?: string;
|
||||
via?: string | null;
|
||||
onSuccess?: () => void;
|
||||
onBack?: () => void;
|
||||
formId?: string;
|
||||
hideActions?: boolean;
|
||||
onLoadingChange?: (isLoading: boolean) => void;
|
||||
onValidityChange?: (isValid: boolean) => void;
|
||||
}) => {
|
||||
const providerType = searchParams.type as ProviderType;
|
||||
const providerId = searchParams.id;
|
||||
@@ -20,7 +34,7 @@ export const UpdateViaServiceAccountForm = ({
|
||||
return await updateCredentialsProvider(providerSecretId, formData);
|
||||
};
|
||||
|
||||
const successNavigationUrl = `/providers/test-connection?type=${providerType}&id=${providerId}&updated=true`;
|
||||
const successNavigationUrl = "/providers";
|
||||
|
||||
return (
|
||||
<BaseCredentialsForm
|
||||
@@ -29,6 +43,13 @@ export const UpdateViaServiceAccountForm = ({
|
||||
providerUid={providerUid}
|
||||
onSubmit={handleUpdateCredentials}
|
||||
successNavigationUrl={successNavigationUrl}
|
||||
via={via}
|
||||
onSuccess={onSuccess}
|
||||
onBack={onBack}
|
||||
formId={formId}
|
||||
hideActions={hideActions}
|
||||
onLoadingChange={onLoadingChange}
|
||||
onValidityChange={onValidityChange}
|
||||
submitButtonText="Next"
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
export * from "./credentials-role-helper";
|
||||
export * from "./provider-title-docs";
|
||||
export * from "./skeleton-provider-workflow";
|
||||
export * from "./vertical-steps";
|
||||
export * from "./workflow-add-provider";
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { CustomLink } from "@/components/ui/custom/custom-link";
|
||||
import { getProviderName } from "@/components/ui/entities/get-provider-logo";
|
||||
import { getProviderLogo } from "@/components/ui/entities/get-provider-logo";
|
||||
import { getProviderHelpText } from "@/lib";
|
||||
import { ProviderType } from "@/types";
|
||||
|
||||
export const ProviderTitleDocs = ({
|
||||
@@ -12,27 +10,13 @@ export const ProviderTitleDocs = ({
|
||||
providerType: ProviderType;
|
||||
}) => {
|
||||
return (
|
||||
<div className="flex flex-col gap-y-2">
|
||||
<div className="flex gap-4">
|
||||
{providerType && getProviderLogo(providerType as ProviderType)}
|
||||
<span className="text-lg font-semibold">
|
||||
{providerType
|
||||
? getProviderName(providerType as ProviderType)
|
||||
: "Unknown Provider"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-end gap-x-2">
|
||||
<p className="text-default-500 text-sm">
|
||||
{getProviderHelpText(providerType as string).text}
|
||||
</p>
|
||||
<CustomLink
|
||||
href={getProviderHelpText(providerType as string).link}
|
||||
size="sm"
|
||||
className="text-nowrap"
|
||||
>
|
||||
Read the docs
|
||||
</CustomLink>
|
||||
</div>
|
||||
<div className="flex gap-4">
|
||||
{providerType && getProviderLogo(providerType as ProviderType)}
|
||||
<span className="text-lg font-semibold">
|
||||
{providerType
|
||||
? getProviderName(providerType as ProviderType)
|
||||
: "Unknown Provider"}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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,97 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { Progress } from "@heroui/progress";
|
||||
import { Spacer } from "@heroui/spacer";
|
||||
import { usePathname } from "next/navigation";
|
||||
import React from "react";
|
||||
|
||||
import { VerticalSteps } from "./vertical-steps";
|
||||
|
||||
const steps = [
|
||||
{
|
||||
title: "Choose your Cloud Provider",
|
||||
description:
|
||||
"Select the cloud provider you wish to connect and specify your preferred authentication method from the supported options.",
|
||||
href: "/providers/connect-account",
|
||||
},
|
||||
{
|
||||
title: "Enter Authentication Details",
|
||||
description:
|
||||
"Provide the necessary credentials to establish a secure connection to your selected cloud provider.",
|
||||
href: "/providers/add-credentials",
|
||||
},
|
||||
{
|
||||
title: "Verify Connection & Start Scan",
|
||||
description:
|
||||
"Ensure your credentials are correct and start scanning your cloud environment.",
|
||||
href: "/providers/test-connection",
|
||||
},
|
||||
];
|
||||
|
||||
const ROUTE_CONFIG: Record<
|
||||
string,
|
||||
{
|
||||
stepIndex: number;
|
||||
stepOverride?: { index: number; title: string; description: string };
|
||||
}
|
||||
> = {
|
||||
"/providers/connect-account": { stepIndex: 0 },
|
||||
"/providers/add-credentials": { stepIndex: 1 },
|
||||
"/providers/test-connection": { stepIndex: 2 },
|
||||
"/providers/update-credentials": {
|
||||
stepIndex: 1,
|
||||
stepOverride: {
|
||||
index: 2,
|
||||
title: "Make sure the new credentials are valid",
|
||||
description: "Valid credentials will take you back to the providers page",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const WorkflowAddProvider = () => {
|
||||
const pathname = usePathname();
|
||||
|
||||
const config = ROUTE_CONFIG[pathname] || { stepIndex: 0 };
|
||||
const currentStep = config.stepIndex;
|
||||
|
||||
const updatedSteps = steps.map((step, index) => {
|
||||
if (config.stepOverride && index === config.stepOverride.index) {
|
||||
return { ...step, ...config.stepOverride };
|
||||
}
|
||||
return step;
|
||||
});
|
||||
|
||||
return (
|
||||
<section className="max-w-sm">
|
||||
<h1 className="mb-2 text-xl font-medium" id="getting-started">
|
||||
Add a Cloud Provider
|
||||
</h1>
|
||||
<p className="text-small text-default-500 mb-5">
|
||||
Complete these steps to configure your cloud provider and initiate your
|
||||
first scan.
|
||||
</p>
|
||||
<Progress
|
||||
classNames={{
|
||||
base: "px-0.5 mb-5",
|
||||
label: "text-small",
|
||||
value: "text-small text-button-primary",
|
||||
indicator: "bg-button-primary",
|
||||
}}
|
||||
label="Steps"
|
||||
maxValue={steps.length - 1}
|
||||
minValue={0}
|
||||
showValueLabel={true}
|
||||
size="md"
|
||||
value={currentStep}
|
||||
valueLabel={`${currentStep + 1} of ${steps.length}`}
|
||||
/>
|
||||
<VerticalSteps
|
||||
hideProgressBars
|
||||
currentStep={currentStep}
|
||||
stepClassName="border border-border-neutral-primary aria-[current]:border-button-primary aria-[current]:text-text-neutral-primary cursor-default"
|
||||
steps={updatedSteps}
|
||||
/>
|
||||
<Spacer y={4} />
|
||||
</section>
|
||||
);
|
||||
};
|
||||
@@ -1,39 +1,45 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useState } from "react";
|
||||
|
||||
import { ProviderWizardModal } from "@/components/providers/wizard";
|
||||
import { Button, Card, CardContent } from "@/components/shadcn";
|
||||
|
||||
import { InfoIcon } from "../icons/Icons";
|
||||
|
||||
export const NoProvidersAdded = () => {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center">
|
||||
<Card variant="base" className="mx-auto w-full max-w-3xl">
|
||||
<CardContent className="flex flex-col items-center gap-4 p-6 text-center sm:p-8">
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<InfoIcon className="h-10 w-10 text-gray-800 dark:text-white" />
|
||||
<h2 className="text-2xl font-bold text-gray-800 dark:text-white">
|
||||
No Cloud Providers Configured
|
||||
</h2>
|
||||
</div>
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
<p className="text-md leading-relaxed text-gray-600 dark:text-gray-300">
|
||||
No cloud providers have been configured. Start by setting up a
|
||||
cloud provider.
|
||||
</p>
|
||||
</div>
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
<Button
|
||||
asChild
|
||||
aria-label="Go to Add Cloud Provider page"
|
||||
className="w-full max-w-xs justify-center"
|
||||
size="lg"
|
||||
>
|
||||
<Link href="/providers/connect-account">Get Started</Link>
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
return (
|
||||
<>
|
||||
<div className="flex min-h-screen items-center justify-center">
|
||||
<Card variant="base" className="mx-auto w-full max-w-3xl">
|
||||
<CardContent className="flex flex-col items-center gap-4 p-6 text-center sm:p-8">
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<InfoIcon className="h-10 w-10 text-gray-800 dark:text-white" />
|
||||
<h2 className="text-2xl font-bold text-gray-800 dark:text-white">
|
||||
No Cloud Providers Configured
|
||||
</h2>
|
||||
</div>
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
<p className="text-md leading-relaxed text-gray-600 dark:text-gray-300">
|
||||
No cloud providers have been configured. Start by setting up a
|
||||
cloud provider.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
aria-label="Open Add Cloud Provider modal"
|
||||
className="w-full max-w-xs justify-center"
|
||||
size="lg"
|
||||
onClick={() => setOpen(true)}
|
||||
>
|
||||
Get Started
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
<ProviderWizardModal open={open} onOpenChange={setOpen} />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -21,6 +21,10 @@ type UseCredentialsFormProps = {
|
||||
providerUid?: string;
|
||||
onSubmit: (formData: FormData) => Promise<ApiResponse>;
|
||||
successNavigationUrl: string;
|
||||
via?: string | null;
|
||||
onSuccess?: () => void;
|
||||
onBack?: () => void;
|
||||
validationMode?: "onSubmit" | "onChange";
|
||||
};
|
||||
|
||||
export const useCredentialsForm = ({
|
||||
@@ -29,21 +33,25 @@ export const useCredentialsForm = ({
|
||||
providerUid,
|
||||
onSubmit,
|
||||
successNavigationUrl,
|
||||
via: viaOverride,
|
||||
onSuccess,
|
||||
onBack,
|
||||
validationMode = "onChange",
|
||||
}: UseCredentialsFormProps) => {
|
||||
const router = useRouter();
|
||||
const searchParamsObj = useSearchParams();
|
||||
const { data: session } = useSession();
|
||||
const via = searchParamsObj.get("via");
|
||||
const effectiveVia = viaOverride ?? searchParamsObj.get("via");
|
||||
|
||||
// Select the appropriate schema based on provider type and via parameter
|
||||
const getFormSchema = () => {
|
||||
if (providerType === "aws" && via === "role") {
|
||||
if (providerType === "aws" && effectiveVia === "role") {
|
||||
return addCredentialsRoleFormSchema(providerType);
|
||||
}
|
||||
if (providerType === "alibabacloud" && via === "role") {
|
||||
if (providerType === "alibabacloud" && effectiveVia === "role") {
|
||||
return addCredentialsRoleFormSchema(providerType);
|
||||
}
|
||||
if (providerType === "gcp" && via === "service-account") {
|
||||
if (providerType === "gcp" && effectiveVia === "service-account") {
|
||||
return addCredentialsServiceAccountFormSchema(providerType);
|
||||
}
|
||||
// For GitHub, M365, and Cloudflare, we need to pass the via parameter to determine which fields are required
|
||||
@@ -52,7 +60,7 @@ export const useCredentialsForm = ({
|
||||
providerType === "m365" ||
|
||||
providerType === "cloudflare"
|
||||
) {
|
||||
return addCredentialsFormSchema(providerType, via);
|
||||
return addCredentialsFormSchema(providerType, effectiveVia);
|
||||
}
|
||||
return addCredentialsFormSchema(providerType);
|
||||
};
|
||||
@@ -67,7 +75,7 @@ export const useCredentialsForm = ({
|
||||
};
|
||||
|
||||
// AWS Role credentials
|
||||
if (providerType === "aws" && via === "role") {
|
||||
if (providerType === "aws" && effectiveVia === "role") {
|
||||
const isCloudEnv = process.env.NEXT_PUBLIC_IS_CLOUD_ENV === "true";
|
||||
const defaultCredentialsType = isCloudEnv
|
||||
? "aws-sdk-default"
|
||||
@@ -86,7 +94,7 @@ export const useCredentialsForm = ({
|
||||
}
|
||||
|
||||
// GCP Service Account
|
||||
if (providerType === "gcp" && via === "service-account") {
|
||||
if (providerType === "gcp" && effectiveVia === "service-account") {
|
||||
return {
|
||||
...baseDefaults,
|
||||
[ProviderCredentialFields.SERVICE_ACCOUNT_KEY]: "",
|
||||
@@ -110,7 +118,7 @@ export const useCredentialsForm = ({
|
||||
};
|
||||
case "m365":
|
||||
// M365 credentials based on via parameter
|
||||
if (via === "app_client_secret") {
|
||||
if (effectiveVia === "app_client_secret") {
|
||||
return {
|
||||
...baseDefaults,
|
||||
[ProviderCredentialFields.CLIENT_ID]: "",
|
||||
@@ -118,7 +126,7 @@ export const useCredentialsForm = ({
|
||||
[ProviderCredentialFields.TENANT_ID]: "",
|
||||
};
|
||||
}
|
||||
if (via === "app_certificate") {
|
||||
if (effectiveVia === "app_certificate") {
|
||||
return {
|
||||
...baseDefaults,
|
||||
[ProviderCredentialFields.CLIENT_ID]: "",
|
||||
@@ -145,19 +153,19 @@ export const useCredentialsForm = ({
|
||||
};
|
||||
case "github":
|
||||
// GitHub credentials based on via parameter
|
||||
if (via === "personal_access_token") {
|
||||
if (effectiveVia === "personal_access_token") {
|
||||
return {
|
||||
...baseDefaults,
|
||||
[ProviderCredentialFields.PERSONAL_ACCESS_TOKEN]: "",
|
||||
};
|
||||
}
|
||||
if (via === "oauth_app") {
|
||||
if (effectiveVia === "oauth_app") {
|
||||
return {
|
||||
...baseDefaults,
|
||||
[ProviderCredentialFields.OAUTH_APP_TOKEN]: "",
|
||||
};
|
||||
}
|
||||
if (via === "github_app") {
|
||||
if (effectiveVia === "github_app") {
|
||||
return {
|
||||
...baseDefaults,
|
||||
[ProviderCredentialFields.GITHUB_APP_ID]: "",
|
||||
@@ -182,7 +190,7 @@ export const useCredentialsForm = ({
|
||||
[ProviderCredentialFields.ATLAS_PRIVATE_KEY]: "",
|
||||
};
|
||||
case "alibabacloud":
|
||||
if (via === "role") {
|
||||
if (effectiveVia === "role") {
|
||||
return {
|
||||
...baseDefaults,
|
||||
[ProviderCredentialFields.ALIBABACLOUD_ROLE_ARN]: "",
|
||||
@@ -198,13 +206,13 @@ export const useCredentialsForm = ({
|
||||
};
|
||||
case "cloudflare":
|
||||
// Cloudflare credentials based on via parameter
|
||||
if (via === "api_token") {
|
||||
if (effectiveVia === "api_token") {
|
||||
return {
|
||||
...baseDefaults,
|
||||
[ProviderCredentialFields.CLOUDFLARE_API_TOKEN]: "",
|
||||
};
|
||||
}
|
||||
if (via === "api_key") {
|
||||
if (effectiveVia === "api_key") {
|
||||
return {
|
||||
...baseDefaults,
|
||||
[ProviderCredentialFields.CLOUDFLARE_API_KEY]: "",
|
||||
@@ -228,7 +236,7 @@ export const useCredentialsForm = ({
|
||||
const form = useForm({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: defaultValues,
|
||||
mode: "onSubmit",
|
||||
mode: validationMode,
|
||||
reValidateMode: "onChange",
|
||||
criteriaMode: "all", // Show all errors for each field
|
||||
});
|
||||
@@ -240,6 +248,11 @@ export const useCredentialsForm = ({
|
||||
|
||||
// Handler for back button
|
||||
const handleBackStep = () => {
|
||||
if (onBack) {
|
||||
onBack();
|
||||
return;
|
||||
}
|
||||
|
||||
const currentParams = new URLSearchParams(window.location.search);
|
||||
currentParams.delete("via");
|
||||
router.push(`?${currentParams.toString()}`);
|
||||
@@ -260,19 +273,25 @@ export const useCredentialsForm = ({
|
||||
|
||||
const isSuccess = handleServerResponse(data);
|
||||
if (isSuccess) {
|
||||
if (onSuccess) {
|
||||
onSuccess();
|
||||
return;
|
||||
}
|
||||
router.push(successNavigationUrl);
|
||||
}
|
||||
};
|
||||
|
||||
const { isSubmitting, errors } = form.formState;
|
||||
const { isSubmitting, isValid, errors } = form.formState;
|
||||
|
||||
return {
|
||||
form,
|
||||
isLoading: isSubmitting,
|
||||
isValid,
|
||||
errors,
|
||||
handleSubmit,
|
||||
handleBackStep,
|
||||
searchParamsObj,
|
||||
effectiveVia,
|
||||
externalId: session?.tenantId || "",
|
||||
};
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user