mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-10-25 21:04:19 +00:00
feat(ui): implement two-phase org setup with discovery polling
This commit is contained in:
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;
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
Reference in new issue
Block a user