Compare commits

...

59 Commits

Author SHA1 Message Date
alejandrobailo
7ca08a8367 fix(ui): reuse provider secret on credentials retry
- Switch credentials step to update flow whenever a provider secret exists

- Persist loaded provider secret id during connection test step

- Add regression tests for add->retry flow after failed role authentication
2026-02-25 10:17:57 +01:00
alejandrobailo
c05ab7a388 fix(ui): use ResizeObserver in useScrollHint to prevent false positives
Replace requestAnimationFrame + window resize listener with
ResizeObserver on the container element. The single rAF fired before
content was fully laid out, causing the scroll hint to appear when
there was no overflow. Bump threshold from 2px to 4px to avoid
sub-pixel rounding noise.
2026-02-24 16:30:44 +01:00
alejandrobailo
82da861ae5 chore(ui): remove transient implementation planning doc 2026-02-24 15:18:28 +01:00
alejandrobailo
f788e5699e fix(ui): improve wizard modal and page object selectors
Use filter with heading content for wizard modal selector. Add
single-account button handling in fillAWSProviderDetails.
2026-02-24 15:17:41 +01:00
alejandrobailo
591405e3dc test(ui): improve auth and invitation E2E test stability
Simplify auth middleware test to use clearCookies and null session
assertion. Remove unnecessary login and navigation from session error
test. Fix InvitationsPage role selector with .or() fallback.
2026-02-24 15:17:37 +01:00
alejandrobailo
2ba2e7b5ff fix(ui): limit concurrent scan launches in launchOrganizationScans
Replace unbounded Promise.allSettled with runWithConcurrencyLimit
capped at 5 concurrent requests. Move error handling into the worker
callback. Add unit test verifying concurrency ceiling.
2026-02-24 15:17:33 +01:00
alejandrobailo
54e2fe76a8 fix(ui): validate path identifiers in organization server actions
Add validatePathIdentifier helper and encodeURIComponent for all
user-controlled path segments. Move revalidatePath after success
check in applyDiscovery. Add unit tests for input rejection and
conditional revalidation.
2026-02-24 15:17:29 +01:00
alejandrobailo
8c9ebfe35f fix(ui): format showAction and update E2E page object 2026-02-24 13:52:04 +01:00
alejandrobailo
de938afd88 test(ui): expand account selection flow and component tests 2026-02-24 13:51:58 +01:00
alejandrobailo
fd530708f9 fix(ui): use ApplyStatus type in adapter test cast 2026-02-24 13:20:34 +01:00
alejandrobailo
d8e467b8ad chore(ui): include test files in TypeScript checking 2026-02-24 13:01:08 +01:00
alejandrobailo
dd76aeea07 test(ui): add account selection flow and component tests 2026-02-24 13:01:04 +01:00
alejandrobailo
38bb2f1e36 fix(ui): keep test button visible during account selection 2026-02-24 13:01:00 +01:00
alejandrobailo
b39113829c fix(ui): wire cloudflare selector into modal credentials flow 2026-02-24 12:11:22 +01:00
alejandrobailo
1ed983ab66 merge: resolve origin/master into feat/PROWLER-1091 2026-02-24 12:09:54 +01:00
alejandrobailo
d93068a41b docs(ui): remove transient planning documents 2026-02-24 12:05:08 +01:00
alejandrobailo
8deb090ad5 test(ui): add tests for hooks, stores, and adapters 2026-02-24 12:05:04 +01:00
alejandrobailo
7437b8af8b feat(ui): add toast action link to org launch scan 2026-02-24 12:04:57 +01:00
alejandrobailo
411e5dee67 fix(ui): add disabled bg-transparent to link button 2026-02-24 12:04:52 +01:00
alejandrobailo
e88cf5edf1 fix(ui): add dynamic modal title and validation mode 2026-02-24 12:04:48 +01:00
alejandrobailo
9adbeea035 fix(ui): use design tokens in wizard stepper 2026-02-24 12:04:42 +01:00
alejandrobailo
f390c23ac2 fix(ui): remove as-any casts and fix type safety 2026-02-24 12:04:38 +01:00
alejandrobailo
6050f86e79 fix(ui): use sessionStorage for wizard stores 2026-02-24 12:04:33 +01:00
alejandrobailo
2f74f6fb2e refactor(ui): extract wizard and org hooks from components 2026-02-24 12:04:28 +01:00
alejandrobailo
8399557128 docs(ui): update org docs for modal-based flow 2026-02-23 16:50:31 +01:00
alejandrobailo
1147c0cef4 test(ui): update providers page object for modal wizard 2026-02-23 16:50:27 +01:00
alejandrobailo
45619079b7 feat(ui): add concurrency limit and provider UID mapping 2026-02-23 16:50:22 +01:00
alejandrobailo
5f22761d7d refactor(ui): remove discovery loader and simplify wizard 2026-02-23 16:50:16 +01:00
alejandrobailo
f38908c1d9 refactor(ui): extract account selection utils and tests 2026-02-23 16:50:10 +01:00
alejandrobailo
bcba2be6ba refactor(ui): extract launchOrganizationScans server action 2026-02-23 16:03:49 +01:00
alejandrobailo
8d9912d3c2 style(ui): fix prettier formatting in tree-item and stepper 2026-02-23 16:03:44 +01:00
alejandrobailo
89133550b9 fix(ui): preserve setup phase on wizard back navigation 2026-02-23 15:56:25 +01:00
alejandrobailo
817349fa3e feat(ui): add scan schedule selector to org launch step 2026-02-23 15:56:20 +01:00
alejandrobailo
f525a120cd fix(ui): resolve stale closure in account selection footer 2026-02-23 15:56:15 +01:00
alejandrobailo
ea0b8a107e docs(ui): update org plan for merged selection and test step 2026-02-23 15:25:30 +01:00
alejandrobailo
e4f98c72b5 refactor(ui): merge connection test into account selection step 2026-02-23 15:25:17 +01:00
alejandrobailo
64193376b5 feat(ui): add secondary action slot to wizard footer 2026-02-23 15:24:20 +01:00
alejandrobailo
a248ac6788 feat(ui): add error tooltip and status icon support to tree-view 2026-02-23 15:23:53 +01:00
alejandrobailo
0dd5401d95 refactor(ui): make org setup idempotent with tenant external ID 2026-02-23 12:30:25 +01:00
alejandrobailo
ac6b6be009 feat(ui): add org lookup and secret update server actions 2026-02-23 12:30:20 +01:00
alejandrobailo
e869f4620d feat(ui): implement two-phase org setup with discovery polling 2026-02-20 17:08:56 +01:00
alejandrobailo
fea3649dd5 feat(ui): add useScrollHint hook for overflow containers 2026-02-20 17:08:51 +01:00
alejandrobailo
81f2ff915c style(ui): update checkbox checked state to tertiary color 2026-02-20 17:08:45 +01:00
alejandrobailo
8678a5ff31 style(ui): make provider wizard modal responsive 2026-02-20 11:21:51 +01:00
alejandrobailo
7feee41f68 style(ui): update AWS organizations CTA badge 2026-02-20 11:21:47 +01:00
alejandrobailo
d9a142a445 refactor(ui): remove old route-based provider flow 2026-02-19 22:55:54 +01:00
alejandrobailo
518fd1bf2d feat(ui): add provider wizard modal with step wrappers 2026-02-19 22:55:47 +01:00
alejandrobailo
cfaec55f0a refactor(ui): add callback injection to workflow forms 2026-02-19 22:55:42 +01:00
alejandrobailo
68262665c6 feat(ui): add provider wizard types, store, and stepper 2026-02-19 22:55:35 +01:00
alejandrobailo
a9d5e1e6d6 style(ui): improve shadcn focus ring and add xl button size 2026-02-19 22:55:29 +01:00
alejandrobailo
635676355a refactor(ui): update stepper icons and connector styles 2026-02-19 15:27:05 +01:00
alejandrobailo
b9620a80af Merge branch 'master' into feat/PROWLER-1091-create-new-stepper-implement-aws-organizations-endpoints 2026-02-18 15:37:18 +01:00
alejandrobailo
9d9067c9e6 docs(ui): add AWS Organizations implementation docs
Add technical documentation for the AWS Organizations bulk connect
feature including API endpoint specs, wizard flow, and implementation
plan with architecture decisions.
2026-02-17 09:46:42 +01:00
alejandrobailo
ea5ab2429c feat(ui): integrate Organizations flow into provider add workflow
Add AWS method selector step between provider type and UID form in
ConnectAccountForm. Replace HeroUI VerticalSteps/Progress with custom
icon-based stepper in WorkflowAddProvider. Remove HeroUI Spacer import
from layout in favor of Tailwind utility class.
2026-02-17 09:46:37 +01:00
alejandrobailo
83bceab584 feat(ui): add discovery, account selection, and connection test steps
Add OrgDiscoveryLoader with polling for async discovery status,
OrgAccountSelection with TreeView for multi-account selection and
aliasing, OrgAccountTreeItem with custom rendering for selection and
testing modes, OrgConnectionTest that applies discovery and validates
connections concurrently, and OrgLaunchScan for scan scheduling.
2026-02-17 09:46:30 +01:00
alejandrobailo
689d044d92 feat(ui): add Organizations wizard modal with stepper
Add multi-step modal wizard for AWS Organizations onboarding. Includes
OrgWizardStepper with visual step circles and connectors, OrgSetupForm
with Zod validation for org details, and OrgWizardModal orchestrating
the Setup → Validate → Launch flow.
2026-02-17 09:46:22 +01:00
alejandrobailo
d3b0c3c393 feat(ui): add RadioCard and AWS method selector components
Add reusable RadioCard component for selectable card options. Add
AwsMethodSelector that presents single-account vs Organizations bulk
connect, with Organizations gated behind NEXT_PUBLIC_IS_CLOUD_ENV.
2026-02-17 09:46:16 +01:00
alejandrobailo
de81f6384e feat(ui): add AWS Organizations server actions and adapters
Add server actions for create organization, create secret, trigger discovery,
poll discovery status, and apply discovery results. Add adapter utilities to
transform flat API response into hierarchical tree data for the TreeView.
2026-02-17 09:46:09 +01:00
alejandrobailo
6648212369 feat(ui): add AWS Organizations types and Zustand store
Add const-enum types for discovery status, apply status, org/OU relations,
and secret state. Add persisted Zustand store to manage org wizard state
across setup, discovery, selection, and connection test phases.
2026-02-17 09:46:03 +01:00
93 changed files with 7277 additions and 1194 deletions

View File

@@ -0,0 +1,177 @@
import { describe, expect, it } from "vitest";
import {
APPLY_STATUS,
ApplyStatus,
DiscoveryResult,
} from "@/types/organizations";
import {
buildAccountLookup,
buildOrgTreeData,
getOuIdsForSelectedAccounts,
getSelectableAccountIds,
} from "./organizations.adapter";
const discoveryFixture: DiscoveryResult = {
roots: [
{
id: "r-root",
arn: "arn:aws:organizations::123:root/o-example/r-root",
name: "Root",
policy_types: [],
},
],
organizational_units: [
{
id: "ou-parent",
name: "Parent OU",
arn: "arn:aws:organizations::123:ou/o-example/ou-parent",
parent_id: "r-root",
},
{
id: "ou-child",
name: "Child OU",
arn: "arn:aws:organizations::123:ou/o-example/ou-child",
parent_id: "ou-parent",
},
],
accounts: [
{
id: "111111111111",
arn: "arn:aws:organizations::123:account/o-example/111111111111",
name: "App Account",
email: "app@example.com",
status: "ACTIVE",
joined_method: "CREATED",
joined_timestamp: "2024-01-01T00:00:00Z",
parent_id: "ou-child",
registration: {
provider_exists: false,
provider_id: null,
organization_relation: "link_required",
organizational_unit_relation: "link_required",
provider_secret_state: "will_create",
apply_status: APPLY_STATUS.READY,
blocked_reasons: [],
},
},
{
id: "222222222222",
arn: "arn:aws:organizations::123:account/o-example/222222222222",
name: "Security Account",
email: "security@example.com",
status: "ACTIVE",
joined_method: "CREATED",
joined_timestamp: "2024-01-01T00:00:00Z",
parent_id: "ou-parent",
registration: {
provider_exists: false,
provider_id: null,
organization_relation: "link_required",
organizational_unit_relation: "link_required",
provider_secret_state: "manual_required",
apply_status: APPLY_STATUS.BLOCKED,
blocked_reasons: ["role_missing"],
},
},
{
id: "333333333333",
arn: "arn:aws:organizations::123:account/o-example/333333333333",
name: "Legacy Account",
email: "legacy@example.com",
status: "ACTIVE",
joined_method: "INVITED",
joined_timestamp: "2024-01-01T00:00:00Z",
parent_id: "r-root",
},
],
};
describe("buildOrgTreeData", () => {
it("builds nested tree structure and marks blocked accounts as disabled", () => {
// Given / When
const treeData = buildOrgTreeData(discoveryFixture);
// Then
expect(treeData).toHaveLength(2);
expect(treeData.map((node) => node.id)).toEqual(
expect.arrayContaining(["ou-parent", "333333333333"]),
);
const parentOuNode = treeData.find((node) => node.id === "ou-parent");
expect(parentOuNode).toBeDefined();
expect(parentOuNode?.children?.map((node) => node.id)).toEqual(
expect.arrayContaining(["ou-child", "222222222222"]),
);
const blockedAccount = parentOuNode?.children?.find(
(node) => node.id === "222222222222",
);
expect(blockedAccount?.disabled).toBe(true);
});
});
describe("getSelectableAccountIds", () => {
it("returns all accounts except explicitly blocked ones", () => {
const selectableIds = getSelectableAccountIds(discoveryFixture);
expect(selectableIds).toEqual(["111111111111", "333333333333"]);
});
it("excludes accounts with explicit non-ready status values", () => {
const discoveryWithUnexpectedStatus = {
...discoveryFixture,
accounts: [
...discoveryFixture.accounts,
{
id: "444444444444",
arn: "arn:aws:organizations::123:account/o-example/444444444444",
name: "Pending Account",
email: "pending@example.com",
status: "ACTIVE",
joined_method: "CREATED",
joined_timestamp: "2024-01-01T00:00:00Z",
parent_id: "r-root",
registration: {
provider_exists: false,
provider_id: null,
organization_relation: "link_required",
organizational_unit_relation: "link_required",
provider_secret_state: "will_create",
apply_status: "pending" as unknown as ApplyStatus,
blocked_reasons: [],
},
},
],
} satisfies DiscoveryResult;
const selectableIds = getSelectableAccountIds(
discoveryWithUnexpectedStatus,
);
expect(selectableIds).toEqual(["111111111111", "333333333333"]);
});
});
describe("buildAccountLookup", () => {
it("creates a lookup map for all discovered accounts", () => {
const lookup = buildAccountLookup(discoveryFixture);
expect(lookup.get("111111111111")?.name).toBe("App Account");
expect(lookup.get("333333333333")?.name).toBe("Legacy Account");
expect(lookup.size).toBe(3);
});
});
describe("getOuIdsForSelectedAccounts", () => {
it("collects all ancestor OUs for selected accounts without duplicates", () => {
const ouIds = getOuIdsForSelectedAccounts(discoveryFixture, [
"111111111111",
"222222222222",
]);
expect(ouIds).toEqual(expect.arrayContaining(["ou-parent", "ou-child"]));
expect(ouIds.length).toBe(2);
});
});

View File

@@ -0,0 +1,145 @@
import { Box, Folder, FolderTree } from "lucide-react";
import {
APPLY_STATUS,
DiscoveredAccount,
DiscoveryResult,
} from "@/types/organizations";
import { TreeDataItem } from "@/types/tree";
/**
* Transforms flat API discovery arrays into hierarchical TreeDataItem[] for TreeView.
*
* Structure: OUs → nested OUs/Accounts (leaf nodes)
* Root nodes are used only internally for parent linking and are not rendered.
* Accounts with apply_status === "blocked" are marked disabled.
*/
export function buildOrgTreeData(result: DiscoveryResult): TreeDataItem[] {
// 1. Create a map of all nodes by ID for parent lookups
const nodeMap = new Map<string, TreeDataItem>();
// 2. Create root nodes
for (const root of result.roots) {
nodeMap.set(root.id, {
id: root.id,
name: root.name,
icon: FolderTree,
children: [],
});
}
// 3. Create OU nodes
for (const ou of result.organizational_units) {
nodeMap.set(ou.id, {
id: ou.id,
name: ou.name,
icon: Folder,
children: [],
});
}
// 4. Create account leaf nodes
for (const account of result.accounts) {
const isBlocked =
account.registration?.apply_status === APPLY_STATUS.BLOCKED;
nodeMap.set(account.id, {
id: account.id,
name: `${account.id}${account.name}`,
icon: Box,
disabled: isBlocked,
});
}
// 5. Nest OUs under their parent root/OU
for (const ou of result.organizational_units) {
const parent = nodeMap.get(ou.parent_id);
if (parent?.children) {
const ouNode = nodeMap.get(ou.id);
if (ouNode) parent.children.push(ouNode);
}
}
// 6. Nest accounts under their parent OU/root
for (const account of result.accounts) {
const parent = nodeMap.get(account.parent_id);
if (parent) {
// Ensure parent has children array (accounts nest under OUs/roots)
if (!parent.children) parent.children = [];
const accountNode = nodeMap.get(account.id);
if (accountNode) parent.children.push(accountNode);
}
}
// 7. Return root children as top-level nodes (roots are not rendered).
return result.roots.flatMap((root) => {
const rootNode = nodeMap.get(root.id);
return rootNode?.children ?? [];
});
}
/**
* Returns IDs of accounts that can be selected.
* Accounts are selectable when registration is READY or not yet present.
* Accounts with explicit non-ready states are excluded.
* Used to pre-select all selectable accounts in the tree.
*/
export function getSelectableAccountIds(result: DiscoveryResult): string[] {
return result.accounts
.filter((account) => {
const applyStatus = account.registration?.apply_status;
if (!applyStatus) {
return true;
}
return applyStatus === APPLY_STATUS.READY;
})
.map((account) => account.id);
}
/**
* Creates a lookup map from account ID to DiscoveredAccount.
* Used by the custom tree renderer to access registration data.
*/
export function buildAccountLookup(
result: DiscoveryResult,
): Map<string, DiscoveredAccount> {
const map = new Map<string, DiscoveredAccount>();
for (const account of result.accounts) {
map.set(account.id, account);
}
return map;
}
/**
* Given selected account IDs, returns the set of OU IDs that are
* ancestors of the selected accounts (needed for the apply request).
*/
export function getOuIdsForSelectedAccounts(
result: DiscoveryResult,
selectedAccountIds: string[],
): string[] {
const selectedSet = new Set(selectedAccountIds);
const ouIds = new Set<string>();
// Build a set of all OU IDs for quick lookup
const allOuIds = new Set(result.organizational_units.map((ou) => ou.id));
// Build parent lookup for OUs
const ouParentMap = new Map<string, string>();
for (const ou of result.organizational_units) {
ouParentMap.set(ou.id, ou.parent_id);
}
// For each selected account, walk up the parent chain and collect OU IDs
for (const account of result.accounts) {
if (!selectedSet.has(account.id)) continue;
let currentParentId = account.parent_id;
while (currentParentId && allOuIds.has(currentParentId)) {
ouIds.add(currentParentId);
currentParentId = ouParentMap.get(currentParentId) ?? "";
}
}
return Array.from(ouIds);
}

View File

@@ -0,0 +1,113 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const {
fetchMock,
getAuthHeadersMock,
handleApiErrorMock,
handleApiResponseMock,
revalidatePathMock,
} = vi.hoisted(() => ({
fetchMock: vi.fn(),
getAuthHeadersMock: vi.fn(),
handleApiErrorMock: vi.fn(),
handleApiResponseMock: vi.fn(),
revalidatePathMock: vi.fn(),
}));
vi.mock("next/cache", () => ({
revalidatePath: revalidatePathMock,
}));
vi.mock("@/lib", () => ({
apiBaseUrl: "https://api.example.com/api/v1",
getAuthHeaders: getAuthHeadersMock,
}));
vi.mock("@/lib/server-actions-helper", () => ({
handleApiError: handleApiErrorMock,
handleApiResponse: handleApiResponseMock,
}));
import {
applyDiscovery,
getDiscovery,
triggerDiscovery,
updateOrganizationSecret,
} from "./organizations";
describe("organizations actions", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.stubGlobal("fetch", fetchMock);
getAuthHeadersMock.mockResolvedValue({ Authorization: "Bearer token" });
handleApiErrorMock.mockReturnValue({ error: "Unexpected error" });
});
it("rejects invalid organization secret identifiers", async () => {
// Given
const formData = new FormData();
formData.set("organizationSecretId", "../secret-id");
formData.set("roleArn", "arn:aws:iam::123456789012:role/ProwlerOrgRole");
formData.set("externalId", "o-abc123def4");
// When
const result = await updateOrganizationSecret(formData);
// Then
expect(result).toEqual({ error: "Invalid organization secret ID" });
expect(fetchMock).not.toHaveBeenCalled();
});
it("rejects invalid discovery identifiers before building the request URL", async () => {
// When
const result = await getDiscovery(
"123e4567-e89b-12d3-a456-426614174000",
"discovery/../id",
);
// Then
expect(result).toEqual({ error: "Invalid discovery ID" });
expect(fetchMock).not.toHaveBeenCalled();
});
it("rejects invalid organization identifiers before triggering discovery", async () => {
// When
const result = await triggerDiscovery("org/id-with-slash");
// Then
expect(result).toEqual({ error: "Invalid organization ID" });
expect(fetchMock).not.toHaveBeenCalled();
});
it("revalidates providers only when apply discovery succeeds", async () => {
// Given
fetchMock.mockResolvedValue(
new Response(JSON.stringify({ data: { id: "apply-1" } }), {
status: 200,
headers: { "Content-Type": "application/json" },
}),
);
handleApiResponseMock.mockResolvedValueOnce({ error: "Apply failed" });
handleApiResponseMock.mockResolvedValueOnce({ data: { id: "apply-1" } });
// When
const failedResult = await applyDiscovery(
"123e4567-e89b-12d3-a456-426614174000",
"223e4567-e89b-12d3-a456-426614174111",
[],
[],
);
const successfulResult = await applyDiscovery(
"123e4567-e89b-12d3-a456-426614174000",
"223e4567-e89b-12d3-a456-426614174111",
[],
[],
);
// Then
expect(failedResult).toEqual({ error: "Apply failed" });
expect(successfulResult).toEqual({ data: { id: "apply-1" } });
expect(revalidatePathMock).toHaveBeenCalledTimes(1);
expect(revalidatePathMock).toHaveBeenCalledWith("/providers");
});
});

View File

@@ -0,0 +1,324 @@
"use server";
import { revalidatePath } from "next/cache";
import { apiBaseUrl, getAuthHeaders } from "@/lib";
import { handleApiError, handleApiResponse } from "@/lib/server-actions-helper";
const PATH_IDENTIFIER_PATTERN = /^[A-Za-z0-9_-]+$/;
type PathIdentifierValidationResult = { value: string } | { error: string };
function validatePathIdentifier(
value: string | null | undefined,
requiredError: string,
invalidError: string,
): PathIdentifierValidationResult {
const normalizedValue = value?.trim();
if (!normalizedValue) {
return { error: requiredError };
}
if (!PATH_IDENTIFIER_PATTERN.test(normalizedValue)) {
return { error: invalidError };
}
return { value: normalizedValue };
}
function hasActionError(result: unknown): result is { error: unknown } {
return Boolean(
result &&
typeof result === "object" &&
"error" in (result as Record<string, unknown>),
);
}
/**
* Creates an AWS Organization resource.
* POST /api/v1/organizations
*/
export const createOrganization = async (formData: FormData) => {
const headers = await getAuthHeaders({ contentType: true });
const url = new URL(`${apiBaseUrl}/organizations`);
const name = formData.get("name") as string;
const externalId = formData.get("externalId") as string;
try {
const response = await fetch(url.toString(), {
method: "POST",
headers,
body: JSON.stringify({
data: {
type: "organizations",
attributes: {
name,
org_type: "aws",
external_id: externalId,
},
},
}),
});
return handleApiResponse(response);
} catch (error) {
return handleApiError(error);
}
};
/**
* Lists AWS Organizations filtered by external ID.
* GET /api/v1/organizations?filter[external_id]={externalId}&filter[org_type]=aws
*/
export const listOrganizationsByExternalId = async (externalId: string) => {
const headers = await getAuthHeaders({ contentType: false });
const url = new URL(`${apiBaseUrl}/organizations`);
url.searchParams.set("filter[external_id]", externalId);
url.searchParams.set("filter[org_type]", "aws");
try {
const response = await fetch(url.toString(), { headers });
return handleApiResponse(response);
} catch (error) {
return handleApiError(error);
}
};
/**
* Creates an organization secret (role-based credentials).
* POST /api/v1/organization-secrets
*/
export const createOrganizationSecret = async (formData: FormData) => {
const headers = await getAuthHeaders({ contentType: true });
const url = new URL(`${apiBaseUrl}/organization-secrets`);
const organizationId = formData.get("organizationId") as string;
const roleArn = formData.get("roleArn") as string;
const externalId = formData.get("externalId") as string;
try {
const response = await fetch(url.toString(), {
method: "POST",
headers,
body: JSON.stringify({
data: {
type: "organization-secrets",
attributes: {
secret_type: "role",
secret: {
role_arn: roleArn,
external_id: externalId,
},
},
relationships: {
organization: {
data: {
type: "organizations",
id: organizationId,
},
},
},
},
}),
});
return handleApiResponse(response);
} catch (error) {
return handleApiError(error);
}
};
/**
* Updates an organization secret (role-based credentials).
* PATCH /api/v1/organization-secrets/{id}
*/
export const updateOrganizationSecret = async (formData: FormData) => {
const headers = await getAuthHeaders({ contentType: true });
const organizationSecretId = formData.get("organizationSecretId") as
| string
| null;
const roleArn = formData.get("roleArn") as string;
const externalId = formData.get("externalId") as string;
const organizationSecretIdValidation = validatePathIdentifier(
organizationSecretId,
"Organization secret ID is required",
"Invalid organization secret ID",
);
if ("error" in organizationSecretIdValidation) {
return organizationSecretIdValidation;
}
const url = new URL(
`${apiBaseUrl}/organization-secrets/${encodeURIComponent(organizationSecretIdValidation.value)}`,
);
try {
const response = await fetch(url.toString(), {
method: "PATCH",
headers,
body: JSON.stringify({
data: {
type: "organization-secrets",
id: organizationSecretIdValidation.value,
attributes: {
secret_type: "role",
secret: {
role_arn: roleArn,
external_id: externalId,
},
},
},
}),
});
return handleApiResponse(response);
} catch (error) {
return handleApiError(error);
}
};
/**
* Lists organization secrets for an organization.
* GET /api/v1/organization-secrets?filter[organization_id]={organizationId}
*/
export const listOrganizationSecretsByOrganizationId = async (
organizationId: string,
) => {
const headers = await getAuthHeaders({ contentType: false });
const url = new URL(`${apiBaseUrl}/organization-secrets`);
url.searchParams.set("filter[organization_id]", organizationId);
try {
const response = await fetch(url.toString(), { headers });
return handleApiResponse(response);
} catch (error) {
return handleApiError(error);
}
};
/**
* Triggers an async discovery of the AWS Organization.
* POST /api/v1/organizations/{id}/discover
*/
export const triggerDiscovery = async (organizationId: string) => {
const headers = await getAuthHeaders({ contentType: false });
const organizationIdValidation = validatePathIdentifier(
organizationId,
"Organization ID is required",
"Invalid organization ID",
);
if ("error" in organizationIdValidation) {
return organizationIdValidation;
}
const url = new URL(
`${apiBaseUrl}/organizations/${encodeURIComponent(organizationIdValidation.value)}/discover`,
);
try {
const response = await fetch(url.toString(), {
method: "POST",
headers,
});
return handleApiResponse(response);
} catch (error) {
return handleApiError(error);
}
};
/**
* Polls the discovery status.
* GET /api/v1/organizations/{orgId}/discoveries/{discoveryId}
*/
export const getDiscovery = async (
organizationId: string,
discoveryId: string,
) => {
const headers = await getAuthHeaders({ contentType: false });
const organizationIdValidation = validatePathIdentifier(
organizationId,
"Organization ID is required",
"Invalid organization ID",
);
if ("error" in organizationIdValidation) {
return organizationIdValidation;
}
const discoveryIdValidation = validatePathIdentifier(
discoveryId,
"Discovery ID is required",
"Invalid discovery ID",
);
if ("error" in discoveryIdValidation) {
return discoveryIdValidation;
}
const url = new URL(
`${apiBaseUrl}/organizations/${encodeURIComponent(organizationIdValidation.value)}/discoveries/${encodeURIComponent(discoveryIdValidation.value)}`,
);
try {
const response = await fetch(url.toString(), { headers });
return handleApiResponse(response);
} catch (error) {
return handleApiError(error);
}
};
/**
* Applies discovery results — creates providers, links to org/OUs, auto-generates secrets.
* POST /api/v1/organizations/{orgId}/discoveries/{discoveryId}/apply
*/
export const applyDiscovery = async (
organizationId: string,
discoveryId: string,
accounts: Array<{ id: string; alias?: string }>,
organizationalUnits: Array<{ id: string }>,
) => {
const headers = await getAuthHeaders({ contentType: true });
const organizationIdValidation = validatePathIdentifier(
organizationId,
"Organization ID is required",
"Invalid organization ID",
);
if ("error" in organizationIdValidation) {
return organizationIdValidation;
}
const discoveryIdValidation = validatePathIdentifier(
discoveryId,
"Discovery ID is required",
"Invalid discovery ID",
);
if ("error" in discoveryIdValidation) {
return discoveryIdValidation;
}
const url = new URL(
`${apiBaseUrl}/organizations/${encodeURIComponent(organizationIdValidation.value)}/discoveries/${encodeURIComponent(discoveryIdValidation.value)}/apply`,
);
try {
const response = await fetch(url.toString(), {
method: "POST",
headers,
body: JSON.stringify({
data: {
type: "organization-discoveries",
attributes: {
accounts,
organizational_units: organizationalUnits,
},
},
}),
});
const result = await handleApiResponse(response);
if (!hasActionError(result)) {
revalidatePath("/providers");
}
return result;
} catch (error) {
return handleApiError(error);
}
};

View File

@@ -0,0 +1,71 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const {
fetchMock,
getAuthHeadersMock,
handleApiErrorMock,
handleApiResponseMock,
} = vi.hoisted(() => ({
fetchMock: vi.fn(),
getAuthHeadersMock: vi.fn(),
handleApiErrorMock: vi.fn(),
handleApiResponseMock: vi.fn(),
}));
vi.mock("@/lib", () => ({
apiBaseUrl: "https://api.example.com/api/v1",
getAuthHeaders: getAuthHeadersMock,
getErrorMessage: (error: unknown) =>
error instanceof Error ? error.message : String(error),
}));
vi.mock("@/lib/server-actions-helper", () => ({
handleApiError: handleApiErrorMock,
handleApiResponse: handleApiResponseMock,
}));
vi.mock("@/lib/sentry-breadcrumbs", () => ({
addScanOperation: vi.fn(),
}));
import { launchOrganizationScans } from "./scans";
describe("launchOrganizationScans", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.stubGlobal("fetch", fetchMock);
getAuthHeadersMock.mockResolvedValue({ Authorization: "Bearer token" });
handleApiResponseMock.mockResolvedValue({ data: { id: "scan-id" } });
handleApiErrorMock.mockReturnValue({ error: "Scan launch failed." });
});
it("limits concurrent launch requests to avoid overwhelming the backend", async () => {
// Given
const providerIds = Array.from(
{ length: 12 },
(_, index) => `provider-${index + 1}`,
);
let activeRequests = 0;
let maxActiveRequests = 0;
fetchMock.mockImplementation(async () => {
activeRequests += 1;
maxActiveRequests = Math.max(maxActiveRequests, activeRequests);
await new Promise((resolve) => setTimeout(resolve, 5));
activeRequests -= 1;
return new Response(JSON.stringify({ data: { id: "scan-id" } }), {
status: 200,
headers: { "Content-Type": "application/json" },
});
});
// When
const result = await launchOrganizationScans(providerIds, "daily");
// Then
expect(maxActiveRequests).toBeLessThanOrEqual(5);
expect(result.successCount).toBe(providerIds.length);
expect(result.failureCount).toBe(0);
});
});

View File

