feat(ui): add provider wizard modal with step wrappers

This commit is contained in:
alejandrobailo
2026-02-19 22:55:47 +01:00
parent cfaec55f0a
commit 518fd1bf2d
13 changed files with 1043 additions and 87 deletions
@@ -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} />
</>
);
};
+3 -3
View File
@@ -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",
)}
>
<div className="size-[18px] shrink-0 rounded-full border border-border-neutral-primary bg-bg-input-primary shadow-xs" />
<div className="border-border-neutral-primary bg-bg-input-primary size-[18px] shrink-0 rounded-full border shadow-xs" />
<div className="flex min-w-0 flex-1 items-center gap-1.5">
<Icon
@@ -102,7 +102,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"
@@ -113,19 +113,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) => {
@@ -140,19 +132,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,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<ProviderProps> {
export function DataTableRowActions<ProviderProps>({
row,
}: DataTableRowActionsProps<ProviderProps>) {
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<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 +96,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,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<WizardVariant>(
WIZARD_VARIANT.PROVIDER,
);
const [currentStep, setCurrentStep] = useState<ProviderWizardStep>(
PROVIDER_WIZARD_STEP.CONNECT,
);
const [orgCurrentStep, setOrgCurrentStep] = useState<OrgWizardStep>(
ORG_WIZARD_STEP.SETUP,
);
const [validatePhase, setValidatePhase] = useState<ValidatePhase>(
VALIDATE_PHASE.DISCOVERY,
);
const [footerConfig, setFooterConfig] =
useState<WizardFooterConfig>(EMPTY_FOOTER_CONFIG);
const [providerTypeHint, setProviderTypeHint] = useState<ProviderType | null>(
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 (
<Modal
open={open}
onOpenChange={handleDialogOpenChange}
size="4xl"
className="flex !h-[90vh] !max-h-[90vh] !min-h-[90vh] !max-w-[60vw] !min-w-[70vw] flex-col overflow-hidden p-8"
>
<DialogHeader className="gap-2 p-0">
<DialogTitle className="text-lg font-semibold">
Adding A Cloud Provider
</DialogTitle>
<div className="text-muted-foreground flex 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-8 flex min-h-0 flex-1 justify-between overflow-hidden">
<div className="box-border w-[328px] shrink-0">
{isProviderFlow ? (
<WizardStepper currentStep={currentStep} />
) : (
<WizardStepper
currentStep={orgCurrentStep}
stepOffset={1}
hasConnectionErrors={hasConnectionErrors}
/>
)}
</div>
<div className="flex-1 overflow-hidden">
<div className="minimal-scrollbar ml-auto h-full w-full max-w-[620px] overflow-y-auto">
{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);
setValidatePhase(VALIDATE_PHASE.DISCOVERY);
}}
onFooterChange={setFooterConfig}
/>
)}
{!isProviderFlow &&
orgCurrentStep === ORG_WIZARD_STEP.VALIDATE &&
validatePhase === VALIDATE_PHASE.DISCOVERY && (
<OrgDiscoveryLoader
onDiscoveryComplete={() => {
setValidatePhase(VALIDATE_PHASE.SELECTION);
}}
/>
)}
{!isProviderFlow &&
orgCurrentStep === ORG_WIZARD_STEP.VALIDATE &&
validatePhase === VALIDATE_PHASE.SELECTION && (
<OrgAccountSelection
onBack={() => {
setOrgCurrentStep(ORG_WIZARD_STEP.SETUP);
}}
onNext={() => {
setValidatePhase(VALIDATE_PHASE.TESTING);
}}
onFooterChange={setFooterConfig}
/>
)}
{!isProviderFlow &&
orgCurrentStep === ORG_WIZARD_STEP.VALIDATE &&
validatePhase === VALIDATE_PHASE.TESTING && (
<OrgConnectionTest
onBack={() => {
setValidatePhase(VALIDATE_PHASE.SELECTION);
}}
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);
setValidatePhase(VALIDATE_PHASE.TESTING);
}}
onFooterChange={setFooterConfig}
/>
)}
</div>
</div>
</div>
{(resolvedFooterConfig.showBack || 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>
{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,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;
}}
/>
);
}
@@ -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 (
<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}
/>
);
}
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 (
<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>
);
}
@@ -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;
}
@@ -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";
@@ -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>
);
}
@@ -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}
/>
);
}
@@ -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>
);
};
+34 -28
View File
@@ -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} />
</>
);
};