From 518fd1bf2d04b9725f7188ae8bf66a3044a0998a Mon Sep 17 00:00:00 2001 From: alejandrobailo Date: Thu, 19 Feb 2026 22:55:47 +0100 Subject: [PATCH] feat(ui): add provider wizard modal with step wrappers --- .../providers/add-provider-button.tsx | 14 +- ui/components/providers/radio-card.tsx | 6 +- .../providers/radio-group-provider.tsx | 37 +- .../table/data-table-row-actions.tsx | 32 +- .../wizard/provider-wizard-modal.tsx | 431 ++++++++++++++++++ .../providers/wizard/steps/connect-step.tsx | 84 ++++ .../wizard/steps/credentials-step.tsx | 253 ++++++++++ .../providers/wizard/steps/footer-controls.ts | 20 + ui/components/providers/wizard/steps/index.ts | 5 + .../providers/wizard/steps/launch-step.tsx | 15 + .../wizard/steps/test-connection-step.tsx | 141 ++++++ .../workflow/provider-title-docs.tsx | 30 +- ui/components/scans/no-providers-added.tsx | 62 +-- 13 files changed, 1043 insertions(+), 87 deletions(-) create mode 100644 ui/components/providers/wizard/provider-wizard-modal.tsx create mode 100644 ui/components/providers/wizard/steps/connect-step.tsx create mode 100644 ui/components/providers/wizard/steps/credentials-step.tsx create mode 100644 ui/components/providers/wizard/steps/footer-controls.ts create mode 100644 ui/components/providers/wizard/steps/index.ts create mode 100644 ui/components/providers/wizard/steps/launch-step.tsx create mode 100644 ui/components/providers/wizard/steps/test-connection-step.tsx diff --git a/ui/components/providers/add-provider-button.tsx b/ui/components/providers/add-provider-button.tsx index 7846616357..223d3a390d 100644 --- a/ui/components/providers/add-provider-button.tsx +++ b/ui/components/providers/add-provider-button.tsx @@ -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 ( - + + + ); }; diff --git a/ui/components/providers/radio-card.tsx b/ui/components/providers/radio-card.tsx index fb9b6e87f6..98885864a0 100644 --- a/ui/components/providers/radio-card.tsx +++ b/ui/components/providers/radio-card.tsx @@ -24,11 +24,11 @@ export function RadioCard({ className={cn( "flex min-h-[72px] w-full items-center gap-4 rounded-lg border px-3 py-2.5 text-left transition-colors", disabled - ? "cursor-not-allowed border-border-neutral-primary bg-bg-neutral-tertiary" - : "hover:border-primary cursor-pointer border-border-neutral-primary bg-bg-neutral-tertiary", + ? "border-border-neutral-primary bg-bg-neutral-tertiary cursor-not-allowed" + : "hover:border-primary border-border-neutral-primary bg-bg-neutral-tertiary cursor-pointer", )} > -
+
= ({ name="providerType" control={control} render={({ field }) => ( -
+
= ({ />
-
+
{filteredProviders.length > 0 ? ( filteredProviders.map((provider) => { @@ -140,19 +132,26 @@ export const RadioGroupProvider: FC = ({ 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", )} > - - - {provider.label} - +
+ {isSelected && ( +
+ )} +
+ +
+ + + {provider.label} + +
); }) diff --git a/ui/components/providers/table/data-table-row-actions.tsx b/ui/components/providers/table/data-table-row-actions.tsx index 023f563006..20ef734ff8 100644 --- a/ui/components/providers/table/data-table-row-actions.tsx +++ b/ui/components/providers/table/data-table-row-actions.tsx @@ -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,6 +14,8 @@ import { ActionDropdownItem, } from "@/components/shadcn/dropdown"; import { Modal } from "@/components/shadcn/modal"; +import { PROVIDER_WIZARD_MODE } from "@/types/provider-wizard"; +import { ProviderType } from "@/types/providers"; import { EditForm } from "../forms"; import { DeleteForm } from "../forms/delete-form"; @@ -25,13 +27,15 @@ interface DataTableRowActionsProps { export function DataTableRowActions({ row, }: DataTableRowActionsProps) { - const router = useRouter(); 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 providerType = (row.original as any).attributes + ?.provider as ProviderType; + const providerUid = (row.original as any).attributes?.uid || ""; + const providerAlias = (row.original as any).attributes?.alias || null; const providerSecretId = (row.original as any).relationships?.secret?.data?.id || null; @@ -66,6 +70,20 @@ export function DataTableRowActions({ > +
({ } label={hasSecret ? "Update Credentials" : "Add Credentials"} - onSelect={() => - router.push( - `/providers/${hasSecret ? "update" : "add"}-credentials?type=${providerType}&id=${providerId}${providerSecretId ? `&secretId=${providerSecretId}` : ""}`, - ) - } + onSelect={() => setIsWizardOpen(true)} /> } diff --git a/ui/components/providers/wizard/provider-wizard-modal.tsx b/ui/components/providers/wizard/provider-wizard-modal.tsx new file mode 100644 index 0000000000..3e4c589050 --- /dev/null +++ b/ui/components/providers/wizard/provider-wizard-modal.tsx @@ -0,0 +1,431 @@ +"use client"; + +import { ExternalLink, Info } from "lucide-react"; +import { useRouter } from "next/navigation"; +import { useEffect, useState } from "react"; + +import { OrgAccountSelection } from "@/components/providers/organizations/org-account-selection"; +import { OrgConnectionTest } from "@/components/providers/organizations/org-connection-test"; +import { OrgDiscoveryLoader } from "@/components/providers/organizations/org-discovery-loader"; +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 { getProviderHelpText } from "@/lib"; +import { useOrgSetupStore } from "@/store/organizations/store"; +import { useProviderWizardStore } from "@/store/provider-wizard/store"; +import { ORG_WIZARD_STEP, OrgWizardStep } from "@/types/organizations"; +import { + PROVIDER_WIZARD_MODE, + PROVIDER_WIZARD_STEP, + ProviderWizardMode, + ProviderWizardStep, +} from "@/types/provider-wizard"; +import { ProviderType } from "@/types/providers"; + +import { ConnectStep } from "./steps/connect-step"; +import { CredentialsStep } from "./steps/credentials-step"; +import { + WIZARD_FOOTER_ACTION_TYPE, + WizardFooterConfig, +} from "./steps/footer-controls"; +import { LaunchStep } from "./steps/launch-step"; +import { TestConnectionStep } from "./steps/test-connection-step"; +import { WizardStepper } from "./wizard-stepper"; + +const WIZARD_VARIANT = { + PROVIDER: "provider", + ORGANIZATIONS: "organizations", +} as const; + +type WizardVariant = (typeof WIZARD_VARIANT)[keyof typeof WIZARD_VARIANT]; + +const VALIDATE_PHASE = { + DISCOVERY: "discovery", + SELECTION: "selection", + TESTING: "testing", +} as const; + +type ValidatePhase = (typeof VALIDATE_PHASE)[keyof typeof VALIDATE_PHASE]; + +const EMPTY_FOOTER_CONFIG: WizardFooterConfig = { + showBack: false, + backLabel: "Back", + showAction: false, + actionLabel: "Next", + actionType: WIZARD_FOOTER_ACTION_TYPE.BUTTON, +}; + +export interface ProviderWizardInitialData { + providerId: string; + providerType: ProviderType; + providerUid: string; + providerAlias: string | null; + secretId?: string | null; + via?: string | null; + mode?: ProviderWizardMode; +} + +interface ProviderWizardModalProps { + open: boolean; + onOpenChange: (open: boolean) => void; + initialData?: ProviderWizardInitialData; +} + +export function ProviderWizardModal({ + open, + onOpenChange, + initialData, +}: ProviderWizardModalProps) { + const router = useRouter(); + const [wizardVariant, setWizardVariant] = useState( + WIZARD_VARIANT.PROVIDER, + ); + const [currentStep, setCurrentStep] = useState( + PROVIDER_WIZARD_STEP.CONNECT, + ); + const [orgCurrentStep, setOrgCurrentStep] = useState( + ORG_WIZARD_STEP.SETUP, + ); + const [validatePhase, setValidatePhase] = useState( + VALIDATE_PHASE.DISCOVERY, + ); + const [footerConfig, setFooterConfig] = + useState(EMPTY_FOOTER_CONFIG); + const [providerTypeHint, setProviderTypeHint] = useState( + null, + ); + + const { + reset: resetProviderWizard, + setProvider, + setVia, + setSecretId, + setMode, + mode, + providerType, + } = useProviderWizardStore(); + const { connectionResults, reset: resetOrgWizard } = useOrgSetupStore(); + + const hasConnectionErrors = Object.values(connectionResults).some( + (status) => status === "error", + ); + + useEffect(() => { + if (!open) { + return; + } + + if (initialData) { + setWizardVariant(WIZARD_VARIANT.PROVIDER); + setProvider({ + id: initialData.providerId, + type: initialData.providerType, + uid: initialData.providerUid, + alias: initialData.providerAlias, + }); + setVia(initialData.via || null); + setSecretId(initialData.secretId || null); + setMode( + initialData.mode || + (initialData.secretId + ? PROVIDER_WIZARD_MODE.UPDATE + : PROVIDER_WIZARD_MODE.ADD), + ); + setCurrentStep(PROVIDER_WIZARD_STEP.CREDENTIALS); + setOrgCurrentStep(ORG_WIZARD_STEP.SETUP); + setValidatePhase(VALIDATE_PHASE.DISCOVERY); + setFooterConfig(EMPTY_FOOTER_CONFIG); + setProviderTypeHint(initialData.providerType); + return; + } + + resetProviderWizard(); + resetOrgWizard(); + setWizardVariant(WIZARD_VARIANT.PROVIDER); + setCurrentStep(PROVIDER_WIZARD_STEP.CONNECT); + setOrgCurrentStep(ORG_WIZARD_STEP.SETUP); + setValidatePhase(VALIDATE_PHASE.DISCOVERY); + setFooterConfig(EMPTY_FOOTER_CONFIG); + setProviderTypeHint(null); + }, [ + initialData, + 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); + setValidatePhase(VALIDATE_PHASE.DISCOVERY); + setFooterConfig(EMPTY_FOOTER_CONFIG); + setProviderTypeHint(null); + 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); + setValidatePhase(VALIDATE_PHASE.DISCOVERY); + setFooterConfig(EMPTY_FOOTER_CONFIG); + setProviderTypeHint(null); + }; + + const backToProviderFlow = () => { + resetOrgWizard(); + setWizardVariant(WIZARD_VARIANT.PROVIDER); + setCurrentStep(PROVIDER_WIZARD_STEP.CONNECT); + setFooterConfig(EMPTY_FOOTER_CONFIG); + setProviderTypeHint(null); + }; + + 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), + showAction: true, + actionLabel: "Go to scans", + actionType: WIZARD_FOOTER_ACTION_TYPE.BUTTON, + onAction: () => { + handleClose(); + router.push("/scans"); + }, + } + : footerConfig; + + useEffect(() => { + if (isProviderFlow) { + return; + } + + if ( + orgCurrentStep === ORG_WIZARD_STEP.VALIDATE && + validatePhase === VALIDATE_PHASE.DISCOVERY + ) { + setFooterConfig({ + showBack: true, + backLabel: "Back", + onBack: () => { + setOrgCurrentStep(ORG_WIZARD_STEP.SETUP); + }, + showAction: false, + actionLabel: "Next", + actionType: WIZARD_FOOTER_ACTION_TYPE.BUTTON, + }); + } + }, [isProviderFlow, orgCurrentStep, validatePhase]); + + return ( + + + + Adding A Cloud Provider + +
+ + For assistance connecting a Cloud Provider visit + +
+
+ +
+
+ {isProviderFlow ? ( + + ) : ( + + )} +
+ +
+
+ {isProviderFlow && currentStep === PROVIDER_WIZARD_STEP.CONNECT && ( + setCurrentStep(PROVIDER_WIZARD_STEP.CREDENTIALS)} + onSelectOrganizations={openOrganizationsFlow} + onFooterChange={setFooterConfig} + onProviderTypeChange={setProviderTypeHint} + /> + )} + + {isProviderFlow && + currentStep === PROVIDER_WIZARD_STEP.CREDENTIALS && ( + setCurrentStep(PROVIDER_WIZARD_STEP.TEST)} + onBack={() => setCurrentStep(PROVIDER_WIZARD_STEP.CONNECT)} + onFooterChange={setFooterConfig} + /> + )} + + {isProviderFlow && currentStep === PROVIDER_WIZARD_STEP.TEST && ( + + setCurrentStep(PROVIDER_WIZARD_STEP.CREDENTIALS) + } + onFooterChange={setFooterConfig} + /> + )} + + {isProviderFlow && currentStep === PROVIDER_WIZARD_STEP.LAUNCH && ( + + )} + + {!isProviderFlow && orgCurrentStep === ORG_WIZARD_STEP.SETUP && ( + { + setOrgCurrentStep(ORG_WIZARD_STEP.VALIDATE); + setValidatePhase(VALIDATE_PHASE.DISCOVERY); + }} + onFooterChange={setFooterConfig} + /> + )} + + {!isProviderFlow && + orgCurrentStep === ORG_WIZARD_STEP.VALIDATE && + validatePhase === VALIDATE_PHASE.DISCOVERY && ( + { + setValidatePhase(VALIDATE_PHASE.SELECTION); + }} + /> + )} + + {!isProviderFlow && + orgCurrentStep === ORG_WIZARD_STEP.VALIDATE && + validatePhase === VALIDATE_PHASE.SELECTION && ( + { + setOrgCurrentStep(ORG_WIZARD_STEP.SETUP); + }} + onNext={() => { + setValidatePhase(VALIDATE_PHASE.TESTING); + }} + onFooterChange={setFooterConfig} + /> + )} + + {!isProviderFlow && + orgCurrentStep === ORG_WIZARD_STEP.VALIDATE && + validatePhase === VALIDATE_PHASE.TESTING && ( + { + setValidatePhase(VALIDATE_PHASE.SELECTION); + }} + onNext={() => { + setOrgCurrentStep(ORG_WIZARD_STEP.LAUNCH); + }} + onSkip={() => { + setOrgCurrentStep(ORG_WIZARD_STEP.LAUNCH); + }} + onFooterChange={setFooterConfig} + /> + )} + + {!isProviderFlow && orgCurrentStep === ORG_WIZARD_STEP.LAUNCH && ( + { + setOrgCurrentStep(ORG_WIZARD_STEP.VALIDATE); + setValidatePhase(VALIDATE_PHASE.TESTING); + }} + onFooterChange={setFooterConfig} + /> + )} +
+
+
+ + {(resolvedFooterConfig.showBack || resolvedFooterConfig.showAction) && ( +
+
+
+ {resolvedFooterConfig.showBack && ( + + )} +
+
+ {resolvedFooterConfig.showAction && ( + + )} +
+
+
+ )} +
+ ); +} diff --git a/ui/components/providers/wizard/steps/connect-step.tsx b/ui/components/providers/wizard/steps/connect-step.tsx new file mode 100644 index 0000000000..ab040c1e43 --- /dev/null +++ b/ui/components/providers/wizard/steps/connect-step.tsx @@ -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 ( + { + backHandlerRef.current = handler; + }} + /> + ); +} diff --git a/ui/components/providers/wizard/steps/credentials-step.tsx b/ui/components/providers/wizard/steps/credentials-step.tsx new file mode 100644 index 0000000000..8b2b4b8581 --- /dev/null +++ b/ui/components/providers/wizard/steps/credentials-step.tsx @@ -0,0 +1,253 @@ +"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 { + 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 ( +
+

+ Provider details are missing. Go back and select a provider. +

+
+ ); + } + + if (formType === "selector") { + if (providerType === "aws") { + return ( + + ); + } + if (providerType === "gcp") { + return ( + + ); + } + if (providerType === "github") { + return ( + + ); + } + if (providerType === "m365") { + return ( + + ); + } + if (providerType === "alibabacloud") { + return ( + + ); + } + return null; + } + + const commonFormProps = { + via, + onSuccess: onNext, + onBack: handleBack, + providerUid: providerUid || undefined, + formId, + hideActions: true, + onLoadingChange: setIsFormLoading, + onValidityChange: setIsFormValid, + }; + + if (formType === "credentials") { + if (shouldUseUpdateForms) { + return ( + + ); + } + + return ( + + ); + } + + if (formType === "role") { + if (shouldUseUpdateForms) { + return ( + + ); + } + + return ( + + ); + } + + if (formType === "service-account") { + if (shouldUseUpdateForms) { + return ( + + ); + } + + return ( + + ); + } + + return ( +
+

+ Select a credential type to continue. +

+
+ ); +} diff --git a/ui/components/providers/wizard/steps/footer-controls.ts b/ui/components/providers/wizard/steps/footer-controls.ts new file mode 100644 index 0000000000..2ccd9add2f --- /dev/null +++ b/ui/components/providers/wizard/steps/footer-controls.ts @@ -0,0 +1,20 @@ +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 interface WizardFooterConfig { + showBack: boolean; + backLabel: string; + backDisabled?: boolean; + onBack?: () => void; + showAction: boolean; + actionLabel: string; + actionDisabled?: boolean; + actionType: WizardFooterActionType; + actionFormId?: string; + onAction?: () => void; +} diff --git a/ui/components/providers/wizard/steps/index.ts b/ui/components/providers/wizard/steps/index.ts new file mode 100644 index 0000000000..f2028f9dfa --- /dev/null +++ b/ui/components/providers/wizard/steps/index.ts @@ -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"; diff --git a/ui/components/providers/wizard/steps/launch-step.tsx b/ui/components/providers/wizard/steps/launch-step.tsx new file mode 100644 index 0000000000..73f5bed03f --- /dev/null +++ b/ui/components/providers/wizard/steps/launch-step.tsx @@ -0,0 +1,15 @@ +"use client"; + +import { CheckCircle2 } from "lucide-react"; + +export function LaunchStep() { + return ( +
+ +

