diff --git a/ui/actions/organizations/organizations.adapter.ts b/ui/actions/organizations/organizations.adapter.ts new file mode 100644 index 0000000000..ae366fb18e --- /dev/null +++ b/ui/actions/organizations/organizations.adapter.ts @@ -0,0 +1,137 @@ +import { Building2, FolderTree, ShieldCheck } 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: Root → OUs → Accounts (leaf nodes) + * 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(); + + // 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: Building2, + 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: ShieldCheck, + 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-level nodes as the tree + return result.roots + .map((root) => nodeMap.get(root.id)) + .filter((node): node is TreeDataItem => node !== undefined); +} + +/** + * Returns IDs of accounts that can be selected (apply_status === "ready"). + * Used to pre-select all selectable accounts in the tree. + */ +export function getSelectableAccountIds(result: DiscoveryResult): string[] { + return result.accounts + .filter( + (account) => account.registration?.apply_status === 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 { + const map = new Map(); + 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(); + + // 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(); + 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); +} diff --git a/ui/actions/organizations/organizations.ts b/ui/actions/organizations/organizations.ts new file mode 100644 index 0000000000..8fb8fe6539 --- /dev/null +++ b/ui/actions/organizations/organizations.ts @@ -0,0 +1,162 @@ +"use server"; + +import { revalidatePath } from "next/cache"; + +import { apiBaseUrl, getAuthHeaders } from "@/lib"; +import { handleApiError, handleApiResponse } from "@/lib/server-actions-helper"; + +/** + * 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); + } +}; + +/** + * 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); + } +}; + +/** + * 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 url = new URL(`${apiBaseUrl}/organizations/${organizationId}/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 url = new URL( + `${apiBaseUrl}/organizations/${organizationId}/discoveries/${discoveryId}`, + ); + + 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 url = new URL( + `${apiBaseUrl}/organizations/${organizationId}/discoveries/${discoveryId}/apply`, + ); + + try { + const response = await fetch(url.toString(), { + method: "POST", + headers, + body: JSON.stringify({ + data: { + type: "organization-discoveries", + attributes: { + accounts, + organizational_units: organizationalUnits, + }, + }, + }), + }); + + revalidatePath("/providers"); + return handleApiResponse(response); + } catch (error) { + return handleApiError(error); + } +};