From de81f6384ed141a00b7fa67135e29cd9a6a780c7 Mon Sep 17 00:00:00 2001 From: alejandrobailo Date: Tue, 17 Feb 2026 09:46:09 +0100 Subject: [PATCH] feat(ui): add AWS Organizations server actions and adapters Add server actions for create organization, create secret, trigger discovery, poll discovery status, and apply discovery results. Add adapter utilities to transform flat API response into hierarchical tree data for the TreeView. --- .../organizations/organizations.adapter.ts | 137 +++++++++++++++ ui/actions/organizations/organizations.ts | 162 ++++++++++++++++++ 2 files changed, 299 insertions(+) create mode 100644 ui/actions/organizations/organizations.adapter.ts create mode 100644 ui/actions/organizations/organizations.ts 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); + } +};