mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-10-30 23:34:27 +00:00
609 lines
21 KiB
TypeScript
609 lines
21 KiB
TypeScript
"use client";
|
|
|
|
import { zodResolver } from "@hookform/resolvers/zod";
|
|
import { Check, Copy, ExternalLink } from "lucide-react";
|
|
import { useSession } from "next-auth/react";
|
|
import type { FormEvent } from "react";
|
|
import { useEffect, useRef, useState } from "react";
|
|
import { Controller, useForm } from "react-hook-form";
|
|
import { z } from "zod";
|
|
|
|
import { updateOrganizationName } from "@/actions/organizations/organizations";
|
|
import { AWSProviderBadge } from "@/components/icons/providers-badge";
|
|
import type { WizardFooterConfig } from "@/components/providers/wizard/steps/footer-controls";
|
|
import { WIZARD_FOOTER_ACTION_TYPE } from "@/components/providers/wizard/steps/footer-controls";
|
|
import type { OrgWizardIntent } from "@/components/providers/wizard/types";
|
|
import { ORG_WIZARD_INTENT } from "@/components/providers/wizard/types";
|
|
import { WizardInputField } from "@/components/providers/workflow/forms/fields";
|
|
import { useToast } from "@/components/shadcn";
|
|
import { Alert, AlertDescription } from "@/components/shadcn/alert";
|
|
import { Button } from "@/components/shadcn/button/button";
|
|
import { Checkbox } from "@/components/shadcn/checkbox/checkbox";
|
|
import { Form } from "@/components/shadcn/form";
|
|
import { Spinner } from "@/components/shadcn/spinner/spinner";
|
|
import { getAWSOrgDeploymentQuickLink } from "@/lib";
|
|
import { organizationNameFallbackHint } from "@/lib/organizations";
|
|
import { useOrgSetupStore } from "@/store/organizations/store";
|
|
import type { OrgSetupPhase } from "@/types/organizations";
|
|
import { ORG_SETUP_PHASE, ORGANIZATION_TYPE } from "@/types/organizations";
|
|
|
|
import { DiscoveryTimeoutNotice } from "./discovery-timeout-notice";
|
|
import { useOrgSetupSubmission } from "./hooks/use-org-setup-submission";
|
|
import { SecretReplaceWarningModal } from "./secret-replace-warning-modal";
|
|
|
|
const orgSetupSchema = z.object({
|
|
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, "IAM 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/ProwlerScan)",
|
|
),
|
|
// OU or root id the StackSet deploys to. UI-only: used to build the
|
|
// CloudFormation quick-create link, not sent to the backend. Its format is
|
|
// validated inline (isOrgUnitIdValid) to gate the deployment button, so a
|
|
// malformed value never blocks the Authenticate submit.
|
|
organizationalUnitId: z.string().trim().optional(),
|
|
deployFromDelegatedAdmin: z.boolean().optional(),
|
|
stackSetDeployed: z.boolean().refine((value) => value, {
|
|
error: "You must confirm the deployment before continuing.",
|
|
}),
|
|
});
|
|
|
|
type OrgSetupFormData = z.infer<typeof orgSetupSchema>;
|
|
|
|
interface OrgSetupFormInitialValues {
|
|
organizationName: string;
|
|
awsOrgId: string;
|
|
}
|
|
|
|
interface OrgSetupFormProps {
|
|
onBack: () => void;
|
|
onClose?: () => void;
|
|
onNext: () => void;
|
|
onFooterChange: (config: WizardFooterConfig) => void;
|
|
onPhaseChange: (phase: OrgSetupPhase) => void;
|
|
initialPhase?: OrgSetupPhase;
|
|
initialValues?: OrgSetupFormInitialValues;
|
|
intent?: OrgWizardIntent;
|
|
}
|
|
|
|
export function OrgSetupForm({
|
|
onBack,
|
|
onClose,
|
|
onNext,
|
|
onFooterChange,
|
|
onPhaseChange,
|
|
initialPhase = ORG_SETUP_PHASE.DETAILS,
|
|
initialValues,
|
|
intent = ORG_WIZARD_INTENT.FULL,
|
|
}: OrgSetupFormProps) {
|
|
const { data: session } = useSession();
|
|
const stackSetExternalId = session?.tenantId ?? "";
|
|
const { organizationId } = useOrgSetupStore();
|
|
const { toast } = useToast();
|
|
const COPY_RESET_TIMEOUT = 1500;
|
|
const [isExternalIdCopied, setIsExternalIdCopied] = useState(false);
|
|
const externalIdCopyTimer = useRef<ReturnType<typeof setTimeout>>(undefined);
|
|
|
|
// Copies text and flips the copied flag back after the timeout, without effects
|
|
const copyWithFeedback = (
|
|
text: string,
|
|
setCopied: (copied: boolean) => void,
|
|
timerRef: { current: ReturnType<typeof setTimeout> | undefined },
|
|
) => {
|
|
navigator.clipboard.writeText(text).then(() => {
|
|
setCopied(true);
|
|
clearTimeout(timerRef.current);
|
|
timerRef.current = setTimeout(() => setCopied(false), COPY_RESET_TIMEOUT);
|
|
});
|
|
};
|
|
|
|
const copyExternalId = (text: string) =>
|
|
copyWithFeedback(text, setIsExternalIdCopied, externalIdCopyTimer);
|
|
const [setupPhase, setSetupPhase] = useState<OrgSetupPhase>(initialPhase);
|
|
const [isSaving, setIsSaving] = useState(false);
|
|
const formId = "org-wizard-setup-form";
|
|
const formRef = useRef<HTMLFormElement>(null);
|
|
|
|
const isReadOnlyOrgId = Boolean(initialValues?.awsOrgId);
|
|
|
|
const form = useForm<OrgSetupFormData>({
|
|
resolver: zodResolver(orgSetupSchema),
|
|
mode: "onChange",
|
|
reValidateMode: "onChange",
|
|
defaultValues: {
|
|
organizationName: initialValues?.organizationName ?? "",
|
|
awsOrgId: initialValues?.awsOrgId ?? "",
|
|
roleArn: "",
|
|
organizationalUnitId: "",
|
|
deployFromDelegatedAdmin: false,
|
|
stackSetDeployed: false,
|
|
},
|
|
});
|
|
const {
|
|
control,
|
|
handleSubmit,
|
|
formState: { errors, isSubmitting, isValid },
|
|
setError,
|
|
watch,
|
|
} = form;
|
|
|
|
const awsOrgId = watch("awsOrgId") || "";
|
|
const isOrgIdValid = /^o-[a-z0-9]{10,32}$/.test(awsOrgId.trim());
|
|
|
|
const organizationalUnitId = watch("organizationalUnitId") || "";
|
|
const deployFromDelegatedAdmin = watch("deployFromDelegatedAdmin") || false;
|
|
const deploymentAccountName = deployFromDelegatedAdmin
|
|
? "delegated administrator account"
|
|
: "management account";
|
|
const deploymentAccountLabel = deployFromDelegatedAdmin
|
|
? "Delegated Administrator Account"
|
|
: "Management Account";
|
|
const isOrgUnitIdValid =
|
|
/^(ou-[a-z0-9]{4,32}-[a-z0-9]{8,32}|r-[a-z0-9]{4,32})$/.test(
|
|
organizationalUnitId.trim(),
|
|
);
|
|
const orgQuickLink =
|
|
stackSetExternalId && isOrgUnitIdValid
|
|
? getAWSOrgDeploymentQuickLink({
|
|
externalId: stackSetExternalId,
|
|
organizationalUnitId: organizationalUnitId.trim(),
|
|
deployFromDelegatedAdmin,
|
|
})
|
|
: null;
|
|
|
|
const {
|
|
apiError,
|
|
setApiError,
|
|
submitOrganizationSetup,
|
|
replaceSecretWarning,
|
|
confirmSecretReplace,
|
|
cancelSecretReplace,
|
|
discoveryTimedOut,
|
|
discoveryFailed,
|
|
isSubmissionPending,
|
|
keepWaitingForDiscovery,
|
|
retryDiscovery,
|
|
} = useOrgSetupSubmission({
|
|
stackSetExternalId,
|
|
onNext,
|
|
setFieldError: (field, message) => {
|
|
switch (field) {
|
|
case "organizationName":
|
|
case "awsOrgId":
|
|
setError(field, { message });
|
|
return true;
|
|
default:
|
|
return false;
|
|
}
|
|
},
|
|
});
|
|
|
|
// `isSubmitting` only covers a submit react-hook-form started itself, not the
|
|
// chain re-entered by confirming a replacement, keeping waiting or retrying.
|
|
const isBusy = isSubmitting || isSubmissionPending;
|
|
|
|
useEffect(() => {
|
|
onPhaseChange(setupPhase);
|
|
}, [onPhaseChange, setupPhase]);
|
|
|
|
useEffect(() => {
|
|
if (setupPhase === ORG_SETUP_PHASE.DETAILS) {
|
|
const isEditName = intent === ORG_WIZARD_INTENT.EDIT_NAME;
|
|
onFooterChange({
|
|
showBack: true,
|
|
backLabel: "Back",
|
|
onBack,
|
|
showAction: true,
|
|
actionLabel: isEditName ? "Save" : "Next",
|
|
actionDisabled: isEditName ? isSaving : !isOrgIdValid,
|
|
actionType: WIZARD_FOOTER_ACTION_TYPE.SUBMIT,
|
|
actionFormId: formId,
|
|
});
|
|
return;
|
|
}
|
|
|
|
const isEditCredentials = intent === ORG_WIZARD_INTENT.EDIT_CREDENTIALS;
|
|
onFooterChange({
|
|
showBack: !isEditCredentials,
|
|
backLabel: "Back",
|
|
backDisabled: isBusy,
|
|
onBack: () => setSetupPhase(ORG_SETUP_PHASE.DETAILS),
|
|
showAction: true,
|
|
actionLabel: "Authenticate",
|
|
actionDisabled: isBusy || !isValid || !stackSetExternalId,
|
|
actionType: WIZARD_FOOTER_ACTION_TYPE.SUBMIT,
|
|
actionFormId: formId,
|
|
});
|
|
}, [
|
|
formId,
|
|
intent,
|
|
isBusy,
|
|
isOrgIdValid,
|
|
isSaving,
|
|
isValid,
|
|
onBack,
|
|
onFooterChange,
|
|
stackSetExternalId,
|
|
setupPhase,
|
|
]);
|
|
|
|
const handleContinueToAccess = () => {
|
|
setApiError(null);
|
|
|
|
if (!isOrgIdValid) {
|
|
setError("awsOrgId", {
|
|
message: awsOrgId.trim()
|
|
? "Must be a valid AWS Organization ID (e.g., o-abc123def4)"
|
|
: "Organization ID is required",
|
|
});
|
|
return;
|
|
}
|
|
|
|
setSetupPhase(ORG_SETUP_PHASE.ACCESS);
|
|
};
|
|
|
|
const handleSaveNameOnly = async () => {
|
|
if (!organizationId) return;
|
|
setIsSaving(true);
|
|
const name = form.getValues("organizationName")?.trim() || "";
|
|
|
|
const result = await updateOrganizationName(organizationId, name);
|
|
|
|
setIsSaving(false);
|
|
|
|
if (result?.error || result?.errors) {
|
|
const errorMsg =
|
|
result.errors?.[0]?.detail ?? result.error ?? "Failed to update name";
|
|
toast({
|
|
variant: "destructive",
|
|
title: "Oops! Something went wrong",
|
|
description: errorMsg,
|
|
});
|
|
return;
|
|
}
|
|
|
|
toast({
|
|
title: "Success!",
|
|
description: "Organization name updated successfully.",
|
|
});
|
|
onClose?.();
|
|
};
|
|
|
|
const handleFormSubmit = (event: FormEvent<HTMLFormElement>) => {
|
|
if (setupPhase === ORG_SETUP_PHASE.DETAILS) {
|
|
event.preventDefault();
|
|
|
|
if (intent === ORG_WIZARD_INTENT.EDIT_NAME) {
|
|
void handleSaveNameOnly();
|
|
return;
|
|
}
|
|
|
|
handleContinueToAccess();
|
|
return;
|
|
}
|
|
|
|
void handleSubmit((data) =>
|
|
submitOrganizationSetup({ ...data, orgType: ORGANIZATION_TYPE.AWS }),
|
|
)(event);
|
|
};
|
|
|
|
useEffect(() => {
|
|
if (!apiError) return;
|
|
formRef.current?.scrollIntoView({ block: "start", behavior: "smooth" });
|
|
}, [apiError]);
|
|
|
|
return (
|
|
<Form {...form}>
|
|
<SecretReplaceWarningModal
|
|
warning={replaceSecretWarning}
|
|
onConfirm={confirmSecretReplace}
|
|
onCancel={cancelSecretReplace}
|
|
/>
|
|
<form
|
|
id={formId}
|
|
ref={formRef}
|
|
onSubmit={handleFormSubmit}
|
|
className="flex flex-col gap-5"
|
|
>
|
|
{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>
|
|
|
|
<p className="text-muted-foreground text-sm">
|
|
Enter the Organization ID for the accounts you want to add to
|
|
Prowler.
|
|
</p>
|
|
</div>
|
|
)}
|
|
|
|
{setupPhase === ORG_SETUP_PHASE.ACCESS && (
|
|
<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 && isBusy && (
|
|
<div className="flex min-h-[220px] items-center justify-center">
|
|
<div className="flex items-center gap-3 py-2">
|
|
<Spinner 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.ACCESS &&
|
|
discoveryTimedOut &&
|
|
!isBusy && (
|
|
<DiscoveryTimeoutNotice
|
|
onKeepWaiting={() => void keepWaitingForDiscovery()}
|
|
onRetry={() => void retryDiscovery()}
|
|
/>
|
|
)}
|
|
|
|
{setupPhase === ORG_SETUP_PHASE.ACCESS &&
|
|
discoveryFailed &&
|
|
!isBusy && (
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
size="sm"
|
|
className="self-start"
|
|
onClick={() => void retryDiscovery()}
|
|
>
|
|
Retry discovery
|
|
</Button>
|
|
)}
|
|
|
|
{setupPhase === ORG_SETUP_PHASE.DETAILS && (
|
|
<div className="flex flex-col gap-4">
|
|
<WizardInputField
|
|
control={control}
|
|
name="awsOrgId"
|
|
label="Organization ID"
|
|
labelPlacement="outside"
|
|
placeholder="e.g. o-123456789-abcdefg"
|
|
isRequired
|
|
normalizeValue={(value) => value.toLowerCase()}
|
|
autoCapitalize="none"
|
|
autoCorrect="off"
|
|
spellCheck={false}
|
|
isReadOnly={isReadOnlyOrgId}
|
|
isDisabled={isReadOnlyOrgId}
|
|
/>
|
|
|
|
<WizardInputField
|
|
control={control}
|
|
name="organizationName"
|
|
label="Name (optional)"
|
|
labelPlacement="outside"
|
|
placeholder=""
|
|
isRequired={false}
|
|
/>
|
|
|
|
<p className="text-muted-foreground text-sm">
|
|
{organizationNameFallbackHint(ORGANIZATION_TYPE.AWS)}
|
|
</p>
|
|
</div>
|
|
)}
|
|
|
|
{setupPhase === ORG_SETUP_PHASE.ACCESS && !isBusy && (
|
|
<div className="flex flex-col gap-8">
|
|
{/* External ID - shown first for both deployment steps */}
|
|
<div className="flex flex-col gap-4">
|
|
<p className="text-text-neutral-primary text-sm leading-7 font-normal">
|
|
Use the following <strong>External ID</strong> when deploying
|
|
the CloudFormation Stack and 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 ||
|
|
"Loading organization external ID..."}
|
|
</span>
|
|
<button
|
|
type="button"
|
|
disabled={!stackSetExternalId}
|
|
onClick={() => copyExternalId(stackSetExternalId)}
|
|
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>
|
|
|
|
{/* Step 1: Choose the deployment target */}
|
|
<div className="flex flex-col gap-4">
|
|
<p className="text-text-neutral-primary text-sm leading-7 font-normal">
|
|
1) Choose the AWS <strong>Organizational Unit</strong> (or root)
|
|
to deploy to. Prowler creates the IAM Role in your deployment
|
|
account and rolls it out to every member account under this
|
|
target.
|
|
</p>
|
|
<WizardInputField
|
|
control={control}
|
|
name="organizationalUnitId"
|
|
label="Organizational Unit or Root ID"
|
|
labelPlacement="outside"
|
|
placeholder="e.g. r-abcd or ou-abcd-1a2b3c4d"
|
|
isRequired={false}
|
|
normalizeValue={(value) => value.toLowerCase()}
|
|
autoCapitalize="none"
|
|
autoCorrect="off"
|
|
spellCheck={false}
|
|
/>
|
|
<p className="text-text-neutral-tertiary text-xs leading-5">
|
|
Find this in the AWS Organizations console. Use your{" "}
|
|
<strong>root ID</strong> (starts with <code>r-</code>) to deploy
|
|
to the whole organization, or an <strong>OU ID</strong> (starts
|
|
with <code>ou-</code>) to target a specific unit.
|
|
</p>
|
|
<div className="flex items-center gap-3">
|
|
<Controller
|
|
name="deployFromDelegatedAdmin"
|
|
control={control}
|
|
render={({ field }) => (
|
|
<>
|
|
<Checkbox
|
|
id="deployFromDelegatedAdmin"
|
|
size="sm"
|
|
checked={field.value}
|
|
onCheckedChange={(checked) =>
|
|
field.onChange(Boolean(checked))
|
|
}
|
|
/>
|
|
<label
|
|
htmlFor="deployFromDelegatedAdmin"
|
|
className="text-text-neutral-secondary text-sm leading-5 font-medium"
|
|
>
|
|
I'm deploying from a Delegated Administrator
|
|
Account (not the Management Account)
|
|
</label>
|
|
</>
|
|
)}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Step 2: Single CloudFormation Stack (role + StackSet) */}
|
|
<div className="flex flex-col gap-4">
|
|
<p className="text-text-neutral-primary text-sm leading-7 font-normal">
|
|
2) Create the CloudFormation Stack in your{" "}
|
|
<strong>{deploymentAccountName}</strong>. It deploys the
|
|
ProwlerScan IAM Role and a service-managed StackSet that rolls
|
|
the IAM Role out to your member accounts in one step.
|
|
</p>
|
|
{orgQuickLink ? (
|
|
<Button
|
|
variant="default"
|
|
size="xl"
|
|
className="w-full justify-start"
|
|
asChild
|
|
>
|
|
<a
|
|
href={orgQuickLink}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
>
|
|
<ExternalLink className="size-5" />
|
|
<span>{`Create Stack in ${deploymentAccountLabel}`}</span>
|
|
</a>
|
|
</Button>
|
|
) : (
|
|
<Button
|
|
type="button"
|
|
variant="default"
|
|
size="xl"
|
|
className="w-full justify-start"
|
|
disabled
|
|
>
|
|
<ExternalLink className="size-5" />
|
|
<span>{`Create Stack in ${deploymentAccountLabel}`}</span>
|
|
</Button>
|
|
)}
|
|
{!isOrgUnitIdValid && (
|
|
<p className="text-text-error-primary text-xs leading-5">
|
|
Enter a valid Organizational Unit or Root ID above to enable
|
|
deployment.
|
|
</p>
|
|
)}
|
|
</div>
|
|
|
|
{/* Step 3: Role ARN + confirm */}
|
|
<div className="flex flex-col gap-4">
|
|
<p className="text-text-neutral-primary text-sm leading-7 font-normal">
|
|
3) Paste the {deploymentAccountName} IAM Role ARN and confirm
|
|
the deployment is complete.
|
|
</p>
|
|
</div>
|
|
|
|
<WizardInputField
|
|
control={control}
|
|
name="roleArn"
|
|
label={`${deploymentAccountLabel} IAM Role ARN`}
|
|
labelPlacement="outside"
|
|
placeholder="e.g. arn:aws:iam::123456789012:role/ProwlerScan"
|
|
isRequired={false}
|
|
requiredIndicator
|
|
/>
|
|
|
|
<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-center gap-3">
|
|
<Controller
|
|
name="stackSetDeployed"
|
|
control={control}
|
|
render={({ field }) => (
|
|
<>
|
|
<Checkbox
|
|
id="stackSetDeployed"
|
|
size="sm"
|
|
checked={field.value}
|
|
onCheckedChange={(checked) =>
|
|
field.onChange(Boolean(checked))
|
|
}
|
|
/>
|
|
<label
|
|
htmlFor="stackSetDeployed"
|
|
className="text-text-neutral-tertiary text-xs leading-5 font-normal"
|
|
>
|
|
The Stack has been successfully deployed in AWS
|
|
<span className="text-text-error-primary">*</span>
|
|
</label>
|
|
</>
|
|
)}
|
|
/>
|
|
</div>
|
|
{errors.stackSetDeployed && (
|
|
<span className="text-text-error-primary text-xs">
|
|
{errors.stackSetDeployed.message}
|
|
</span>
|
|
)}
|
|
</div>
|
|
)}
|
|
</form>
|
|
</Form>
|
|
);
|
|
}
|