feat(ui): implement two-phase org setup with discovery polling

This commit is contained in:
alejandrobailo committed 2026-02-20 17:08:56 +01:00
1 parent fea3649dd5
commit e869f4620d
6 files changed
+590 -125

No files matched your search

@@ -1,6 +1,6 @@
"use client";
import { Ban, Box } from "lucide-react";
import { Ban, Box, Boxes } from "lucide-react";
import { RadioCard } from "@/components/providers/radio-card";
@@ -28,7 +28,7 @@ export function AwsMethodSelector({
/>
<RadioCard
icon={Ban}
icon={isCloudEnv ? Boxes : Ban}
title="Add Multiple Accounts With AWS Organizations"
onClick={onSelectOrganizations}
disabled={!isCloudEnv}
@@ -1,40 +1,65 @@
"use client";
import { zodResolver } from "@hookform/resolvers/zod";
import { Loader2 } from "lucide-react";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { Check, Copy, ExternalLink, Loader2 } from "lucide-react";
import { FormEvent, useEffect, useState } from "react";
import { Controller, useForm } from "react-hook-form";
import { z } from "zod";
import {
createOrganization,
createOrganizationSecret,
getDiscovery,
triggerDiscovery,
} from "@/actions/organizations/organizations";
import {
buildOrgTreeData,
getSelectableAccountIds,
} from "@/actions/organizations/organizations.adapter";
import { AWSProviderBadge } from "@/components/icons/providers-badge";
import {
WIZARD_FOOTER_ACTION_TYPE,
WizardFooterConfig,
} from "@/components/providers/wizard/steps/footer-controls";
import { Alert, AlertDescription } from "@/components/shadcn/alert";
import { Button } from "@/components/shadcn/button/button";
import { Checkbox } from "@/components/shadcn/checkbox/checkbox";
import { Input } from "@/components/shadcn/input/input";
import { TreeSpinner } from "@/components/shadcn/tree-view/tree-spinner";
import { getAWSCredentialsTemplateLinks } from "@/lib";
import { useOrgSetupStore } from "@/store/organizations/store";
import {
DISCOVERY_STATUS,
DiscoveryResult,
ORG_SETUP_PHASE,
OrgSetupPhase,
} from "@/types/organizations";
const DISCOVERY_POLL_INTERVAL_MS = 3000;
const DISCOVERY_MAX_RETRIES = 60;
const DEBUG_SCOPE = "[OrgSetupForm]";
const orgSetupSchema = z.object({
organizationName: z
.string()
.min(3, "Organization name must be at least 3 characters"),
organizationName: z.string().trim().optional(),
awsOrgId: z
.string()
.trim()
.min(1, "Organization ID is required")
.regex(
/^o-[a-z0-9]{10,32}$/,
"Must be a valid AWS Organization ID (e.g., o-abc123def4)",
),
roleArn: z
.string()
.trim()
.min(1, "Role ARN is required")
.regex(
/^arn:aws:iam::\d{12}:role\//,
"Must be a valid IAM Role ARN (e.g., arn:aws:iam::123456789012:role/ProwlerOrgRole)",
),
externalId: z.string().min(1, "External ID is required"),
stackSetDeployed: z.boolean().refine((value) => value, {
message: "You must confirm the StackSet deployment before continuing.",
}),
});
type OrgSetupFormData = z.infer<typeof orgSetupSchema>;
@@ -43,22 +68,43 @@ interface OrgSetupFormProps {
onBack: () => void;
onNext: () => void;
onFooterChange: (config: WizardFooterConfig) => void;
onPhaseChange: (phase: OrgSetupPhase) => void;
}
export function OrgSetupForm({
onBack,
onNext,
onFooterChange,
onPhaseChange,
}: OrgSetupFormProps) {
const debugLog = (message: string, payload?: unknown) => {
if (process.env.NODE_ENV === "production") return;
if (payload === undefined) {
console.error(`${DEBUG_SCOPE} ${message}`);
return;
}
console.error(`${DEBUG_SCOPE} ${message}`, payload);
};
const [apiError, setApiError] = useState<string | null>(null);
const { setOrganization, setDiscovery } = useOrgSetupStore();
const [isExternalIdCopied, setIsExternalIdCopied] = useState(false);
const [stackSetExternalId] = useState(() => generateStackSetExternalId());
const [setupPhase, setSetupPhase] = useState<OrgSetupPhase>(
ORG_SETUP_PHASE.DETAILS,
);
const { setOrganization, setDiscovery, setSelectedAccountIds } =
useOrgSetupStore();
const formId = "org-wizard-setup-form";
const {
control,
register,
handleSubmit,
formState: { errors, isSubmitting, isValid },
setError,
watch,
} = useForm<OrgSetupFormData>({
resolver: zodResolver(orgSetupSchema),
mode: "onChange",
@@ -67,68 +113,233 @@ export function OrgSetupForm({
organizationName: "",
awsOrgId: "",
roleArn: "",
externalId: "",
stackSetDeployed: false,
},
});
const awsOrgId = watch("awsOrgId") || "";
const isOrgIdValid = /^o-[a-z0-9]{10,32}$/.test(awsOrgId.trim());
const stackSetQuickLink =
getAWSCredentialsTemplateLinks(stackSetExternalId).cloudformationQuickLink;
useEffect(() => {
onPhaseChange(setupPhase);
}, [onPhaseChange, setupPhase]);
useEffect(() => {
if (setupPhase === ORG_SETUP_PHASE.DETAILS) {
onFooterChange({
showBack: true,
backLabel: "Back",
onBack,
showAction: true,
actionLabel: "Next",
actionDisabled: !isOrgIdValid,
actionType: WIZARD_FOOTER_ACTION_TYPE.SUBMIT,
actionFormId: formId,
});
return;
}
onFooterChange({
showBack: true,
backLabel: "Back",
backDisabled: isSubmitting,
onBack,
onBack: () => setSetupPhase(ORG_SETUP_PHASE.DETAILS),
showAction: true,
actionLabel: "Next",
actionLabel: "Authenticate",
actionDisabled: isSubmitting || !isValid,
actionType: WIZARD_FOOTER_ACTION_TYPE.SUBMIT,
actionFormId: formId,
});
}, [formId, isSubmitting, isValid, onBack, onFooterChange]);
}, [
formId,
isOrgIdValid,
isSubmitting,
isValid,
onBack,
onFooterChange,
setupPhase,
]);
const onSubmit = async (data: OrgSetupFormData) => {
const handleContinueToAccess = () => {
setApiError(null);
// Step 1: Create Organization
const orgFormData = new FormData();
orgFormData.set("name", data.organizationName);
orgFormData.set("externalId", data.awsOrgId);
const orgResult = await createOrganization(orgFormData);
if (orgResult?.error) {
handleServerError(orgResult, "Organization");
if (!isOrgIdValid) {
setError("awsOrgId", {
message: awsOrgId.trim()
? "Must be a valid AWS Organization ID (e.g., o-abc123def4)"
: "Organization ID is required",
});
return;
}
const orgId = orgResult.data.id;
setSetupPhase(ORG_SETUP_PHASE.ACCESS);
};
// Step 2: Create Organization Secret
const secretFormData = new FormData();
secretFormData.set("organizationId", orgId);
secretFormData.set("roleArn", data.roleArn);
secretFormData.set("externalId", data.externalId);
const secretResult = await createOrganizationSecret(secretFormData);
if (secretResult?.error) {
handleServerError(secretResult, "Secret");
const handleFormSubmit = (event: FormEvent<HTMLFormElement>) => {
if (setupPhase === ORG_SETUP_PHASE.DETAILS) {
event.preventDefault();
handleContinueToAccess();
return;
}
// Step 3: Trigger Discovery
const discoveryResult = await triggerDiscovery(orgId);
void handleSubmit(onSubmit)(event);
};
if (discoveryResult?.error) {
setApiError(discoveryResult.error);
return;
const onSubmit = async (data: OrgSetupFormData) => {
try {
setApiError(null);
debugLog("Authenticate submit started", {
awsOrgId: data.awsOrgId,
hasRoleArn: Boolean(data.roleArn),
stackSetDeployed: data.stackSetDeployed,
});
const resolvedOrganizationName =
data.organizationName?.trim() || data.awsOrgId;
// Step 1: Create Organization
const orgFormData = new FormData();
orgFormData.set("name", resolvedOrganizationName);
orgFormData.set("externalId", data.awsOrgId);
const orgResult = await createOrganization(orgFormData);
debugLog("createOrganization response received", {
hasError: Boolean(orgResult?.error),
hasErrorsArray: Boolean(orgResult?.errors?.length),
});
if (orgResult?.error) {
handleServerError(orgResult, "Organization");
return;
}
const orgId = orgResult.data.id;
setOrganization(orgId, resolvedOrganizationName, data.awsOrgId);
// Step 2: Create Organization Secret
const secretFormData = new FormData();
secretFormData.set("organizationId", orgId);
secretFormData.set("roleArn", data.roleArn);
secretFormData.set("externalId", stackSetExternalId);
const secretResult = await createOrganizationSecret(secretFormData);
debugLog("createOrganizationSecret response received", {
hasError: Boolean(secretResult?.error),
hasErrorsArray: Boolean(secretResult?.errors?.length),
});
if (secretResult?.error) {
handleServerError(secretResult, "Secret");
return;
}
// Step 3: Trigger Discovery
const discoveryResult = await triggerDiscovery(orgId);
debugLog("triggerDiscovery response received", {
hasError: Boolean(discoveryResult?.error),
discoveryId: discoveryResult?.data?.id ?? null,
});
if (discoveryResult?.error) {
setApiError(discoveryResult.error);
return;
}
const discoveryId = discoveryResult.data.id;
const resolvedDiscoveryResult = await pollDiscoveryResult(
orgId,
discoveryId,
);
if (!resolvedDiscoveryResult) {
debugLog("pollDiscoveryResult returned null (authentication failed)");
return;
}
const selectableAccountIds = getSelectableAccountIds(
resolvedDiscoveryResult,
);
buildOrgTreeData(resolvedDiscoveryResult);
setDiscovery(discoveryId, resolvedDiscoveryResult);
setSelectedAccountIds(selectableAccountIds);
// Discovery succeeded; advance to next wizard step.
debugLog("Authenticate flow succeeded, advancing to next step", {
discoveryId,
});
onNext();
} catch (error) {
console.error(`${DEBUG_SCOPE} Unexpected authenticate error`, error);
setApiError(
"Authentication failed. Please verify the StackSet deployment and Role ARN, then try again.",
);
}
};
const pollDiscoveryResult = async (
organizationId: string,
discoveryId: string,
): Promise<DiscoveryResult | null> => {
for (let attempt = 0; attempt < DISCOVERY_MAX_RETRIES; attempt += 1) {
debugLog("Polling discovery status", {
attempt: attempt + 1,
maxAttempts: DISCOVERY_MAX_RETRIES,
organizationId,
discoveryId,
});
const result = await getDiscovery(organizationId, discoveryId);
if (result?.error) {
console.error(`${DEBUG_SCOPE} getDiscovery returned error`, {
attempt: attempt + 1,
error: result.error,
});
setApiError(
`Authentication failed. Please verify the StackSet deployment and Role ARN, then try again. ${result.error}`,
);
return null;
}
const status = result.data.attributes.status;
debugLog("Discovery status response", {
attempt: attempt + 1,
status,
});
if (status === DISCOVERY_STATUS.SUCCEEDED) {
debugLog("Discovery succeeded");
return result.data.attributes.result as DiscoveryResult;
}
if (status === DISCOVERY_STATUS.FAILED) {
const backendError = result.data.attributes.error;
console.error(`${DEBUG_SCOPE} Discovery failed`, {
attempt: attempt + 1,
backendError,
});
setApiError(
backendError
? `Authentication failed. Please verify the StackSet deployment and Role ARN, then try again. ${backendError}`
: "Authentication failed. Please verify the StackSet deployment and Role ARN, then try again.",
);
return null;
}
await new Promise((resolve) =>
setTimeout(resolve, DISCOVERY_POLL_INTERVAL_MS),
);
}
const discoveryId = discoveryResult.data.id;
// Store in Zustand and advance
setOrganization(orgId, data.organizationName, data.awsOrgId);
setDiscovery(discoveryId, null as never);
onNext();
setApiError(
"Authentication timed out. Please verify the credentials and try again.",
);
console.error(`${DEBUG_SCOPE} Discovery polling timed out`, {
maxAttempts: DISCOVERY_MAX_RETRIES,
pollIntervalMs: DISCOVERY_POLL_INTERVAL_MS,
});
return null;
};
const handleServerError = (
@@ -138,12 +349,18 @@ export function OrgSetupForm({
},
context: string,
) => {
console.error(`${DEBUG_SCOPE} handleServerError`, {
context,
error: result.error ?? null,
errors: result.errors ?? [],
});
if (result.errors?.length) {
for (const err of result.errors) {
const pointer = err.source?.pointer ?? "";
if (pointer.includes("external_id")) {
if (pointer.includes("external_id") && context === "Organization") {
setError("awsOrgId", { message: err.detail });
setApiError(err.detail);
} else if (pointer.includes("name")) {
setError("organizationName", { message: err.detail });
} else {
@@ -155,92 +372,227 @@ export function OrgSetupForm({
}
};
useEffect(() => {
if (!apiError) return;
document
.getElementById(formId)
?.scrollIntoView({ block: "start", behavior: "smooth" });
}, [apiError, formId]);
return (
<form
id={formId}
onSubmit={handleSubmit(onSubmit)}
onSubmit={handleFormSubmit}
className="flex flex-col gap-5"
>
<div className="flex flex-col gap-1">
<h3 className="text-lg font-semibold">Organization Details</h3>
<p className="text-muted-foreground text-sm">
Enter your AWS Organization details to discover all accounts.
</p>
</div>
{setupPhase === ORG_SETUP_PHASE.DETAILS && (
<div className="flex flex-col gap-6">
<div className="flex items-center gap-4">
<AWSProviderBadge size={32} />
<h3 className="text-base font-semibold">
Amazon Web Services (AWS) / Organization Details
</h3>
</div>
{apiError && (
<div className="border-destructive/50 bg-destructive/10 text-destructive rounded-md border px-4 py-3 text-sm">
{apiError}
<p className="text-muted-foreground text-sm">
Enter the Organization ID for the accounts you want to add to
Prowler.
</p>
</div>
)}
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-1.5">
<label htmlFor="organizationName" className="text-sm font-medium">
Organization Name
</label>
<Input
id="organizationName"
placeholder="e.g. My AWS Organization"
{...register("organizationName")}
/>
{errors.organizationName && (
<span className="text-destructive text-xs">
{errors.organizationName.message}
{setupPhase === ORG_SETUP_PHASE.ACCESS && !isSubmitting && (
<div className="flex flex-col gap-8">
<div className="flex items-center gap-4">
<AWSProviderBadge size={32} />
<h3 className="text-base font-semibold">
Amazon Web Services (AWS) / Authentication Details
</h3>
</div>
</div>
)}
{setupPhase === ORG_SETUP_PHASE.ACCESS && isSubmitting && (
<div className="flex min-h-[220px] items-center justify-center">
<div className="flex items-center gap-3 py-2">
<TreeSpinner className="size-6" />
<p className="text-sm font-medium">Gathering AWS Accounts...</p>
</div>
</div>
)}
{apiError && (
<Alert variant="error">
<AlertDescription className="text-text-error-primary">
{apiError}
</AlertDescription>
</Alert>
)}
{setupPhase === ORG_SETUP_PHASE.DETAILS && (
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-1.5">
<label htmlFor="awsOrgId" className="text-sm font-medium">
Organization ID
</label>
<Input
id="awsOrgId"
placeholder="e.g. o-123456789-abcdefg"
required
aria-required="true"
{...register("awsOrgId")}
/>
{errors.awsOrgId && (
<span className="text-text-error-primary text-xs">
{errors.awsOrgId.message}
</span>
)}
</div>
<div className="flex flex-col gap-1.5">
<label htmlFor="organizationName" className="text-sm font-medium">
Name (optional)
</label>
<Input
id="organizationName"
placeholder=""
{...register("organizationName")}
/>
{errors.organizationName && (
<span className="text-text-error-primary text-xs">
{errors.organizationName.message}
</span>
)}
</div>
<p className="text-muted-foreground text-sm">
If left blank, Prowler will use the Organization name stored in AWS.
</p>
</div>
)}
{setupPhase === ORG_SETUP_PHASE.ACCESS && !isSubmitting && (
<div className="flex flex-col gap-8">
<div className="flex flex-col gap-4">
<p className="text-text-neutral-primary text-sm leading-7 font-normal">
1) Launch the Prowler CloudFormation StackSet in your AWS Console.
</p>
<Button
variant="outline"
size="lg"
className="border-border-input-primary bg-bg-input-primary text-button-tertiary hover:bg-bg-input-primary active:bg-bg-input-primary h-12 w-full justify-start"
asChild
>
<a
href={stackSetQuickLink}
target="_blank"
rel="noopener noreferrer"
>
<ExternalLink className="size-5" />
<span>
Prowler CloudFormation StackSet for AWS Organizations
</span>
</a>
</Button>
</div>
<div className="flex flex-col gap-4">
<p className="text-text-neutral-primary text-sm leading-7 font-normal">
2) Use the following Prowler External ID parameter in the
StackSet.
</p>
<div className="flex items-center gap-3">
<span className="text-text-neutral-tertiary text-xs">
External ID:
</span>
<div className="bg-bg-neutral-tertiary border-border-input-primary flex h-10 max-w-full items-center gap-3 rounded-full border px-4">
<span className="truncate text-xs font-medium">
{stackSetExternalId}
</span>
<button
type="button"
onClick={async () => {
try {
await navigator.clipboard.writeText(stackSetExternalId);
setIsExternalIdCopied(true);
setTimeout(() => setIsExternalIdCopied(false), 1500);
} catch {
// Ignore clipboard errors (e.g., unsupported browser context).
}
}}
className="text-text-neutral-secondary hover:text-text-neutral-primary shrink-0 transition-colors"
aria-label="Copy external ID"
>
{isExternalIdCopied ? (
<Check className="size-4" />
) : (
<Copy className="size-4" />
)}
</button>
</div>
</div>
</div>
<div className="flex flex-col gap-4">
<p className="text-text-neutral-primary text-sm leading-7 font-normal">
3) Copy the Prowler IAM Role ARN from AWS and confirm the StackSet
is successfully deployed by clicking the checkbox below.
</p>
</div>
<div className="flex flex-col gap-1.5">
<label htmlFor="roleArn" className="text-sm font-medium">
Role ARN
</label>
<Input
id="roleArn"
placeholder="e.g. arn:aws:iam::123456789012:role/ProwlerOrgRole"
{...register("roleArn")}
/>
{errors.roleArn && (
<span className="text-text-error-primary text-xs">
{errors.roleArn.message}
</span>
)}
</div>
<p className="text-text-neutral-tertiary text-sm">
* It may take up to 60 seconds for AWS to generate the IAM Role ARN
</p>
<div className="flex items-start gap-4">
<Controller
name="stackSetDeployed"
control={control}
render={({ field }) => (
<>
<Checkbox
id="stackSetDeployed"
className="mt-0.5"
checked={field.value}
onCheckedChange={(checked) =>
field.onChange(Boolean(checked))
}
/>
<label
htmlFor="stackSetDeployed"
className="text-text-neutral-primary text-sm leading-7 font-normal"
>
The StackSet has been successfully deployed in AWS
</label>
</>
)}
/>
</div>
{errors.stackSetDeployed && (
<span className="text-text-error-primary text-xs">
{errors.stackSetDeployed.message}
</span>
)}
</div>
)}
<div className="flex flex-col gap-1.5">
<label htmlFor="awsOrgId" className="text-sm font-medium">
AWS Organization ID
</label>
<Input
id="awsOrgId"
placeholder="e.g. o-abc123def4"
{...register("awsOrgId")}
/>
{errors.awsOrgId && (
<span className="text-destructive text-xs">
{errors.awsOrgId.message}
</span>
)}
</div>
<div className="flex flex-col gap-1.5">
<label htmlFor="roleArn" className="text-sm font-medium">
Role ARN
</label>
<Input
id="roleArn"
placeholder="e.g. arn:aws:iam::123456789012:role/ProwlerOrgRole"
{...register("roleArn")}
/>
{errors.roleArn && (
<span className="text-destructive text-xs">
{errors.roleArn.message}
</span>
)}
</div>
<div className="flex flex-col gap-1.5">
<label htmlFor="externalId" className="text-sm font-medium">
External ID
</label>
<Input
id="externalId"
placeholder="Enter the external ID for role assumption"
{...register("externalId")}
/>
{errors.externalId && (
<span className="text-destructive text-xs">
{errors.externalId.message}
</span>
)}
</div>
</div>
{isSubmitting && (
{setupPhase === ORG_SETUP_PHASE.DETAILS && isSubmitting && (
<div className="text-muted-foreground flex items-center justify-end gap-2 text-sm">
<Loader2 className="size-4 animate-spin" />
Setting up organization...
@@ -249,3 +601,16 @@ export function OrgSetupForm({
</form>
);
}
function generateStackSetExternalId() {
const characters = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
const parts = [3, 4, 4, 3, 6, 6];
return parts
.map((length) =>
Array.from({ length }, () =>
characters.charAt(Math.floor(Math.random() * characters.length)),
).join(""),
)
.join("-");
}
@@ -12,10 +12,16 @@ import { OrgSetupForm } from "@/components/providers/organizations/org-setup-for
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 { 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 {
ORG_SETUP_PHASE,
ORG_WIZARD_STEP,
OrgSetupPhase,
OrgWizardStep,
} from "@/types/organizations";
import {
PROVIDER_WIZARD_MODE,
PROVIDER_WIZARD_STEP,
@@ -24,6 +30,7 @@ import {
} from "@/types/provider-wizard";
import { ProviderType } from "@/types/providers";
import { getOrganizationsStepperOffset } from "./provider-wizard-modal.utils";
import { ConnectStep } from "./steps/connect-step";
import { CredentialsStep } from "./steps/credentials-step";
import {
@@ -96,6 +103,14 @@ export function ProviderWizardModal({
const [providerTypeHint, setProviderTypeHint] = useState<ProviderType | null>(
null,
);
const [orgSetupPhase, setOrgSetupPhase] = useState<OrgSetupPhase>(
ORG_SETUP_PHASE.DETAILS,
);
const scrollHintRefreshToken = `${wizardVariant}-${currentStep}-${orgCurrentStep}-${orgSetupPhase}-${validatePhase}`;
const { containerRef, showScrollHint, handleScroll } = useScrollHint({
enabled: open,
refreshToken: scrollHintRefreshToken,
});
const {
reset: resetProviderWizard,
@@ -138,6 +153,7 @@ export function ProviderWizardModal({
setValidatePhase(VALIDATE_PHASE.DISCOVERY);
setFooterConfig(EMPTY_FOOTER_CONFIG);
setProviderTypeHint(initialData.providerType);
setOrgSetupPhase(ORG_SETUP_PHASE.DETAILS);
return;
}
@@ -149,6 +165,7 @@ export function ProviderWizardModal({
setValidatePhase(VALIDATE_PHASE.DISCOVERY);
setFooterConfig(EMPTY_FOOTER_CONFIG);
setProviderTypeHint(null);
setOrgSetupPhase(ORG_SETUP_PHASE.DETAILS);
}, [
initialData,
open,
@@ -169,6 +186,7 @@ export function ProviderWizardModal({
setValidatePhase(VALIDATE_PHASE.DISCOVERY);
setFooterConfig(EMPTY_FOOTER_CONFIG);
setProviderTypeHint(null);
setOrgSetupPhase(ORG_SETUP_PHASE.DETAILS);
onOpenChange(false);
};
@@ -196,6 +214,7 @@ export function ProviderWizardModal({
setValidatePhase(VALIDATE_PHASE.DISCOVERY);
setFooterConfig(EMPTY_FOOTER_CONFIG);
setProviderTypeHint(null);
setOrgSetupPhase(ORG_SETUP_PHASE.DETAILS);
};
const backToProviderFlow = () => {
@@ -204,6 +223,7 @@ export function ProviderWizardModal({
setCurrentStep(PROVIDER_WIZARD_STEP.CONNECT);
setFooterConfig(EMPTY_FOOTER_CONFIG);
setProviderTypeHint(null);
setOrgSetupPhase(ORG_SETUP_PHASE.DETAILS);
};
const isProviderFlow = wizardVariant === WIZARD_VARIANT.PROVIDER;
@@ -240,6 +260,7 @@ export function ProviderWizardModal({
backLabel: "Back",
onBack: () => {
setOrgCurrentStep(ORG_WIZARD_STEP.SETUP);
setOrgSetupPhase(ORG_SETUP_PHASE.DETAILS);
},
showAction: false,
actionLabel: "Next",
@@ -278,15 +299,22 @@ export function ProviderWizardModal({
) : (
<WizardStepper
currentStep={orgCurrentStep}
stepOffset={1}
stepOffset={getOrganizationsStepperOffset(
orgCurrentStep,
orgSetupPhase,
)}
hasConnectionErrors={hasConnectionErrors}
/>
)}
</div>
<div aria-hidden className="hidden w-[100px] min-w-0 shrink lg:block" />
<div className="flex-1 overflow-hidden">
<div className="minimal-scrollbar h-full w-full overflow-y-auto lg:ml-auto lg:max-w-[620px] xl:max-w-[700px]">
<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)}
@@ -324,9 +352,10 @@ export function ProviderWizardModal({
onBack={backToProviderFlow}
onNext={() => {
setOrgCurrentStep(ORG_WIZARD_STEP.VALIDATE);
setValidatePhase(VALIDATE_PHASE.DISCOVERY);
setValidatePhase(VALIDATE_PHASE.SELECTION);
}}
onFooterChange={setFooterConfig}
onPhaseChange={setOrgSetupPhase}
/>
)}
@@ -346,6 +375,7 @@ export function ProviderWizardModal({
<OrgAccountSelection
onBack={() => {
setOrgCurrentStep(ORG_WIZARD_STEP.SETUP);
setOrgSetupPhase(ORG_SETUP_PHASE.DETAILS);
}}
onNext={() => {
setValidatePhase(VALIDATE_PHASE.TESTING);
@@ -382,6 +412,17 @@ export function ProviderWizardModal({
/>
)}
</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>
@@ -0,0 +1,34 @@
import { describe, expect, it } from "vitest";
import { ORG_SETUP_PHASE, ORG_WIZARD_STEP } from "@/types/organizations";
import { getOrganizationsStepperOffset } 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);
});
});
@@ -0,0 +1,17 @@
import {
ORG_SETUP_PHASE,
ORG_WIZARD_STEP,
OrgSetupPhase,
OrgWizardStep,
} from "@/types/organizations";
export function getOrganizationsStepperOffset(
currentStep: OrgWizardStep,
setupPhase: OrgSetupPhase,
) {
if (currentStep === ORG_WIZARD_STEP.SETUP) {
return setupPhase === ORG_SETUP_PHASE.ACCESS ? 1 : 0;
}
return 1;
}
+8
View File
@@ -52,6 +52,14 @@ export const ORG_WIZARD_STEP = {
export type OrgWizardStep =
(typeof ORG_WIZARD_STEP)[keyof typeof ORG_WIZARD_STEP];
export const ORG_SETUP_PHASE = {
DETAILS: "details",
ACCESS: "access",
} as const;
export type OrgSetupPhase =
(typeof ORG_SETUP_PHASE)[keyof typeof ORG_SETUP_PHASE];
// ─── Discovery Result Interfaces ──────────────────────────────────────────────
export interface AccountRegistration {