mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-03-31 21:27:28 +00:00
Compare commits
59 Commits
dependabot
...
feat/PROWL
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7ca08a8367 | ||
|
|
c05ab7a388 | ||
|
|
82da861ae5 | ||
|
|
f788e5699e | ||
|
|
591405e3dc | ||
|
|
2ba2e7b5ff | ||
|
|
54e2fe76a8 | ||
|
|
8c9ebfe35f | ||
|
|
de938afd88 | ||
|
|
fd530708f9 | ||
|
|
d8e467b8ad | ||
|
|
dd76aeea07 | ||
|
|
38bb2f1e36 | ||
|
|
b39113829c | ||
|
|
1ed983ab66 | ||
|
|
d93068a41b | ||
|
|
8deb090ad5 | ||
|
|
7437b8af8b | ||
|
|
411e5dee67 | ||
|
|
e88cf5edf1 | ||
|
|
9adbeea035 | ||
|
|
f390c23ac2 | ||
|
|
6050f86e79 | ||
|
|
2f74f6fb2e | ||
|
|
8399557128 | ||
|
|
1147c0cef4 | ||
|
|
45619079b7 | ||
|
|
5f22761d7d | ||
|
|
f38908c1d9 | ||
|
|
bcba2be6ba | ||
|
|
8d9912d3c2 | ||
|
|
89133550b9 | ||
|
|
817349fa3e | ||
|
|
f525a120cd | ||
|
|
ea0b8a107e | ||
|
|
e4f98c72b5 | ||
|
|
64193376b5 | ||
|
|
a248ac6788 | ||
|
|
0dd5401d95 | ||
|
|
ac6b6be009 | ||
|
|
e869f4620d | ||
|
|
fea3649dd5 | ||
|
|
81f2ff915c | ||
|
|
8678a5ff31 | ||
|
|
7feee41f68 | ||
|
|
d9a142a445 | ||
|
|
518fd1bf2d | ||
|
|
cfaec55f0a | ||
|
|
68262665c6 | ||
|
|
a9d5e1e6d6 | ||
|
|
635676355a | ||
|
|
b9620a80af | ||
|
|
9d9067c9e6 | ||
|
|
ea5ab2429c | ||
|
|
83bceab584 | ||
|
|
689d044d92 | ||
|
|
d3b0c3c393 | ||
|
|
de81f6384e | ||
|
|
6648212369 |
177
ui/actions/organizations/organizations.adapter.test.ts
Normal file
177
ui/actions/organizations/organizations.adapter.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
145
ui/actions/organizations/organizations.adapter.ts
Normal file
145
ui/actions/organizations/organizations.adapter.ts
Normal 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);
|
||||
}
|
||||
113
ui/actions/organizations/organizations.test.ts
Normal file
113
ui/actions/organizations/organizations.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
324
ui/actions/organizations/organizations.ts
Normal file
324
ui/actions/organizations/organizations.ts
Normal 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);
|
||||
}
|
||||
};
|
||||
71
ui/actions/scans/scans.test.ts
Normal file
71
ui/actions/scans/scans.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -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 });
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
import React from "react";
|
||||
|
||||
import { ConnectAccountForm } from "@/components/providers/workflow/forms";
|
||||
|
||||
export default function ConnectAccountPage() {
|
||||
return <ConnectAccountForm />;
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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} />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
130
ui/components/providers/organizations/org-account-selection.tsx
Normal file
130
ui/components/providers/organizations/org-account-selection.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
127
ui/components/providers/organizations/org-account-tree-item.tsx
Normal file
127
ui/components/providers/organizations/org-account-tree-item.tsx
Normal 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 };
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
177
ui/components/providers/organizations/org-launch-scan.tsx
Normal file
177
ui/components/providers/organizations/org-launch-scan.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
407
ui/components/providers/organizations/org-setup-form.tsx
Normal file
407
ui/components/providers/organizations/org-setup-form.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
53
ui/components/providers/radio-card.tsx
Normal file
53
ui/components/providers/radio-card.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
})
|
||||
|
||||
@@ -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 />}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
3
ui/components/providers/wizard/index.ts
Normal file
3
ui/components/providers/wizard/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export * from "./provider-wizard-modal";
|
||||
export * from "./steps";
|
||||
export * from "./wizard-stepper";
|
||||
273
ui/components/providers/wizard/provider-wizard-modal.tsx
Normal file
273
ui/components/providers/wizard/provider-wizard-modal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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";
|
||||
}
|
||||
84
ui/components/providers/wizard/steps/connect-step.tsx
Normal file
84
ui/components/providers/wizard/steps/connect-step.tsx
Normal 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;
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
261
ui/components/providers/wizard/steps/credentials-step.tsx
Normal file
261
ui/components/providers/wizard/steps/credentials-step.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
29
ui/components/providers/wizard/steps/footer-controls.ts
Normal file
29
ui/components/providers/wizard/steps/footer-controls.ts
Normal 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;
|
||||
}
|
||||
5
ui/components/providers/wizard/steps/index.ts
Normal file
5
ui/components/providers/wizard/steps/index.ts
Normal 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";
|
||||
15
ui/components/providers/wizard/steps/launch-step.tsx
Normal file
15
ui/components/providers/wizard/steps/launch-step.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
146
ui/components/providers/wizard/steps/test-connection-step.tsx
Normal file
146
ui/components/providers/wizard/steps/test-connection-step.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
12
ui/components/providers/wizard/types.ts
Normal file
12
ui/components/providers/wizard/types.ts
Normal 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;
|
||||
}
|
||||
160
ui/components/providers/wizard/wizard-stepper.tsx
Normal file
160
ui/components/providers/wizard/wizard-stepper.tsx
Normal 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");
|
||||
}
|
||||
@@ -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"
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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";
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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} />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
) : (
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
) : (
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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 || "",
|
||||
};
|
||||
};
|
||||
|
||||
63
ui/hooks/use-scroll-hint.ts
Normal file
63
ui/hooks/use-scroll-hint.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -1 +1,3 @@
|
||||
export * from "./organizations/store";
|
||||
export * from "./provider-wizard/store";
|
||||
export * from "./ui/store";
|
||||
|
||||
33
ui/store/organizations/store.test.ts
Normal file
33
ui/store/organizations/store.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
203
ui/store/organizations/store.ts
Normal file
203
ui/store/organizations/store.ts
Normal 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,
|
||||
}),
|
||||
},
|
||||
),
|
||||
);
|
||||
62
ui/store/provider-wizard/store.test.ts
Normal file
62
ui/store/provider-wizard/store.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
57
ui/store/provider-wizard/store.ts
Normal file
57
ui/store/provider-wizard/store.ts
Normal 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),
|
||||
},
|
||||
),
|
||||
);
|
||||
@@ -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();
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
@@ -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");
|
||||
},
|
||||
);
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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
173
ui/types/organizations.ts
Normal 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];
|
||||
26
ui/types/provider-wizard.ts
Normal file
26
ui/types/provider-wizard.ts
Normal 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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user