@@ -13,6 +13,41 @@ import {
} from "@/lib/provider-filters";
import { addScanOperation } from "@/lib/sentry-breadcrumbs";
import { handleApiError, handleApiResponse } from "@/lib/server-actions-helper";
const ORGANIZATION_SCAN_CONCURRENCY_LIMIT = 5;
export async function runWithConcurrencyLimit<T, R>(
items: T[],
concurrencyLimit: number,
worker: (item: T, index: number) => Promise<R>,
): Promise<R[]> {
if (items.length === 0) {
return [];
}
const normalizedConcurrency = Math.max(1, Math.floor(concurrencyLimit));
const results = new Array<R>(items.length);
let currentIndex = 0;
const runWorker = async () => {
while (currentIndex < items.length) {
const assignedIndex = currentIndex;
currentIndex += 1;
results[assignedIndex] = await worker(
items[assignedIndex],
assignedIndex,
);
}
};
const workers = Array.from(
{ length: Math.min(normalizedConcurrency, items.length) },
() => runWorker(),
);
await Promise.all(workers);
return results;
}
export const getScans = async ({
page = 1,
query = "",
@@ -165,6 +200,73 @@ export const scheduleDaily = async (formData: FormData) => {
}
};
export const launchOrganizationScans = async (
providerIds: string[],
scheduleOption: "daily" | "single",
) => {
const validProviderIds = providerIds.filter(Boolean);
if (validProviderIds.length === 0) {
return {
successCount: 0,
failureCount: 0,
totalCount: 0,
};
}
const launchResults = await runWithConcurrencyLimit(
validProviderIds,
ORGANIZATION_SCAN_CONCURRENCY_LIMIT,
async (providerId) => {
try {
const formData = new FormData();
formData.set("providerId", providerId);
const result =
scheduleOption === "daily"
? await scheduleDaily(formData)
: await scanOnDemand(formData);
return {
providerId,
ok: !result?.error,
error: result?.error ? String(result.error) : null,
};
} catch (error) {
return {
providerId,
ok: false,
error:
error instanceof Error ? error.message : "Failed to launch scan.",
};
}
},
);
const summary = launchResults.reduce(
(acc, item) => {
if (item.ok) {
acc.successCount += 1;
return acc;
}
acc.failureCount += 1;
acc.errors.push({
providerId: item.providerId,
error: item.error || "Failed to launch scan.",
});
return acc;
},
{
successCount: 0,
failureCount: 0,
totalCount: validProviderIds.length,
errors: [] as Array<{ providerId: string; error: string }>,
},
);
return summary;
};
export const updateScan = async (formData: FormData) => {
const headers = await getAuthHeaders({ contentType: true });

View File

@@ -1,78 +0,0 @@
import { getProvider } from "@/actions/providers/providers";
import {
AddViaCredentialsForm,
AddViaRoleForm,
} from "@/components/providers/workflow/forms";
import { SelectViaAlibabaCloud } from "@/components/providers/workflow/forms/select-credentials-type/alibabacloud";
import { SelectViaAWS } from "@/components/providers/workflow/forms/select-credentials-type/aws";
import { SelectViaCloudflare } from "@/components/providers/workflow/forms/select-credentials-type/cloudflare";
import {
AddViaServiceAccountForm,
SelectViaGCP,
} from "@/components/providers/workflow/forms/select-credentials-type/gcp";
import { SelectViaGitHub } from "@/components/providers/workflow/forms/select-credentials-type/github";
import { SelectViaM365 } from "@/components/providers/workflow/forms/select-credentials-type/m365";
import { getProviderFormType } from "@/lib/provider-helpers";
import { ProviderType } from "@/types/providers";
interface Props {
searchParams: Promise<{ type: ProviderType; id: string; via?: string }>;
}
export default async function AddCredentialsPage({ searchParams }: Props) {
const resolvedSearchParams = await searchParams;
const { type: providerType, via, id: providerId } = resolvedSearchParams;
const formType = getProviderFormType(providerType, via);
// Fetch provider data to get the UID (needed for OCI)
let providerUid: string | undefined;
if (providerId) {
const formData = new FormData();
formData.append("id", providerId);
const providerResponse = await getProvider(formData);
if (providerResponse?.data?.attributes?.uid) {
providerUid = providerResponse.data.attributes.uid;
}
}
switch (formType) {
case "selector":
if (providerType === "aws") return <SelectViaAWS initialVia={via} />;
if (providerType === "gcp") return <SelectViaGCP initialVia={via} />;
if (providerType === "github")
return <SelectViaGitHub initialVia={via} />;
if (providerType === "m365") return <SelectViaM365 initialVia={via} />;
if (providerType === "alibabacloud")
return <SelectViaAlibabaCloud initialVia={via} />;
if (providerType === "cloudflare")
return <SelectViaCloudflare initialVia={via} />;
return null;
case "credentials":
return (
<AddViaCredentialsForm
searchParams={resolvedSearchParams}
providerUid={providerUid}
/>
);
case "role":
return (
<AddViaRoleForm
searchParams={resolvedSearchParams}
providerUid={providerUid}
/>
);
case "service-account":
return (
<AddViaServiceAccountForm
searchParams={resolvedSearchParams}
providerUid={providerUid}
/>
);
default:
return null;
}
}

View File

@@ -1,7 +0,0 @@
import React from "react";
import { ConnectAccountForm } from "@/components/providers/workflow/forms";
export default function ConnectAccountPage() {
return <ConnectAccountForm />;
}

View File

@@ -1,32 +0,0 @@
import "@/styles/globals.css";
import { Spacer } from "@heroui/spacer";
import React from "react";
import { WorkflowAddProvider } from "@/components/providers/workflow";
import { NavigationHeader } from "@/components/ui";
interface ProviderLayoutProps {
children: React.ReactNode;
}
export default function ProviderLayout({ children }: ProviderLayoutProps) {
return (
<>
<NavigationHeader
title="Connect a Cloud Provider"
icon="icon-park-outline:close-small"
href="/providers"
/>
<Spacer y={8} />
<div className="grid grid-cols-1 gap-8 lg:grid-cols-12">
<div className="order-1 my-auto hidden h-full lg:col-span-4 lg:col-start-2 lg:block">
<WorkflowAddProvider />
</div>
<div className="order-2 my-auto lg:col-span-5 lg:col-start-6">
{children}
</div>
</div>
</>
);
}

View File

@@ -1,46 +0,0 @@
import { redirect } from "next/navigation";
import React, { Suspense } from "react";
import { getProvider } from "@/actions/providers";
import { SkeletonProviderWorkflow } from "@/components/providers/workflow";
import { TestConnectionForm } from "@/components/providers/workflow/forms";
interface Props {
searchParams: Promise<{ type: string; id: string; updated: string }>;
}
export default async function TestConnectionPage({ searchParams }: Props) {
const resolvedSearchParams = await searchParams;
const providerId = resolvedSearchParams.id;
if (!providerId) {
redirect("/providers/connect-account");
}
return (
<Suspense fallback={<SkeletonProviderWorkflow />}>
<SSRTestConnection searchParams={resolvedSearchParams} />
</Suspense>
);
}
async function SSRTestConnection({
searchParams,
}: {
searchParams: { type: string; id: string; updated: string };
}) {
const formData = new FormData();
formData.append("id", searchParams.id);
const providerData = await getProvider(formData);
if (providerData.errors) {
redirect("/providers/connect-account");
}
return (
<TestConnectionForm
searchParams={searchParams}
providerData={providerData}
/>
);
}

View File

@@ -1,76 +0,0 @@
import { redirect } from "next/navigation";
import React from "react";
import { getProvider } from "@/actions/providers/providers";
import { CredentialsUpdateInfo } from "@/components/providers";
import {
UpdateViaCredentialsForm,
UpdateViaRoleForm,
} from "@/components/providers/workflow/forms";
import { UpdateViaServiceAccountForm } from "@/components/providers/workflow/forms/update-via-service-account-key-form";
import { getProviderFormType } from "@/lib/provider-helpers";
import { ProviderType } from "@/types/providers";
interface Props {
searchParams: Promise<{
type: ProviderType;
id: string;
via?: string;
secretId?: string;
}>;
}
export default async function UpdateCredentialsPage({ searchParams }: Props) {
const resolvedSearchParams = await searchParams;
const { type: providerType, via, id: providerId } = resolvedSearchParams;
if (!providerId) {
redirect("/providers");
}
const formType = getProviderFormType(providerType, via);
const formData = new FormData();
formData.append("id", providerId);
const providerResponse = await getProvider(formData);
if (providerResponse?.errors) {
redirect("/providers");
}
const providerUid = providerResponse?.data?.attributes?.uid;
switch (formType) {
case "selector":
return (
<CredentialsUpdateInfo providerType={providerType} initialVia={via} />
);
case "credentials":
return (
<UpdateViaCredentialsForm
searchParams={resolvedSearchParams}
providerUid={providerUid}
/>
);
case "role":
return (
<UpdateViaRoleForm
searchParams={resolvedSearchParams}
providerUid={providerUid}
/>
);
case "service-account":
return (
<UpdateViaServiceAccountForm
searchParams={resolvedSearchParams}
providerUid={providerUid}
/>
);
default:
return null;
}
}

View File

@@ -5,7 +5,7 @@ import { Spacer } from "@heroui/spacer";
import { usePathname, useSearchParams } from "next/navigation";
import React from "react";
import { VerticalSteps } from "@/components/providers/workflow/vertical-steps";
import { cn } from "@/lib/utils";
import type { LighthouseProvider } from "@/types/lighthouse";
import { getProviderConfig } from "../llm-provider-registry";
@@ -75,12 +75,60 @@ export const WorkflowConnectLLM = () => {
value={currentStep + 1}
valueLabel={`${currentStep + 1} of ${steps.length}`}
/>
<VerticalSteps
hideProgressBars
currentStep={currentStep}
stepClassName="border border-border-neutral-primary aria-[current]:bg-bg-neutral-primary cursor-default"
steps={steps}
/>
<nav aria-label="Progress">
<ol className="flex flex-col gap-y-3">
{steps.map((step, index) => {
const isActive = index === currentStep;
const isComplete = index < currentStep;
return (
<li
key={step.title}
className="border-border-neutral-primary rounded-large border px-3 py-2.5"
>
<div className="flex items-center gap-4">
<div
className={cn(
"flex h-[34px] w-[34px] items-center justify-center rounded-full border text-sm font-semibold",
isComplete &&
"bg-button-primary border-button-primary text-white",
isActive &&
"border-button-primary text-button-primary bg-transparent",
!isActive &&
!isComplete &&
"text-default-500 border-border-neutral-primary bg-transparent",
)}
>
{index + 1}
</div>
<div className="flex-1 text-left">
<div
className={cn(
"text-medium font-medium",
isActive || isComplete
? "text-default-foreground"
: "text-default-500",
)}
>
{step.title}
</div>
<div
className={cn(
"text-small",
isActive || isComplete
? "text-default-600"
: "text-default-500",
)}
>
{step.description}
</div>
</div>
</div>
</li>
);
})}
</ol>
</nav>
<Spacer y={4} />
</section>
);

View File

@@ -1,18 +1,22 @@
"use client";
import Link from "next/link";
import { useState } from "react";
import { ProviderWizardModal } from "@/components/providers/wizard";
import { Button } from "@/components/shadcn";
import { AddIcon } from "../icons";
export const AddProviderButton = () => {
const [open, setOpen] = useState(false);
return (
<Button asChild>
<Link href="/providers/connect-account">
<>
<Button onClick={() => setOpen(true)}>
Add Cloud Provider
<AddIcon size={20} />
</Link>
</Button>
</Button>
<ProviderWizardModal open={open} onOpenChange={setOpen} />
</>
);
};

View File

@@ -5,4 +5,5 @@ export * from "./forms/delete-form";
export * from "./link-to-scans";
export * from "./muted-findings-config-button";
export * from "./provider-info";
export * from "./radio-card";
export * from "./radio-group-provider";

View File

@@ -0,0 +1,61 @@
"use client";
import { Ban, Box, Boxes } from "lucide-react";
import { RadioCard } from "@/components/providers/radio-card";
interface AwsMethodSelectorProps {
onSelectSingle: () => void;
onSelectOrganizations: () => void;
}
export function AwsMethodSelector({
onSelectSingle,
onSelectOrganizations,
}: AwsMethodSelectorProps) {
const isCloudEnv = process.env.NEXT_PUBLIC_IS_CLOUD_ENV === "true";
return (
<div className="flex flex-col gap-3">
<p className="text-muted-foreground text-sm">
Select a method to add your accounts to Prowler.
</p>
<RadioCard
icon={Box}
title="Add A Single AWS Cloud Account"
onClick={onSelectSingle}
/>
<RadioCard
icon={isCloudEnv ? Boxes : Ban}
title="Add Multiple Accounts With AWS Organizations"
onClick={onSelectOrganizations}
disabled={!isCloudEnv}
>
{!isCloudEnv && <CtaBadge />}
</RadioCard>
</div>
);
}
function CtaBadge() {
return (
<a
href="https://prowler.com/pricing"
target="_blank"
rel="noopener noreferrer"
className="flex h-[52px] shrink-0 items-center justify-center rounded-lg px-4 py-3 transition-opacity hover:opacity-90"
style={{
backgroundImage:
"linear-gradient(112deg, rgb(46, 229, 155) 3.5%, rgb(98, 223, 240) 98.8%)",
}}
>
<div className="flex items-center gap-1.5">
<span className="text-primary-foreground text-sm leading-6 font-bold">
Available in Prowler Cloud
</span>
</div>
</a>
);
}

View File

@@ -0,0 +1,284 @@
import { act, renderHook, waitFor } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { useOrgSetupStore } from "@/store/organizations/store";
import { APPLY_STATUS } from "@/types/organizations";
import { useOrgAccountSelectionFlow } from "./use-org-account-selection-flow";
const organizationsActionsMock = vi.hoisted(() => ({
applyDiscovery: vi.fn(),
}));
const providersActionsMock = vi.hoisted(() => ({
checkConnectionProvider: vi.fn(),
getProvider: vi.fn(),
}));
vi.mock(
"@/actions/organizations/organizations",
() => organizationsActionsMock,
);
vi.mock("@/actions/providers/providers", () => providersActionsMock);
const TEST_ACCOUNTS = ["111111111111", "222222222222"] as const;
function setupDiscoveryAndSelection(
selectedAccountIds: string[] = [TEST_ACCOUNTS[0]],
) {
useOrgSetupStore
.getState()
.setOrganization("org-1", "My Organization", "o-abc123def4");
useOrgSetupStore.getState().setDiscovery("discovery-1", {
roots: [{ id: "r-root", arn: "arn:root", name: "Root", policy_types: [] }],
organizational_units: [],
accounts: [
{
id: TEST_ACCOUNTS[0],
name: "Account One",
arn: `arn:aws:organizations::${TEST_ACCOUNTS[0]}:account/o-123/${TEST_ACCOUNTS[0]}`,
email: "one@example.com",
status: "ACTIVE",
joined_method: "CREATED",
joined_timestamp: "2024-01-01T00:00:00Z",
parent_id: "r-root",
registration: {
provider_exists: false,
provider_id: null,
organization_relation: "link_required",
organizational_unit_relation: "not_applicable",
provider_secret_state: "will_create",
apply_status: APPLY_STATUS.READY,
blocked_reasons: [],
},
},
{
id: TEST_ACCOUNTS[1],
name: "Account Two",
arn: `arn:aws:organizations::${TEST_ACCOUNTS[1]}:account/o-123/${TEST_ACCOUNTS[1]}`,
email: "two@example.com",
status: "ACTIVE",
joined_method: "CREATED",
joined_timestamp: "2024-01-01T00:00:00Z",
parent_id: "r-root",
registration: {
provider_exists: false,
provider_id: null,
organization_relation: "link_required",
organizational_unit_relation: "not_applicable",
provider_secret_state: "will_create",
apply_status: APPLY_STATUS.READY,
blocked_reasons: [],
},
},
],
});
useOrgSetupStore.getState().setSelectedAccountIds(selectedAccountIds);
}
function buildApplySuccessResult(accountIds: string[]) {
const accountProviderMappings = accountIds.map((accountId, index) => ({
account_id: accountId,
provider_id: `provider-${String.fromCharCode(97 + index)}`,
}));
const providers = accountProviderMappings.map((mapping) => ({
id: mapping.provider_id,
}));
return {
data: {
attributes: {
account_provider_mappings: accountProviderMappings,
},
relationships: {
providers: {
data: providers,
},
},
},
};
}
describe("useOrgAccountSelectionFlow", () => {
beforeEach(() => {
sessionStorage.clear();
localStorage.clear();
useOrgSetupStore.getState().reset();
organizationsActionsMock.applyDiscovery.mockReset();
providersActionsMock.checkConnectionProvider.mockReset();
providersActionsMock.getProvider.mockReset();
setupDiscoveryAndSelection();
});
it("applies selected accounts, tests all connections, and advances on full success", async () => {
// Given
organizationsActionsMock.applyDiscovery.mockResolvedValue(
buildApplySuccessResult([TEST_ACCOUNTS[0]]),
);
providersActionsMock.checkConnectionProvider.mockResolvedValue({
data: {},
});
const onNext = vi.fn();
const onFooterChange = vi.fn();
let latestFooterConfig: {
onAction?: () => void;
} | null = null;
onFooterChange.mockImplementation((config) => {
latestFooterConfig = config;
});
renderHook(() =>
useOrgAccountSelectionFlow({
onBack: vi.fn(),
onNext,
onSkip: vi.fn(),
onFooterChange,
}),
);
// When
await waitFor(() => {
expect(latestFooterConfig?.onAction).toBeDefined();
});
act(() => {
latestFooterConfig?.onAction?.();
});
// Then
await waitFor(() => {
expect(organizationsActionsMock.applyDiscovery).toHaveBeenCalledWith(
"org-1",
"discovery-1",
[{ id: TEST_ACCOUNTS[0] }],
[],
);
expect(
providersActionsMock.checkConnectionProvider,
).toHaveBeenCalledTimes(1);
expect(onNext).toHaveBeenCalledTimes(1);
});
});
it("retests only failed providers when retrying without changing account selection", async () => {
// Given
setupDiscoveryAndSelection([...TEST_ACCOUNTS]);
organizationsActionsMock.applyDiscovery.mockResolvedValue(
buildApplySuccessResult([...TEST_ACCOUNTS]),
);
const testedProviderIds: string[] = [];
const providerAttempts: Record<string, number> = {};
providersActionsMock.checkConnectionProvider.mockImplementation(
async (formData: FormData) => {
const providerId = String(formData.get("providerId"));
testedProviderIds.push(providerId);
providerAttempts[providerId] = (providerAttempts[providerId] ?? 0) + 1;
if (providerId === "provider-a" && providerAttempts[providerId] === 1) {
return { error: "Connection failed." };
}
return { data: {} };
},
);
const onNext = vi.fn();
const onFooterChange = vi.fn();
let latestFooterConfig: {
onAction?: () => void;
actionDisabled?: boolean;
} | null = null;
onFooterChange.mockImplementation((config) => {
latestFooterConfig = config;
});
renderHook(() =>
useOrgAccountSelectionFlow({
onBack: vi.fn(),
onNext,
onSkip: vi.fn(),
onFooterChange,
}),
);
// When
await waitFor(() => {
expect(latestFooterConfig?.onAction).toBeDefined();
expect(latestFooterConfig?.actionDisabled).toBe(false);
});
act(() => {
latestFooterConfig?.onAction?.();
});
await waitFor(() => {
expect(
providersActionsMock.checkConnectionProvider,
).toHaveBeenCalledTimes(2);
});
act(() => {
latestFooterConfig?.onAction?.();
});
// Then
await waitFor(() => {
expect(organizationsActionsMock.applyDiscovery).toHaveBeenCalledTimes(1);
expect(
providersActionsMock.checkConnectionProvider,
).toHaveBeenCalledTimes(3);
expect(onNext).toHaveBeenCalledTimes(1);
});
expect(testedProviderIds.filter((id) => id === "provider-a")).toHaveLength(
2,
);
expect(testedProviderIds.filter((id) => id === "provider-b")).toHaveLength(
1,
);
});
it("keeps Test Connections action visible after reselection in testing view", async () => {
// Given
organizationsActionsMock.applyDiscovery.mockResolvedValue({
errors: [{ detail: "Apply failed." }],
});
const onFooterChange = vi.fn();
let latestFooterConfig: {
showAction?: boolean;
actionDisabled?: boolean;
onAction?: () => void;
} | null = null;
onFooterChange.mockImplementation((config) => {
latestFooterConfig = config;
});
const { result } = renderHook(() =>
useOrgAccountSelectionFlow({
onBack: vi.fn(),
onNext: vi.fn(),
onSkip: vi.fn(),
onFooterChange,
}),
);
// When
await waitFor(() => {
expect(latestFooterConfig?.showAction).toBe(true);
expect(latestFooterConfig?.onAction).toBeDefined();
});
act(() => {
latestFooterConfig?.onAction?.();
});
await waitFor(() => {
expect(organizationsActionsMock.applyDiscovery).toHaveBeenCalledTimes(1);
});
act(() => {
result.current.handleTreeSelectionChange(["222222222222"]);
});
// Then
await waitFor(() => {
expect(latestFooterConfig?.showAction).toBe(true);
expect(latestFooterConfig?.actionDisabled).toBe(false);
expect(latestFooterConfig?.onAction).toBeDefined();
});
});
});

View File

@@ -0,0 +1,547 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { applyDiscovery } from "@/actions/organizations/organizations";
import { getOuIdsForSelectedAccounts } from "@/actions/organizations/organizations.adapter";
import {
checkConnectionProvider,
getProvider,
} from "@/actions/providers/providers";
import {
WIZARD_FOOTER_ACTION_TYPE,
WizardFooterConfig,
} from "@/components/providers/wizard/steps/footer-controls";
import { useOrgSetupStore } from "@/store/organizations/store";
import {
CONNECTION_TEST_STATUS,
ConnectionTestStatus,
} from "@/types/organizations";
import { TREE_ITEM_STATUS, TreeDataItem } from "@/types/tree";
import {
buildAccountToProviderMap,
canAdvanceToLaunchStep,
getLaunchableProviderIds,
pollConnectionTask,
runWithConcurrencyLimit,
} from "../org-account-selection.utils";
interface SelectionState {
hasSelectableDescendants: boolean;
allSelectableDescendantsSelected: boolean;
}
function collectFullySelectedNodeIds(
node: TreeDataItem,
selectedAccountIdSet: Set<string>,
selectableAccountIdSet: Set<string>,
selectedNodeIds: Set<string>,
): SelectionState {
if (selectableAccountIdSet.has(node.id)) {
return {
hasSelectableDescendants: true,
allSelectableDescendantsSelected: selectedAccountIdSet.has(node.id),
};
}
const children = node.children ?? [];
let hasSelectableDescendants = false;
let allSelectableDescendantsSelected = true;
for (const child of children) {
const childSelectionState = collectFullySelectedNodeIds(
child,
selectedAccountIdSet,
selectableAccountIdSet,
selectedNodeIds,
);
if (!childSelectionState.hasSelectableDescendants) {
continue;
}
hasSelectableDescendants = true;
allSelectableDescendantsSelected =
allSelectableDescendantsSelected &&
childSelectionState.allSelectableDescendantsSelected;
}
if (hasSelectableDescendants && allSelectableDescendantsSelected) {
selectedNodeIds.add(node.id);
}
return {
hasSelectableDescendants,
allSelectableDescendantsSelected,
};
}
function buildTreeSelectedIds(
treeData: TreeDataItem[],
selectedAccountIds: string[],
selectableAccountIdSet: Set<string>,
): string[] {
const selectedAccountIdSet = new Set(selectedAccountIds);
const selectedNodeIds = new Set<string>();
for (const rootNode of treeData) {
collectFullySelectedNodeIds(
rootNode,
selectedAccountIdSet,
selectableAccountIdSet,
selectedNodeIds,
);
}
return [...selectedAccountIds, ...Array.from(selectedNodeIds)];
}
function buildTreeWithConnectionState(
nodes: TreeDataItem[],
selectedAccountIdsSet: Set<string>,
accountToProviderMap: Map<string, string>,
connectionResults: Record<string, ConnectionTestStatus>,
connectionErrors: Record<string, string>,
showPendingState: boolean,
): TreeDataItem[] {
return nodes.map((node) => {
const children = node.children
? buildTreeWithConnectionState(
node.children,
selectedAccountIdsSet,
accountToProviderMap,
connectionResults,
connectionErrors,
showPendingState,
)
: undefined;
let isLoading = node.isLoading;
let status = node.status;
let errorMessage = node.errorMessage;
if (selectedAccountIdsSet.has(node.id)) {
const providerId = accountToProviderMap.get(node.id);
const connectionStatus = providerId
? connectionResults[providerId]
: undefined;
if (connectionStatus === CONNECTION_TEST_STATUS.SUCCESS) {
isLoading = false;
status = TREE_ITEM_STATUS.SUCCESS;
errorMessage = undefined;
} else if (connectionStatus === CONNECTION_TEST_STATUS.ERROR) {
isLoading = false;
status = TREE_ITEM_STATUS.ERROR;
errorMessage =
(providerId && connectionErrors[providerId]) || "Connection failed.";
} else if (
showPendingState ||
connectionStatus === CONNECTION_TEST_STATUS.PENDING
) {
isLoading = true;
status = undefined;
errorMessage = undefined;
}
}
return {
...node,
children,
isLoading,
status,
errorMessage,
};
});
}
function extractErrorMessage(response: unknown, fallback: string): string {
if (!response || typeof response !== "object") {
return fallback;
}
const responseRecord = response as {
error?: string;
errors?: Array<{ detail?: string }>;
};
const detailedError = responseRecord.errors?.[0]?.detail;
return detailedError || responseRecord.error || fallback;
}
function getSelectionKey(ids: string[]) {
return [...ids].sort().join(",");
}
interface UseOrgAccountSelectionFlowProps {
onBack: () => void;
onNext: () => void;
onSkip: () => void;
onFooterChange: (config: WizardFooterConfig) => void;
}
export function useOrgAccountSelectionFlow({
onBack,
onNext,
onSkip,
onFooterChange,
}: UseOrgAccountSelectionFlowProps) {
const {
organizationId,
organizationExternalId,
discoveryId,
discoveryResult,
treeData,
accountLookup,
selectableAccountIds,
selectableAccountIdSet,
selectedAccountIds,
accountAliases,
createdProviderIds,
connectionResults,
connectionErrors,
setSelectedAccountIds,
setAccountAlias,
setCreatedProviderIds,
clearValidationState,
setConnectionError,
setConnectionResult,
} = useOrgSetupStore();
const [isTestingView, setIsTestingView] = useState(false);
const [isApplying, setIsApplying] = useState(false);
const [isTesting, setIsTesting] = useState(false);
const [applyError, setApplyError] = useState<string | null>(null);
const [accountToProviderMap, setAccountToProviderMap] = useState<
Map<string, string>
>(new Map());
const isMountedRef = useRef(true);
const hasAppliedRef = useRef(false);
const lastAppliedSelectionKeyRef = useRef<string>("");
const startTestingActionRef = useRef<() => void>(() => {});
const sanitizedSelectedAccountIds = selectedAccountIds.filter((id) =>
selectableAccountIdSet.has(id),
);
const selectedAccountKey = getSelectionKey(sanitizedSelectedAccountIds);
const selectedIdsForTree = buildTreeSelectedIds(
treeData,
sanitizedSelectedAccountIds,
selectableAccountIdSet,
);
const selectedAccountIdSet = new Set(sanitizedSelectedAccountIds);
const selectedCount = sanitizedSelectedAccountIds.length;
const totalAccounts = selectableAccountIds.length;
const hasConnectionErrors = Object.values(connectionResults).some(
(status) => status === CONNECTION_TEST_STATUS.ERROR,
);
const launchableProviderIds = getLaunchableProviderIds(
createdProviderIds,
connectionResults,
);
const canAdvanceToLaunch = canAdvanceToLaunchStep(
createdProviderIds,
connectionResults,
);
const showHeaderHelperText = !isTestingView || isApplying || isTesting;
const isSelectionLocked = isApplying || isTesting;
const treeDataWithConnectionState = isTestingView
? buildTreeWithConnectionState(
treeData,
selectedAccountIdSet,
accountToProviderMap,
connectionResults,
connectionErrors,
isApplying || isTesting,
)
: treeData;
useEffect(() => {
isMountedRef.current = true;
return () => {
isMountedRef.current = false;
};
}, []);
const testAllConnections = async (providerIds: string[]) => {
setIsTesting(true);
for (const id of providerIds) {
setConnectionResult(id, CONNECTION_TEST_STATUS.PENDING);
setConnectionError(id, null);
}
await runWithConcurrencyLimit(providerIds, 5, async (providerId) => {
if (!isMountedRef.current) {
return;
}
try {
const formData = new FormData();
formData.set("providerId", providerId);
const checkResult = await checkConnectionProvider(formData);
if (!isMountedRef.current) {
return;
}
if (checkResult?.error || checkResult?.errors?.length) {
setConnectionResult(providerId, CONNECTION_TEST_STATUS.ERROR);
setConnectionError(
providerId,
extractErrorMessage(checkResult, "Connection test failed."),
);
return;
}
const taskId = checkResult?.data?.id;
if (!taskId) {
setConnectionResult(providerId, CONNECTION_TEST_STATUS.SUCCESS);
setConnectionError(providerId, null);
return;
}
const taskResult = await pollConnectionTask(taskId);
if (!isMountedRef.current) {
return;
}
setConnectionResult(
providerId,
taskResult.success
? CONNECTION_TEST_STATUS.SUCCESS
: CONNECTION_TEST_STATUS.ERROR,
);
setConnectionError(
providerId,
taskResult.success
? null
: taskResult.error || "Connection failed for this account.",
);
} catch {
if (!isMountedRef.current) {
return;
}
setConnectionResult(providerId, CONNECTION_TEST_STATUS.ERROR);
setConnectionError(
providerId,
"Unexpected error during connection test.",
);
}
});
if (!isMountedRef.current) {
return;
}
setIsTesting(false);
const latestResults = useOrgSetupStore.getState().connectionResults;
const allPassed =
providerIds.length > 0 &&
providerIds.every(
(providerId) =>
latestResults[providerId] === CONNECTION_TEST_STATUS.SUCCESS,
);
if (allPassed) {
onNext();
}
};
const handleApplyAndTest = async () => {
if (!organizationId || !discoveryId || !discoveryResult) {
return;
}
setApplyError(null);
setIsApplying(true);
const accounts = sanitizedSelectedAccountIds.map((id) => ({
id,
...(accountAliases[id] ? { alias: accountAliases[id] } : {}),
}));
const ouIds = getOuIdsForSelectedAccounts(
discoveryResult,
sanitizedSelectedAccountIds,
);
const organizationalUnits = ouIds.map((id) => ({ id }));
const result = await applyDiscovery(
organizationId,
discoveryId,
accounts,
organizationalUnits,
);
if (!isMountedRef.current) {
return;
}
if (result?.error || result?.errors?.length) {
setApplyError(extractErrorMessage(result, "Failed to apply discovery."));
setIsApplying(false);
hasAppliedRef.current = false;
return;
}
const providerIds: string[] =
result.data?.relationships?.providers?.data?.map(
(provider: { id: string }) => provider.id,
) ?? [];
setCreatedProviderIds(providerIds);
const mapping = await buildAccountToProviderMap({
selectedAccountIds: sanitizedSelectedAccountIds,
providerIds,
applyResult: result,
resolveProviderUidById: async (providerId) => {
const providerFormData = new FormData();
providerFormData.set("id", providerId);
const providerResponse = await getProvider(providerFormData);
if (providerResponse?.error || providerResponse?.errors?.length) {
return null;
}
return typeof providerResponse?.data?.attributes?.uid === "string"
? providerResponse.data.attributes.uid
: null;
},
});
if (!isMountedRef.current) {
return;
}
setAccountToProviderMap(mapping);
setIsApplying(false);
lastAppliedSelectionKeyRef.current = selectedAccountKey;
await testAllConnections(providerIds);
};
const handleStartTesting = () => {
setIsTestingView(true);
if (applyError) {
setApplyError(null);
hasAppliedRef.current = false;
lastAppliedSelectionKeyRef.current = "";
}
const shouldApplySelection =
!hasAppliedRef.current ||
lastAppliedSelectionKeyRef.current !== selectedAccountKey;
if (shouldApplySelection) {
hasAppliedRef.current = true;
void handleApplyAndTest();
return;
}
const failedProviderIds = createdProviderIds.filter(
(providerId) =>
connectionResults[providerId] === CONNECTION_TEST_STATUS.ERROR,
);
const providerIdsToTest =
failedProviderIds.length > 0 ? failedProviderIds : createdProviderIds;
void testAllConnections(providerIdsToTest);
};
startTestingActionRef.current = handleStartTesting;
useEffect(() => {
if (!isTestingView) {
onFooterChange({
showBack: true,
backLabel: "Back",
onBack,
showSecondaryAction: false,
secondaryActionLabel: "",
secondaryActionVariant: "outline",
secondaryActionType: WIZARD_FOOTER_ACTION_TYPE.BUTTON,
showAction: true,
actionLabel: "Test Connections",
actionDisabled: selectedCount === 0,
actionType: WIZARD_FOOTER_ACTION_TYPE.BUTTON,
onAction: () => {
startTestingActionRef.current();
},
});
return;
}
const canRetry = hasConnectionErrors || Boolean(applyError);
const hasSelectedAccounts = selectedCount > 0;
onFooterChange({
showBack: true,
backLabel: "Back",
backDisabled: isApplying || isTesting,
onBack: () => setIsTestingView(false),
showSecondaryAction: true,
secondaryActionLabel: "Skip Connection Validation",
secondaryActionDisabled: isApplying || isTesting || !canAdvanceToLaunch,
secondaryActionVariant: "link",
secondaryActionType: WIZARD_FOOTER_ACTION_TYPE.BUTTON,
onSecondaryAction: () => {
setCreatedProviderIds(launchableProviderIds);
onSkip();
},
showAction: isApplying || isTesting || canRetry || hasSelectedAccounts,
actionLabel: "Test Connections",
actionDisabled: isApplying || isTesting || !hasSelectedAccounts,
actionType: WIZARD_FOOTER_ACTION_TYPE.BUTTON,
onAction: hasSelectedAccounts
? () => {
startTestingActionRef.current();
}
: undefined,
});
}, [
applyError,
hasConnectionErrors,
isApplying,
isTesting,
isTestingView,
launchableProviderIds,
onBack,
onFooterChange,
onSkip,
selectedCount,
canAdvanceToLaunch,
setCreatedProviderIds,
]);
const handleTreeSelectionChange = (ids: string[]) => {
const filteredIds = ids.filter((id) => selectableAccountIdSet.has(id));
const nextSelectedAccountKey = getSelectionKey(filteredIds);
if (nextSelectedAccountKey !== selectedAccountKey) {
hasAppliedRef.current = false;
lastAppliedSelectionKeyRef.current = "";
setApplyError(null);
setAccountToProviderMap(new Map());
clearValidationState();
}
setSelectedAccountIds(filteredIds);
};
return {
accountAliases,
accountLookup,
applyError,
canAdvanceToLaunch,
discoveryResult,
handleTreeSelectionChange,
hasConnectionErrors,
isTesting,
isTestingView,
isSelectionLocked,
organizationExternalId,
selectedCount,
selectedIdsForTree,
setAccountAlias,
showHeaderHelperText,
totalAccounts,
treeDataWithConnectionState,
};
}

View File

@@ -0,0 +1,193 @@
import { act, renderHook } from "@testing-library/react";
import { createElement, type PropsWithChildren, StrictMode } from "react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { useOrgSetupStore } from "@/store/organizations/store";
import { APPLY_STATUS, DISCOVERY_STATUS } from "@/types/organizations";
import { useOrgSetupSubmission } from "./use-org-setup-submission";
const organizationsActionsMock = vi.hoisted(() => ({
createOrganization: vi.fn(),
createOrganizationSecret: vi.fn(),
getDiscovery: vi.fn(),
listOrganizationsByExternalId: vi.fn(),
listOrganizationSecretsByOrganizationId: vi.fn(),
triggerDiscovery: vi.fn(),
updateOrganizationSecret: vi.fn(),
}));
vi.mock(
"@/actions/organizations/organizations",
() => organizationsActionsMock,
);
function StrictModeWrapper({ children }: PropsWithChildren) {
return createElement(StrictMode, null, children);
}
describe("useOrgSetupSubmission", () => {
beforeEach(() => {
sessionStorage.clear();
localStorage.clear();
useOrgSetupStore.getState().reset();
for (const mockFn of Object.values(organizationsActionsMock)) {
mockFn.mockReset();
}
});
it("completes the setup chain and stores selectable accounts", async () => {
// Given
const onNext = vi.fn();
const setFieldError = vi.fn();
const discoveryResult = {
roots: [
{ id: "r-root", arn: "arn:root", name: "Root", policy_types: [] },
],
organizational_units: [],
accounts: [
{
id: "111111111111",
name: "Account One",
arn: "arn:aws:organizations::111111111111:account/o-123/111111111111",
email: "one@example.com",
status: "ACTIVE",
joined_method: "CREATED",
joined_timestamp: "2024-01-01T00:00:00Z",
parent_id: "r-root",
registration: {
provider_exists: false,
provider_id: null,
organization_relation: "link_required",
organizational_unit_relation: "not_applicable",
provider_secret_state: "will_create",
apply_status: APPLY_STATUS.READY,
blocked_reasons: [],
},
},
{
id: "222222222222",
name: "Account Two",
arn: "arn:aws:organizations::222222222222:account/o-123/222222222222",
email: "two@example.com",
status: "ACTIVE",
joined_method: "CREATED",
joined_timestamp: "2024-01-01T00:00:00Z",
parent_id: "r-root",
registration: {
provider_exists: false,
provider_id: null,
organization_relation: "link_required",
organizational_unit_relation: "not_applicable",
provider_secret_state: "will_create",
apply_status: APPLY_STATUS.BLOCKED,
blocked_reasons: ["Already linked"],
},
},
],
};
organizationsActionsMock.listOrganizationsByExternalId.mockResolvedValue({
data: [],
});
organizationsActionsMock.createOrganization.mockResolvedValue({
data: { id: "org-1" },
});
organizationsActionsMock.listOrganizationSecretsByOrganizationId.mockResolvedValue(
{
data: [],
},
);
organizationsActionsMock.createOrganizationSecret.mockResolvedValue({
data: { id: "secret-1" },
});
organizationsActionsMock.triggerDiscovery.mockResolvedValue({
data: { id: "discovery-1" },
});
organizationsActionsMock.getDiscovery.mockResolvedValue({
data: {
attributes: {
status: DISCOVERY_STATUS.SUCCEEDED,
result: discoveryResult,
},
},
});
const { result } = renderHook(
() =>
useOrgSetupSubmission({
stackSetExternalId: "tenant-external-id",
onNext,
setFieldError,
}),
{ wrapper: StrictModeWrapper },
);
// When
await act(async () => {
await result.current.submitOrganizationSetup({
organizationName: "Acme",
awsOrgId: "o-abc123def4",
roleArn: "arn:aws:iam::123456789012:role/ProwlerOrgRole",
});
});
// Then
expect(onNext).toHaveBeenCalledTimes(1);
expect(setFieldError).not.toHaveBeenCalled();
const state = useOrgSetupStore.getState();
expect(state.organizationId).toBe("org-1");
expect(state.organizationExternalId).toBe("o-abc123def4");
expect(state.discoveryId).toBe("discovery-1");
expect(state.selectedAccountIds).toEqual(["111111111111"]);
expect(state.selectableAccountIds).toEqual(["111111111111"]);
});
it("maps external_id server errors to awsOrgId field errors", async () => {
// Given
const onNext = vi.fn();
const setFieldError = vi.fn();
organizationsActionsMock.listOrganizationsByExternalId.mockResolvedValue({
data: [],
});
organizationsActionsMock.createOrganization.mockResolvedValue({
errors: [
{
detail: "Organization with this external_id already exists.",
source: { pointer: "/data/attributes/external_id" },
},
],
});
const { result } = renderHook(() =>
useOrgSetupSubmission({
stackSetExternalId: "tenant-external-id",
onNext,
setFieldError,
}),
);
// When
await act(async () => {
await result.current.submitOrganizationSetup({
organizationName: "Acme",
awsOrgId: "o-abc123def4",
roleArn: "arn:aws:iam::123456789012:role/ProwlerOrgRole",
});
});
// Then
expect(setFieldError).toHaveBeenCalledWith(
"awsOrgId",
"Organization with this external_id already exists.",
);
expect(result.current.apiError).toBe(
"Organization with this external_id already exists.",
);
expect(onNext).not.toHaveBeenCalled();
expect(
organizationsActionsMock.createOrganizationSecret,
).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,320 @@
"use client";
import { useEffect, useRef, useState } from "react";
import {
createOrganization,
createOrganizationSecret,
getDiscovery,
listOrganizationsByExternalId,
listOrganizationSecretsByOrganizationId,
triggerDiscovery,
updateOrganizationSecret,
} from "@/actions/organizations/organizations";
import { getSelectableAccountIds } from "@/actions/organizations/organizations.adapter";
import { useOrgSetupStore } from "@/store/organizations/store";
import { DISCOVERY_STATUS, DiscoveryResult } from "@/types/organizations";
const DISCOVERY_POLL_INTERVAL_MS = 3000;
const DISCOVERY_MAX_RETRIES = 60;
function sleepWithAbort(ms: number, signal: AbortSignal): Promise<void> {
return new Promise((resolve) => {
if (signal.aborted) {
resolve();
return;
}
const timeoutId = window.setTimeout(resolve, ms);
signal.addEventListener(
"abort",
() => {
window.clearTimeout(timeoutId);
resolve();
},
{ once: true },
);
});
}
interface OrgSetupSubmissionData {
organizationName?: string;
awsOrgId: string;
roleArn: string;
}
interface UseOrgSetupSubmissionProps {
stackSetExternalId: string;
onNext: () => void;
setFieldError: (
field: "awsOrgId" | "organizationName",
message: string,
) => void;
}
interface ServerErrorResult {
error?: string;
errors?: Array<{ detail: string; source?: { pointer: string } }>;
}
export function useOrgSetupSubmission({
stackSetExternalId,
onNext,
setFieldError,
}: UseOrgSetupSubmissionProps) {
const [apiError, setApiError] = useState<string | null>(null);
const isMountedRef = useRef(true);
const discoveryAbortControllerRef = useRef<AbortController | null>(null);
const {
setOrganization,
setDiscovery,
setSelectedAccountIds,
clearValidationState,
} = useOrgSetupStore();
useEffect(() => {
isMountedRef.current = true;
return () => {
isMountedRef.current = false;
discoveryAbortControllerRef.current?.abort();
};
}, []);
const handleServerError = (result: ServerErrorResult, context: string) => {
if (!isMountedRef.current) {
return;
}
if (result.errors?.length) {
for (const err of result.errors) {
const pointer = err.source?.pointer ?? "";
if (pointer.includes("external_id") && context === "Organization") {
setFieldError("awsOrgId", err.detail);
setApiError(err.detail);
} else if (pointer.includes("name")) {
setFieldError("organizationName", err.detail);
} else {
setApiError(err.detail);
}
}
} else {
setApiError(result.error ?? `Failed to create ${context}`);
}
};
const pollDiscoveryResult = async (
organizationId: string,
discoveryId: string,
signal: AbortSignal,
): Promise<DiscoveryResult | null> => {
for (let attempt = 0; attempt < DISCOVERY_MAX_RETRIES; attempt += 1) {
if (signal.aborted || !isMountedRef.current) {
return null;
}
const result = await getDiscovery(organizationId, discoveryId);
if (signal.aborted || !isMountedRef.current) {
return null;
}
if (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;
if (status === DISCOVERY_STATUS.SUCCEEDED) {
return result.data.attributes.result as DiscoveryResult;
}
if (status === DISCOVERY_STATUS.FAILED) {
const backendError = result.data.attributes.error;
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 sleepWithAbort(DISCOVERY_POLL_INTERVAL_MS, signal);
}
if (signal.aborted || !isMountedRef.current) {
return null;
}
setApiError(
"Authentication timed out. Please verify the credentials and try again.",
);
return null;
};
const submitOrganizationSetup = async (data: OrgSetupSubmissionData) => {
discoveryAbortControllerRef.current?.abort();
const abortController = new AbortController();
discoveryAbortControllerRef.current = abortController;
const isCancelled = () =>
!isMountedRef.current || abortController.signal.aborted;
const setApiErrorIfActive = (message: string) => {
if (!isCancelled()) {
setApiError(message);
}
};
try {
if (!isCancelled()) {
setApiError(null);
}
clearValidationState();
const resolvedOrganizationName =
data.organizationName?.trim() || data.awsOrgId;
const existingOrganizationsResult = await listOrganizationsByExternalId(
data.awsOrgId,
);
if (isCancelled()) {
return;
}
if (existingOrganizationsResult?.error) {
setApiErrorIfActive(existingOrganizationsResult.error);
return;
}
const existingOrganization = Array.isArray(
existingOrganizationsResult?.data,
)
? existingOrganizationsResult.data.find(
(organization: {
id: string;
attributes?: { external_id?: string; org_type?: string };
}) =>
organization?.attributes?.external_id === data.awsOrgId &&
organization?.attributes?.org_type === "aws",
)
: null;
let orgId = existingOrganization?.id as string | undefined;
if (!orgId) {
const orgFormData = new FormData();
orgFormData.set("name", resolvedOrganizationName);
orgFormData.set("externalId", data.awsOrgId);
const orgResult = await createOrganization(orgFormData);
if (isCancelled()) {
return;
}
if (orgResult?.error || orgResult?.errors?.length) {
handleServerError(orgResult, "Organization");
return;
}
orgId = orgResult.data.id;
}
if (!orgId) {
setApiErrorIfActive(
"Unable to resolve organization ID for authentication.",
);
return;
}
const organizationNameForStore =
existingOrganization?.attributes?.name ?? resolvedOrganizationName;
setOrganization(orgId, organizationNameForStore, data.awsOrgId);
const existingSecretsResult =
await listOrganizationSecretsByOrganizationId(orgId);
if (isCancelled()) {
return;
}
if (existingSecretsResult?.error) {
setApiErrorIfActive(existingSecretsResult.error);
return;
}
const existingSecretId =
Array.isArray(existingSecretsResult?.data) &&
existingSecretsResult.data.length > 0
? (existingSecretsResult.data[0]?.id as string | undefined)
: undefined;
let secretResult;
if (existingSecretId) {
const patchSecretFormData = new FormData();
patchSecretFormData.set("organizationSecretId", existingSecretId);
patchSecretFormData.set("roleArn", data.roleArn);
patchSecretFormData.set("externalId", stackSetExternalId);
secretResult = await updateOrganizationSecret(patchSecretFormData);
} else {
const createSecretFormData = new FormData();
createSecretFormData.set("organizationId", orgId);
createSecretFormData.set("roleArn", data.roleArn);
createSecretFormData.set("externalId", stackSetExternalId);
secretResult = await createOrganizationSecret(createSecretFormData);
}
if (isCancelled()) {
return;
}
if (secretResult?.error) {
handleServerError(secretResult, "Secret");
return;
}
const discoveryResult = await triggerDiscovery(orgId);
if (isCancelled()) {
return;
}
if (discoveryResult?.error) {
setApiErrorIfActive(discoveryResult.error);
return;
}
const discoveryId = discoveryResult.data.id;
const resolvedDiscoveryResult = await pollDiscoveryResult(
orgId,
discoveryId,
abortController.signal,
);
if (!resolvedDiscoveryResult || isCancelled()) {
return;
}
const selectableAccountIds = getSelectableAccountIds(
resolvedDiscoveryResult,
);
setDiscovery(discoveryId, resolvedDiscoveryResult);
setSelectedAccountIds(selectableAccountIds);
onNext();
} catch {
if (!isCancelled()) {
setApiError(
"Authentication failed. Please verify the StackSet deployment and Role ARN, then try again.",
);
}
} finally {
if (discoveryAbortControllerRef.current === abortController) {
discoveryAbortControllerRef.current = null;
}
}
};
return {
apiError,
setApiError,
submitOrganizationSetup,
};
}

View File

@@ -0,0 +1,126 @@
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { APPLY_STATUS } from "@/types/organizations";
import { OrgAccountSelection } from "./org-account-selection";
const { useOrgAccountSelectionFlowMock, handleTreeSelectionChangeMock } =
vi.hoisted(() => ({
useOrgAccountSelectionFlowMock: vi.fn(),
handleTreeSelectionChangeMock: vi.fn(),
}));
vi.mock("./hooks/use-org-account-selection-flow", () => ({
useOrgAccountSelectionFlow: useOrgAccountSelectionFlowMock,
}));
describe("OrgAccountSelection", () => {
let baseFlowState: Record<string, unknown>;
beforeEach(() => {
useOrgAccountSelectionFlowMock.mockReset();
handleTreeSelectionChangeMock.mockReset();
const accountLookup = new Map([
[
"222222222222",
{
id: "222222222222",
name: "Account Two",
arn: "arn:aws:organizations::222222222222:account/o-123/222222222222",
email: "two@example.com",
status: "ACTIVE",
joined_method: "CREATED",
joined_timestamp: "2024-01-01T00:00:00Z",
parent_id: "r-root",
registration: {
provider_exists: false,
provider_id: null,
organization_relation: "link_required",
organizational_unit_relation: "not_applicable",
provider_secret_state: "will_create",
apply_status: APPLY_STATUS.READY,
blocked_reasons: [],
},
},
],
]);
baseFlowState = {
accountAliases: {},
accountLookup,
applyError: null,
canAdvanceToLaunch: false,
discoveryResult: {
roots: [],
organizational_units: [],
accounts: [],
},
handleTreeSelectionChange: handleTreeSelectionChangeMock,
hasConnectionErrors: true,
isTesting: false,
isTestingView: true,
isSelectionLocked: false,
organizationExternalId: "o-abc123def4",
selectedCount: 1,
selectedIdsForTree: [],
setAccountAlias: vi.fn(),
showHeaderHelperText: true,
totalAccounts: 2,
treeDataWithConnectionState: [
{
id: "222222222222",
name: "222222222222 - Account Two",
},
],
};
useOrgAccountSelectionFlowMock.mockReturnValue(baseFlowState);
});
it("allows changing account selection after finishing connection tests", async () => {
// Given
const user = userEvent.setup();
render(
<OrgAccountSelection
onBack={vi.fn()}
onNext={vi.fn()}
onSkip={vi.fn()}
onFooterChange={vi.fn()}
/>,
);
// When
await user.click(screen.getByRole("checkbox"));
// Then
expect(handleTreeSelectionChangeMock).toHaveBeenCalledWith([
"222222222222",
]);
});
it("locks account selection while apply or connection test is running", async () => {
// Given
const user = userEvent.setup();
useOrgAccountSelectionFlowMock.mockReturnValue({
...baseFlowState,
isSelectionLocked: true,
});
render(
<OrgAccountSelection
onBack={vi.fn()}
onNext={vi.fn()}
onSkip={vi.fn()}
onFooterChange={vi.fn()}
/>,
);
// When
await user.click(screen.getByRole("checkbox"));
// Then
expect(handleTreeSelectionChangeMock).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,130 @@
"use client";
import { AlertTriangle } from "lucide-react";
import { AWSProviderBadge } from "@/components/icons/providers-badge";
import { WizardFooterConfig } from "@/components/providers/wizard/steps/footer-controls";
import { Alert, AlertDescription } from "@/components/shadcn/alert";
import { TreeView } from "@/components/shadcn/tree-view";
import { useOrgAccountSelectionFlow } from "./hooks/use-org-account-selection-flow";
import { OrgAccountTreeItem, TREE_ITEM_MODE } from "./org-account-tree-item";
interface OrgAccountSelectionProps {
onBack: () => void;
onNext: () => void;
onSkip: () => void;
onFooterChange: (config: WizardFooterConfig) => void;
}
export function OrgAccountSelection({
onBack,
onNext,
onSkip,
onFooterChange,
}: OrgAccountSelectionProps) {
const {
accountAliases,
accountLookup,
applyError,
canAdvanceToLaunch,
discoveryResult,
handleTreeSelectionChange,
hasConnectionErrors,
isTesting,
isTestingView,
isSelectionLocked,
organizationExternalId,
selectedCount,
selectedIdsForTree,
setAccountAlias,
showHeaderHelperText,
totalAccounts,
treeDataWithConnectionState,
} = useOrgAccountSelectionFlow({
onBack,
onNext,
onSkip,
onFooterChange,
});
if (!discoveryResult) {
return (
<div className="text-muted-foreground py-8 text-center text-sm">
No discovery data available.
</div>
);
}
return (
<div className="flex min-h-0 flex-1 flex-col gap-5">
<div className="flex flex-col gap-3">
<div className="flex items-center gap-4">
<AWSProviderBadge size={32} />
<h3 className="text-base font-semibold">My Organization</h3>
</div>
<div className="ml-12 flex items-center gap-3">
<span className="text-text-neutral-tertiary text-xs">UID:</span>
<div className="bg-bg-neutral-tertiary border-border-input-primary inline-flex h-10 items-center rounded-full border px-4">
<span className="text-xs font-medium">
{organizationExternalId || "N/A"}
</span>
</div>
</div>
{showHeaderHelperText && (
<p className="text-muted-foreground text-sm">
{isTestingView
? "Testing account connections..."
: "Confirm all accounts under this Organization you want to add to Prowler."}{" "}
{!isTestingView &&
`${selectedCount} of ${totalAccounts} accounts selected.`}
</p>
)}
</div>
{isTestingView && applyError && (
<Alert variant="error">
<AlertTriangle />
<AlertDescription className="text-text-error-primary">
{applyError}
</AlertDescription>
</Alert>
)}
{isTestingView && hasConnectionErrors && !isTesting && (
<Alert variant="error">
<AlertTriangle />
<AlertDescription className="text-text-error-primary">
{canAdvanceToLaunch
? "There was a problem connecting to some accounts. Hover each account to check the error."
: "No accounts connected successfully. Fix the connection errors and retry before launching scans."}
</AlertDescription>
</Alert>
)}
<div className="border-border-neutral-secondary min-h-0 flex-1 overflow-y-auto rounded-md border p-2">
<TreeView
data={treeDataWithConnectionState}
showCheckboxes
enableSelectChildren
expandAll
selectedIds={selectedIdsForTree}
onSelectionChange={
isSelectionLocked ? () => {} : handleTreeSelectionChange
}
renderItem={(params) => (
<OrgAccountTreeItem
params={params}
mode={TREE_ITEM_MODE.SELECTION}
accountLookup={accountLookup}
aliases={accountAliases}
onAliasChange={setAccountAlias}
/>
)}
/>
</div>
</div>
);
}

View File

@@ -0,0 +1,176 @@
import { describe, expect, it, vi } from "vitest";
import { CONNECTION_TEST_STATUS } from "@/types/organizations";
import {
buildAccountToProviderMap,
canAdvanceToLaunchStep,
getLaunchableProviderIds,
pollConnectionTask,
runWithConcurrencyLimit,
} from "./org-account-selection.utils";
describe("buildAccountToProviderMap", () => {
it("uses explicit account-provider mappings when apply response is unordered", async () => {
// Given
const resolveProviderUidById = vi.fn();
const selectedAccountIds = ["111111111111", "222222222222"];
const providerIds = ["provider-b", "provider-a"];
const applyResult = {
data: {
attributes: {
account_provider_mappings: [
{
account_id: "111111111111",
provider_id: "provider-a",
},
{
account_id: "222222222222",
provider_id: "provider-b",
},
],
},
},
};
// When
const map = await buildAccountToProviderMap({
selectedAccountIds,
providerIds,
applyResult,
resolveProviderUidById,
});
// Then
expect(map.get("111111111111")).toBe("provider-a");
expect(map.get("222222222222")).toBe("provider-b");
expect(resolveProviderUidById).not.toHaveBeenCalled();
});
it("falls back to provider uid matching when explicit mappings are missing", async () => {
// Given
const selectedAccountIds = ["111111111111", "222222222222"];
const providerIds = ["provider-a", "provider-b", "provider-c"];
const resolveProviderUidById = vi.fn(async (providerId: string) => {
if (providerId === "provider-a") return "222222222222";
if (providerId === "provider-c") return "111111111111";
return "999999999999";
});
// When
const map = await buildAccountToProviderMap({
selectedAccountIds,
providerIds,
applyResult: {},
resolveProviderUidById,
});
// Then
expect(map.get("111111111111")).toBe("provider-c");
expect(map.get("222222222222")).toBe("provider-a");
});
});
describe("runWithConcurrencyLimit", () => {
it("processes work with the configured concurrency cap", async () => {
// Given
const items = Array.from({ length: 8 }, (_, index) => index + 1);
let activeWorkers = 0;
let maxActiveWorkers = 0;
// When
const results = await runWithConcurrencyLimit(items, 3, async (item) => {
activeWorkers += 1;
maxActiveWorkers = Math.max(maxActiveWorkers, activeWorkers);
await new Promise((resolve) => setTimeout(resolve, 1));
activeWorkers -= 1;
return item * 2;
});
// Then
expect(maxActiveWorkers).toBeLessThanOrEqual(3);
expect(results).toEqual([2, 4, 6, 8, 10, 12, 14, 16]);
});
});
describe("pollConnectionTask", () => {
it("uses progressive delays and returns connection result from the final task payload", async () => {
// Given
const sleeps: number[] = [];
const getTaskById = vi
.fn()
.mockResolvedValueOnce({
data: { attributes: { state: "executing" } },
})
.mockResolvedValueOnce({
data: { attributes: { state: "executing" } },
})
.mockResolvedValueOnce({
data: {
attributes: {
state: "completed",
result: { connected: false, error: "Role trust policy mismatch." },
},
},
});
// When
const result = await pollConnectionTask("task-1", {
getTaskById,
sleep: async (delay) => {
sleeps.push(delay);
},
maxRetries: 5,
});
// Then
expect(sleeps).toEqual([2000, 3000]);
expect(getTaskById).toHaveBeenCalledTimes(3);
expect(result).toEqual({
success: false,
error: "Role trust policy mismatch.",
});
});
});
describe("launch gating", () => {
it("blocks advancing when all tested providers failed", () => {
// Given
const providerIds = ["provider-a", "provider-b"];
const connectionResults = {
"provider-a": CONNECTION_TEST_STATUS.ERROR,
"provider-b": CONNECTION_TEST_STATUS.ERROR,
};
// When
const launchableProviderIds = getLaunchableProviderIds(
providerIds,
connectionResults,
);
const canAdvance = canAdvanceToLaunchStep(providerIds, connectionResults);
// Then
expect(launchableProviderIds).toEqual([]);
expect(canAdvance).toBe(false);
});
it("allows advancing and keeps only successful providers", () => {
// Given
const providerIds = ["provider-a", "provider-b"];
const connectionResults = {
"provider-a": CONNECTION_TEST_STATUS.SUCCESS,
"provider-b": CONNECTION_TEST_STATUS.ERROR,
};
// When
const launchableProviderIds = getLaunchableProviderIds(
providerIds,
connectionResults,
);
const canAdvance = canAdvanceToLaunchStep(providerIds, connectionResults);
// Then
expect(launchableProviderIds).toEqual(["provider-a"]);
expect(canAdvance).toBe(true);
});
});

View File

@@ -0,0 +1,272 @@
import {
CONNECTION_TEST_STATUS,
ConnectionTestStatus,
} from "@/types/organizations";
const DEFAULT_CONCURRENCY_LIMIT = 5;
const DEFAULT_POLL_DELAYS_MS = [2000, 3000, 5000] as const;
interface AccountProviderMapping {
account_id: string;
provider_id: string;
}
interface BuildAccountToProviderMapParams {
selectedAccountIds: string[];
providerIds: string[];
applyResult: unknown;
resolveProviderUidById: (providerId: string) => Promise<string | null>;
}
interface PollConnectionTaskOptions {
getTaskById?: (taskId: string) => Promise<unknown>;
sleep?: (ms: number) => Promise<void>;
maxRetries?: number;
delaysMs?: number[];
}
export interface PollConnectionTaskResult {
success: boolean;
error?: string;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null;
}
function getPollingDelay(attempt: number, delaysMs: number[]): number {
if (delaysMs.length === 0) {
return DEFAULT_POLL_DELAYS_MS[DEFAULT_POLL_DELAYS_MS.length - 1];
}
const delayIndex = Math.min(attempt, delaysMs.length - 1);
return delaysMs[delayIndex] ?? delaysMs[delaysMs.length - 1];
}
function normalizeAccountProviderMapping(
value: unknown,
): AccountProviderMapping | null {
if (!isRecord(value)) {
return null;
}
const attributes = isRecord(value.attributes) ? value.attributes : null;
const accountId =
(typeof value.account_id === "string" && value.account_id) ||
(typeof attributes?.account_id === "string" && attributes.account_id) ||
(typeof value.id === "string" && value.id) ||
null;
const providerId =
(typeof value.provider_id === "string" && value.provider_id) ||
(typeof attributes?.provider_id === "string" && attributes.provider_id) ||
null;
if (!accountId || !providerId) {
return null;
}
return {
account_id: accountId,
provider_id: providerId,
};
}
function extractAccountProviderMappings(applyResult: unknown) {
if (!isRecord(applyResult)) {
return [];
}
const data = isRecord(applyResult.data) ? applyResult.data : null;
if (!data) {
return [];
}
const attributes = isRecord(data.attributes) ? data.attributes : null;
const relationships = isRecord(data.relationships)
? data.relationships
: null;
const attributeMappings = Array.isArray(attributes?.account_provider_mappings)
? attributes.account_provider_mappings
: [];
const relationshipNode = isRecord(relationships?.account_provider_mappings)
? relationships.account_provider_mappings
: null;
const relationshipMappings = Array.isArray(relationshipNode?.data)
? relationshipNode.data
: [];
return [...attributeMappings, ...relationshipMappings]
.map(normalizeAccountProviderMapping)
.filter((mapping): mapping is AccountProviderMapping => mapping !== null);
}
export async function runWithConcurrencyLimit<T, R>(
items: T[],
concurrencyLimit: number,
worker: (item: T, index: number) => Promise<R>,
): Promise<R[]> {
if (items.length === 0) {
return [];
}
const normalizedConcurrency = Math.max(1, Math.floor(concurrencyLimit));
const results = new Array<R>(items.length);
let currentIndex = 0;
const runWorker = async () => {
while (currentIndex < items.length) {
const assignedIndex = currentIndex;
currentIndex += 1;
results[assignedIndex] = await worker(
items[assignedIndex],
assignedIndex,
);
}
};
const workers = Array.from(
{ length: Math.min(normalizedConcurrency, items.length) },
() => runWorker(),
);
await Promise.all(workers);
return results;
}
export async function buildAccountToProviderMap({
selectedAccountIds,
providerIds,
applyResult,
resolveProviderUidById,
}: BuildAccountToProviderMapParams): Promise<Map<string, string>> {
const selectedAccountIdSet = new Set(selectedAccountIds);
const explicitMappings = extractAccountProviderMappings(applyResult);
if (explicitMappings.length > 0) {
const mappedProviders = new Map<string, string>();
for (const mapping of explicitMappings) {
if (!selectedAccountIdSet.has(mapping.account_id)) {
continue;
}
mappedProviders.set(mapping.account_id, mapping.provider_id);
}
if (mappedProviders.size > 0) {
return mappedProviders;
}
}
const fallbackEntries = await runWithConcurrencyLimit(
providerIds,
DEFAULT_CONCURRENCY_LIMIT,
async (providerId) => {
const providerUid = await resolveProviderUidById(providerId);
if (!providerUid || !selectedAccountIdSet.has(providerUid)) {
return null;
}
return { accountId: providerUid, providerId };
},
);
const fallbackMapping = new Map<string, string>();
for (const entry of fallbackEntries) {
if (!entry) {
continue;
}
fallbackMapping.set(entry.accountId, entry.providerId);
}
return fallbackMapping;
}
export async function pollConnectionTask(
taskId: string,
{
getTaskById,
sleep = async (ms: number) =>
new Promise((resolve) => setTimeout(resolve, ms)),
maxRetries = 20,
delaysMs = [...DEFAULT_POLL_DELAYS_MS],
}: PollConnectionTaskOptions = {},
): Promise<PollConnectionTaskResult> {
const inProgressStates = new Set([
"available",
"scheduled",
"executing",
"pending",
"running",
]);
const taskFetcher =
getTaskById ??
(async (currentTaskId: string) => {
const { getTask } = await import("@/actions/task/tasks");
return getTask(currentTaskId);
});
for (let attempt = 0; attempt < maxRetries; attempt += 1) {
const taskResponse = await taskFetcher(taskId);
if (isRecord(taskResponse) && typeof taskResponse.error === "string") {
return { success: false, error: taskResponse.error };
}
const data =
isRecord(taskResponse) && isRecord(taskResponse.data)
? taskResponse.data
: null;
const attributes = isRecord(data?.attributes) ? data.attributes : null;
const state =
typeof attributes?.state === "string" ? attributes.state : null;
const result = isRecord(attributes?.result) ? attributes.result : null;
if (state === "completed") {
const connected =
typeof result?.connected === "boolean" ? result.connected : true;
if (connected) {
return { success: true };
}
return {
success: false,
error:
(typeof result?.error === "string" && result.error) ||
"Connection failed for this account.",
};
}
if (state === "failed") {
return {
success: false,
error:
(typeof result?.error === "string" && result.error) ||
"Connection test task failed.",
};
}
if (!state || !inProgressStates.has(state)) {
return { success: false, error: "Unexpected task state." };
}
await sleep(getPollingDelay(attempt, delaysMs));
}
return { success: false, error: "Connection test timed out." };
}
export function getLaunchableProviderIds(
providerIds: string[],
connectionResults: Record<string, ConnectionTestStatus>,
): string[] {
return providerIds.filter(
(providerId) =>
connectionResults[providerId] === CONNECTION_TEST_STATUS.SUCCESS,
);
}
export function canAdvanceToLaunchStep(
providerIds: string[],
connectionResults: Record<string, ConnectionTestStatus>,
): boolean {
return getLaunchableProviderIds(providerIds, connectionResults).length > 0;
}

View File

@@ -0,0 +1,127 @@
"use client";
import { AlertCircle } from "lucide-react";
import { Input } from "@/components/shadcn/input/input";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/shadcn/tooltip";
import { cn } from "@/lib/utils";
import { APPLY_STATUS, DiscoveredAccount } from "@/types/organizations";
import { TreeRenderItemParams } from "@/types/tree";
const TREE_ITEM_MODE = {
SELECTION: "selection",
} as const;
type TreeItemMode = (typeof TREE_ITEM_MODE)[keyof typeof TREE_ITEM_MODE];
interface OrgAccountTreeItemProps {
params: TreeRenderItemParams;
mode: TreeItemMode;
accountLookup: Map<string, DiscoveredAccount>;
aliases: Record<string, string>;
onAliasChange?: (accountId: string, alias: string) => void;
}
export function OrgAccountTreeItem({
params,
mode,
accountLookup,
aliases,
onAliasChange,
}: OrgAccountTreeItemProps) {
const { item, isLeaf } = params;
const account = accountLookup.get(item.id);
const isOuNode = item.id.startsWith("ou-");
const ItemIcon = item.icon;
const idColumnClass = "w-44 shrink-0";
const aliasInputClass = "h-9 w-full max-w-64 text-sm";
// OU nodes: show OU id + alias/name (input in selection mode).
if (!account && isOuNode) {
const ouDisplayName = aliases[item.id] ?? item.name;
const isSelectionMode = mode === TREE_ITEM_MODE.SELECTION && onAliasChange;
return (
<div className="flex flex-1 items-center gap-3">
<div className={`${idColumnClass} flex items-center gap-2`}>
{ItemIcon && (
<ItemIcon className="text-muted-foreground size-4 shrink-0" />
)}
<span className="text-sm">{item.id}</span>
</div>
<div className="min-w-0 flex-1">
{isSelectionMode ? (
<Input
className={aliasInputClass}
placeholder="Name (optional)"
value={ouDisplayName}
onChange={(e) => onAliasChange(item.id, e.target.value)}
onClick={(e) => e.stopPropagation()}
/>
) : (
<span className="text-muted-foreground line-clamp-1 text-xs">
{ouDisplayName}
</span>
)}
</div>
</div>
);
}
// Any remaining non-account node (unexpected fallback).
if (!account || !isLeaf) {
return <span className="text-sm font-medium">{item.name}</span>;
}
const isBlocked = account.registration?.apply_status === APPLY_STATUS.BLOCKED;
const blockedReasons = account.registration?.blocked_reasons ?? [];
return (
<div className="flex flex-1 items-center gap-3">
{/* Account ID */}
<div className={cn(idColumnClass, "flex items-center gap-2")}>
{ItemIcon && (
<ItemIcon className="text-muted-foreground size-4 shrink-0" />
)}
<span className={cn("text-sm", isBlocked && "text-muted-foreground")}>
{account.id}
</span>
</div>
{/* Name / alias input */}
<div className="min-w-0 flex-1">
{mode === TREE_ITEM_MODE.SELECTION && !isBlocked && onAliasChange ? (
<Input
className={aliasInputClass}
placeholder="Name (optional)"
value={aliases[account.id] ?? account.name}
onChange={(e) => onAliasChange(account.id, e.target.value)}
onClick={(e) => e.stopPropagation()}
/>
) : (
<span className="text-muted-foreground line-clamp-1 text-xs">
{aliases[account.id] || account.name}
</span>
)}
</div>
{/* Blocked reason tooltip */}
{isBlocked && blockedReasons.length > 0 && (
<Tooltip>
<TooltipTrigger asChild>
<AlertCircle className="text-destructive size-4 shrink-0" />
</TooltipTrigger>
<TooltipContent>
<p className="text-xs">{blockedReasons.join(", ")}</p>
</TooltipContent>
</Tooltip>
)}
</div>
);
}
export { TREE_ITEM_MODE, type TreeItemMode };

View File

@@ -0,0 +1,79 @@
import { act, render, waitFor } from "@testing-library/react";
import type { ComponentProps } from "react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { useOrgSetupStore } from "@/store/organizations/store";
import { OrgLaunchScan } from "./org-launch-scan";
const { launchOrganizationScansMock, pushMock, toastMock } = vi.hoisted(() => ({
launchOrganizationScansMock: vi.fn(),
pushMock: vi.fn(),
toastMock: vi.fn(),
}));
vi.mock("@/actions/scans/scans", () => ({
launchOrganizationScans: launchOrganizationScansMock,
}));
vi.mock("next/navigation", () => ({
useRouter: () => ({
push: pushMock,
}),
}));
vi.mock("@/components/ui", () => ({
ToastAction: ({ children, ...props }: ComponentProps<"button">) => (
<button {...props}>{children}</button>
),
useToast: () => ({
toast: toastMock,
}),
}));
describe("OrgLaunchScan", () => {
beforeEach(() => {
sessionStorage.clear();
localStorage.clear();
launchOrganizationScansMock.mockReset();
pushMock.mockReset();
toastMock.mockReset();
useOrgSetupStore.getState().reset();
useOrgSetupStore
.getState()
.setOrganization("org-1", "My Organization", "o-abc123def4");
useOrgSetupStore.getState().setCreatedProviderIds(["provider-1"]);
});
it("shows a success toast with an action linking to scans", async () => {
// Given
launchOrganizationScansMock.mockResolvedValue({ successCount: 1 });
const onFooterChange = vi.fn();
render(
<OrgLaunchScan
onClose={vi.fn()}
onBack={vi.fn()}
onFooterChange={onFooterChange}
/>,
);
// When
await waitFor(() => {
expect(onFooterChange).toHaveBeenCalled();
});
const footerConfig = onFooterChange.mock.calls.at(-1)?.[0];
await act(async () => {
footerConfig.onAction?.();
});
// Then
await waitFor(() => {
expect(toastMock).toHaveBeenCalledTimes(1);
});
const toastPayload = toastMock.mock.calls[0]?.[0];
expect(toastPayload.title).toBe("Scan Launched");
expect(toastPayload.action).toBeDefined();
expect(toastPayload.action.props.children.props.href).toBe("/scans");
});
});

View File

@@ -0,0 +1,177 @@
"use client";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useEffect, useRef, useState } from "react";
import { launchOrganizationScans } from "@/actions/scans/scans";
import { AWSProviderBadge } from "@/components/icons/providers-badge";
import {
WIZARD_FOOTER_ACTION_TYPE,
WizardFooterConfig,
} from "@/components/providers/wizard/steps/footer-controls";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/shadcn/select/select";
import { TreeSpinner } from "@/components/shadcn/tree-view/tree-spinner";
import { TreeStatusIcon } from "@/components/shadcn/tree-view/tree-status-icon";
import { ToastAction, useToast } from "@/components/ui";
import { useOrgSetupStore } from "@/store/organizations/store";
import { TREE_ITEM_STATUS } from "@/types/tree";
interface OrgLaunchScanProps {
onClose: () => void;
onBack: () => void;
onFooterChange: (config: WizardFooterConfig) => void;
}
const SCAN_SCHEDULE = {
DAILY: "daily",
SINGLE: "single",
} as const;
type ScanScheduleOption = (typeof SCAN_SCHEDULE)[keyof typeof SCAN_SCHEDULE];
export function OrgLaunchScan({
onClose,
onBack,
onFooterChange,
}: OrgLaunchScanProps) {
const router = useRouter();
const { toast } = useToast();
const { organizationExternalId, createdProviderIds, reset } =
useOrgSetupStore();
const [isLaunching, setIsLaunching] = useState(false);
const [scheduleOption, setScheduleOption] = useState<ScanScheduleOption>(
SCAN_SCHEDULE.DAILY,
);
const launchActionRef = useRef<() => void>(() => {});
const handleLaunchScan = async () => {
setIsLaunching(true);
const result = await launchOrganizationScans(
createdProviderIds,
scheduleOption,
);
const successCount = result.successCount;
setIsLaunching(false);
reset();
onClose();
router.push("/providers");
toast({
title: "Scan Launched",
description:
scheduleOption === SCAN_SCHEDULE.DAILY
? `Daily scan scheduled for ${successCount} account${successCount !== 1 ? "s" : ""}.`
: `Single scan launched for ${successCount} account${successCount !== 1 ? "s" : ""}.`,
action: (
<ToastAction altText="Go to scans" asChild>
<Link href="/scans">Go to scans</Link>
</ToastAction>
),
});
};
launchActionRef.current = () => {
void handleLaunchScan();
};
useEffect(() => {
onFooterChange({
showBack: true,
backLabel: "Back",
backDisabled: isLaunching,
onBack,
showAction: true,
actionLabel: "Launch scan",
actionDisabled: isLaunching || createdProviderIds.length === 0,
actionType: WIZARD_FOOTER_ACTION_TYPE.BUTTON,
onAction: () => {
launchActionRef.current();
},
});
}, [createdProviderIds.length, isLaunching, onBack, onFooterChange]);
return (
<div className="flex min-h-0 flex-1 flex-col gap-8">
<div className="flex flex-col gap-3">
<div className="flex items-center gap-4">
<AWSProviderBadge size={32} />
<h3 className="text-base font-semibold">My Organization</h3>
</div>
<div className="ml-12 flex items-center gap-3">
<span className="text-text-neutral-tertiary text-xs">UID:</span>
<div className="bg-bg-neutral-tertiary border-border-input-primary inline-flex h-10 items-center rounded-full border px-4">
<span className="text-xs font-medium">
{organizationExternalId || "N/A"}
</span>
</div>
</div>
</div>
{isLaunching ? (
<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">Launching scans...</p>
</div>
</div>
) : (
<div className="flex max-w-2xl flex-col gap-6">
<div className="flex items-center gap-3">
<TreeStatusIcon
status={TREE_ITEM_STATUS.SUCCESS}
className="size-6"
/>
<h3 className="text-sm font-semibold">Accounts Connected!</h3>
</div>
<p className="text-text-neutral-secondary text-sm">
Your accounts are connected to Prowler and ready to Scan!
</p>
{createdProviderIds.length === 0 && (
<p className="text-text-error-primary text-sm">
No successfully connected accounts are available to launch scans.
Go back and retry connection tests.
</p>
)}
<div className="flex flex-col gap-4">
<p className="text-text-neutral-secondary text-sm">
Select a Prowler scan schedule for these accounts.
</p>
<Select
value={scheduleOption}
onValueChange={(value) =>
setScheduleOption(value as ScanScheduleOption)
}
disabled={isLaunching}
>
<SelectTrigger className="w-full max-w-[376px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value={SCAN_SCHEDULE.DAILY}>
Scan Daily (every 24 hours)
</SelectItem>
<SelectItem value={SCAN_SCHEDULE.SINGLE}>
Run a single scan (no recurring schedule)
</SelectItem>
</SelectContent>
</Select>
</div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,407 @@
"use client";
import { zodResolver } from "@hookform/resolvers/zod";
import { Check, Copy, ExternalLink } from "lucide-react";
import { useSession } from "next-auth/react";
import { FormEvent, useEffect, useState } from "react";
import { Controller, useForm } from "react-hook-form";
import { z } from "zod";
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 { ORG_SETUP_PHASE, OrgSetupPhase } from "@/types/organizations";
import { useOrgSetupSubmission } from "./hooks/use-org-setup-submission";
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, "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)",
),
stackSetDeployed: z.boolean().refine((value) => value, {
message: "You must confirm the StackSet deployment before continuing.",
}),
});
type OrgSetupFormData = z.infer<typeof orgSetupSchema>;
interface OrgSetupFormProps {
onBack: () => void;
onNext: () => void;
onFooterChange: (config: WizardFooterConfig) => void;
onPhaseChange: (phase: OrgSetupPhase) => void;
initialPhase?: OrgSetupPhase;
}
export function OrgSetupForm({
onBack,
onNext,
onFooterChange,
onPhaseChange,
initialPhase = ORG_SETUP_PHASE.DETAILS,
}: OrgSetupFormProps) {
const { data: session } = useSession();
const [isExternalIdCopied, setIsExternalIdCopied] = useState(false);
const stackSetExternalId = session?.tenantId ?? "";
const [setupPhase, setSetupPhase] = useState<OrgSetupPhase>(initialPhase);
const formId = "org-wizard-setup-form";
const {
control,
register,
handleSubmit,
formState: { errors, isSubmitting, isValid },
setError,
watch,
} = useForm<OrgSetupFormData>({
resolver: zodResolver(orgSetupSchema),
mode: "onChange",
reValidateMode: "onChange",
defaultValues: {
organizationName: "",
awsOrgId: "",
roleArn: "",
stackSetDeployed: false,
},
});
const awsOrgIdField = register("awsOrgId", {
setValueAs: (value: unknown) =>
typeof value === "string" ? value.toLowerCase() : value,
});
const awsOrgId = watch("awsOrgId") || "";
const isOrgIdValid = /^o-[a-z0-9]{10,32}$/.test(awsOrgId.trim());
const stackSetQuickLink =
stackSetExternalId &&
getAWSCredentialsTemplateLinks(stackSetExternalId).cloudformationQuickLink;
const { apiError, setApiError, submitOrganizationSetup } =
useOrgSetupSubmission({
stackSetExternalId,
onNext,
setFieldError: (field, message) => {
setError(field, { message });
},
});
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: () => setSetupPhase(ORG_SETUP_PHASE.DETAILS),
showAction: true,
actionLabel: "Authenticate",
actionDisabled: isSubmitting || !isValid || !stackSetExternalId,
actionType: WIZARD_FOOTER_ACTION_TYPE.SUBMIT,
actionFormId: formId,
});
}, [
formId,
isOrgIdValid,
isSubmitting,
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 handleFormSubmit = (event: FormEvent<HTMLFormElement>) => {
if (setupPhase === ORG_SETUP_PHASE.DETAILS) {
event.preventDefault();
handleContinueToAccess();
return;
}
void handleSubmit((data) => submitOrganizationSetup(data))(event);
};
useEffect(() => {
if (!apiError) return;
document
.getElementById(formId)
?.scrollIntoView({ block: "start", behavior: "smooth" });
}, [apiError, formId]);
return (
<form
id={formId}
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 && 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"
autoCapitalize="none"
autoCorrect="off"
spellCheck={false}
{...awsOrgIdField}
onInput={(event) => {
const loweredValue = event.currentTarget.value.toLowerCase();
if (event.currentTarget.value !== loweredValue) {
event.currentTarget.value = loweredValue;
}
}}
/>
{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"
disabled={!stackSetQuickLink}
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 || "Loading organization external ID..."}
</span>
<button
type="button"
disabled={!stackSetExternalId}
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>
)}
</form>
);
}

View File

@@ -0,0 +1,53 @@
import { cn } from "@/lib/utils";
interface RadioCardProps {
icon: React.ComponentType<{ className?: string }>;
title: string;
onClick: () => void;
disabled?: boolean;
/** Optional trailing content (e.g. a CTA badge). */
children?: React.ReactNode;
}
export function RadioCard({
icon: Icon,
title,
onClick,
disabled = false,
children,
}: RadioCardProps) {
return (
<button
type="button"
onClick={onClick}
disabled={disabled}
className={cn(
"flex min-h-[72px] w-full items-center gap-4 rounded-lg border px-3 py-2.5 text-left transition-colors",
disabled
? "border-border-neutral-primary bg-bg-neutral-tertiary cursor-not-allowed"
: "hover:border-primary border-border-neutral-primary bg-bg-neutral-tertiary cursor-pointer",
)}
>
<div className="border-border-neutral-primary bg-bg-input-primary size-[18px] shrink-0 rounded-full border shadow-xs" />
<div className="flex min-w-0 flex-1 items-center gap-1.5">
<Icon
className={cn(
"size-[18px] shrink-0",
disabled ? "text-text-neutral-tertiary" : "text-muted-foreground",
)}
/>
<span
className={cn(
"truncate text-sm leading-6",
disabled ? "text-text-neutral-tertiary" : "text-foreground",
)}
>
{title}
</span>
</div>
{children}
</button>
);
}

View File

@@ -114,7 +114,7 @@ export const RadioGroupProvider: FC<RadioGroupProviderProps> = ({
name="providerType"
control={control}
render={({ field }) => (
<div className="flex h-[calc(100vh-200px)] flex-col px-4">
<div className="flex flex-col px-4">
<div className="relative z-10 shrink-0 pb-4">
<SearchInput
aria-label="Search providers"
@@ -125,19 +125,11 @@ export const RadioGroupProvider: FC<RadioGroupProviderProps> = ({
/>
</div>
<div className="minimal-scrollbar relative flex-1 overflow-y-auto pr-3">
<div className="relative">
<div
role="listbox"
aria-label="Select a provider"
className="flex flex-col gap-3"
style={{
maskImage:
"linear-gradient(to bottom, transparent, black 24px)",
WebkitMaskImage:
"linear-gradient(to bottom, transparent, black 24px)",
paddingTop: "24px",
marginTop: "-24px",
}}
>
{filteredProviders.length > 0 ? (
filteredProviders.map((provider) => {
@@ -152,19 +144,26 @@ export const RadioGroupProvider: FC<RadioGroupProviderProps> = ({
aria-selected={isSelected}
onClick={() => field.onChange(provider.value)}
className={cn(
"flex w-full cursor-pointer items-center gap-3 rounded-lg border p-4 text-left transition-all",
"hover:border-button-primary",
"focus-visible:border-button-primary focus-visible:ring-button-primary focus:outline-none focus-visible:ring-1",
"flex min-h-[72px] w-full items-center gap-4 rounded-lg border px-3 py-2.5 text-left transition-colors",
"focus-visible:border-primary focus-visible:outline-none",
isSelected
? "border-button-primary bg-bg-neutral-tertiary"
: "border-border-neutral-secondary bg-bg-neutral-secondary",
? "border-primary bg-bg-neutral-tertiary"
: "border-border-neutral-primary bg-bg-neutral-tertiary hover:border-primary",
isInvalid && "border-bg-fail",
)}
>
<BadgeComponent size={26} />
<span className="text-text-neutral-primary text-sm font-medium">
{provider.label}
</span>
<div className="border-border-neutral-primary bg-bg-input-primary flex size-[18px] shrink-0 items-center justify-center rounded-full border shadow-xs">
{isSelected && (
<div className="bg-primary size-2.5 rounded-full" />
)}
</div>
<div className="flex min-w-0 flex-1 items-center gap-1.5">
<BadgeComponent size={26} />
<span className="text-foreground text-sm leading-6">
{provider.label}
</span>
</div>
</button>
);
})

View File

@@ -2,11 +2,11 @@
import { Row } from "@tanstack/react-table";
import { Pencil, PlugZap, Trash2 } from "lucide-react";
import { useRouter } from "next/navigation";
import { useState } from "react";
import { checkConnectionProvider } from "@/actions/providers/providers";
import { VerticalDotsIcon } from "@/components/icons";
import { ProviderWizardModal } from "@/components/providers/wizard";
import { Button } from "@/components/shadcn";
import {
ActionDropdown,
@@ -14,26 +14,27 @@ import {
ActionDropdownItem,
} from "@/components/shadcn/dropdown";
import { Modal } from "@/components/shadcn/modal";
import { PROVIDER_WIZARD_MODE } from "@/types/provider-wizard";
import { ProviderProps } from "@/types/providers";
import { EditForm } from "../forms";
import { DeleteForm } from "../forms/delete-form";
interface DataTableRowActionsProps<ProviderProps> {
interface DataTableRowActionsProps {
row: Row<ProviderProps>;
}
export function DataTableRowActions<ProviderProps>({
row,
}: DataTableRowActionsProps<ProviderProps>) {
const router = useRouter();
export function DataTableRowActions({ row }: DataTableRowActionsProps) {
const [isEditOpen, setIsEditOpen] = useState(false);
const [isDeleteOpen, setIsDeleteOpen] = useState(false);
const [isWizardOpen, setIsWizardOpen] = useState(false);
const [loading, setLoading] = useState(false);
const providerId = (row.original as { id: string }).id;
const providerType = (row.original as any).attributes?.provider;
const providerAlias = (row.original as any).attributes?.alias;
const providerSecretId =
(row.original as any).relationships?.secret?.data?.id || null;
const provider = row.original;
const providerId = provider.id;
const providerType = provider.attributes.provider;
const providerUid = provider.attributes.uid;
const providerAlias = provider.attributes.alias ?? null;
const providerSecretId = provider.relationships.secret.data?.id ?? null;
const handleTestConnection = async () => {
setLoading(true);
@@ -43,7 +44,7 @@ export function DataTableRowActions<ProviderProps>({
setLoading(false);
};
const hasSecret = (row.original as any).relationships?.secret?.data;
const hasSecret = Boolean(provider.relationships.secret.data);
return (
<>
@@ -66,6 +67,20 @@ export function DataTableRowActions<ProviderProps>({
>
<DeleteForm providerId={providerId} setIsOpen={setIsDeleteOpen} />
</Modal>
<ProviderWizardModal
open={isWizardOpen}
onOpenChange={setIsWizardOpen}
initialData={{
providerId,
providerType,
providerUid,
providerAlias,
secretId: providerSecretId,
mode: providerSecretId
? PROVIDER_WIZARD_MODE.UPDATE
: PROVIDER_WIZARD_MODE.ADD,
}}
/>
<div className="relative flex items-center justify-end gap-2">
<ActionDropdown
@@ -78,11 +93,7 @@ export function DataTableRowActions<ProviderProps>({
<ActionDropdownItem
icon={<Pencil />}
label={hasSecret ? "Update Credentials" : "Add Credentials"}
onSelect={() =>
router.push(
`/providers/${hasSecret ? "update" : "add"}-credentials?type=${providerType}&id=${providerId}${providerSecretId ? `&secretId=${providerSecretId}` : ""}`,
)
}
onSelect={() => setIsWizardOpen(true)}
/>
<ActionDropdownItem
icon={<PlugZap />}

View File

@@ -0,0 +1,188 @@
import { act, renderHook, waitFor } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { useOrgSetupStore } from "@/store/organizations/store";
import { useProviderWizardStore } from "@/store/provider-wizard/store";
import { ORG_WIZARD_STEP } from "@/types/organizations";
import {
PROVIDER_WIZARD_MODE,
PROVIDER_WIZARD_STEP,
} from "@/types/provider-wizard";
import { useProviderWizardController } from "./use-provider-wizard-controller";
const { pushMock } = vi.hoisted(() => ({
pushMock: vi.fn(),
}));
vi.mock("next/navigation", () => ({
useRouter: () => ({
push: pushMock,
}),
}));
vi.mock("next-auth/react", () => ({
useSession: () => ({
data: null,
status: "unauthenticated",
}),
}));
describe("useProviderWizardController", () => {
beforeEach(() => {
sessionStorage.clear();
localStorage.clear();
pushMock.mockReset();
useProviderWizardStore.getState().reset();
useOrgSetupStore.getState().reset();
});
it("hydrates update mode when initial data is provided", async () => {
// Given
const onOpenChange = vi.fn();
// When
const { result } = renderHook(() =>
useProviderWizardController({
open: true,
onOpenChange,
initialData: {
providerId: "provider-1",
providerType: "aws",
providerUid: "111111111111",
providerAlias: "production",
secretId: "secret-1",
mode: PROVIDER_WIZARD_MODE.UPDATE,
},
}),
);
// Then
await waitFor(() => {
expect(result.current.currentStep).toBe(PROVIDER_WIZARD_STEP.CREDENTIALS);
});
expect(result.current.modalTitle).toBe("Update Provider Credentials");
expect(result.current.isProviderFlow).toBe(true);
expect(result.current.docsLink).toBe(
"https://goto.prowler.com/provider-aws",
);
const state = useProviderWizardStore.getState();
expect(state.providerId).toBe("provider-1");
expect(state.providerType).toBe("aws");
expect(state.providerUid).toBe("111111111111");
expect(state.providerAlias).toBe("production");
expect(state.secretId).toBe("secret-1");
expect(state.mode).toBe(PROVIDER_WIZARD_MODE.UPDATE);
});
it("switches into and out of organizations flow", () => {
// Given
const onOpenChange = vi.fn();
const { result } = renderHook(() =>
useProviderWizardController({
open: true,
onOpenChange,
}),
);
// When
act(() => {
result.current.openOrganizationsFlow();
});
// Then
expect(result.current.wizardVariant).toBe("organizations");
expect(result.current.isProviderFlow).toBe(false);
expect(result.current.orgCurrentStep).toBe(ORG_WIZARD_STEP.SETUP);
// When
act(() => {
result.current.backToProviderFlow();
});
// Then
expect(result.current.wizardVariant).toBe("provider");
expect(result.current.isProviderFlow).toBe(true);
expect(result.current.currentStep).toBe(PROVIDER_WIZARD_STEP.CONNECT);
});
it("moves to launch step after a successful connection test in add mode", () => {
// Given
const onOpenChange = vi.fn();
const { result } = renderHook(() =>
useProviderWizardController({
open: true,
onOpenChange,
}),
);
// When
act(() => {
result.current.setCurrentStep(PROVIDER_WIZARD_STEP.TEST);
result.current.handleTestSuccess();
});
// Then
expect(result.current.currentStep).toBe(PROVIDER_WIZARD_STEP.LAUNCH);
expect(onOpenChange).not.toHaveBeenCalled();
});
it("closes and navigates when launch footer action is triggered", () => {
// Given
const onOpenChange = vi.fn();
const { result } = renderHook(() =>
useProviderWizardController({
open: true,
onOpenChange,
}),
);
// When
act(() => {
result.current.setCurrentStep(PROVIDER_WIZARD_STEP.LAUNCH);
});
const { resolvedFooterConfig } = result.current;
act(() => {
resolvedFooterConfig.onAction?.();
});
// Then
expect(pushMock).toHaveBeenCalledWith("/scans");
expect(onOpenChange).toHaveBeenCalledWith(false);
expect(result.current.currentStep).toBe(PROVIDER_WIZARD_STEP.CONNECT);
});
it("does not reset organizations step when org store updates while modal is open", () => {
// Given
const onOpenChange = vi.fn();
const { result } = renderHook(() =>
useProviderWizardController({
open: true,
onOpenChange,
}),
);
act(() => {
result.current.openOrganizationsFlow();
result.current.setOrgCurrentStep(ORG_WIZARD_STEP.VALIDATE);
});
// When
act(() => {
useOrgSetupStore
.getState()
.setOrganization("org-1", "My Org", "o-abc123def4");
useOrgSetupStore.getState().setDiscovery("disc-1", {
roots: [],
organizational_units: [],
accounts: [],
});
});
// Then
expect(result.current.wizardVariant).toBe("organizations");
expect(result.current.orgCurrentStep).toBe(ORG_WIZARD_STEP.VALIDATE);
});
});

View File

@@ -0,0 +1,244 @@
"use client";
import { useRouter } from "next/navigation";
import { useEffect, useState } from "react";
import { getProviderHelpText } from "@/lib/external-urls";
import { useOrgSetupStore } from "@/store/organizations/store";
import { useProviderWizardStore } from "@/store/provider-wizard/store";
import {
ORG_SETUP_PHASE,
ORG_WIZARD_STEP,
OrgSetupPhase,
OrgWizardStep,
} from "@/types/organizations";
import {
PROVIDER_WIZARD_MODE,
PROVIDER_WIZARD_STEP,
ProviderWizardStep,
} from "@/types/provider-wizard";
import { ProviderType } from "@/types/providers";
import { getProviderWizardModalTitle } from "../provider-wizard-modal.utils";
import {
WIZARD_FOOTER_ACTION_TYPE,
WizardFooterConfig,
} from "../steps/footer-controls";
import type { ProviderWizardInitialData } from "../types";
const WIZARD_VARIANT = {
PROVIDER: "provider",
ORGANIZATIONS: "organizations",
} as const;
type WizardVariant = (typeof WIZARD_VARIANT)[keyof typeof WIZARD_VARIANT];
const EMPTY_FOOTER_CONFIG: WizardFooterConfig = {
showBack: false,
backLabel: "Back",
showSecondaryAction: false,
secondaryActionLabel: "",
secondaryActionVariant: "outline",
secondaryActionType: WIZARD_FOOTER_ACTION_TYPE.BUTTON,
showAction: false,
actionLabel: "Next",
actionType: WIZARD_FOOTER_ACTION_TYPE.BUTTON,
};
interface UseProviderWizardControllerProps {
open: boolean;
onOpenChange: (open: boolean) => void;
initialData?: ProviderWizardInitialData;
}
export function useProviderWizardController({
open,
onOpenChange,
initialData,
}: UseProviderWizardControllerProps) {
const initialProviderId = initialData?.providerId ?? null;
const initialProviderType = initialData?.providerType ?? null;
const initialProviderUid = initialData?.providerUid ?? null;
const initialProviderAlias = initialData?.providerAlias ?? null;
const initialSecretId = initialData?.secretId ?? null;
const initialVia = initialData?.via ?? null;
const initialMode = initialData?.mode ?? null;
const router = useRouter();
const [wizardVariant, setWizardVariant] = useState<WizardVariant>(
WIZARD_VARIANT.PROVIDER,
);
const [currentStep, setCurrentStep] = useState<ProviderWizardStep>(
PROVIDER_WIZARD_STEP.CONNECT,
);
const [orgCurrentStep, setOrgCurrentStep] = useState<OrgWizardStep>(
ORG_WIZARD_STEP.SETUP,
);
const [footerConfig, setFooterConfig] =
useState<WizardFooterConfig>(EMPTY_FOOTER_CONFIG);
const [providerTypeHint, setProviderTypeHint] = useState<ProviderType | null>(
null,
);
const [orgSetupPhase, setOrgSetupPhase] = useState<OrgSetupPhase>(
ORG_SETUP_PHASE.DETAILS,
);
const {
reset: resetProviderWizard,
setProvider,
setVia,
setSecretId,
setMode,
mode,
providerType,
} = useProviderWizardStore();
const { reset: resetOrgWizard } = useOrgSetupStore();
useEffect(() => {
if (!open) {
return;
}
if (initialProviderId && initialProviderType && initialProviderUid) {
setWizardVariant(WIZARD_VARIANT.PROVIDER);
setProvider({
id: initialProviderId,
type: initialProviderType,
uid: initialProviderUid,
alias: initialProviderAlias,
});
setVia(initialVia);
setSecretId(initialSecretId);
setMode(
initialMode ||
(initialSecretId
? PROVIDER_WIZARD_MODE.UPDATE
: PROVIDER_WIZARD_MODE.ADD),
);
setCurrentStep(PROVIDER_WIZARD_STEP.CREDENTIALS);
setOrgCurrentStep(ORG_WIZARD_STEP.SETUP);
setFooterConfig(EMPTY_FOOTER_CONFIG);
setProviderTypeHint(initialProviderType);
setOrgSetupPhase(ORG_SETUP_PHASE.DETAILS);
return;
}
resetProviderWizard();
resetOrgWizard();
setWizardVariant(WIZARD_VARIANT.PROVIDER);
setCurrentStep(PROVIDER_WIZARD_STEP.CONNECT);
setOrgCurrentStep(ORG_WIZARD_STEP.SETUP);
setFooterConfig(EMPTY_FOOTER_CONFIG);
setProviderTypeHint(null);
setOrgSetupPhase(ORG_SETUP_PHASE.DETAILS);
}, [
initialMode,
initialProviderAlias,
initialProviderId,
initialProviderType,
initialProviderUid,
initialSecretId,
initialVia,
open,
resetOrgWizard,
resetProviderWizard,
setMode,
setProvider,
setSecretId,
setVia,
]);
const handleClose = () => {
resetProviderWizard();
resetOrgWizard();
setWizardVariant(WIZARD_VARIANT.PROVIDER);
setCurrentStep(PROVIDER_WIZARD_STEP.CONNECT);
setOrgCurrentStep(ORG_WIZARD_STEP.SETUP);
setFooterConfig(EMPTY_FOOTER_CONFIG);
setProviderTypeHint(null);
setOrgSetupPhase(ORG_SETUP_PHASE.DETAILS);
onOpenChange(false);
};
const handleDialogOpenChange = (nextOpen: boolean) => {
if (nextOpen) {
onOpenChange(true);
return;
}
handleClose();
};
const handleTestSuccess = () => {
if (mode === PROVIDER_WIZARD_MODE.UPDATE) {
handleClose();
return;
}
setCurrentStep(PROVIDER_WIZARD_STEP.LAUNCH);
};
const openOrganizationsFlow = () => {
resetOrgWizard();
setWizardVariant(WIZARD_VARIANT.ORGANIZATIONS);
setOrgCurrentStep(ORG_WIZARD_STEP.SETUP);
setFooterConfig(EMPTY_FOOTER_CONFIG);
setProviderTypeHint(null);
setOrgSetupPhase(ORG_SETUP_PHASE.DETAILS);
};
const backToProviderFlow = () => {
resetOrgWizard();
setWizardVariant(WIZARD_VARIANT.PROVIDER);
setCurrentStep(PROVIDER_WIZARD_STEP.CONNECT);
setFooterConfig(EMPTY_FOOTER_CONFIG);
setProviderTypeHint(null);
setOrgSetupPhase(ORG_SETUP_PHASE.DETAILS);
};
const isProviderFlow = wizardVariant === WIZARD_VARIANT.PROVIDER;
const docsLink = getProviderHelpText(
isProviderFlow ? (providerTypeHint ?? providerType ?? "") : "aws",
).link;
const resolvedFooterConfig: WizardFooterConfig =
isProviderFlow && currentStep === PROVIDER_WIZARD_STEP.LAUNCH
? {
showBack: true,
backLabel: "Back",
onBack: () => setCurrentStep(PROVIDER_WIZARD_STEP.TEST),
showSecondaryAction: false,
secondaryActionLabel: "",
secondaryActionVariant: "outline",
secondaryActionType: WIZARD_FOOTER_ACTION_TYPE.BUTTON,
showAction: true,
actionLabel: "Go to scans",
actionType: WIZARD_FOOTER_ACTION_TYPE.BUTTON,
onAction: () => {
handleClose();
router.push("/scans");
},
}
: footerConfig;
const modalTitle = getProviderWizardModalTitle(mode);
return {
currentStep,
docsLink,
footerConfig,
handleClose,
handleDialogOpenChange,
handleTestSuccess,
isProviderFlow,
modalTitle,
openOrganizationsFlow,
orgCurrentStep,
orgSetupPhase,
providerTypeHint,
resolvedFooterConfig,
setCurrentStep,
setFooterConfig,
setOrgCurrentStep,
setOrgSetupPhase,
setProviderTypeHint,
backToProviderFlow,
wizardVariant,
};
}

View File

@@ -0,0 +1,3 @@
export * from "./provider-wizard-modal";
export * from "./steps";
export * from "./wizard-stepper";

View File

@@ -0,0 +1,273 @@
"use client";
import { ExternalLink, Info } from "lucide-react";
import { OrgAccountSelection } from "@/components/providers/organizations/org-account-selection";
import { OrgLaunchScan } from "@/components/providers/organizations/org-launch-scan";
import { OrgSetupForm } from "@/components/providers/organizations/org-setup-form";
import { Button } from "@/components/shadcn/button/button";
import { DialogHeader, DialogTitle } from "@/components/shadcn/dialog";
import { Modal } from "@/components/shadcn/modal";
import { useScrollHint } from "@/hooks/use-scroll-hint";
import { ORG_SETUP_PHASE, ORG_WIZARD_STEP } from "@/types/organizations";
import { PROVIDER_WIZARD_STEP } from "@/types/provider-wizard";
import { useProviderWizardController } from "./hooks/use-provider-wizard-controller";
import { getOrganizationsStepperOffset } from "./provider-wizard-modal.utils";
import { ConnectStep } from "./steps/connect-step";
import { CredentialsStep } from "./steps/credentials-step";
import { WIZARD_FOOTER_ACTION_TYPE } from "./steps/footer-controls";
import { LaunchStep } from "./steps/launch-step";
import { TestConnectionStep } from "./steps/test-connection-step";
import type { ProviderWizardInitialData } from "./types";
import { WizardStepper } from "./wizard-stepper";
interface ProviderWizardModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
initialData?: ProviderWizardInitialData;
}
export function ProviderWizardModal({
open,
onOpenChange,
initialData,
}: ProviderWizardModalProps) {
const {
backToProviderFlow,
currentStep,
docsLink,
handleClose,
handleDialogOpenChange,
handleTestSuccess,
isProviderFlow,
modalTitle,
openOrganizationsFlow,
orgCurrentStep,
orgSetupPhase,
resolvedFooterConfig,
setCurrentStep,
setFooterConfig,
setOrgCurrentStep,
setOrgSetupPhase,
setProviderTypeHint,
wizardVariant,
} = useProviderWizardController({
open,
onOpenChange,
initialData,
});
const scrollHintRefreshToken = `${wizardVariant}-${currentStep}-${orgCurrentStep}-${orgSetupPhase}`;
const { containerRef, showScrollHint, handleScroll } = useScrollHint({
enabled: open,
refreshToken: scrollHintRefreshToken,
});
return (
<Modal
open={open}
onOpenChange={handleDialogOpenChange}
size="4xl"
className="flex !h-[90vh] !max-h-[90vh] !min-h-[90vh] !w-[calc(100vw-24px)] !max-w-[1192px] flex-col overflow-hidden p-4 sm:!w-[calc(100vw-40px)] sm:p-6 lg:!w-[calc(100vw-64px)] lg:p-8"
>
<DialogHeader className="gap-2 p-0">
<DialogTitle className="text-lg font-semibold">
{modalTitle}
</DialogTitle>
<div className="text-muted-foreground flex flex-wrap items-center gap-2 text-sm">
<Info className="size-4 shrink-0" />
<span>For assistance connecting a Cloud Provider visit</span>
<Button variant="link" size="link-sm" className="h-auto p-0" asChild>
<a href={docsLink} target="_blank" rel="noopener noreferrer">
<ExternalLink className="size-3.5 shrink-0" />
<span>Prowler Docs</span>
</a>
</Button>
</div>
</DialogHeader>
<div className="mt-6 flex min-h-0 flex-1 flex-col overflow-hidden lg:mt-8 lg:flex-row">
<div className="mb-4 box-border w-full shrink-0 lg:mb-0 lg:w-[328px]">
{isProviderFlow ? (
<WizardStepper currentStep={currentStep} />
) : (
<WizardStepper
currentStep={orgCurrentStep}
stepOffset={getOrganizationsStepperOffset(
orgCurrentStep,
orgSetupPhase,
)}
/>
)}
</div>
<div aria-hidden className="hidden w-[100px] min-w-0 shrink lg:block" />
<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)}
onSelectOrganizations={openOrganizationsFlow}
onFooterChange={setFooterConfig}
onProviderTypeChange={setProviderTypeHint}
/>
)}
{isProviderFlow &&
currentStep === PROVIDER_WIZARD_STEP.CREDENTIALS && (
<CredentialsStep
onNext={() => setCurrentStep(PROVIDER_WIZARD_STEP.TEST)}
onBack={() => setCurrentStep(PROVIDER_WIZARD_STEP.CONNECT)}
onFooterChange={setFooterConfig}
/>
)}
{isProviderFlow && currentStep === PROVIDER_WIZARD_STEP.TEST && (
<TestConnectionStep
onSuccess={handleTestSuccess}
onResetCredentials={() =>
setCurrentStep(PROVIDER_WIZARD_STEP.CREDENTIALS)
}
onFooterChange={setFooterConfig}
/>
)}
{isProviderFlow && currentStep === PROVIDER_WIZARD_STEP.LAUNCH && (
<LaunchStep />
)}
{!isProviderFlow && orgCurrentStep === ORG_WIZARD_STEP.SETUP && (
<OrgSetupForm
onBack={backToProviderFlow}
onNext={() => {
setOrgCurrentStep(ORG_WIZARD_STEP.VALIDATE);
}}
onFooterChange={setFooterConfig}
onPhaseChange={setOrgSetupPhase}
initialPhase={orgSetupPhase}
/>
)}
{!isProviderFlow && orgCurrentStep === ORG_WIZARD_STEP.VALIDATE && (
<OrgAccountSelection
onBack={() => {
setOrgCurrentStep(ORG_WIZARD_STEP.SETUP);
setOrgSetupPhase(ORG_SETUP_PHASE.ACCESS);
}}
onNext={() => {
setOrgCurrentStep(ORG_WIZARD_STEP.LAUNCH);
}}
onSkip={() => {
setOrgCurrentStep(ORG_WIZARD_STEP.LAUNCH);
}}
onFooterChange={setFooterConfig}
/>
)}
{!isProviderFlow && orgCurrentStep === ORG_WIZARD_STEP.LAUNCH && (
<OrgLaunchScan
onClose={handleClose}
onBack={() => {
setOrgCurrentStep(ORG_WIZARD_STEP.VALIDATE);
}}
onFooterChange={setFooterConfig}
/>
)}
</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>
{(resolvedFooterConfig.showBack ||
resolvedFooterConfig.showSecondaryAction ||
resolvedFooterConfig.showAction) && (
<div className="mt-8 pt-6">
<div className="flex items-center justify-between">
<div>
{resolvedFooterConfig.showBack && (
<Button
type="button"
variant="outline"
size="xl"
disabled={resolvedFooterConfig.backDisabled}
onClick={resolvedFooterConfig.onBack}
>
{resolvedFooterConfig.backLabel}
</Button>
)}
</div>
<div className="flex items-center gap-6">
{resolvedFooterConfig.showSecondaryAction && (
<Button
size={
resolvedFooterConfig.secondaryActionVariant === "link"
? "link-sm"
: "xl"
}
className={
resolvedFooterConfig.secondaryActionVariant === "link"
? "h-auto p-0"
: undefined
}
variant={resolvedFooterConfig.secondaryActionVariant}
type={
resolvedFooterConfig.secondaryActionType ===
WIZARD_FOOTER_ACTION_TYPE.SUBMIT
? "submit"
: "button"
}
form={resolvedFooterConfig.secondaryActionFormId}
disabled={resolvedFooterConfig.secondaryActionDisabled}
onClick={
resolvedFooterConfig.secondaryActionType ===
WIZARD_FOOTER_ACTION_TYPE.BUTTON
? resolvedFooterConfig.onSecondaryAction
: undefined
}
>
{resolvedFooterConfig.secondaryActionLabel}
</Button>
)}
{resolvedFooterConfig.showAction && (
<Button
size="xl"
type={
resolvedFooterConfig.actionType ===
WIZARD_FOOTER_ACTION_TYPE.SUBMIT
? "submit"
: "button"
}
form={resolvedFooterConfig.actionFormId}
disabled={resolvedFooterConfig.actionDisabled}
onClick={
resolvedFooterConfig.actionType ===
WIZARD_FOOTER_ACTION_TYPE.BUTTON
? resolvedFooterConfig.onAction
: undefined
}
>
{resolvedFooterConfig.actionLabel}
</Button>
)}
</div>
</div>
</div>
)}
</Modal>
);
}

View File

@@ -0,0 +1,52 @@
import { describe, expect, it } from "vitest";
import { ORG_SETUP_PHASE, ORG_WIZARD_STEP } from "@/types/organizations";
import { PROVIDER_WIZARD_MODE } from "@/types/provider-wizard";
import {
getOrganizationsStepperOffset,
getProviderWizardModalTitle,
} 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);
});
});
describe("getProviderWizardModalTitle", () => {
it("returns add title for add mode", () => {
const title = getProviderWizardModalTitle(PROVIDER_WIZARD_MODE.ADD);
expect(title).toBe("Adding A Cloud Provider");
});
it("returns update title for update mode", () => {
const title = getProviderWizardModalTitle(PROVIDER_WIZARD_MODE.UPDATE);
expect(title).toBe("Update Provider Credentials");
});
});

View File

@@ -0,0 +1,29 @@
import {
ORG_SETUP_PHASE,
ORG_WIZARD_STEP,
OrgSetupPhase,
OrgWizardStep,
} from "@/types/organizations";
import {
PROVIDER_WIZARD_MODE,
ProviderWizardMode,
} from "@/types/provider-wizard";
export function getOrganizationsStepperOffset(
currentStep: OrgWizardStep,
setupPhase: OrgSetupPhase,
) {
if (currentStep === ORG_WIZARD_STEP.SETUP) {
return setupPhase === ORG_SETUP_PHASE.ACCESS ? 1 : 0;
}
return 1;
}
export function getProviderWizardModalTitle(mode: ProviderWizardMode) {
if (mode === PROVIDER_WIZARD_MODE.UPDATE) {
return "Update Provider Credentials";
}
return "Adding A Cloud Provider";
}

View File

@@ -0,0 +1,84 @@
"use client";
import { useEffect, useRef, useState } from "react";
import {
ConnectAccountForm,
ConnectAccountSuccessData,
} from "@/components/providers/workflow/forms";
import { useProviderWizardStore } from "@/store/provider-wizard/store";
import { PROVIDER_WIZARD_MODE } from "@/types/provider-wizard";
import { ProviderType } from "@/types/providers";
import {
WIZARD_FOOTER_ACTION_TYPE,
WizardFooterConfig,
} from "./footer-controls";
interface ConnectStepProps {
onNext: () => void;
onSelectOrganizations: () => void;
onFooterChange: (config: WizardFooterConfig) => void;
onProviderTypeChange: (providerType: ProviderType | null) => void;
}
export function ConnectStep({
onNext,
onSelectOrganizations,
onFooterChange,
onProviderTypeChange,
}: ConnectStepProps) {
const { setProvider, setVia, setSecretId, setMode } =
useProviderWizardStore();
const backHandlerRef = useRef<(() => void) | null>(null);
const [uiState, setUiState] = useState({
showBack: false,
showAction: false,
actionLabel: "Next",
actionDisabled: true,
isLoading: false,
});
const formId = "provider-wizard-connect-form";
const handleSuccess = (data: ConnectAccountSuccessData) => {
setProvider({
id: data.id,
type: data.providerType,
uid: data.uid,
alias: data.alias,
});
setVia(null);
setSecretId(null);
setMode(PROVIDER_WIZARD_MODE.ADD);
onNext();
};
useEffect(() => {
onFooterChange({
showBack: uiState.showBack,
backLabel: "Back",
backDisabled: uiState.isLoading,
onBack: () => backHandlerRef.current?.(),
showAction: uiState.showAction,
actionLabel: uiState.actionLabel,
actionDisabled: uiState.actionDisabled || uiState.isLoading,
actionType: WIZARD_FOOTER_ACTION_TYPE.SUBMIT,
actionFormId: formId,
});
}, [onFooterChange, uiState]);
return (
<ConnectAccountForm
formId={formId}
hideNavigation
onSuccess={handleSuccess}
onSelectOrganizations={onSelectOrganizations}
onProviderTypeChange={onProviderTypeChange}
onUiStateChange={setUiState}
onBackHandlerChange={(handler) => {
backHandlerRef.current = handler;
}}
/>
);
}

View File

@@ -0,0 +1,79 @@
import { render, screen } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { useProviderWizardStore } from "@/store/provider-wizard/store";
import { PROVIDER_WIZARD_MODE } from "@/types/provider-wizard";
import { CredentialsStep } from "./credentials-step";
vi.mock("../../workflow/forms", () => ({
AddViaCredentialsForm: () => <div>add-via-credentials-form</div>,
AddViaRoleForm: () => <div>add-via-role-form</div>,
UpdateViaCredentialsForm: () => <div>update-via-credentials-form</div>,
UpdateViaRoleForm: () => <div>update-via-role-form</div>,
}));
vi.mock("../../workflow/forms/select-credentials-type/aws", () => ({
SelectViaAWS: () => <div>select-via-aws</div>,
}));
vi.mock("../../workflow/forms/select-credentials-type/alibabacloud", () => ({
SelectViaAlibabaCloud: () => <div>select-via-alibabacloud</div>,
}));
vi.mock("../../workflow/forms/select-credentials-type/cloudflare", () => ({
SelectViaCloudflare: () => <div>select-via-cloudflare</div>,
}));
vi.mock("../../workflow/forms/select-credentials-type/gcp", () => ({
AddViaServiceAccountForm: () => <div>add-via-service-account-form</div>,
SelectViaGCP: () => <div>select-via-gcp</div>,
}));
vi.mock("../../workflow/forms/select-credentials-type/github", () => ({
SelectViaGitHub: () => <div>select-via-github</div>,
}));
vi.mock("../../workflow/forms/select-credentials-type/m365", () => ({
SelectViaM365: () => <div>select-via-m365</div>,
}));
vi.mock("../../workflow/forms/update-via-service-account-key-form", () => ({
UpdateViaServiceAccountForm: () => (
<div>update-via-service-account-form</div>
),
}));
describe("CredentialsStep", () => {
beforeEach(() => {
sessionStorage.clear();
localStorage.clear();
useProviderWizardStore.getState().reset();
});
it("renders update role form when secret already exists in add mode", () => {
// Given
useProviderWizardStore.setState({
providerId: "provider-1",
providerType: "aws",
providerUid: "111111111111",
providerAlias: "Production",
via: "role",
secretId: "secret-1",
mode: PROVIDER_WIZARD_MODE.ADD,
});
// When
render(
<CredentialsStep
onNext={vi.fn()}
onBack={vi.fn()}
onFooterChange={vi.fn()}
/>,
);
// Then
expect(screen.getByText("update-via-role-form")).toBeInTheDocument();
expect(screen.queryByText("add-via-role-form")).not.toBeInTheDocument();
});
});

View File

@@ -0,0 +1,261 @@
"use client";
import { useEffect, useState } from "react";
import { getProviderFormType } from "@/lib/provider-helpers";
import { useProviderWizardStore } from "@/store/provider-wizard/store";
import { ProviderType } from "@/types/providers";
import {
AddViaCredentialsForm,
AddViaRoleForm,
UpdateViaCredentialsForm,
UpdateViaRoleForm,
} from "../../workflow/forms";
import { SelectViaAlibabaCloud } from "../../workflow/forms/select-credentials-type/alibabacloud";
import { SelectViaAWS } from "../../workflow/forms/select-credentials-type/aws";
import { SelectViaCloudflare } from "../../workflow/forms/select-credentials-type/cloudflare";
import {
AddViaServiceAccountForm,
SelectViaGCP,
} from "../../workflow/forms/select-credentials-type/gcp";
import { SelectViaGitHub } from "../../workflow/forms/select-credentials-type/github";
import { SelectViaM365 } from "../../workflow/forms/select-credentials-type/m365";
import { UpdateViaServiceAccountForm } from "../../workflow/forms/update-via-service-account-key-form";
import {
WIZARD_FOOTER_ACTION_TYPE,
WizardFooterConfig,
} from "./footer-controls";
interface CredentialsStepProps {
onNext: () => void;
onBack: () => void;
onFooterChange: (config: WizardFooterConfig) => void;
}
export function CredentialsStep({
onNext,
onBack,
onFooterChange,
}: CredentialsStepProps) {
const { providerId, providerType, providerUid, via, secretId, setVia } =
useProviderWizardStore();
const [isFormLoading, setIsFormLoading] = useState(false);
const [isFormValid, setIsFormValid] = useState(false);
const formId = "provider-wizard-credentials-form";
const hasProviderContext = Boolean(providerType && providerId);
const formType =
providerType && providerId
? getProviderFormType(providerType, via || undefined)
: null;
const shouldUseUpdateForms = Boolean(secretId);
const handleBack = () => {
if (via) {
setVia(null);
return;
}
onBack();
};
const handleViaChange = (value: string) => {
setVia(value);
};
useEffect(() => {
setIsFormValid(false);
}, [formType, via]);
useEffect(() => {
if (!hasProviderContext) {
onFooterChange({
showBack: true,
backLabel: "Back",
onBack,
showAction: false,
actionLabel: "Authenticate",
actionType: WIZARD_FOOTER_ACTION_TYPE.BUTTON,
});
return;
}
const isSelector = formType === "selector";
onFooterChange({
showBack: true,
backLabel: "Back",
backDisabled: isFormLoading,
onBack: () => {
if (via) {
setVia(null);
return;
}
onBack();
},
showAction: !isSelector,
actionLabel: "Authenticate",
actionDisabled: isFormLoading || !isFormValid,
actionType: WIZARD_FOOTER_ACTION_TYPE.SUBMIT,
actionFormId: formId,
});
}, [
hasProviderContext,
formType,
formId,
isFormLoading,
isFormValid,
onBack,
onFooterChange,
setVia,
via,
]);
if (!providerType || !providerId) {
return (
<div className="flex h-full items-center justify-center py-8">
<p className="text-muted-foreground text-sm">
Provider details are missing. Go back and select a provider.
</p>
</div>
);
}
if (formType === "selector") {
if (providerType === "aws") {
return (
<SelectViaAWS
initialVia={via || undefined}
onViaChange={handleViaChange}
/>
);
}
if (providerType === "gcp") {
return (
<SelectViaGCP
initialVia={via || undefined}
onViaChange={handleViaChange}
/>
);
}
if (providerType === "github") {
return (
<SelectViaGitHub
initialVia={via || undefined}
onViaChange={handleViaChange}
/>
);
}
if (providerType === "m365") {
return (
<SelectViaM365
initialVia={via || undefined}
onViaChange={handleViaChange}
/>
);
}
if (providerType === "alibabacloud") {
return (
<SelectViaAlibabaCloud
initialVia={via || undefined}
onViaChange={handleViaChange}
/>
);
}
if (providerType === "cloudflare") {
return (
<SelectViaCloudflare
initialVia={via || undefined}
onViaChange={handleViaChange}
/>
);
}
return null;
}
const commonFormProps = {
via,
onSuccess: onNext,
onBack: handleBack,
providerUid: providerUid || undefined,
formId,
hideActions: true,
onLoadingChange: setIsFormLoading,
onValidityChange: setIsFormValid,
validationMode: "onChange" as const,
};
if (formType === "credentials") {
if (shouldUseUpdateForms) {
return (
<UpdateViaCredentialsForm
searchParams={{
type: providerType,
id: providerId,
secretId: secretId || undefined,
}}
{...commonFormProps}
/>
);
}
return (
<AddViaCredentialsForm
searchParams={{ type: providerType, id: providerId }}
{...commonFormProps}
/>
);
}
if (formType === "role") {
if (shouldUseUpdateForms) {
return (
<UpdateViaRoleForm
searchParams={{
type: providerType,
id: providerId,
secretId: secretId || undefined,
}}
{...commonFormProps}
/>
);
}
return (
<AddViaRoleForm
searchParams={{ type: providerType, id: providerId }}
{...commonFormProps}
/>
);
}
if (formType === "service-account") {
if (shouldUseUpdateForms) {
return (
<UpdateViaServiceAccountForm
searchParams={{
type: providerType,
id: providerId,
secretId: secretId || undefined,
}}
{...commonFormProps}
/>
);
}
return (
<AddViaServiceAccountForm
searchParams={{ type: providerType as ProviderType, id: providerId }}
{...commonFormProps}
/>
);
}
return (
<div className="flex flex-col gap-4 py-6">
<p className="text-muted-foreground text-sm">
Select a credential type to continue.
</p>
</div>
);
}

View File

@@ -0,0 +1,29 @@
export const WIZARD_FOOTER_ACTION_TYPE = {
BUTTON: "button",
SUBMIT: "submit",
} as const;
export type WizardFooterActionType =
(typeof WIZARD_FOOTER_ACTION_TYPE)[keyof typeof WIZARD_FOOTER_ACTION_TYPE];
export type WizardFooterSecondaryActionVariant = "outline" | "link";
export interface WizardFooterConfig {
showBack: boolean;
backLabel: string;
backDisabled?: boolean;
onBack?: () => void;
showSecondaryAction?: boolean;
secondaryActionLabel?: string;
secondaryActionDisabled?: boolean;
secondaryActionVariant?: WizardFooterSecondaryActionVariant;
secondaryActionType?: WizardFooterActionType;
secondaryActionFormId?: string;
onSecondaryAction?: () => void;
showAction: boolean;
actionLabel: string;
actionDisabled?: boolean;
actionType: WizardFooterActionType;
actionFormId?: string;
onAction?: () => void;
}

View File

@@ -0,0 +1,5 @@
export * from "./connect-step";
export * from "./credentials-step";
export * from "./footer-controls";
export * from "./launch-step";
export * from "./test-connection-step";

View File

@@ -0,0 +1,15 @@
"use client";
import { CheckCircle2 } from "lucide-react";
export function LaunchStep() {
return (
<div className="flex min-h-[320px] flex-col items-center justify-center gap-4 text-center">
<CheckCircle2 className="text-success size-12" />
<h3 className="text-xl font-semibold">Provider connected successfully</h3>
<p className="text-muted-foreground text-sm">
Continue with the action button to go to scans.
</p>
</div>
);
}

View File

@@ -0,0 +1,67 @@
import { render, screen, waitFor } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { useProviderWizardStore } from "@/store/provider-wizard/store";
import { PROVIDER_WIZARD_MODE } from "@/types/provider-wizard";
import { TestConnectionStep } from "./test-connection-step";
const { getProviderMock } = vi.hoisted(() => ({
getProviderMock: vi.fn(),
}));
vi.mock("@/actions/providers", () => ({
getProvider: getProviderMock,
}));
vi.mock("../../workflow/forms/test-connection-form", () => ({
TestConnectionForm: () => <div data-testid="test-connection-form" />,
}));
describe("TestConnectionStep", () => {
beforeEach(() => {
sessionStorage.clear();
localStorage.clear();
getProviderMock.mockReset();
useProviderWizardStore.getState().reset();
});
it("stores provider secret id after loading provider data", async () => {
// Given
useProviderWizardStore.setState({
providerId: "provider-1",
providerType: "aws",
mode: PROVIDER_WIZARD_MODE.ADD,
});
getProviderMock.mockResolvedValue({
data: {
id: "provider-1",
attributes: {
uid: "111111111111",
provider: "aws",
alias: "Production",
connection: { connected: false, last_checked_at: null },
scanner_args: {},
},
relationships: {
secret: { data: { type: "provider-secrets", id: "secret-1" } },
},
},
});
// When
render(
<TestConnectionStep
onSuccess={vi.fn()}
onResetCredentials={vi.fn()}
onFooterChange={vi.fn()}
/>,
);
// Then
await waitFor(() => {
expect(screen.getByTestId("test-connection-form")).toBeInTheDocument();
});
expect(useProviderWizardStore.getState().secretId).toBe("secret-1");
});
});

View File

@@ -0,0 +1,146 @@
"use client";
import { Loader2 } from "lucide-react";
import { useEffect, useState } from "react";
import { getProvider } from "@/actions/providers";
import { useProviderWizardStore } from "@/store/provider-wizard/store";
import { PROVIDER_WIZARD_MODE } from "@/types/provider-wizard";
import {
TestConnectionForm,
TestConnectionProviderData,
} from "../../workflow/forms/test-connection-form";
import {
WIZARD_FOOTER_ACTION_TYPE,
WizardFooterConfig,
} from "./footer-controls";
interface TestConnectionStepProps {
onSuccess: () => void;
onResetCredentials: () => void;
onFooterChange: (config: WizardFooterConfig) => void;
}
export function TestConnectionStep({
onSuccess,
onResetCredentials,
onFooterChange,
}: TestConnectionStepProps) {
const { providerId, providerType, mode, setSecretId } =
useProviderWizardStore();
const [providerData, setProviderData] =
useState<TestConnectionProviderData | null>(null);
const [isLoadingProvider, setIsLoadingProvider] = useState(true);
const [isFormLoading, setIsFormLoading] = useState(false);
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const formId = "provider-wizard-test-connection-form";
useEffect(() => {
let isMounted = true;
async function loadProvider() {
if (!providerId || !providerType) {
setErrorMessage("Provider information is missing.");
setIsLoadingProvider(false);
return;
}
setIsLoadingProvider(true);
setErrorMessage(null);
const formData = new FormData();
formData.append("id", providerId);
const response = await getProvider(formData);
if (!isMounted) {
return;
}
if (response?.errors?.length) {
setErrorMessage(
response.errors[0]?.detail || "Failed to load provider.",
);
setSecretId(null);
setProviderData(null);
setIsLoadingProvider(false);
return;
}
const resolvedSecretId =
response?.data?.relationships?.secret?.data?.id ?? null;
setSecretId(resolvedSecretId);
setProviderData(response as TestConnectionProviderData);
setIsLoadingProvider(false);
}
loadProvider();
return () => {
isMounted = false;
};
}, [providerId, providerType, setSecretId]);
useEffect(() => {
const canSubmit = !isLoadingProvider && !errorMessage && !!providerData;
onFooterChange({
showBack: true,
backLabel: "Back",
backDisabled: isFormLoading,
onBack: onResetCredentials,
showAction: canSubmit,
actionLabel:
mode === PROVIDER_WIZARD_MODE.UPDATE
? "Check connection"
: "Launch scan",
actionDisabled: isFormLoading,
actionType: WIZARD_FOOTER_ACTION_TYPE.SUBMIT,
actionFormId: formId,
});
}, [
errorMessage,
isFormLoading,
isLoadingProvider,
mode,
onFooterChange,
onResetCredentials,
providerData,
]);
if (isLoadingProvider) {
return (
<div className="flex min-h-[320px] items-center justify-center">
<Loader2 className="text-muted-foreground size-6 animate-spin" />
</div>
);
}
if (errorMessage || !providerData || !providerId || !providerType) {
return (
<div className="flex min-h-[320px] flex-col items-center justify-center gap-4 text-center">
<p className="text-muted-foreground text-sm">
{errorMessage || "Unable to load provider details."}
</p>
</div>
);
}
return (
<TestConnectionForm
formId={formId}
hideActions
onLoadingChange={setIsFormLoading}
searchParams={{
type: providerType,
id: providerId,
updated: mode === PROVIDER_WIZARD_MODE.UPDATE ? "true" : "false",
}}
providerData={providerData}
onSuccess={onSuccess}
onResetCredentials={onResetCredentials}
/>
);
}

View File

@@ -0,0 +1,12 @@
import { ProviderWizardMode } from "@/types/provider-wizard";
import { ProviderType } from "@/types/providers";
export interface ProviderWizardInitialData {
providerId: string;
providerType: ProviderType;
providerUid: string;
providerAlias: string | null;
secretId?: string | null;
via?: string | null;
mode?: ProviderWizardMode;
}

View File

@@ -0,0 +1,160 @@
"use client";
import { CircleCheckBig, FolderGit2, KeyRound, Rocket } from "lucide-react";
import { ReactElement } from "react";
import { ProwlerShort } from "@/components/icons/prowler/ProwlerIcons";
import { cn } from "@/lib/utils";
import { IconComponent, IconSvgProps } from "@/types/components";
interface WizardStepperProps {
currentStep: number;
stepOffset?: number;
}
interface StepConfig {
label: string;
description: string;
icon: IconComponent;
}
const STEPS: StepConfig[] = [
{
label: "Link a Cloud Provider",
description: "Enter the provider details you would like to add in Prowler.",
icon: FolderGit2,
},
{
label: "Authenticate Credentials",
description:
"Authorize a secure connection between Prowler and your provider.",
icon: KeyRound,
},
{
label: "Validate Connection",
description:
"Review provider resources and test the connection to Prowler.",
icon: Rocket,
},
{
label: "Launch Scan",
description: "Scan newly connected resources.",
icon: ProwlerShort,
},
];
export function WizardStepper({
currentStep,
stepOffset = 0,
}: WizardStepperProps) {
const activeVisualStep = Math.max(
0,
Math.min(currentStep + stepOffset, STEPS.length - 1),
);
return (
<nav aria-label="Wizard progress" className="flex flex-col gap-0">
{STEPS.map((step, index) => {
const isComplete = index < activeVisualStep;
const isActive = index === activeVisualStep;
const isInactive = index > activeVisualStep;
return (
<div key={step.label} className="flex items-start gap-3">
<div className="flex flex-col items-center">
<StepCircle
isComplete={isComplete}
isActive={isActive}
icon={step.icon}
/>
{index < STEPS.length - 1 && (
<StepConnector isComplete={isComplete} />
)}
</div>
<div className="flex flex-col gap-1 pt-[10px]">
<span
className={cn(
"text-lg leading-7 font-normal",
isActive && "text-text-neutral-primary",
isComplete && "text-text-neutral-primary",
isInactive && "text-text-neutral-tertiary",
)}
>
{step.label}
</span>
<p className="text-text-neutral-secondary text-xs leading-5">
{step.description}
</p>
</div>
</div>
);
})}
</nav>
);
}
interface StepCircleProps {
isComplete: boolean;
isActive: boolean;
icon: IconComponent;
}
function StepCircle({ isComplete, isActive, icon: Icon }: StepCircleProps) {
if (isComplete) {
return (
<div className="bg-button-primary-press flex size-[44px] shrink-0 items-center justify-center rounded-full">
<CircleCheckBig className="text-bg-neutral-primary size-6" />
</div>
);
}
if (isActive) {
return (
<div className="border-border-input-primary-pressed bg-bg-neutral-secondary flex size-[44px] shrink-0 items-center justify-center rounded-full border">
<StepIcon icon={Icon} className="text-border-input-primary-pressed" />
</div>
);
}
return (
<div className="border-border-neutral-secondary bg-bg-neutral-secondary flex size-[44px] shrink-0 items-center justify-center rounded-full border">
<StepIcon icon={Icon} className="text-text-neutral-tertiary" />
</div>
);
}
function StepConnector({ isComplete }: { isComplete: boolean }) {
if (isComplete) {
return <div className="bg-border-input-primary-pressed h-14 w-px" />;
}
return (
<div
className="h-14 w-px"
style={{
backgroundImage:
"repeating-linear-gradient(to bottom, var(--color-bg-data-muted) 0px, var(--color-bg-data-muted) 4px, transparent 4px, transparent 8px)",
}}
/>
);
}
function StepIcon({
icon: Icon,
className,
}: {
icon: IconComponent;
className: string;
}) {
if (isCustomSvgIcon(Icon)) {
return <Icon size={24} className={className} />;
}
return <Icon className={cn("size-6", className)} />;
}
function isCustomSvgIcon(
icon: IconComponent,
): icon is (props: IconSvgProps) => ReactElement {
return !("displayName" in icon && typeof icon.displayName === "string");
}

View File

@@ -8,9 +8,23 @@ import { BaseCredentialsForm } from "./base-credentials-form";
export const AddViaCredentialsForm = ({
searchParams,
providerUid,
via,
onSuccess,
onBack,
formId,
hideActions,
onLoadingChange,
onValidityChange,
}: {
searchParams: { type: string; id: string };
providerUid?: string;
via?: string | null;
onSuccess?: () => void;
onBack?: () => void;
formId?: string;
hideActions?: boolean;
onLoadingChange?: (isLoading: boolean) => void;
onValidityChange?: (isValid: boolean) => void;
}) => {
const providerType = searchParams.type as ProviderType;
const providerId = searchParams.id;
@@ -19,7 +33,7 @@ export const AddViaCredentialsForm = ({
return await addCredentialsProvider(formData);
};
const successNavigationUrl = `/providers/test-connection?type=${providerType}&id=${providerId}`;
const successNavigationUrl = "/providers";
return (
<BaseCredentialsForm
@@ -28,6 +42,13 @@ export const AddViaCredentialsForm = ({
providerUid={providerUid}
onSubmit={handleAddCredentials}
successNavigationUrl={successNavigationUrl}
via={via}
onSuccess={onSuccess}
onBack={onBack}
formId={formId}
hideActions={hideActions}
onLoadingChange={onLoadingChange}
onValidityChange={onValidityChange}
submitButtonText="Next"
/>
);

View File

@@ -8,9 +8,23 @@ import { BaseCredentialsForm } from "./base-credentials-form";
export const AddViaRoleForm = ({
searchParams,
providerUid,
via,
onSuccess,
onBack,
formId,
hideActions,
onLoadingChange,
onValidityChange,
}: {
searchParams: { type: string; id: string };
providerUid?: string;
via?: string | null;
onSuccess?: () => void;
onBack?: () => void;
formId?: string;
hideActions?: boolean;
onLoadingChange?: (isLoading: boolean) => void;
onValidityChange?: (isValid: boolean) => void;
}) => {
const providerType = searchParams.type as ProviderType;
const providerId = searchParams.id;
@@ -19,7 +33,7 @@ export const AddViaRoleForm = ({
return await addCredentialsProvider(formData);
};
const successNavigationUrl = `/providers/test-connection?type=${providerType}&id=${providerId}`;
const successNavigationUrl = "/providers";
return (
<BaseCredentialsForm
@@ -28,6 +42,13 @@ export const AddViaRoleForm = ({
providerUid={providerUid}
onSubmit={handleAddCredentials}
successNavigationUrl={successNavigationUrl}
via={via}
onSuccess={onSuccess}
onBack={onBack}
formId={formId}
hideActions={hideActions}
onLoadingChange={onLoadingChange}
onValidityChange={onValidityChange}
submitButtonText="Next"
/>
);

View File

@@ -2,6 +2,7 @@
import { Divider } from "@heroui/divider";
import { ChevronLeftIcon, ChevronRightIcon, Loader2 } from "lucide-react";
import { useEffect } from "react";
import { Control, UseFormSetValue } from "react-hook-form";
import { Button } from "@/components/shadcn";
@@ -62,8 +63,16 @@ type BaseCredentialsFormProps = {
providerUid?: string;
onSubmit: (formData: FormData) => Promise<ApiResponse>;
successNavigationUrl: string;
via?: string | null;
onSuccess?: () => void;
onBack?: () => void;
formId?: string;
hideActions?: boolean;
onLoadingChange?: (isLoading: boolean) => void;
onValidityChange?: (isValid: boolean) => void;
submitButtonText?: string;
showBackButton?: boolean;
validationMode?: "onSubmit" | "onChange";
};
export const BaseCredentialsForm = ({
@@ -72,15 +81,24 @@ export const BaseCredentialsForm = ({
providerUid,
onSubmit,
successNavigationUrl,
via,
onSuccess,
onBack,
formId,
hideActions = false,
onLoadingChange,
onValidityChange,
submitButtonText = "Next",
showBackButton = true,
validationMode,
}: BaseCredentialsFormProps) => {
const {
form,
isLoading,
isValid,
handleSubmit,
handleBackStep,
searchParamsObj,
effectiveVia,
externalId,
} = useCredentialsForm({
providerType,
@@ -88,13 +106,26 @@ export const BaseCredentialsForm = ({
providerUid,
onSubmit,
successNavigationUrl,
via,
onSuccess,
onBack,
validationMode,
});
useEffect(() => {
onLoadingChange?.(isLoading);
}, [isLoading, onLoadingChange]);
useEffect(() => {
onValidityChange?.(isValid);
}, [isValid, onValidityChange]);
const templateLinks = getAWSCredentialsTemplateLinks(externalId);
return (
<Form {...form}>
<form
id={formId}
onSubmit={form.handleSubmit(handleSubmit)}
className="flex flex-col gap-4"
>
@@ -120,7 +151,7 @@ export const BaseCredentialsForm = ({
<Divider />
{providerType === "aws" && searchParamsObj.get("via") === "role" && (
{providerType === "aws" && effectiveVia === "role" && (
<AWSRoleCredentialsForm
control={form.control as unknown as Control<AWSCredentialsRole>}
setValue={
@@ -130,7 +161,7 @@ export const BaseCredentialsForm = ({
templateLinks={templateLinks}
/>
)}
{providerType === "aws" && searchParamsObj.get("via") !== "role" && (
{providerType === "aws" && effectiveVia !== "role" && (
<AWSStaticCredentialsForm
control={form.control as unknown as Control<AWSCredentials>}
/>
@@ -140,36 +171,30 @@ export const BaseCredentialsForm = ({
control={form.control as unknown as Control<AzureCredentials>}
/>
)}
{providerType === "m365" &&
searchParamsObj.get("via") === "app_client_secret" && (
<M365ClientSecretCredentialsForm
control={
form.control as unknown as Control<M365ClientSecretCredentials>
}
/>
)}
{providerType === "m365" &&
searchParamsObj.get("via") === "app_certificate" && (
<M365CertificateCredentialsForm
control={
form.control as unknown as Control<M365CertificateCredentials>
}
/>
)}
{providerType === "gcp" &&
searchParamsObj.get("via") === "service-account" && (
<GCPServiceAccountKeyForm
control={form.control as unknown as Control<GCPServiceAccountKey>}
/>
)}
{providerType === "gcp" &&
searchParamsObj.get("via") !== "service-account" && (
<GCPDefaultCredentialsForm
control={
form.control as unknown as Control<GCPDefaultCredentials>
}
/>
)}
{providerType === "m365" && effectiveVia === "app_client_secret" && (
<M365ClientSecretCredentialsForm
control={
form.control as unknown as Control<M365ClientSecretCredentials>
}
/>
)}
{providerType === "m365" && effectiveVia === "app_certificate" && (
<M365CertificateCredentialsForm
control={
form.control as unknown as Control<M365CertificateCredentials>
}
/>
)}
{providerType === "gcp" && effectiveVia === "service-account" && (
<GCPServiceAccountKeyForm
control={form.control as unknown as Control<GCPServiceAccountKey>}
/>
)}
{providerType === "gcp" && effectiveVia !== "service-account" && (
<GCPDefaultCredentialsForm
control={form.control as unknown as Control<GCPDefaultCredentials>}
/>
)}
{providerType === "kubernetes" && (
<KubernetesCredentialsForm
control={form.control as unknown as Control<KubernetesCredentials>}
@@ -178,7 +203,7 @@ export const BaseCredentialsForm = ({
{providerType === "github" && (
<GitHubCredentialsForm
control={form.control}
credentialsType={searchParamsObj.get("via") || undefined}
credentialsType={effectiveVia || undefined}
/>
)}
{providerType === "iac" && (
@@ -198,71 +223,69 @@ export const BaseCredentialsForm = ({
}
/>
)}
{providerType === "alibabacloud" &&
searchParamsObj.get("via") === "role" && (
<AlibabaCloudRoleCredentialsForm
control={
form.control as unknown as Control<AlibabaCloudCredentialsRole>
}
/>
)}
{providerType === "alibabacloud" &&
searchParamsObj.get("via") !== "role" && (
<AlibabaCloudStaticCredentialsForm
control={
form.control as unknown as Control<AlibabaCloudCredentials>
}
/>
)}
{providerType === "cloudflare" &&
searchParamsObj.get("via") === "api_token" && (
<CloudflareApiTokenCredentialsForm
control={
form.control as unknown as Control<CloudflareTokenCredentials>
}
/>
)}
{providerType === "cloudflare" &&
searchParamsObj.get("via") === "api_key" && (
<CloudflareApiKeyCredentialsForm
control={
form.control as unknown as Control<CloudflareApiKeyCredentials>
}
/>
)}
{providerType === "alibabacloud" && effectiveVia === "role" && (
<AlibabaCloudRoleCredentialsForm
control={
form.control as unknown as Control<AlibabaCloudCredentialsRole>
}
/>
)}
{providerType === "alibabacloud" && effectiveVia !== "role" && (
<AlibabaCloudStaticCredentialsForm
control={
form.control as unknown as Control<AlibabaCloudCredentials>
}
/>
)}
{providerType === "cloudflare" && effectiveVia === "api_token" && (
<CloudflareApiTokenCredentialsForm
control={
form.control as unknown as Control<CloudflareTokenCredentials>
}
/>
)}
{providerType === "cloudflare" && effectiveVia === "api_key" && (
<CloudflareApiKeyCredentialsForm
control={
form.control as unknown as Control<CloudflareApiKeyCredentials>
}
/>
)}
{providerType === "openstack" && (
<OpenStackCredentialsForm
control={form.control as unknown as Control<OpenStackCredentials>}
/>
)}
<div className="flex w-full justify-end gap-4">
{showBackButton && requiresBackButton(searchParamsObj.get("via")) && (
{!hideActions && (
<div className="flex w-full justify-end gap-4">
{showBackButton && requiresBackButton(effectiveVia) && (
<Button
type="button"
variant="ghost"
size="lg"
onClick={handleBackStep}
disabled={isLoading}
>
{!isLoading && <ChevronLeftIcon size={24} />}
Back
</Button>
)}
<Button
type="button"
variant="ghost"
type="submit"
variant="default"
size="lg"
onClick={handleBackStep}
disabled={isLoading}
>
{!isLoading && <ChevronLeftIcon size={24} />}
Back
{isLoading ? (
<Loader2 className="animate-spin" />
) : (
<ChevronRightIcon size={24} />
)}
{isLoading ? "Loading" : submitButtonText}
</Button>
)}
<Button
type="submit"
variant="default"
size="lg"
disabled={isLoading}
>
{isLoading ? (
<Loader2 className="animate-spin" />
) : (
<ChevronRightIcon size={24} />
)}
{isLoading ? "Loading" : submitButtonText}
</Button>
</div>
</div>
)}
</form>
</Form>
);

View File

@@ -8,6 +8,7 @@ import { useForm } from "react-hook-form";
import { z } from "zod";
import { addProvider } from "@/actions/providers/providers";
import { AwsMethodSelector } from "@/components/providers/organizations/aws-method-selector";
import { ProviderTitleDocs } from "@/components/providers/workflow/provider-title-docs";
import { Button } from "@/components/shadcn";
import { useToast } from "@/components/ui";
@@ -19,6 +20,29 @@ import { RadioGroupProvider } from "../../radio-group-provider";
export type FormValues = z.infer<typeof addProviderFormSchema>;
export interface ConnectAccountSuccessData {
id: string;
providerType: ProviderType;
uid: string;
alias: string | null;
}
interface ConnectAccountFormProps {
onSuccess?: (data: ConnectAccountSuccessData) => void;
onSelectOrganizations?: () => void;
onProviderTypeChange?: (providerType: ProviderType | null) => void;
formId?: string;
hideNavigation?: boolean;
onUiStateChange?: (state: {
showBack: boolean;
showAction: boolean;
actionLabel: string;
actionDisabled: boolean;
isLoading: boolean;
}) => void;
onBackHandlerChange?: (handler: () => void) => void;
}
// Helper function for labels and placeholders
const getProviderFieldDetails = (providerType?: ProviderType) => {
switch (providerType) {
@@ -90,9 +114,18 @@ const getProviderFieldDetails = (providerType?: ProviderType) => {
}
};
export const ConnectAccountForm = () => {
export const ConnectAccountForm = ({
onSuccess,
onSelectOrganizations,
onProviderTypeChange,
formId,
hideNavigation = false,
onUiStateChange,
onBackHandlerChange,
}: ConnectAccountFormProps) => {
const { toast } = useToast();
const [prevStep, setPrevStep] = useState(1);
const [awsMethod, setAwsMethod] = useState<"single" | null>(null);
const router = useRouter();
const formSchema = addProviderFormSchema;
@@ -104,9 +137,12 @@ export const ConnectAccountForm = () => {
providerUid: "",
providerAlias: "",
},
mode: "onChange",
reValidateMode: "onChange",
});
const providerType = form.watch("providerType");
const providerUid = form.watch("providerUid");
const providerFieldDetails = getProviderFieldDetails(providerType);
const isLoading = form.formState.isSubmitting;
@@ -161,10 +197,20 @@ export const ConnectAccountForm = () => {
// Go to the next step after successful submission
const {
id,
attributes: { provider: providerType },
attributes: { provider: createdProviderType, uid, alias },
} = data.data;
router.push(`/providers/add-credentials?type=${providerType}&id=${id}`);
if (onSuccess) {
onSuccess({
id,
providerType: createdProviderType,
uid: uid || values.providerUid,
alias: alias ?? values.providerAlias ?? null,
});
return;
}
router.push("/providers");
}
} catch (error: unknown) {
console.error("Error during submission:", error);
@@ -180,10 +226,19 @@ export const ConnectAccountForm = () => {
};
const handleBackStep = () => {
// If in UID form after choosing single, go back to method selector
if (prevStep === 2 && awsMethod === "single") {
setAwsMethod(null);
form.setValue("providerUid", "");
form.setValue("providerAlias", "");
return;
}
setPrevStep((prev) => prev - 1);
//Deselect the providerType if the user is going back to the first step
// Deselect the providerType if the user is going back to the first step
if (prevStep === 2) {
form.setValue("providerType", undefined as unknown as ProviderType);
setAwsMethod(null);
}
// Reset the providerUid and providerAlias fields when going back
form.setValue("providerUid", "");
@@ -196,9 +251,61 @@ export const ConnectAccountForm = () => {
}
}, [providerType]);
useEffect(() => {
onProviderTypeChange?.(providerType ?? null);
}, [onProviderTypeChange, providerType]);
useEffect(() => {
onBackHandlerChange?.(() => {
// If in UID form after choosing single, go back to method selector
if (prevStep === 2 && awsMethod === "single") {
setAwsMethod(null);
form.setValue("providerUid", "");
form.setValue("providerAlias", "");
return;
}
setPrevStep((prev) => prev - 1);
// Deselect the providerType if the user is going back to the first step
if (prevStep === 2) {
form.setValue("providerType", undefined as unknown as ProviderType);
setAwsMethod(null);
}
// Reset the providerUid and providerAlias fields when going back
form.setValue("providerUid", "");
form.setValue("providerAlias", "");
});
}, [onBackHandlerChange, prevStep, awsMethod, providerType, isLoading, form]);
useEffect(() => {
const canSubmit =
prevStep === 2 &&
(providerType !== "aws" || awsMethod === "single") &&
providerUid.trim().length > 0 &&
form.formState.isValid;
onUiStateChange?.({
showBack: prevStep === 2,
showAction:
prevStep === 2 && (providerType !== "aws" || awsMethod === "single"),
actionLabel: "Next",
actionDisabled: !canSubmit || isLoading,
isLoading,
});
}, [
awsMethod,
form.formState.isValid,
isLoading,
onUiStateChange,
prevStep,
providerType,
providerUid,
]);
return (
<Form {...form}>
<form
id={formId}
onSubmit={form.handleSubmit(onSubmitClient)}
className="flex flex-col gap-4"
>
@@ -210,64 +317,77 @@ export const ConnectAccountForm = () => {
errorMessage={form.formState.errors.providerType?.message}
/>
)}
{/* Step 2: UID, alias, and credentials (if AWS) */}
{prevStep === 2 && (
{/* Step 2: AWS method selector (only for AWS, before choosing method) */}
{prevStep === 2 && providerType === "aws" && awsMethod === null && (
<>
<ProviderTitleDocs providerType={providerType} />
<CustomInput
control={form.control}
name="providerUid"
type="text"
label={providerFieldDetails.label}
labelPlacement="inside"
placeholder={providerFieldDetails.placeholder}
variant="bordered"
isRequired
/>
<CustomInput
control={form.control}
name="providerAlias"
type="text"
label="Provider alias (optional)"
labelPlacement="inside"
placeholder="Enter the provider alias"
variant="bordered"
isRequired={false}
<AwsMethodSelector
onSelectSingle={() => setAwsMethod("single")}
onSelectOrganizations={() => {
onSelectOrganizations?.();
}}
/>
</>
)}
{/* Navigation buttons */}
<div className="flex w-full justify-end gap-4">
{/* Show "Back" button only in Step 2 */}
{prevStep === 2 && (
<Button
type="button"
variant="ghost"
size="lg"
onClick={handleBackStep}
disabled={isLoading}
>
{!isLoading && <ChevronLeftIcon size={24} />}
Back
</Button>
{/* Step 2: UID, alias form (non-AWS or AWS single account) */}
{prevStep === 2 &&
(providerType !== "aws" || awsMethod === "single") && (
<>
<ProviderTitleDocs providerType={providerType} />
<CustomInput
control={form.control}
name="providerUid"
type="text"
label={providerFieldDetails.label}
labelPlacement="inside"
placeholder={providerFieldDetails.placeholder}
variant="bordered"
isRequired
/>
<CustomInput
control={form.control}
name="providerAlias"
type="text"
label="Provider alias (optional)"
labelPlacement="inside"
placeholder="Enter the provider alias"
variant="bordered"
isRequired={false}
/>
</>
)}
{/* Show "Next" button in Step 2 */}
{prevStep === 2 && (
<Button
type="submit"
variant="default"
size="lg"
disabled={isLoading}
>
{isLoading ? (
<Loader2 className="animate-spin" />
) : (
<ChevronRightIcon size={24} />
{!hideNavigation && (
<div className="flex w-full justify-end gap-4">
{prevStep === 2 && (
<Button
type="button"
variant="ghost"
size="lg"
onClick={handleBackStep}
disabled={isLoading}
>
{!isLoading && <ChevronLeftIcon size={24} />}
Back
</Button>
)}
{prevStep === 2 &&
(providerType !== "aws" || awsMethod === "single") && (
<Button
type="submit"
variant="default"
size="lg"
disabled={isLoading}
>
{isLoading ? (
<Loader2 className="animate-spin" />
) : (
<ChevronRightIcon size={24} />
)}
{isLoading ? "Loading" : "Next"}
</Button>
)}
{isLoading ? "Loading" : "Next"}
</Button>
)}
</div>
</div>
)}
</form>
</Form>
);

View File

@@ -9,10 +9,12 @@ import { RadioGroupAlibabaCloudViaCredentialsTypeForm } from "./radio-group-alib
interface SelectViaAlibabaCloudProps {
initialVia?: string;
onViaChange?: (via: string) => void;
}
export const SelectViaAlibabaCloud = ({
initialVia,
onViaChange,
}: SelectViaAlibabaCloudProps) => {
const router = useRouter();
const form = useForm({
@@ -22,6 +24,11 @@ export const SelectViaAlibabaCloud = ({
});
const handleSelectionChange = (value: string) => {
if (onViaChange) {
onViaChange(value);
return;
}
const url = new URL(window.location.href);
url.searchParams.set("via", value);
router.push(url.toString());

View File

@@ -9,9 +9,13 @@ import { RadioGroupAWSViaCredentialsTypeForm } from "./radio-group-aws-via-crede
interface SelectViaAWSProps {
initialVia?: string;
onViaChange?: (via: string) => void;
}
export const SelectViaAWS = ({ initialVia }: SelectViaAWSProps) => {
export const SelectViaAWS = ({
initialVia,
onViaChange,
}: SelectViaAWSProps) => {
const router = useRouter();
const form = useForm({
defaultValues: {
@@ -20,6 +24,11 @@ export const SelectViaAWS = ({ initialVia }: SelectViaAWSProps) => {
});
const handleSelectionChange = (value: string) => {
if (onViaChange) {
onViaChange(value);
return;
}
const url = new URL(window.location.href);
url.searchParams.set("via", value);
router.push(url.toString());

View File

@@ -9,10 +9,12 @@ import { RadioGroupCloudflareViaCredentialsTypeForm } from "./radio-group-cloudf
interface SelectViaCloudflareProps {
initialVia?: string;
onViaChange?: (value: string) => void;
}
export const SelectViaCloudflare = ({
initialVia,
onViaChange,
}: SelectViaCloudflareProps) => {
const router = useRouter();
const form = useForm({
@@ -22,6 +24,11 @@ export const SelectViaCloudflare = ({
});
const handleSelectionChange = (value: string) => {
if (onViaChange) {
onViaChange(value);
return;
}
const url = new URL(window.location.href);
url.searchParams.set("via", value);
router.push(url.toString());

View File

@@ -8,9 +8,23 @@ import { BaseCredentialsForm } from "../../base-credentials-form";
export const AddViaServiceAccountForm = ({
searchParams,
providerUid,
via,
onSuccess,
onBack,
formId,
hideActions,
onLoadingChange,
onValidityChange,
}: {
searchParams: { type: ProviderType; id: string };
providerUid?: string;
via?: string | null;
onSuccess?: () => void;
onBack?: () => void;
formId?: string;
hideActions?: boolean;
onLoadingChange?: (isLoading: boolean) => void;
onValidityChange?: (isValid: boolean) => void;
}) => {
const providerType = searchParams.type;
const providerId = searchParams.id;
@@ -19,7 +33,7 @@ export const AddViaServiceAccountForm = ({
return await addCredentialsProvider(formData);
};
const successNavigationUrl = `/providers/test-connection?type=${providerType}&id=${providerId}`;
const successNavigationUrl = "/providers";
return (
<BaseCredentialsForm
@@ -28,6 +42,13 @@ export const AddViaServiceAccountForm = ({
providerUid={providerUid}
onSubmit={handleAddCredentials}
successNavigationUrl={successNavigationUrl}
via={via}
onSuccess={onSuccess}
onBack={onBack}
formId={formId}
hideActions={hideActions}
onLoadingChange={onLoadingChange}
onValidityChange={onValidityChange}
submitButtonText="Next"
/>
);

View File

@@ -9,9 +9,13 @@ import { RadioGroupGCPViaCredentialsTypeForm } from "./radio-group-gcp-via-crede
interface SelectViaGCPProps {
initialVia?: string;
onViaChange?: (via: string) => void;
}
export const SelectViaGCP = ({ initialVia }: SelectViaGCPProps) => {
export const SelectViaGCP = ({
initialVia,
onViaChange,
}: SelectViaGCPProps) => {
const router = useRouter();
const form = useForm({
defaultValues: {
@@ -20,6 +24,11 @@ export const SelectViaGCP = ({ initialVia }: SelectViaGCPProps) => {
});
const handleSelectionChange = (value: string) => {
if (onViaChange) {
onViaChange(value);
return;
}
const url = new URL(window.location.href);
url.searchParams.set("via", value);
router.push(url.toString());

View File

@@ -9,9 +9,13 @@ import { RadioGroupGitHubViaCredentialsTypeForm } from "./radio-group-github-via
interface SelectViaGitHubProps {
initialVia?: string;
onViaChange?: (via: string) => void;
}
export const SelectViaGitHub = ({ initialVia }: SelectViaGitHubProps) => {
export const SelectViaGitHub = ({
initialVia,
onViaChange,
}: SelectViaGitHubProps) => {
const router = useRouter();
const form = useForm({
defaultValues: {
@@ -20,6 +24,11 @@ export const SelectViaGitHub = ({ initialVia }: SelectViaGitHubProps) => {
});
const handleSelectionChange = (value: string) => {
if (onViaChange) {
onViaChange(value);
return;
}
const url = new URL(window.location.href);
url.searchParams.set("via", value);
router.push(url.toString());

View File

@@ -9,9 +9,13 @@ import { RadioGroupM365ViaCredentialsTypeForm } from "./radio-group-m365-via-cre
interface SelectViaM365Props {
initialVia?: string;
onViaChange?: (via: string) => void;
}
export const SelectViaM365 = ({ initialVia }: SelectViaM365Props) => {
export const SelectViaM365 = ({
initialVia,
onViaChange,
}: SelectViaM365Props) => {
const router = useRouter();
const form = useForm({
defaultValues: {
@@ -20,6 +24,11 @@ export const SelectViaM365 = ({ initialVia }: SelectViaM365Props) => {
});
const handleSelectionChange = (value: string) => {
if (onViaChange) {
onViaChange(value);
return;
}
const url = new URL(window.location.href);
url.searchParams.set("via", value);
router.push(url.toString());

View File

@@ -6,7 +6,7 @@ import { Icon } from "@iconify/react";
import { Loader2 } from "lucide-react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useState } from "react";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { z } from "zod";
@@ -28,40 +28,53 @@ import { ProviderInfo } from "../..";
type FormValues = z.input<typeof testConnectionFormSchema>;
export const TestConnectionForm = ({
searchParams,
providerData,
}: {
searchParams: { type: string; id: string; updated: string };
providerData: {
data: {
id: string;
type: string;
attributes: {
uid: string;
connection: {
connected: boolean | null;
last_checked_at: string | null;
};
provider: ProviderType;
alias: string;
scanner_args: Record<string, unknown>;
export interface TestConnectionProviderData {
data: {
id: string;
type: string;
attributes: {
uid: string;
connection: {
connected: boolean | null;
last_checked_at: string | null;
};
relationships: {
secret: {
data: {
type: string;
id: string;
} | null;
};
provider: ProviderType;
alias: string;
scanner_args: Record<string, unknown>;
};
relationships: {
secret: {
data: {
type: string;
id: string;
} | null;
};
};
};
}) => {
}
interface TestConnectionFormProps {
searchParams: { type: string; id: string; updated: string };
providerData: TestConnectionProviderData;
onSuccess?: () => void;
onResetCredentials?: () => void;
formId?: string;
hideActions?: boolean;
onLoadingChange?: (isLoading: boolean) => void;
}
export const TestConnectionForm = ({
searchParams,
providerData,
onSuccess,
onResetCredentials: onResetCredentialsCallback,
formId,
hideActions = false,
onLoadingChange,
}: TestConnectionFormProps) => {
const { toast } = useToast();
const router = useRouter();
const providerType = searchParams.type;
const providerId = searchParams.id;
const [apiErrorMessage, setApiErrorMessage] = useState<string | null>(null);
@@ -85,6 +98,10 @@ export const TestConnectionForm = ({
const isLoading = form.formState.isSubmitting;
const isUpdated = searchParams?.updated === "true";
useEffect(() => {
onLoadingChange?.(isLoading || isResettingCredentials);
}, [isLoading, isResettingCredentials, onLoadingChange]);
const onSubmitClient = async (values: FormValues) => {
const formData = new FormData();
formData.append("providerId", values.providerId);
@@ -123,7 +140,13 @@ export const TestConnectionForm = ({
error: connected ? null : error || "Unknown error",
});
if (connected && isUpdated) return router.push("/providers");
if (connected && isUpdated) {
if (onSuccess) {
onSuccess();
return;
}
return router.push("/providers");
}
if (connected && !isUpdated) {
try {
@@ -150,6 +173,11 @@ export const TestConnectionForm = ({
description: data.error,
});
} else {
if (onSuccess) {
onSuccess();
return;
}
setIsRedirecting(true);
router.push("/scans");
}
@@ -174,7 +202,7 @@ export const TestConnectionForm = ({
}
};
const onResetCredentials = async () => {
const handleResetCredentials = async () => {
setIsResettingCredentials(true);
// Check if provider the provider has no credentials
@@ -183,20 +211,23 @@ export const TestConnectionForm = ({
const hasNoCredentials = !providerSecretId;
if (hasNoCredentials) {
// If no credentials, redirect to add credentials page
router.push(
`/providers/add-credentials?type=${providerType}&id=${providerId}`,
);
if (onResetCredentialsCallback) {
onResetCredentialsCallback();
} else {
router.push("/providers");
}
setIsResettingCredentials(false);
return;
}
// If provider has credentials, delete them first
try {
await deleteCredentials(providerSecretId);
// After successful deletion, redirect to add credentials page
router.push(
`/providers/add-credentials?type=${providerType}&id=${providerId}`,
);
if (onResetCredentialsCallback) {
onResetCredentialsCallback();
} else {
router.push("/providers");
}
} catch (error) {
console.error("Failed to delete credentials:", error);
} finally {
@@ -226,6 +257,7 @@ export const TestConnectionForm = ({
return (
<Form {...form}>
<form
id={formId}
onSubmit={form.handleSubmit(onSubmitClient)}
className="flex flex-col gap-4"
>
@@ -299,52 +331,58 @@ export const TestConnectionForm = ({
<input type="hidden" name="providerId" value={providerId} />
<div className="flex w-full justify-end sm:gap-6">
{apiErrorMessage ? (
<Button variant="outline" size="lg" asChild>
<Link href="/providers">Back to providers</Link>
</Button>
) : connectionStatus?.error ? (
<Button
onClick={isUpdated ? () => router.back() : onResetCredentials}
type="button"
variant="secondary"
size="lg"
disabled={isResettingCredentials}
>
{isResettingCredentials ? (
<Loader2 className="animate-spin" />
) : (
<CheckIcon size={24} />
)}
{isResettingCredentials
? "Loading"
: isUpdated
? "Update credentials"
: "Reset credentials"}
</Button>
) : (
<Button
type={
isUpdated && connectionStatus?.connected ? "button" : "submit"
}
variant="default"
size="lg"
disabled={isLoading}
>
{isLoading ? (
<Loader2 className="animate-spin" />
) : (
!isUpdated && <RocketIcon size={24} />
)}
{isLoading
? "Loading"
: isUpdated
? "Check connection"
: "Launch scan"}
</Button>
)}
</div>
{!hideActions && (
<div className="flex w-full justify-end sm:gap-6">
{apiErrorMessage ? (
<Button variant="outline" size="lg" asChild>
<Link href="/providers">Back to providers</Link>
</Button>
) : connectionStatus?.error ? (
<Button
onClick={
isUpdated
? onResetCredentialsCallback || (() => router.back())
: handleResetCredentials
}
type="button"
variant="secondary"
size="lg"
disabled={isResettingCredentials}
>
{isResettingCredentials ? (
<Loader2 className="animate-spin" />
) : (
<CheckIcon size={24} />
)}
{isResettingCredentials
? "Loading"
: isUpdated
? "Update credentials"
: "Reset credentials"}
</Button>
) : (
<Button
type={
isUpdated && connectionStatus?.connected ? "button" : "submit"
}
variant="default"
size="lg"
disabled={isLoading}
>
{isLoading ? (
<Loader2 className="animate-spin" />
) : (
!isUpdated && <RocketIcon size={24} />
)}
{isLoading
? "Loading"
: isUpdated
? "Check connection"
: "Launch scan"}
</Button>
)}
</div>
)}
</form>
</Form>
);

View File

@@ -8,9 +8,23 @@ import { BaseCredentialsForm } from "./base-credentials-form";
export const UpdateViaCredentialsForm = ({
searchParams,
providerUid,
via,
onSuccess,
onBack,
formId,
hideActions,
onLoadingChange,
onValidityChange,
}: {
searchParams: { type: string; id: string; secretId?: string };
providerUid?: string;
via?: string | null;
onSuccess?: () => void;
onBack?: () => void;
formId?: string;
hideActions?: boolean;
onLoadingChange?: (isLoading: boolean) => void;
onValidityChange?: (isValid: boolean) => void;
}) => {
const providerType = searchParams.type as ProviderType;
const providerId = searchParams.id;
@@ -20,7 +34,7 @@ export const UpdateViaCredentialsForm = ({
return await updateCredentialsProvider(providerSecretId, formData);
};
const successNavigationUrl = `/providers/test-connection?type=${providerType}&id=${providerId}&updated=true`;
const successNavigationUrl = "/providers";
return (
<BaseCredentialsForm
@@ -29,6 +43,13 @@ export const UpdateViaCredentialsForm = ({
providerUid={providerUid}
onSubmit={handleUpdateCredentials}
successNavigationUrl={successNavigationUrl}
via={via}
onSuccess={onSuccess}
onBack={onBack}
formId={formId}
hideActions={hideActions}
onLoadingChange={onLoadingChange}
onValidityChange={onValidityChange}
submitButtonText="Next"
/>
);

View File

@@ -8,9 +8,23 @@ import { BaseCredentialsForm } from "./base-credentials-form";
export const UpdateViaRoleForm = ({
searchParams,
providerUid,
via,
onSuccess,
onBack,
formId,
hideActions,
onLoadingChange,
onValidityChange,
}: {
searchParams: { type: string; id: string; secretId?: string };
providerUid?: string;
via?: string | null;
onSuccess?: () => void;
onBack?: () => void;
formId?: string;
hideActions?: boolean;
onLoadingChange?: (isLoading: boolean) => void;
onValidityChange?: (isValid: boolean) => void;
}) => {
const providerType = searchParams.type as ProviderType;
const providerId = searchParams.id;
@@ -20,7 +34,7 @@ export const UpdateViaRoleForm = ({
return await updateCredentialsProvider(providerSecretId, formData);
};
const successNavigationUrl = `/providers/test-connection?type=${providerType}&id=${providerId}&updated=true`;
const successNavigationUrl = "/providers";
return (
<BaseCredentialsForm
@@ -29,6 +43,13 @@ export const UpdateViaRoleForm = ({
providerUid={providerUid}
onSubmit={handleUpdateCredentials}
successNavigationUrl={successNavigationUrl}
via={via}
onSuccess={onSuccess}
onBack={onBack}
formId={formId}
hideActions={hideActions}
onLoadingChange={onLoadingChange}
onValidityChange={onValidityChange}
submitButtonText="Next"
/>
);

View File

@@ -8,9 +8,23 @@ import { BaseCredentialsForm } from "./base-credentials-form";
export const UpdateViaServiceAccountForm = ({
searchParams,
providerUid,
via,
onSuccess,
onBack,
formId,
hideActions,
onLoadingChange,
onValidityChange,
}: {
searchParams: { type: string; id: string; secretId?: string };
providerUid?: string;
via?: string | null;
onSuccess?: () => void;
onBack?: () => void;
formId?: string;
hideActions?: boolean;
onLoadingChange?: (isLoading: boolean) => void;
onValidityChange?: (isValid: boolean) => void;
}) => {
const providerType = searchParams.type as ProviderType;
const providerId = searchParams.id;
@@ -20,7 +34,7 @@ export const UpdateViaServiceAccountForm = ({
return await updateCredentialsProvider(providerSecretId, formData);
};
const successNavigationUrl = `/providers/test-connection?type=${providerType}&id=${providerId}&updated=true`;
const successNavigationUrl = "/providers";
return (
<BaseCredentialsForm
@@ -29,6 +43,13 @@ export const UpdateViaServiceAccountForm = ({
providerUid={providerUid}
onSubmit={handleUpdateCredentials}
successNavigationUrl={successNavigationUrl}
via={via}
onSuccess={onSuccess}
onBack={onBack}
formId={formId}
hideActions={hideActions}
onLoadingChange={onLoadingChange}
onValidityChange={onValidityChange}
submitButtonText="Next"
/>
);

View File

@@ -1,5 +1,3 @@
export * from "./credentials-role-helper";
export * from "./provider-title-docs";
export * from "./skeleton-provider-workflow";
export * from "./vertical-steps";
export * from "./workflow-add-provider";

View File

@@ -1,9 +1,7 @@
"use client";
import { CustomLink } from "@/components/ui/custom/custom-link";
import { getProviderName } from "@/components/ui/entities/get-provider-logo";
import { getProviderLogo } from "@/components/ui/entities/get-provider-logo";
import { getProviderHelpText } from "@/lib";
import { ProviderType } from "@/types";
export const ProviderTitleDocs = ({
@@ -12,27 +10,13 @@ export const ProviderTitleDocs = ({
providerType: ProviderType;
}) => {
return (
<div className="flex flex-col gap-y-2">
<div className="flex gap-4">
{providerType && getProviderLogo(providerType as ProviderType)}
<span className="text-lg font-semibold">
{providerType
? getProviderName(providerType as ProviderType)
: "Unknown Provider"}
</span>
</div>
<div className="flex items-end gap-x-2">
<p className="text-default-500 text-sm">
{getProviderHelpText(providerType as string).text}
</p>
<CustomLink
href={getProviderHelpText(providerType as string).link}
size="sm"
className="text-nowrap"
>
Read the docs
</CustomLink>
</div>
<div className="flex gap-4">
{providerType && getProviderLogo(providerType as ProviderType)}
<span className="text-lg font-semibold">
{providerType
? getProviderName(providerType as ProviderType)
: "Unknown Provider"}
</span>
</div>
);
};

View File

@@ -1,294 +0,0 @@
"use client";
import { cn } from "@heroui/theme";
import { useControlledState } from "@react-stately/utils";
import { domAnimation, LazyMotion, m } from "framer-motion";
import type { ComponentProps } from "react";
import React from "react";
export type VerticalStepProps = {
className?: string;
description?: React.ReactNode;
title?: React.ReactNode;
};
export interface VerticalStepsProps
extends React.HTMLAttributes<HTMLButtonElement> {
/**
* An array of steps.
*
* @default []
*/
steps?: VerticalStepProps[];
/**
* The color of the steps.
*
* @default "primary"
*/
color?:
| "primary"
| "secondary"
| "success"
| "warning"
| "danger"
| "default";
/**
* The current step index.
*/
currentStep?: number;
/**
* The default step index.
*
* @default 0
*/
defaultStep?: number;
/**
* Whether to hide the progress bars.
*
* @default false
*/
hideProgressBars?: boolean;
/**
* The custom class for the steps wrapper.
*/
className?: string;
/**
* The custom class for the step.
*/
stepClassName?: string;
/**
* Callback function when the step index changes.
*/
onStepChange?: (stepIndex: number) => void;
}
function CheckIcon(props: ComponentProps<"svg">) {
return (
<svg
{...props}
fill="none"
stroke="currentColor"
strokeWidth={2}
viewBox="0 0 24 24"
>
<m.path
animate={{ pathLength: 1 }}
d="M5 13l4 4L19 7"
initial={{ pathLength: 0 }}
strokeLinecap="round"
strokeLinejoin="round"
transition={{
delay: 0.2,
type: "tween",
ease: "easeOut",
duration: 0.3,
}}
/>
</svg>
);
}
export const VerticalSteps = React.forwardRef<
HTMLButtonElement,
VerticalStepsProps
>(
(
{
color = "primary",
steps = [],
defaultStep = 0,
onStepChange,
currentStep: currentStepProp,
hideProgressBars = false,
stepClassName,
className,
...props
},
ref,
) => {
const [currentStep, setCurrentStep] = useControlledState(
currentStepProp,
defaultStep,
onStepChange,
);
const colors = React.useMemo(() => {
let userColor;
let fgColor;
const colorsVars = [
"[--active-fg-color:var(--step-fg-color)]",
"[--active-border-color:var(--step-color)]",
"[--active-color:var(--step-color)]",
"[--complete-background-color:var(--step-color)]",
"[--complete-border-color:var(--step-color)]",
"[--inactive-border-color:hsl(var(--heroui-default-300))]",
"[--inactive-color:hsl(var(--heroui-default-300))]",
];
switch (color) {
case "primary":
userColor = "[--step-color:hsl(var(--heroui-primary))]";
fgColor = "[--step-fg-color:hsl(var(--heroui-primary-foreground))]";
break;
case "secondary":
userColor = "[--step-color:hsl(var(--heroui-secondary))]";
fgColor = "[--step-fg-color:hsl(var(--heroui-secondary-foreground))]";
break;
case "success":
userColor = "[--step-color:hsl(var(--heroui-success))]";
fgColor = "[--step-fg-color:hsl(var(--heroui-success-foreground))]";
break;
case "warning":
userColor = "[--step-color:hsl(var(--heroui-warning))]";
fgColor = "[--step-fg-color:hsl(var(--heroui-warning-foreground))]";
break;
case "danger":
userColor = "[--step-color:hsl(var(--heroui-error))]";
fgColor = "[--step-fg-color:hsl(var(--heroui-error-foreground))]";
break;
case "default":
userColor = "[--step-color:hsl(var(--heroui-default))]";
fgColor = "[--step-fg-color:hsl(var(--heroui-default-foreground))]";
break;
default:
userColor = "[--step-color:hsl(var(--heroui-primary))]";
fgColor = "[--step-fg-color:hsl(var(--heroui-primary-foreground))]";
break;
}
if (!className?.includes("--step-fg-color")) colorsVars.unshift(fgColor);
if (!className?.includes("--step-color")) colorsVars.unshift(userColor);
if (!className?.includes("--inactive-bar-color"))
colorsVars.push(
"[--inactive-bar-color:hsl(var(--heroui-default-300))]",
);
return colorsVars;
}, [color, className]);
return (
<nav aria-label="Progress" className="max-w-fit">
<ol className={cn("flex flex-col gap-y-3", colors, className)}>
{steps?.map((step, stepIdx) => {
const status =
currentStep === stepIdx
? "active"
: currentStep < stepIdx
? "inactive"
: "complete";
return (
<li key={stepIdx} className="relative">
<div className="flex w-full max-w-full items-center">
<button
key={stepIdx}
ref={ref}
aria-current={status === "active" ? "step" : undefined}
className={cn(
"group rounded-large flex w-full cursor-pointer items-center justify-center gap-4 px-3 py-2.5",
stepClassName,
)}
onClick={() => setCurrentStep(stepIdx)}
{...props}
>
<div className="flex h-full items-center">
<LazyMotion features={domAnimation}>
<div className="relative">
<m.div
animate={status}
className={cn(
"border-medium text-large text-default-foreground relative flex h-[34px] w-[34px] items-center justify-center rounded-full font-semibold",
{
"shadow-lg": status === "complete",
},
)}
data-status={status}
initial={false}
transition={{ duration: 0.25 }}
variants={{
inactive: {
backgroundColor: "transparent",
borderColor: "var(--inactive-border-color)",
color: "var(--inactive-color)",
},
active: {
backgroundColor: "transparent",
borderColor: "var(--bg-button-primary)",
color: "var(--bg-button-primary)",
},
complete: {
backgroundColor: "var(--bg-button-primary)",
borderColor: "var(--bg-button-primary)",
},
}}
>
<div className="flex items-center justify-center">
{status === "complete" ? (
<CheckIcon className="h-6 w-6 text-(--active-fg-color)" />
) : (
<span>{stepIdx + 1}</span>
)}
</div>
</m.div>
</div>
</LazyMotion>
</div>
<div className="flex-1 text-left">
<div>
<div
className={cn(
"text-medium text-default-foreground font-medium transition-[color,opacity] duration-300 group-active:opacity-70",
{
"text-default-500": status === "inactive",
},
)}
>
{step.title}
</div>
<div
className={cn(
"text-tiny text-default-600 lg:text-small transition-[color,opacity] duration-300 group-active:opacity-70",
{
"text-default-500": status === "inactive",
},
)}
>
{step.description}
</div>
</div>
</div>
</button>
</div>
{stepIdx < steps.length - 1 && !hideProgressBars && (
<div
aria-hidden="true"
className={cn(
"pointer-events-none absolute top-[calc(64px*var(--idx)+1)] left-3 flex h-1/2 -translate-y-1/3 items-center px-4",
)}
style={{
// @ts-expect-error
"--idx": stepIdx,
}}
>
<div
className={cn(
"relative h-full w-0.5 bg-(--inactive-bar-color) transition-colors duration-300",
"after:absolute after:block after:h-0 after:w-full after:bg-(--active-border-color) after:transition-[height] after:duration-300 after:content-['']",
{
"after:h-full": stepIdx < currentStep,
},
)}
/>
</div>
)}
</li>
);
})}
</ol>
</nav>
);
},
);
VerticalSteps.displayName = "VerticalSteps";

View File

@@ -1,97 +0,0 @@
"use client";
import { Progress } from "@heroui/progress";
import { Spacer } from "@heroui/spacer";
import { usePathname } from "next/navigation";
import React from "react";
import { VerticalSteps } from "./vertical-steps";
const steps = [
{
title: "Choose your Cloud Provider",
description:
"Select the cloud provider you wish to connect and specify your preferred authentication method from the supported options.",
href: "/providers/connect-account",
},
{
title: "Enter Authentication Details",
description:
"Provide the necessary credentials to establish a secure connection to your selected cloud provider.",
href: "/providers/add-credentials",
},
{
title: "Verify Connection & Start Scan",
description:
"Ensure your credentials are correct and start scanning your cloud environment.",
href: "/providers/test-connection",
},
];
const ROUTE_CONFIG: Record<
string,
{
stepIndex: number;
stepOverride?: { index: number; title: string; description: string };
}
> = {
"/providers/connect-account": { stepIndex: 0 },
"/providers/add-credentials": { stepIndex: 1 },
"/providers/test-connection": { stepIndex: 2 },
"/providers/update-credentials": {
stepIndex: 1,
stepOverride: {
index: 2,
title: "Make sure the new credentials are valid",
description: "Valid credentials will take you back to the providers page",
},
},
};
export const WorkflowAddProvider = () => {
const pathname = usePathname();
const config = ROUTE_CONFIG[pathname] || { stepIndex: 0 };
const currentStep = config.stepIndex;
const updatedSteps = steps.map((step, index) => {
if (config.stepOverride && index === config.stepOverride.index) {
return { ...step, ...config.stepOverride };
}
return step;
});
return (
<section className="max-w-sm">
<h1 className="mb-2 text-xl font-medium" id="getting-started">
Add a Cloud Provider
</h1>
<p className="text-small text-default-500 mb-5">
Complete these steps to configure your cloud provider and initiate your
first scan.
</p>
<Progress
classNames={{
base: "px-0.5 mb-5",
label: "text-small",
value: "text-small text-button-primary",
indicator: "bg-button-primary",
}}
label="Steps"
maxValue={steps.length - 1}
minValue={0}
showValueLabel={true}
size="md"
value={currentStep}
valueLabel={`${currentStep + 1} of ${steps.length}`}
/>
<VerticalSteps
hideProgressBars
currentStep={currentStep}
stepClassName="border border-border-neutral-primary aria-[current]:border-button-primary aria-[current]:text-text-neutral-primary cursor-default"
steps={updatedSteps}
/>
<Spacer y={4} />
</section>
);
};

View File

@@ -1,39 +1,45 @@
"use client";
import Link from "next/link";
import { useState } from "react";
import { ProviderWizardModal } from "@/components/providers/wizard";
import { Button, Card, CardContent } from "@/components/shadcn";
import { InfoIcon } from "../icons/Icons";
export const NoProvidersAdded = () => {
return (
<div className="flex min-h-screen items-center justify-center">
<Card variant="base" className="mx-auto w-full max-w-3xl">
<CardContent className="flex flex-col items-center gap-4 p-6 text-center sm:p-8">
<div className="flex flex-col items-center gap-4">
<InfoIcon className="h-10 w-10 text-gray-800 dark:text-white" />
<h2 className="text-2xl font-bold text-gray-800 dark:text-white">
No Cloud Providers Configured
</h2>
</div>
<div className="flex flex-col items-center gap-3">
<p className="text-md leading-relaxed text-gray-600 dark:text-gray-300">
No cloud providers have been configured. Start by setting up a
cloud provider.
</p>
</div>
const [open, setOpen] = useState(false);
<Button
asChild
aria-label="Go to Add Cloud Provider page"
className="w-full max-w-xs justify-center"
size="lg"
>
<Link href="/providers/connect-account">Get Started</Link>
</Button>
</CardContent>
</Card>
</div>
return (
<>
<div className="flex min-h-screen items-center justify-center">
<Card variant="base" className="mx-auto w-full max-w-3xl">
<CardContent className="flex flex-col items-center gap-4 p-6 text-center sm:p-8">
<div className="flex flex-col items-center gap-4">
<InfoIcon className="h-10 w-10 text-gray-800 dark:text-white" />
<h2 className="text-2xl font-bold text-gray-800 dark:text-white">
No Cloud Providers Configured
</h2>
</div>
<div className="flex flex-col items-center gap-3">
<p className="text-md leading-relaxed text-gray-600 dark:text-gray-300">
No cloud providers have been configured. Start by setting up a
cloud provider.
</p>
</div>
<Button
aria-label="Open Add Cloud Provider modal"
className="w-full max-w-xs justify-center"
size="lg"
onClick={() => setOpen(true)}
>
Get Started
</Button>
</CardContent>
</Card>
</div>
<ProviderWizardModal open={open} onOpenChange={setOpen} />
</>
);
};

View File

@@ -21,7 +21,7 @@ const buttonVariants = cva(
"border border-border-neutral-secondary bg-bg-neutral-secondary hover:bg-bg-neutral-tertiary active:bg-border-neutral-tertiary text-text-neutral-primary focus-visible:ring-border-neutral-tertiary/50",
ghost:
"text-text-neutral-primary hover:bg-bg-neutral-tertiary active:bg-border-neutral-secondary focus-visible:ring-border-neutral-secondary/50",
link: "text-button-tertiary underline-offset-4 hover:text-button-tertiary-hover",
link: "text-button-tertiary underline-offset-4 hover:text-button-tertiary-hover disabled:bg-transparent",
// Menu variant like secondary but more padding and the back is almost transparent
menu: "backdrop-blur-xl bg-white/60 dark:bg-white/5 border border-white/80 dark:border-white/10 text-text-neutral-primary dark:text-white shadow-lg hover:bg-white/70 dark:hover:bg-white/10 hover:border-white/90 dark:hover:border-white/30 active:bg-white/80 dark:active:bg-white/15 active:scale-[0.98] focus-visible:ring-button-primary/50 transition-all duration-200",
"menu-active":
@@ -33,6 +33,7 @@ const buttonVariants = cva(
default: "h-9 px-4 py-2 has-[>svg]:px-3",
sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
xl: "h-12 rounded-md px-8 text-base has-[>svg]:px-6",
icon: "size-9",
"icon-sm": "size-8",
"icon-lg": "size-10",

View File

@@ -46,9 +46,9 @@ function Checkbox({
// Default state
"bg-bg-input-primary border-border-input-primary shadow-[0_1px_2px_0_rgba(0,0,0,0.1)]",
// Checked state
"data-[state=checked]:bg-button-primary data-[state=checked]:border-button-primary data-[state=checked]:text-white",
"data-[state=checked]:bg-button-tertiary-active data-[state=checked]:border-button-tertiary-active data-[state=checked]:text-white",
// Indeterminate state
"data-[state=indeterminate]:bg-button-primary data-[state=indeterminate]:border-button-primary data-[state=indeterminate]:text-white",
"data-[state=indeterminate]:bg-button-tertiary-active data-[state=indeterminate]:border-button-tertiary-active data-[state=indeterminate]:text-white",
// Focus state
"focus-visible:border-border-input-primary-press focus-visible:ring-border-input-primary-press/50 focus-visible:ring-2",
// Disabled state

View File

@@ -11,7 +11,7 @@ const inputVariants = cva(
variants: {
variant: {
default:
"border-border-input-primary bg-bg-input-primary dark:bg-input/30 hover:bg-bg-neutral-secondary dark:hover:bg-input/50 focus:border-border-input-primary-press focus:ring-1 focus:ring-border-input-primary-press focus:ring-offset-1 placeholder:text-text-neutral-tertiary",
"border-border-input-primary bg-bg-input-primary dark:bg-input/30 hover:bg-bg-neutral-secondary dark:hover:bg-input/50 focus:border-border-input-primary-press focus:ring-1 focus:ring-inset focus:ring-border-input-primary-press placeholder:text-text-neutral-tertiary",
ghost:
"border-transparent bg-transparent hover:bg-bg-neutral-tertiary focus:bg-bg-neutral-tertiary placeholder:text-text-neutral-tertiary",
},

View File

@@ -25,7 +25,7 @@ const searchInputVariants = cva(
variants: {
variant: {
default:
"border-border-input-primary bg-bg-input-primary dark:bg-input/30 hover:bg-bg-neutral-secondary dark:hover:bg-input/50 focus:border-border-input-primary-press focus:ring-1 focus:ring-border-input-primary-press focus:ring-offset-1",
"border-border-input-primary bg-bg-input-primary dark:bg-input/30 hover:bg-bg-neutral-secondary dark:hover:bg-input/50 focus:border-border-input-primary-press focus:ring-1 focus:ring-inset focus:ring-border-input-primary-press",
ghost:
"border-transparent bg-transparent hover:bg-bg-neutral-tertiary focus:bg-bg-neutral-tertiary",
},

View File

@@ -45,7 +45,7 @@ function TooltipContent({
data-slot="tooltip-content"
sideOffset={sideOffset}
className={cn(
"border-border-neutral-tertiary bg-bg-neutral-tertiary text-text-neutral-primary animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-fit origin-(--radix-tooltip-content-transform-origin) rounded-lg border px-3 py-1.5 text-xs text-balance shadow-lg",
"border-border-neutral-tertiary bg-bg-neutral-tertiary text-text-neutral-primary animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-fit max-w-[min(700px,calc(100vw-2rem))] origin-(--radix-tooltip-content-transform-origin) rounded-lg border px-2 py-1.5 text-left text-xs break-all whitespace-normal shadow-lg",
className,
)}
{...props}

View File

@@ -3,6 +3,11 @@
import { KeyboardEvent } from "react";
import { Checkbox } from "@/components/shadcn/checkbox";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/shadcn/tooltip";
import { cn } from "@/lib/utils";
import { TreeLeafProps } from "@/types/tree";
@@ -30,6 +35,25 @@ export function TreeLeaf({
renderItem,
}: TreeLeafProps) {
const isSelected = selectedIds.includes(item.id);
const shouldReplaceCheckboxWithState =
showCheckboxes && (item.isLoading || Boolean(item.status));
const statusIcon =
!item.isLoading && item.status ? (
item.status === "error" && item.errorMessage ? (
<Tooltip>
<TooltipTrigger asChild>
<span>
<TreeStatusIcon status={item.status} />
</span>
</TooltipTrigger>
<TooltipContent>
<p className="text-xs">{item.errorMessage}</p>
</TooltipContent>
</Tooltip>
) : (
<TreeStatusIcon status={item.status} />
)
) : null;
const handleSelect = () => {
if (!item.disabled) {
@@ -50,7 +74,6 @@ export function TreeLeaf({
"flex items-center gap-2 rounded-md px-2 py-1.5",
"hover:bg-prowler-white/5 cursor-pointer",
"focus-visible:ring-border-input-primary-press focus-visible:ring-2 focus-visible:outline-none",
isSelected && "bg-prowler-white/10",
item.disabled && "cursor-not-allowed opacity-50",
item.className,
)}
@@ -62,12 +85,17 @@ export function TreeLeaf({
aria-selected={isSelected}
aria-disabled={item.disabled}
>
{item.isLoading && <TreeSpinner />}
{!item.isLoading && item.status && (
<TreeStatusIcon status={item.status} />
{!showCheckboxes && item.isLoading && <TreeSpinner />}
{!showCheckboxes && statusIcon}
{showCheckboxes && shouldReplaceCheckboxWithState && (
<>
{item.isLoading && <TreeSpinner />}
{statusIcon}
</>
)}
{showCheckboxes && (
{showCheckboxes && !shouldReplaceCheckboxWithState && (
<Checkbox
size="sm"
checked={isSelected}

View File

@@ -5,6 +5,11 @@ import { ChevronRightIcon } from "lucide-react";
import { KeyboardEvent } from "react";
import { Checkbox } from "@/components/shadcn/checkbox";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/shadcn/tooltip";
import { cn } from "@/lib/utils";
import { TreeNodeProps } from "@/types/tree";
@@ -34,11 +39,27 @@ export function TreeNode({
onExpandedChange,
showCheckboxes,
renderItem,
expandAll,
enableSelectChildren,
}: TreeNodeProps) {
const isExpanded = expandAll || expandedIds.includes(item.id);
const isExpanded = expandedIds.includes(item.id);
const isSelected = selectedIds.includes(item.id);
const statusIcon =
!item.isLoading && item.status ? (
item.status === "error" && item.errorMessage ? (
<Tooltip>
<TooltipTrigger asChild>
<span>
<TreeStatusIcon status={item.status} />
</span>
</TooltipTrigger>
<TooltipContent>
<p className="text-xs">{item.errorMessage}</p>
</TooltipContent>
</Tooltip>
) : (
<TreeStatusIcon status={item.status} />
)
) : null;
// Calculate indeterminate state based on descendant selection
const descendantIds = getAllDescendantIds(item);
@@ -91,7 +112,6 @@ export function TreeNode({
"flex items-center gap-2 rounded-md px-2 py-1.5",
"hover:bg-prowler-white/5 cursor-pointer",
"focus-visible:ring-border-input-primary-press focus-visible:ring-2 focus-visible:outline-none",
isSelected && "bg-prowler-white/10",
item.disabled && "cursor-not-allowed opacity-50",
item.className,
)}
@@ -125,9 +145,7 @@ export function TreeNode({
)}
</button>
{!item.isLoading && item.status && (
<TreeStatusIcon status={item.status} />
)}
{statusIcon}
{showCheckboxes && (
<Checkbox
@@ -170,7 +188,7 @@ export function TreeNode({
>
{item.children?.map((child) => (
<li key={child.id}>
{child.children ? (
{child.children && child.children.length > 0 ? (
<TreeNode
item={child}
level={level + 1}
@@ -180,7 +198,6 @@ export function TreeNode({
onExpandedChange={onExpandedChange}
showCheckboxes={showCheckboxes}
renderItem={renderItem}
expandAll={expandAll}
enableSelectChildren={enableSelectChildren}
/>
) : (

View File

@@ -9,6 +9,25 @@ import { TreeLeaf } from "./tree-leaf";
import { TreeNode } from "./tree-node";
import { getAllDescendantIds } from "./utils";
function getInitialExpandedIds(data: TreeDataItem[] | TreeDataItem): string[] {
const items = Array.isArray(data) ? data : [data];
const expandableIds: string[] = [];
const stack = [...items];
while (stack.length > 0) {
const current = stack.pop();
if (!current) continue;
if (current.children && current.children.length > 0) {
expandableIds.push(current.id);
stack.push(...current.children);
}
}
return expandableIds;
}
/**
* TreeView component for rendering hierarchical data structures.
*
@@ -56,7 +75,9 @@ export function TreeView({
renderItem,
}: TreeViewProps) {
const [internalSelectedIds, setInternalSelectedIds] = useState<string[]>([]);
const [internalExpandedIds, setInternalExpandedIds] = useState<string[]>([]);
const [internalExpandedIds, setInternalExpandedIds] = useState<string[]>(
expandAll ? getInitialExpandedIds(data) : [],
);
const selectedIds = controlledSelectedIds ?? internalSelectedIds;
const expandedIds = controlledExpandedIds ?? internalExpandedIds;
@@ -108,7 +129,7 @@ export function TreeView({
<ul className="space-y-1">
{items.map((item) => (
<li key={item.id}>
{item.children ? (
{item.children && item.children.length > 0 ? (
<TreeNode
item={item}
level={0}
@@ -118,7 +139,6 @@ export function TreeView({
onExpandedChange={handleExpandedChange}
showCheckboxes={showCheckboxes}
renderItem={renderItem}
expandAll={expandAll}
enableSelectChildren={enableSelectChildren}
/>
) : (

View File

@@ -5,7 +5,7 @@ import { TreeDataItem } from "@/types/tree";
* Used to calculate consistent padding for nested tree items.
*/
export const TREE_INDENT_REM = 1.25;
export const TREE_LEAF_EXTRA_PADDING_REM = 1.5;
export const TREE_LEAF_EXTRA_PADDING_REM = 1.75;
/**
* Calculates the left padding for a tree node based on its nesting level.

View File

@@ -3,6 +3,7 @@ export * from "./use-credentials-form";
export * from "./use-form-server-errors";
export * from "./use-local-storage";
export * from "./use-related-filters";
export * from "./use-scroll-hint";
export * from "./use-sidebar";
export * from "./use-store";
export * from "./use-url-filters";

View File

@@ -21,6 +21,10 @@ type UseCredentialsFormProps = {
providerUid?: string;
onSubmit: (formData: FormData) => Promise<ApiResponse>;
successNavigationUrl: string;
via?: string | null;
onSuccess?: () => void;
onBack?: () => void;
validationMode?: "onSubmit" | "onChange";
};
export const useCredentialsForm = ({
@@ -29,21 +33,25 @@ export const useCredentialsForm = ({
providerUid,
onSubmit,
successNavigationUrl,
via: viaOverride,
onSuccess,
onBack,
validationMode = "onChange",
}: UseCredentialsFormProps) => {
const router = useRouter();
const searchParamsObj = useSearchParams();
const { data: session } = useSession();
const via = searchParamsObj.get("via");
const effectiveVia = viaOverride ?? searchParamsObj.get("via");
// Select the appropriate schema based on provider type and via parameter
const getFormSchema = () => {
if (providerType === "aws" && via === "role") {
if (providerType === "aws" && effectiveVia === "role") {
return addCredentialsRoleFormSchema(providerType);
}
if (providerType === "alibabacloud" && via === "role") {
if (providerType === "alibabacloud" && effectiveVia === "role") {
return addCredentialsRoleFormSchema(providerType);
}
if (providerType === "gcp" && via === "service-account") {
if (providerType === "gcp" && effectiveVia === "service-account") {
return addCredentialsServiceAccountFormSchema(providerType);
}
// For GitHub, M365, and Cloudflare, we need to pass the via parameter to determine which fields are required
@@ -52,7 +60,7 @@ export const useCredentialsForm = ({
providerType === "m365" ||
providerType === "cloudflare"
) {
return addCredentialsFormSchema(providerType, via);
return addCredentialsFormSchema(providerType, effectiveVia);
}
return addCredentialsFormSchema(providerType);
};
@@ -67,7 +75,7 @@ export const useCredentialsForm = ({
};
// AWS Role credentials
if (providerType === "aws" && via === "role") {
if (providerType === "aws" && effectiveVia === "role") {
const isCloudEnv = process.env.NEXT_PUBLIC_IS_CLOUD_ENV === "true";
const defaultCredentialsType = isCloudEnv
? "aws-sdk-default"
@@ -86,7 +94,7 @@ export const useCredentialsForm = ({
}
// GCP Service Account
if (providerType === "gcp" && via === "service-account") {
if (providerType === "gcp" && effectiveVia === "service-account") {
return {
...baseDefaults,
[ProviderCredentialFields.SERVICE_ACCOUNT_KEY]: "",
@@ -110,7 +118,7 @@ export const useCredentialsForm = ({
};
case "m365":
// M365 credentials based on via parameter
if (via === "app_client_secret") {
if (effectiveVia === "app_client_secret") {
return {
...baseDefaults,
[ProviderCredentialFields.CLIENT_ID]: "",
@@ -118,7 +126,7 @@ export const useCredentialsForm = ({
[ProviderCredentialFields.TENANT_ID]: "",
};
}
if (via === "app_certificate") {
if (effectiveVia === "app_certificate") {
return {
...baseDefaults,
[ProviderCredentialFields.CLIENT_ID]: "",
@@ -145,19 +153,19 @@ export const useCredentialsForm = ({
};
case "github":
// GitHub credentials based on via parameter
if (via === "personal_access_token") {
if (effectiveVia === "personal_access_token") {
return {
...baseDefaults,
[ProviderCredentialFields.PERSONAL_ACCESS_TOKEN]: "",
};
}
if (via === "oauth_app") {
if (effectiveVia === "oauth_app") {
return {
...baseDefaults,
[ProviderCredentialFields.OAUTH_APP_TOKEN]: "",
};
}
if (via === "github_app") {
if (effectiveVia === "github_app") {
return {
...baseDefaults,
[ProviderCredentialFields.GITHUB_APP_ID]: "",
@@ -182,7 +190,7 @@ export const useCredentialsForm = ({
[ProviderCredentialFields.ATLAS_PRIVATE_KEY]: "",
};
case "alibabacloud":
if (via === "role") {
if (effectiveVia === "role") {
return {
...baseDefaults,
[ProviderCredentialFields.ALIBABACLOUD_ROLE_ARN]: "",
@@ -198,13 +206,13 @@ export const useCredentialsForm = ({
};
case "cloudflare":
// Cloudflare credentials based on via parameter
if (via === "api_token") {
if (effectiveVia === "api_token") {
return {
...baseDefaults,
[ProviderCredentialFields.CLOUDFLARE_API_TOKEN]: "",
};
}
if (via === "api_key") {
if (effectiveVia === "api_key") {
return {
...baseDefaults,
[ProviderCredentialFields.CLOUDFLARE_API_KEY]: "",
@@ -228,7 +236,7 @@ export const useCredentialsForm = ({
const form = useForm({
resolver: zodResolver(formSchema),
defaultValues: defaultValues,
mode: "onSubmit",
mode: validationMode,
reValidateMode: "onChange",
criteriaMode: "all", // Show all errors for each field
});
@@ -240,6 +248,11 @@ export const useCredentialsForm = ({
// Handler for back button
const handleBackStep = () => {
if (onBack) {
onBack();
return;
}
const currentParams = new URLSearchParams(window.location.search);
currentParams.delete("via");
router.push(`?${currentParams.toString()}`);
@@ -260,19 +273,25 @@ export const useCredentialsForm = ({
const isSuccess = handleServerResponse(data);
if (isSuccess) {
if (onSuccess) {
onSuccess();
return;
}
router.push(successNavigationUrl);
}
};
const { isSubmitting, errors } = form.formState;
const { isSubmitting, isValid, errors } = form.formState;
return {
form,
isLoading: isSubmitting,
isValid,
errors,
handleSubmit,
handleBackStep,
searchParamsObj,
effectiveVia,
externalId: session?.tenantId || "",
};
};

View File

@@ -0,0 +1,63 @@
"use client";
import { UIEvent, useEffect, useRef, useState } from "react";
interface UseScrollHintOptions {
enabled?: boolean;
refreshToken?: string | number;
}
const SCROLL_THRESHOLD_PX = 4;
function shouldShowScrollHint(element: HTMLDivElement) {
const hasOverflow =
element.scrollHeight - element.clientHeight > SCROLL_THRESHOLD_PX;
const isAtBottom =
element.scrollTop + element.clientHeight >=
element.scrollHeight - SCROLL_THRESHOLD_PX;
return hasOverflow && !isAtBottom;
}
export function useScrollHint({
enabled = true,
refreshToken,
}: UseScrollHintOptions = {}) {
const containerRef = useRef<HTMLDivElement | null>(null);
const [showScrollHint, setShowScrollHint] = useState(false);
useEffect(() => {
if (!enabled) {
setShowScrollHint(false);
return;
}
const element = containerRef.current;
if (!element) return;
const recalculate = () => {
const el = containerRef.current;
if (!el) return;
setShowScrollHint(shouldShowScrollHint(el));
};
const observer = new ResizeObserver(recalculate);
observer.observe(element);
recalculate();
return () => {
observer.disconnect();
};
}, [enabled, refreshToken]);
const handleScroll = (event: UIEvent<HTMLDivElement>) => {
setShowScrollHint(shouldShowScrollHint(event.currentTarget));
};
return {
containerRef,
showScrollHint,
handleScroll,
};
}

View File

@@ -1 +1,3 @@
export * from "./organizations/store";
export * from "./provider-wizard/store";
export * from "./ui/store";

View File

@@ -0,0 +1,33 @@
import { beforeEach, describe, expect, it } from "vitest";
import { useOrgSetupStore } from "./store";
describe("useOrgSetupStore", () => {
beforeEach(() => {
sessionStorage.clear();
localStorage.clear();
useOrgSetupStore.getState().reset();
});
it("persists organization wizard state in sessionStorage", () => {
// Given
useOrgSetupStore
.getState()
.setOrganization("org-1", "My Org", "o-abc123def4");
useOrgSetupStore.getState().setDiscovery("discovery-1", {
roots: [],
organizational_units: [],
accounts: [],
});
useOrgSetupStore
.getState()
.setSelectedAccountIds(["111111111111", "222222222222"]);
// When
const persistedValue = sessionStorage.getItem("org-setup-store");
// Then
expect(persistedValue).toBeTruthy();
expect(localStorage.getItem("org-setup-store")).toBeNull();
});
});

View File

@@ -0,0 +1,203 @@
import { create } from "zustand";
import { createJSONStorage, persist } from "zustand/middleware";
import {
buildAccountLookup,
buildOrgTreeData,
getSelectableAccountIds,
} from "@/actions/organizations/organizations.adapter";
import {
ConnectionTestStatus,
DiscoveredAccount,
DiscoveryResult,
} from "@/types/organizations";
import { TreeDataItem } from "@/types/tree";
interface DerivedDiscoveryState {
treeData: TreeDataItem[];
accountLookup: Map<string, DiscoveredAccount>;
selectableAccountIds: string[];
selectableAccountIdSet: Set<string>;
}
function buildDerivedDiscoveryState(
discoveryResult: DiscoveryResult | null,
): DerivedDiscoveryState {
if (!discoveryResult) {
return {
treeData: [],
accountLookup: new Map<string, DiscoveredAccount>(),
selectableAccountIds: [],
selectableAccountIdSet: new Set<string>(),
};
}
const selectableAccountIds = getSelectableAccountIds(discoveryResult);
return {
treeData: buildOrgTreeData(discoveryResult),
accountLookup: buildAccountLookup(discoveryResult),
selectableAccountIds,
selectableAccountIdSet: new Set(selectableAccountIds),
};
}
interface OrgSetupState {
// Identity
organizationId: string | null;
organizationName: string | null;
organizationExternalId: string | null;
discoveryId: string | null;
// Discovery
discoveryResult: DiscoveryResult | null;
treeData: TreeDataItem[];
accountLookup: Map<string, DiscoveredAccount>;
selectableAccountIds: string[];
selectableAccountIdSet: Set<string>;
// Selection + aliases
selectedAccountIds: string[];
accountAliases: Record<string, string>;
// Apply result
createdProviderIds: string[];
// Connection test results
connectionResults: Record<string, ConnectionTestStatus>;
connectionErrors: Record<string, string>;
// Actions
setOrganization: (id: string, name: string, externalId: string) => void;
setDiscovery: (id: string, result: DiscoveryResult) => void;
setSelectedAccountIds: (ids: string[]) => void;
setAccountAlias: (accountId: string, alias: string) => void;
setCreatedProviderIds: (ids: string[]) => void;
clearValidationState: () => void;
setConnectionError: (providerId: string, error: string | null) => void;
setConnectionResult: (
providerId: string,
status: ConnectionTestStatus,
) => void;
reset: () => void;
}
const initialState = {
organizationId: null,
organizationName: null,
organizationExternalId: null,
discoveryId: null,
discoveryResult: null,
treeData: [],
accountLookup: new Map<string, DiscoveredAccount>(),
selectableAccountIds: [],
selectableAccountIdSet: new Set<string>(),
selectedAccountIds: [],
accountAliases: {},
createdProviderIds: [],
connectionResults: {},
connectionErrors: {},
};
export const useOrgSetupStore = create<OrgSetupState>()(
persist(
(set) => ({
...initialState,
setOrganization: (id, name, externalId) =>
set({
organizationId: id,
organizationName: name,
organizationExternalId: externalId,
}),
setDiscovery: (id, result) =>
set((state) => {
const derivedState = buildDerivedDiscoveryState(result);
return {
discoveryId: id,
discoveryResult: result,
...derivedState,
selectedAccountIds: state.selectedAccountIds.filter((accountId) =>
derivedState.selectableAccountIdSet.has(accountId),
),
};
}),
setSelectedAccountIds: (ids) =>
set((state) => ({
selectedAccountIds: ids.filter((accountId) =>
state.selectableAccountIdSet.has(accountId),
),
})),
setAccountAlias: (accountId, alias) =>
set((state) => ({
accountAliases: { ...state.accountAliases, [accountId]: alias },
})),
setCreatedProviderIds: (ids) => set({ createdProviderIds: ids }),
clearValidationState: () =>
set({
createdProviderIds: [],
connectionResults: {},
connectionErrors: {},
}),
setConnectionError: (providerId, error) =>
set((state) => {
if (!error) {
const { [providerId]: _, ...rest } = state.connectionErrors;
return { connectionErrors: rest };
}
return {
connectionErrors: {
...state.connectionErrors,
[providerId]: error,
},
};
}),
setConnectionResult: (providerId, status) =>
set((state) => ({
connectionResults: {
...state.connectionResults,
[providerId]: status,
},
})),
reset: () => set(initialState),
}),
{
name: "org-setup-store",
storage: createJSONStorage(() => sessionStorage),
merge: (persistedState, currentState) => {
const mergedState = {
...currentState,
...(persistedState as Partial<OrgSetupState>),
};
const derivedState = buildDerivedDiscoveryState(
mergedState.discoveryResult,
);
return {
...mergedState,
...derivedState,
selectedAccountIds: mergedState.selectedAccountIds.filter(
(accountId) => derivedState.selectableAccountIdSet.has(accountId),
),
};
},
partialize: (state) => ({
organizationId: state.organizationId,
organizationName: state.organizationName,
organizationExternalId: state.organizationExternalId,
discoveryId: state.discoveryId,
discoveryResult: state.discoveryResult,
selectedAccountIds: state.selectedAccountIds,
accountAliases: state.accountAliases,
}),
},
),
);

View File

@@ -0,0 +1,62 @@
import { beforeEach, describe, expect, it } from "vitest";
import { PROVIDER_WIZARD_MODE } from "@/types/provider-wizard";
import { useProviderWizardStore } from "./store";
describe("useProviderWizardStore", () => {
beforeEach(() => {
sessionStorage.clear();
localStorage.clear();
useProviderWizardStore.getState().reset();
});
it("stores provider identity and mode, then resets to defaults", () => {
useProviderWizardStore.getState().setProvider({
id: "provider-1",
type: "aws",
uid: "123456789012",
alias: "prod-account",
});
useProviderWizardStore.getState().setVia("role");
useProviderWizardStore.getState().setSecretId("secret-1");
useProviderWizardStore.getState().setMode(PROVIDER_WIZARD_MODE.UPDATE);
const afterSet = useProviderWizardStore.getState();
expect(afterSet.providerId).toBe("provider-1");
expect(afterSet.providerType).toBe("aws");
expect(afterSet.providerUid).toBe("123456789012");
expect(afterSet.providerAlias).toBe("prod-account");
expect(afterSet.via).toBe("role");
expect(afterSet.secretId).toBe("secret-1");
expect(afterSet.mode).toBe(PROVIDER_WIZARD_MODE.UPDATE);
useProviderWizardStore.getState().reset();
const afterReset = useProviderWizardStore.getState();
expect(afterReset.providerId).toBeNull();
expect(afterReset.providerType).toBeNull();
expect(afterReset.providerUid).toBeNull();
expect(afterReset.providerAlias).toBeNull();
expect(afterReset.via).toBeNull();
expect(afterReset.secretId).toBeNull();
expect(afterReset.mode).toBe(PROVIDER_WIZARD_MODE.ADD);
});
it("persists provider wizard state in sessionStorage", () => {
// Given
useProviderWizardStore.getState().setProvider({
id: "provider-1",
type: "aws",
uid: "123456789012",
alias: "prod-account",
});
// When
const persistedValue = sessionStorage.getItem("provider-wizard-store");
// Then
expect(persistedValue).toBeTruthy();
expect(localStorage.getItem("provider-wizard-store")).toBeNull();
});
});

View File

@@ -0,0 +1,57 @@
import { create } from "zustand";
import { createJSONStorage, persist } from "zustand/middleware";
import {
PROVIDER_WIZARD_MODE,
ProviderWizardIdentity,
ProviderWizardMode,
} from "@/types/provider-wizard";
import { ProviderType } from "@/types/providers";
interface ProviderWizardState {
providerId: string | null;
providerType: ProviderType | null;
providerUid: string | null;
providerAlias: string | null;
via: string | null;
secretId: string | null;
mode: ProviderWizardMode;
setProvider: (provider: ProviderWizardIdentity) => void;
setVia: (via: string | null) => void;
setSecretId: (secretId: string | null) => void;
setMode: (mode: ProviderWizardMode) => void;
reset: () => void;
}
const initialState = {
providerId: null,
providerType: null,
providerUid: null,
providerAlias: null,
via: null,
secretId: null,
mode: PROVIDER_WIZARD_MODE.ADD,
};
export const useProviderWizardStore = create<ProviderWizardState>()(
persist(
(set) => ({
...initialState,
setProvider: (provider) =>
set({
providerId: provider.id,
providerType: provider.type,
providerUid: provider.uid,
providerAlias: provider.alias,
}),
setVia: (via) => set({ via }),
setSecretId: (secretId) => set({ secretId }),
setMode: (mode) => set({ mode }),
reset: () => set(initialState),
}),
{
name: "provider-wizard-store",
storage: createJSONStorage(() => sessionStorage),
},
),
);

View File

@@ -1,6 +1,6 @@
import { test } from "@playwright/test";
import { expect, test } from "@playwright/test";
import { TEST_CREDENTIALS } from "../helpers";
import { getSessionWithoutCookies, TEST_CREDENTIALS } from "../helpers";
import { ProvidersPage } from "../providers/providers-page";
import { ScansPage } from "../scans/scans-page";
import { SignInPage } from "../sign-in-base/sign-in-base-page";
@@ -40,24 +40,14 @@ test.describe("Middleware Error Handling", () => {
await providersPage.goto();
await providersPage.verifyPageLoaded();
const cookies = await context.cookies();
const sessionCookie = cookies.find((c) =>
c.name.includes("authjs.session-token"),
);
// Remove auth cookies to simulate a broken/expired session deterministically.
await context.clearCookies();
if (sessionCookie) {
await context.clearCookies();
await context.addCookies([
{
...sessionCookie,
value: "invalid-session-token",
},
]);
const expiredSession = await getSessionWithoutCookies(page);
expect(expiredSession).toBeNull();
await scansPage.goto();
// With invalid session, should redirect to sign-in
await signInPage.verifyOnSignInPage();
}
await scansPage.goto();
await signInPage.verifyOnSignInPage();
},
);

View File

@@ -1,8 +1,5 @@
import { expect, test } from "@playwright/test";
import { TEST_CREDENTIALS } from "../helpers";
import { ProvidersPage } from "../providers/providers-page";
import { ScansPage } from "../scans/scans-page";
import { SignInPage } from "../sign-in-base/sign-in-base-page";
test.describe("Session Error Messages", () => {
@@ -65,28 +62,10 @@ test.describe("Session Error Messages", () => {
{ tag: ["@e2e", "@auth", "@session", "@AUTH-SESSION-E2E-004"] },
async ({ page, context }) => {
const signInPage = new SignInPage(page);
const scansPage = new ScansPage(page);
const providersPage = new ProvidersPage(page);
await signInPage.loginAndVerify(TEST_CREDENTIALS.VALID);
// Navigate to a specific page (just need to be on a protected route)
await scansPage.goto();
await expect(page.locator("main")).toBeVisible();
// Navigate to a safe public page before clearing cookies
// This prevents background requests from the protected page (scans)
// triggering a client-side redirect race condition when cookies are cleared
await signInPage.goto();
// Clear cookies to simulate session expiry
await context.clearCookies();
// Try to navigate to a different protected route
// Use fresh navigation to force middleware evaluation
await providersPage.gotoFresh();
// Should be redirected to login with callbackUrl
// Navigate directly to a protected route and assert callbackUrl preservation.
await page.goto("/providers", { waitUntil: "commit" });
await signInPage.verifyRedirectWithCallback("/providers");
},
);

View File

@@ -1,9 +1,7 @@
import { Page, Locator, expect } from "@playwright/test";
import { BasePage } from "../base-page";
export class InvitationsPage extends BasePage {
// Page heading
readonly pageHeadingSendInvitation: Locator;
readonly pageHeadingInvitations: Locator;
@@ -18,34 +16,52 @@ export class InvitationsPage extends BasePage {
readonly reviewInvitationDetailsButton: Locator;
readonly shareUrl: Locator;
constructor(page: Page) {
super(page);
// Page heading
this.pageHeadingInvitations = page.getByRole("heading", { name: "Invitations" });
this.pageHeadingSendInvitation = page.getByRole("heading", { name: "Send Invitation" });
this.pageHeadingInvitations = page.getByRole("heading", {
name: "Invitations",
});
this.pageHeadingSendInvitation = page.getByRole("heading", {
name: "Send Invitation",
});
// Button to invite a new user
this.inviteButton = page.getByRole("link", { name: "Send Invitation", exact: true });
this.sendInviteButton = page.getByRole("button", { name: "Send Invitation", exact: true });
this.inviteButton = page.getByRole("link", {
name: "Send Invitation",
exact: true,
});
this.sendInviteButton = page.getByRole("button", {
name: "Send Invitation",
exact: true,
});
// Form inputs
this.emailInput = page.getByRole("textbox", { name: "Email" });
// Form select
this.roleSelect = page.getByRole("combobox", { name: /Role|Select a role/i });
this.roleSelect = page
.getByRole("combobox", { name: /Role|Select a role/i })
.or(page.getByRole("button", { name: /Role|Select a role/i }))
.first();
// Form details
this.reviewInvitationDetailsButton = page.getByRole('button', { name: /Review Invitation Details/i });
this.reviewInvitationDetailsButton = page.getByRole("button", {
name: /Review Invitation Details/i,
});
// Multiple strategies to find the share URL
this.shareUrl = page.locator('a[href*="/sign-up?invitation_token="], [data-testid="share-url"], .share-url, code, pre').first();
this.shareUrl = page
.locator(
'a[href*="/sign-up?invitation_token="], [data-testid="share-url"], .share-url, code, pre',
)
.first();
}
async goto(): Promise<void> {
// Navigate to the invitations page
await super.goto("/invitations");
}
@@ -83,18 +99,21 @@ export class InvitationsPage extends BasePage {
// Select the role option
// Open the role dropdown
await expect(this.roleSelect).toBeVisible({ timeout: 15000 });
await this.roleSelect.click();
// Prefer ARIA role option inside listbox
const option = this.page.getByRole("option", { name: new RegExp(`^${role}$`, "i") });
const option = this.page.getByRole("option", {
name: new RegExp(`^${role}$`, "i"),
});
if (await option.count()) {
await option.first().click();
} else {
throw new Error(`Role option ${role} not found`);
}
// Ensure the combobox now shows the chosen value
await expect(this.roleSelect).toContainText(new RegExp(role, "i"));
// Ensure a role value was selected in the trigger
await expect(this.roleSelect).not.toContainText(/Select a role/i);
}
async verifyInviteDataPageLoaded(): Promise<void> {
@@ -108,7 +127,7 @@ export class InvitationsPage extends BasePage {
// Get the share url text content
const text = await this.shareUrl.textContent();
if (!text) {
throw new Error("Share url not found");
}

View File

@@ -194,6 +194,9 @@ export interface AlibabaCloudProviderCredential {
// Providers page
export class ProvidersPage extends BasePage {
readonly wizardModal: Locator;
readonly wizardTitle: Locator;
// Alias input
readonly aliasInput: Locator;
@@ -288,12 +291,31 @@ export class ProvidersPage extends BasePage {
constructor(page: Page) {
super(page);
// Button to add a new cloud provider
this.addProviderButton = page.getByRole("link", {
name: "Add Cloud Provider",
exact: true,
this.wizardModal = page
.getByRole("dialog")
.filter({
has: page.getByRole("heading", {
name: /Adding A Cloud Provider|Update Provider Credentials/i,
}),
})
.first();
this.wizardTitle = page.getByRole("heading", {
name: /Adding A Cloud Provider|Update Provider Credentials/i,
});
// Button to add a new cloud provider
this.addProviderButton = page
.getByRole("button", {
name: "Add Cloud Provider",
exact: true,
})
.or(
page.getByRole("link", {
name: "Add Cloud Provider",
exact: true,
}),
);
// Table displaying existing providers
this.providersTable = page.getByRole("table");
@@ -507,6 +529,25 @@ export class ProvidersPage extends BasePage {
await this.addProviderButton.click();
}
async openProviderWizardModal(): Promise<void> {
await this.clickAddProvider();
await this.verifyWizardModalOpen();
}
async closeProviderWizardModal(): Promise<void> {
await this.page.keyboard.press("Escape");
await expect(this.wizardModal).not.toBeVisible();
}
async verifyWizardModalOpen(): Promise<void> {
await expect(this.wizardModal).toBeVisible();
await expect(this.wizardTitle).toBeVisible();
}
async advanceWizardStep(): Promise<void> {
await this.clickNext();
}
private async selectProviderRadio(radio: Locator): Promise<void> {
// Force click to handle overlay intercepts
await radio.click({ force: true });
@@ -536,8 +577,71 @@ export class ProvidersPage extends BasePage {
await this.selectProviderRadio(this.githubProviderRadio);
}
async selectAWSSingleAccountMethod(): Promise<void> {
await this.page
.getByRole("button", {
name: "Add A Single AWS Cloud Account",
exact: true,
})
.click();
}
async selectAWSOrganizationsMethod(): Promise<void> {
await this.page
.getByRole("button", {
name: "Add Multiple Accounts With AWS Organizations",
exact: true,
})
.click();
}
async verifyOrganizationsAuthenticationStepLoaded(): Promise<void> {
await this.verifyWizardModalOpen();
await expect(
this.page.getByRole("heading", {
name: /Authentication Details/i,
}),
).toBeVisible();
}
async verifyOrganizationsAccountSelectionStepLoaded(): Promise<void> {
await this.verifyWizardModalOpen();
await expect(
this.page.getByText(
/Confirm all accounts under this Organization you want to add to Prowler\./i,
),
).toBeVisible();
}
async verifyOrganizationsLaunchStepLoaded(): Promise<void> {
await this.verifyWizardModalOpen();
await expect(this.page.getByText(/Accounts Connected!/i)).toBeVisible();
}
async chooseOrganizationsScanSchedule(
option: "daily" | "single",
): Promise<void> {
const trigger = this.page.getByRole("combobox");
await trigger.click();
const optionName =
option === "single"
? "Run a single scan (no recurring schedule)"
: "Scan Daily (every 24 hours)";
await this.page.getByRole("option", { name: optionName }).click();
}
async fillAWSProviderDetails(data: AWSProviderData): Promise<void> {
// Fill the AWS provider details
const singleAccountButton = this.page.getByRole("button", {
name: "Add A Single AWS Cloud Account",
exact: true,
});
if (await singleAccountButton.isVisible().catch(() => false)) {
await singleAccountButton.click();
}
await this.accountIdInput.fill(data.accountId);
@@ -599,116 +703,83 @@ export class ProvidersPage extends BasePage {
}
async clickNext(): Promise<void> {
// The wizard interface may use different labels for its primary action button on each step.
// This function determines which button to click depending on the current URL and page content.
await this.verifyWizardModalOpen();
// Get the current page URL
const url = this.page.url();
// If on the "connect-account" step, click the "Next" button
if (/\/providers\/connect-account/.test(url)) {
await this.nextButton.click();
const launchScanButton = this.page.getByRole("button", {
name: "Launch scan",
exact: true,
});
if (await launchScanButton.isVisible().catch(() => false)) {
await launchScanButton.click();
await this.handleLaunchScanCompletion();
return;
}
// If on the "add-credentials" step, check for "Save" and "Next" buttons
if (/\/providers\/add-credentials/.test(url)) {
// Some UI implementations use "Save" instead of "Next" for primary action
const actionNames = [
"Go to scans",
"Authenticate",
"Next",
"Save",
"Check connection",
] as const;
if (await this.saveButton.count()) {
await this.saveButton.click();
return;
}
// If "Save" is not present, try clicking the "Next" button
if (await this.nextButton.count()) {
await this.nextButton.click();
for (const actionName of actionNames) {
const button = this.page.getByRole("button", {
name: actionName,
exact: true,
});
if (await button.isVisible().catch(() => false)) {
await button.click();
return;
}
}
// If on the "test-connection" step, click the "Launch scan" button
if (/\/providers\/test-connection/.test(url)) {
const buttonByText = this.page
.locator("button")
.filter({ hasText: "Launch scan" });
await buttonByText.click();
// Wait for either success (redirect to scans) or error message to appear
const errorMessage = this.page
.locator(
"div.border-border-error, div.bg-red-100, p.text-text-error-primary, p.text-danger",
)
.first();
// Helper to check and throw error if visible
const checkAndThrowError = async (): Promise<void> => {
const isErrorVisible = await errorMessage
.isVisible()
.catch(() => false);
if (isErrorVisible) {
const errorText = await errorMessage.textContent();
throw new Error(
`Test connection failed with error: ${errorText?.trim() || "Unknown error"}`,
);
}
};
try {
// Wait up to 15 seconds for either the error message or redirect
await Promise.race([
errorMessage.waitFor({ state: "visible", timeout: 15000 }),
this.page.waitForURL(/\/scans/, { timeout: 15000 }),
]);
// If we're still on test-connection page, check for error
if (/\/providers\/test-connection/.test(this.page.url())) {
await checkAndThrowError();
}
} catch (error) {
await checkAndThrowError();
throw error;
}
return;
}
// Fallback logic: try finding any common primary action buttons in expected order
const candidates: Array<{ name: string | RegExp; exact?: boolean }> = [
{ name: "Next", exact: true }, // Try the "Next" button (exact match to avoid Next.js dev tools)
{ name: "Save", exact: true }, // Try the "Save" button
{ name: "Launch scan" }, // Try the "Launch scan" button
{ name: /Continue|Proceed/i }, // Try "Continue" or "Proceed" (case-insensitive)
];
// Try each candidate name and click it if found
for (const candidate of candidates) {
// Exclude Next.js dev tools button by filtering out buttons with aria-haspopup attribute
const btn = this.page
.getByRole("button", {
name: candidate.name,
exact: candidate.exact,
})
.and(this.page.locator(":not([aria-haspopup])"));
if (await btn.count()) {
await btn.click();
return;
}
}
// If none of the expected action buttons are present, throw an error
throw new Error(
"Could not find an actionable Next/Save/Launch scan button on this step",
"Could not find an actionable primary button in the provider wizard modal.",
);
}
async selectCredentialsType(type: AWSCredentialType): Promise<void> {
// Ensure we are on the add-credentials page where the selector exists
private async handleLaunchScanCompletion(): Promise<void> {
const errorMessage = this.page
.locator(
"div.border-border-error, div.bg-red-100, p.text-text-error-primary, p.text-danger",
)
.first();
const goToScansButton = this.page.getByRole("button", {
name: "Go to scans",
exact: true,
});
await expect(this.page).toHaveURL(/\/providers\/add-credentials/);
try {
await Promise.race([
this.page.waitForURL(/\/scans/, { timeout: 30000 }),
goToScansButton.waitFor({ state: "visible", timeout: 30000 }),
errorMessage.waitFor({ state: "visible", timeout: 30000 }),
]);
} catch {
// Continue and inspect visible state below.
}
const isErrorVisible = await errorMessage.isVisible().catch(() => false);
if (isErrorVisible) {
const errorText = await errorMessage.textContent();
throw new Error(
`Test connection failed with error: ${errorText?.trim() || "Unknown error"}`,
);
}
const isGoToScansVisible = await goToScansButton
.isVisible()
.catch(() => false);
if (isGoToScansVisible) {
await goToScansButton.click();
await this.page.waitForURL(/\/scans/, { timeout: 30000 });
}
}
async selectCredentialsType(type: AWSCredentialType): Promise<void> {
await this.verifyWizardModalOpen();
await expect(this.roleCredentialsRadio).toBeVisible();
if (type === AWS_CREDENTIAL_OPTIONS.AWS_ROLE_ARN) {
await this.roleCredentialsRadio.click({ force: true });
@@ -720,9 +791,8 @@ export class ProvidersPage extends BasePage {
}
async selectM365CredentialsType(type: M365CredentialType): Promise<void> {
// Ensure we are on the add-credentials page where the selector exists
await expect(this.page).toHaveURL(/\/providers\/add-credentials/);
await this.verifyWizardModalOpen();
await expect(this.m365StaticCredentialsRadio).toBeVisible();
if (type === M365_CREDENTIAL_OPTIONS.M365_CREDENTIALS) {
await this.m365StaticCredentialsRadio.click({ force: true });
@@ -734,9 +804,8 @@ export class ProvidersPage extends BasePage {
}
async selectGCPCredentialsType(type: GCPCredentialType): Promise<void> {
// Ensure we are on the add-credentials page where the selector exists
await expect(this.page).toHaveURL(/\/providers\/add-credentials/);
await this.verifyWizardModalOpen();
await expect(this.gcpServiceAccountRadio).toBeVisible();
if (type === GCP_CREDENTIAL_OPTIONS.GCP_SERVICE_ACCOUNT) {
await this.gcpServiceAccountRadio.click({ force: true });
} else {
@@ -745,9 +814,8 @@ export class ProvidersPage extends BasePage {
}
async selectGitHubCredentialsType(type: GitHubCredentialType): Promise<void> {
// Ensure we are on the add-credentials page where the selector exists
await expect(this.page).toHaveURL(/\/providers\/add-credentials/);
await this.verifyWizardModalOpen();
await expect(this.githubPersonalAccessTokenRadio).toBeVisible();
if (type === GITHUB_CREDENTIAL_OPTIONS.GITHUB_PERSONAL_ACCESS_TOKEN) {
await this.githubPersonalAccessTokenRadio.click({ force: true });
@@ -960,9 +1028,8 @@ export class ProvidersPage extends BasePage {
async selectAlibabaCloudCredentialsType(
type: AlibabaCloudCredentialType,
): Promise<void> {
// Ensure we are on the add-credentials page where the selector exists
await expect(this.page).toHaveURL(/\/providers\/add-credentials/);
await this.verifyWizardModalOpen();
await expect(this.alibabacloudStaticCredentialsRadio).toBeVisible();
if (type === ALIBABACLOUD_CREDENTIAL_OPTIONS.ALIBABACLOUD_CREDENTIALS) {
await this.alibabacloudStaticCredentialsRadio.click({ force: true });
@@ -1047,6 +1114,7 @@ export class ProvidersPage extends BasePage {
// Verify the connect account page is loaded
await this.verifyPageHasProwlerTitle();
await this.verifyWizardModalOpen();
await expect(this.awsProviderRadio).toBeVisible();
await expect(this.ociProviderRadio).toBeVisible();
await expect(this.gcpProviderRadio).toBeVisible();
@@ -1061,6 +1129,7 @@ export class ProvidersPage extends BasePage {
// Verify the credentials page is loaded
await this.verifyPageHasProwlerTitle();
await this.verifyWizardModalOpen();
await expect(this.roleCredentialsRadio).toBeVisible();
}
@@ -1115,7 +1184,7 @@ export class ProvidersPage extends BasePage {
// Verify the launch scan page is loaded
await this.verifyPageHasProwlerTitle();
await expect(this.page).toHaveURL(/\/providers\/test-connection/);
await this.verifyWizardModalOpen();
// Verify the Launch scan button is visible
const launchScanButton = this.page
@@ -1202,12 +1271,24 @@ export class ProvidersPage extends BasePage {
async verifyUpdateCredentialsPageLoaded(): Promise<void> {
// Verify the update credentials page is loaded
await this.verifyPageHasProwlerTitle();
await expect(this.page).toHaveURL(/\/providers\/update-credentials/);
await this.verifyWizardModalOpen();
await expect(
this.page.getByRole("button", { name: "Authenticate", exact: true }),
).toBeVisible();
}
async verifyTestConnectionPageLoaded(): Promise<void> {
// Verify the test connection page is loaded
await this.verifyPageHasProwlerTitle();
await expect(this.page).toHaveURL(/\/providers\/test-connection/);
await this.verifyWizardModalOpen();
const testConnectionAction = this.page
.getByRole("button", { name: "Launch scan", exact: true })
.or(
this.page.getByRole("button", {
name: "Check connection",
exact: true,
}),
);
await expect(testConnectionAction).toBeVisible();
}
}

View File

@@ -32,10 +32,7 @@
},
"exclude": [
"node_modules",
"vitest.config.ts",
"vitest.setup.ts",
"**/*.test.ts",
"**/*.test.tsx"
"vitest.config.ts"
],
"include": [
"next-env.d.ts",

View File

@@ -80,55 +80,55 @@ export const addProviderFormSchema = z
z.object({
providerType: z.literal("aws"),
[ProviderCredentialFields.PROVIDER_ALIAS]: z.string(),
providerUid: z.string(),
providerUid: z.string().trim().min(12, "Provider ID is required"),
}),
z.object({
providerType: z.literal("azure"),
[ProviderCredentialFields.PROVIDER_ALIAS]: z.string(),
providerUid: z.string(),
providerUid: z.string().trim().min(1, "Provider ID is required"),
awsCredentialsType: z.string().optional(),
}),
z.object({
providerType: z.literal("m365"),
[ProviderCredentialFields.PROVIDER_ALIAS]: z.string(),
providerUid: z.string(),
providerUid: z.string().trim().min(1, "Provider ID is required"),
}),
z.object({
providerType: z.literal("gcp"),
[ProviderCredentialFields.PROVIDER_ALIAS]: z.string(),
providerUid: z.string(),
providerUid: z.string().trim().min(1, "Provider ID is required"),
awsCredentialsType: z.string().optional(),
}),
z.object({
providerType: z.literal("kubernetes"),
[ProviderCredentialFields.PROVIDER_ALIAS]: z.string(),
providerUid: z.string(),
providerUid: z.string().trim().min(1, "Provider ID is required"),
awsCredentialsType: z.string().optional(),
}),
z.object({
providerType: z.literal("github"),
[ProviderCredentialFields.PROVIDER_ALIAS]: z.string(),
providerUid: z.string(),
providerUid: z.string().trim().min(1, "Provider ID is required"),
}),
z.object({
providerType: z.literal("iac"),
[ProviderCredentialFields.PROVIDER_ALIAS]: z.string(),
providerUid: z.string(),
providerUid: z.string().trim().min(1, "Provider ID is required"),
}),
z.object({
providerType: z.literal("oraclecloud"),
[ProviderCredentialFields.PROVIDER_ALIAS]: z.string(),
providerUid: z.string(),
providerUid: z.string().trim().min(1, "Provider ID is required"),
}),
z.object({
providerType: z.literal("mongodbatlas"),
[ProviderCredentialFields.PROVIDER_ALIAS]: z.string(),
providerUid: z.string(),
providerUid: z.string().trim().min(1, "Provider ID is required"),
}),
z.object({
providerType: z.literal("alibabacloud"),
[ProviderCredentialFields.PROVIDER_ALIAS]: z.string(),
providerUid: z.string(),
providerUid: z.string().trim().min(1, "Provider ID is required"),
}),
z.object({
providerType: z.literal("cloudflare"),

View File

@@ -2,7 +2,9 @@ export * from "./authFormSchema";
export * from "./components";
export * from "./filters";
export * from "./formSchemas";
export * from "./organizations";
export * from "./processors";
export * from "./provider-wizard";
export * from "./providers";
export * from "./resources";
export * from "./scans";

173
ui/types/organizations.ts Normal file
View File

@@ -0,0 +1,173 @@
// ─── Const Enums ──────────────────────────────────────────────────────────────
export const DISCOVERY_STATUS = {
PENDING: "pending",
RUNNING: "running",
SUCCEEDED: "succeeded",
FAILED: "failed",
} as const;
export type DiscoveryStatus =
(typeof DISCOVERY_STATUS)[keyof typeof DISCOVERY_STATUS];
export const APPLY_STATUS = {
READY: "ready",
BLOCKED: "blocked",
} as const;
export type ApplyStatus = (typeof APPLY_STATUS)[keyof typeof APPLY_STATUS];
export const ORG_RELATION = {
ALREADY_LINKED: "already_linked",
LINK_REQUIRED: "link_required",
LINKED_TO_OTHER: "linked_to_other_organization",
} as const;
export type OrgRelation = (typeof ORG_RELATION)[keyof typeof ORG_RELATION];
export const OU_RELATION = {
NOT_APPLICABLE: "not_applicable",
ALREADY_LINKED: "already_linked",
LINK_REQUIRED: "link_required",
LINKED_TO_OTHER_OU: "linked_to_other_ou",
UNCHANGED: "unchanged",
} as const;
export type OuRelation = (typeof OU_RELATION)[keyof typeof OU_RELATION];
export const SECRET_STATE = {
ALREADY_EXISTS: "already_exists",
WILL_CREATE: "will_create",
MANUAL_REQUIRED: "manual_required",
} as const;
export type SecretState = (typeof SECRET_STATE)[keyof typeof SECRET_STATE];
export const ORG_WIZARD_STEP = {
SETUP: 0,
VALIDATE: 1,
LAUNCH: 2,
} as const;
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 {
provider_exists: boolean;
provider_id: string | null;
organization_relation: OrgRelation;
organizational_unit_relation: OuRelation;
provider_secret_state: SecretState;
apply_status: ApplyStatus;
blocked_reasons: string[];
}
export interface DiscoveredAccount {
id: string;
name: string;
arn: string;
email: string;
status: "ACTIVE" | "SUSPENDED" | "PENDING_CLOSURE" | "CLOSED";
joined_method: "INVITED" | "CREATED";
joined_timestamp: string;
parent_id: string;
registration?: AccountRegistration;
}
export interface DiscoveredOu {
id: string;
name: string;
arn: string;
parent_id: string;
}
export interface DiscoveredRoot {
id: string;
arn: string;
name: string;
policy_types: Array<{ Type: string; Status: string }>;
}
export interface DiscoveryResult {
roots: DiscoveredRoot[];
organizational_units: DiscoveredOu[];
accounts: DiscoveredAccount[];
}
// ─── JSON:API Resource Interfaces ─────────────────────────────────────────────
export interface OrganizationAttributes {
name: string;
org_type: "aws" | "azure" | "gcp";
external_id: string;
metadata: Record<string, unknown>;
root_external_id: string | null;
inserted_at?: string;
updated_at?: string;
}
export interface OrganizationResource {
id: string;
type: "organizations";
attributes: OrganizationAttributes;
}
export interface DiscoveryAttributes {
status: DiscoveryStatus;
result: DiscoveryResult | Record<string, never>;
error: string | null;
inserted_at: string;
updated_at: string;
}
export interface DiscoveryResource {
id: string;
type: "organization-discoveries";
attributes: DiscoveryAttributes;
}
export interface ApplyResultAttributes {
providers_created_count: number;
providers_linked_count: number;
providers_applied_count: number;
organizational_units_created_count: number;
}
export interface ApplyResultRelationships {
providers: {
data: Array<{ type: "providers"; id: string }>;
meta: { count: number };
};
organizational_units: {
data: Array<{ type: "organizational-units"; id: string }>;
meta: { count: number };
};
}
export interface ApplyResultResource {
id: string;
type: "organization-discovery-apply-results";
attributes: ApplyResultAttributes;
relationships: ApplyResultRelationships;
}
// ─── Connection Test Status ───────────────────────────────────────────────────
export const CONNECTION_TEST_STATUS = {
PENDING: "pending",
SUCCESS: "success",
ERROR: "error",
} as const;
export type ConnectionTestStatus =
(typeof CONNECTION_TEST_STATUS)[keyof typeof CONNECTION_TEST_STATUS];

View File

@@ -0,0 +1,26 @@
import { ProviderType } from "./providers";
export const PROVIDER_WIZARD_STEP = {
CONNECT: 0,
CREDENTIALS: 1,
TEST: 2,
LAUNCH: 3,
} as const;
export type ProviderWizardStep =
(typeof PROVIDER_WIZARD_STEP)[keyof typeof PROVIDER_WIZARD_STEP];
export const PROVIDER_WIZARD_MODE = {
ADD: "add",
UPDATE: "update",
} as const;
export type ProviderWizardMode =
(typeof PROVIDER_WIZARD_MODE)[keyof typeof PROVIDER_WIZARD_MODE];
export interface ProviderWizardIdentity {
id: string;
type: ProviderType;
uid: string | null;
alias: string | null;
}

View File

@@ -35,6 +35,8 @@ export interface TreeDataItem {
isLoading?: boolean;
/** Status indicator shown after loading (success/error) */
status?: TreeItemStatus;
/** Optional error detail used by status icon tooltip */
errorMessage?: string;
/** Additional CSS classes for the item */
className?: string;
}
@@ -97,7 +99,6 @@ export interface TreeNodeProps {
onExpandedChange: (ids: string[]) => void;
showCheckboxes: boolean;
renderItem?: (params: TreeRenderItemParams) => React.ReactNode;
expandAll: boolean;
enableSelectChildren: boolean;
}