Provider connected successfully

+

+ Continue with the action button to go to scans. +

+
+ ); +} diff --git a/ui/components/providers/wizard/steps/test-connection-step.tsx b/ui/components/providers/wizard/steps/test-connection-step.tsx new file mode 100644 index 0000000000..0c1ffea896 --- /dev/null +++ b/ui/components/providers/wizard/steps/test-connection-step.tsx @@ -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(null); + const [isLoadingProvider, setIsLoadingProvider] = useState(true); + const [isFormLoading, setIsFormLoading] = useState(false); + const [errorMessage, setErrorMessage] = useState(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 ( +
+ +
+ ); + } + + if (errorMessage || !providerData || !providerId || !providerType) { + return ( +
+

+ {errorMessage || "Unable to load provider details."} +

+
+ ); + } + + return ( + + ); +} diff --git a/ui/components/providers/workflow/provider-title-docs.tsx b/ui/components/providers/workflow/provider-title-docs.tsx index 51081f17ce..9afc788e79 100644 --- a/ui/components/providers/workflow/provider-title-docs.tsx +++ b/ui/components/providers/workflow/provider-title-docs.tsx @@ -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 ( -
-
- {providerType && getProviderLogo(providerType as ProviderType)} - - {providerType - ? getProviderName(providerType as ProviderType) - : "Unknown Provider"} - -
-
-

- {getProviderHelpText(providerType as string).text} -

- - Read the docs - -
+
+ {providerType && getProviderLogo(providerType as ProviderType)} + + {providerType + ? getProviderName(providerType as ProviderType) + : "Unknown Provider"} +
); }; diff --git a/ui/components/scans/no-providers-added.tsx b/ui/components/scans/no-providers-added.tsx index 7e9340a6ad..da9d8bf318 100644 --- a/ui/components/scans/no-providers-added.tsx +++ b/ui/components/scans/no-providers-added.tsx @@ -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 ( -
- - -
- -

- No Cloud Providers Configured -

-
-
-

- No cloud providers have been configured. Start by setting up a - cloud provider. -

-
+ const [open, setOpen] = useState(false); - -
-
-
+ return ( + <> +
+ + +
+ +

+ No Cloud Providers Configured +

+
+
+

+ No cloud providers have been configured. Start by setting up a + cloud provider. +

+
+ + +
+
+
+ + ); };