Files

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&apos;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>
);
}