docs(ui): add AWS Organizations implementation docs

Add technical documentation for the AWS Organizations bulk connect
feature including API endpoint specs, wizard flow, and implementation
plan with architecture decisions.
This commit is contained in:
alejandrobailo
2026-02-17 09:46:42 +01:00
parent ea5ab2429c
commit 9d9067c9e6
2 changed files with 1305 additions and 0 deletions
+894
View File
@@ -0,0 +1,894 @@
# AWS Organizations Bulk Account Connection - Frontend Implementation Guide
## Overview
This document provides all the backend context a frontend developer needs to implement the AWS Organizations bulk account connection feature in the Prowler UI. This feature allows SaaS users to connect all their AWS accounts at once using AWS Organizations, instead of adding them one by one.
**OSS vs SaaS behavior:** In the open-source version, the "Add Multiple Accounts With AWS Organizations" option is **disabled** with a "Get Prowler Cloud" CTA badge. In the SaaS version, this option is fully functional.
---
## Table of Contents
1. [User Workflow (from Figma)](#1-user-workflow)
2. [Backend API Endpoints](#2-backend-api-endpoints)
3. [Data Models & Resource Types](#3-data-models--resource-types)
4. [Step-by-Step API Flow](#4-step-by-step-api-flow)
5. [Discovery Result Schema](#5-discovery-result-schema)
6. [Apply Endpoint Details](#6-apply-endpoint-details)
7. [Error Handling](#7-error-handling)
8. [Existing Frontend Architecture](#8-existing-frontend-architecture)
9. [Testing Strategy](#9-testing-strategy)
10. [PR References](#10-pr-references)
---
## 1. User Workflow
Based on the Figma designs, the workflow adds a **method selection** step at the beginning of the existing provider form (Step 1), when the user selects AWS:
### Step 1: Choose Connection Method (NEW - AWS only)
After selecting AWS as the provider type, the user sees two radio options:
1. **"Add A Single AWS Cloud Account"** - Existing flow (continues to current UID input)
2. **"Add Multiple Accounts With AWS Organizations"** - New flow (described below)
- **OSS:** Disabled, shows a gradient "Get Prowler Cloud" CTA badge (see Figma node `10248:3258`)
- **SaaS:** Enabled, enters the Organizations workflow
### Step 2: Organization Setup (NEW)
When "Add Multiple Accounts" is selected, the user:
1. Enters an **Organization Name** (free text, min 3 chars)
2. Enters the **AWS Organization ID** (e.g., `o-abc123def4`)
3. Enters **Role ARN** for the management account role (e.g., `arn:aws:iam::123456789012:role/ProwlerOrgRole`)
4. Enters **External ID** for secure role assumption
5. Clicks "Next" to create the Organization + Secret and trigger discovery
### Step 3: Review Discovered Accounts (NEW)
After discovery completes, the user sees:
- A tree/list of all discovered **Organizational Units (OUs)**
- A list of all discovered **AWS accounts** with their status:
- Which accounts are new vs. already connected
- Which accounts have conflicts (already linked to another org)
- Which accounts will get auto-generated credentials
- The user can **select/deselect** individual accounts and OUs to apply
- Each account shows its `registration` status (ready/blocked)
### Step 4: Apply & Connect
The user clicks "Apply" to bulk-create all providers, link them to the organization and OUs, and auto-generate per-account role-based credentials.
### Step 5: Validate Connection & Launch Scan
Same as the existing Step 3 (test connection), but now applied to the newly created providers.
---
## 2. Backend API Endpoints
All endpoints use **JSON:API** format (`Content-Type: application/vnd.api+json`). Base URL: `/api/v1/`.
### 2.1 Organizations CRUD
| Method | URL | Description | Response Code |
|----------|------------------------------|--------------------------------|---------------|
| `GET` | `/organizations` | List organizations | 200 |
| `POST` | `/organizations` | Create organization | 201 |
| `GET` | `/organizations/{id}` | Retrieve organization | 200 |
| `PATCH` | `/organizations/{id}` | Update organization | 200 |
| `DELETE` | `/organizations/{id}` | Soft-delete org (async) | 202 (Task) |
> **DELETE behavior:** Returns `202 Accepted` with a `tasks` resource body and a `content-location` header pointing to the task URL (same pattern as `DELETE /providers`). The soft-delete takes effect immediately in the UI (resources disappear from listings), while the async task handles background cleanup (unlinking providers, cascading to OUs, etc.).
**Filters:** `org_type` (exact), `external_id` (exact)
### 2.2 Organization Secrets CRUD
| Method | URL | Description | Response Code |
|----------|--------------------------------|------------------------|---------------|
| `GET` | `/organization-secrets` | List secrets | 200 |
| `POST` | `/organization-secrets` | Create secret | 201 |
| `GET` | `/organization-secrets/{id}` | Retrieve secret | 200 |
| `PATCH` | `/organization-secrets/{id}` | Update secret | 200 |
| `DELETE` | `/organization-secrets/{id}` | Hard delete secret | 204 |
**Filters:** `organization_id` (UUID)
### 2.3 Discovery Endpoints
| Method | URL | Description | Response Code |
|--------|------------------------------------------------------|---------------------------|---------------|
| `POST` | `/organizations/{id}/discover` | Trigger async discovery | 202 |
| `GET` | `/organizations/{id}/discoveries` | List discoveries | 200 |
| `GET` | `/organizations/{org_id}/discoveries/{id}` | Retrieve discovery result | 200 |
| `POST` | `/organizations/{org_id}/discoveries/{id}/apply` | Apply discovery results | 200 |
### 2.4 Organizational Units CRUD
| Method | URL | Description | Response Code |
|----------|----------------------------------|----------------------|---------------|
| `GET` | `/organizational-units` | List OUs | 200 |
| `POST` | `/organizational-units` | Create OU | 201 |
| `GET` | `/organizational-units/{id}` | Retrieve OU | 200 |
| `PATCH` | `/organizational-units/{id}` | Update OU | 200 |
| `DELETE` | `/organizational-units/{id}` | Soft-delete OU | 202 (Task) |
> **DELETE behavior:** Same as Organizations DELETE - returns `202 Accepted` with a `tasks` resource body and `content-location` header. Soft-delete is immediate in the UI; background task handles cleanup.
**Filters:** `organization_id` (UUID), `parent_id` (UUID), `external_id` (exact)
### 2.5 Relationship Endpoints (Provider Linkage)
| Method | URL | Description | Response Code |
|----------|------------------------------------------------------------|--------------------------------|---------------|
| `POST` | `/organizations/{id}/relationships/providers` | Add providers to org | 204 |
| `PATCH` | `/organizations/{id}/relationships/providers` | Replace org providers | 204 |
| `DELETE` | `/organizations/{id}/relationships/providers` | Remove all org providers | 204 |
| `POST` | `/organizational-units/{id}/relationships/providers` | Add providers to OU | 204 |
| `PATCH` | `/organizational-units/{id}/relationships/providers` | Replace OU providers | 204 |
| `DELETE` | `/organizational-units/{id}/relationships/providers` | Remove all OU providers | 204 |
---
## 3. Data Models & Resource Types
### 3.1 JSON:API Resource Types
| Resource Type | Description |
|------------------------------------------|-------------------------------------------|
| `organizations` | AWS/Azure/GCP organization |
| `organizational-units` | OU within an organization |
| `organization-secrets` | Encrypted role-assumption credentials |
| `organization-discoveries` | Async discovery job record |
| `organization-discovery-apply-results` | Response from applying a discovery |
| `organization-provider-relationships` | Org-to-provider linkage |
| `organizational-unit-provider-relationships` | OU-to-provider linkage |
| `tasks` | Async task (returned on DELETE 202) |
### 3.2 Organization Model
```typescript
interface Organization {
id: string; // UUID
type: "organizations";
attributes: {
name: string; // min 3 chars
org_type: "aws" | "azure" | "gcp";
external_id: string; // e.g., "o-abc123def4"
metadata: Record<string, any>;
root_external_id: string | null; // Set after discovery (e.g., "r-abc1")
inserted_at: string; // ISO datetime
updated_at: string; // ISO datetime
};
relationships: {
providers: { data: Array<{ type: "providers"; id: string }> };
organizational_units: { data: Array<{ type: "organizational-units"; id: string }> };
};
}
```
### 3.3 Organization Secret Model
```typescript
interface OrganizationSecret {
id: string;
type: "organization-secrets";
attributes: {
secret_type: "role"; // Only "role" supported currently
// NOTE: `secret` field is WRITE-ONLY - never returned in GET responses
inserted_at: string;
updated_at: string;
};
relationships: {
organization: { data: { type: "organizations"; id: string } };
};
}
// Write-only secret payload (for POST/PATCH)
interface OrganizationSecretPayload {
role_arn: string; // e.g., "arn:aws:iam::123456789012:role/ProwlerOrgRole"
external_id: string; // Required for secure cross-account access
role_session_name?: string; // Optional, defaults to "ProwlerSession"
session_duration?: number; // Optional, in seconds
}
```
### 3.4 Organization Discovery Model
```typescript
interface OrganizationDiscovery {
id: string;
type: "organization-discoveries";
attributes: {
status: "pending" | "running" | "succeeded" | "failed";
result: DiscoveryResult | {}; // Populated when status === "succeeded"
error: string | null; // Populated when status === "failed"
inserted_at: string;
updated_at: string;
};
relationships: {
organization: { data: { type: "organizations"; id: string } };
};
}
```
### 3.5 Organizational Unit Model
```typescript
interface OrganizationalUnit {
id: string; // UUID (internal Prowler ID)
type: "organizational-units";
attributes: {
name: string; // min 3 chars
external_id: string; // AWS OU ID (e.g., "ou-abc1-12345678")
metadata: Record<string, any>; // May contain { arn: "..." }
inserted_at: string;
updated_at: string;
};
relationships: {
organization: { data: { type: "organizations"; id: string } };
parent: { data: { type: "organizational-units"; id: string } | null };
providers: { data: Array<{ type: "providers"; id: string }> };
};
}
```
---
## 4. Step-by-Step API Flow
### Flow 1: Create Organization + Secret + Trigger Discovery
```
Frontend Backend
| |
|-- POST /organizations ---------------------------->| (1) Create org
|<-- 201 { org.id } --------------------------------|
| |
|-- POST /organization-secrets --------------------->| (2) Create secret
|<-- 201 { secret.id } -----------------------------|
| |
|-- POST /organizations/{org.id}/discover ---------->| (3) Trigger discovery
|<-- 202 { discovery.id, status: "pending" } -------|
| |
|-- GET /organizations/{org.id}/discoveries/{id} --->| (4) Poll for status
|<-- 200 { status: "running" } ---------------------| (repeat until done)
| |
|-- GET /organizations/{org.id}/discoveries/{id} --->| (5) Discovery complete
|<-- 200 { status: "succeeded", result: {...} } ----|
| |
```
### Flow 2: Review & Apply Discovery
```
Frontend Backend
| |
| (User reviews discovered accounts in the UI) |
| (User selects which accounts/OUs to apply) |
| |
|-- POST /.../discoveries/{id}/apply --------------->| (6) Apply selections
|<-- 200 { providers_created_count, ... } -----------|
| |
| (Redirect to test-connection for created providers)|
| |
```
### Detailed Request/Response Examples (Verified from Bruno API Client)
#### (1) Create Organization
```http
POST /api/v1/organizations
Content-Type: application/vnd.api+json
{
"data": {
"type": "organizations",
"attributes": {
"name": "Prowler org",
"org_type": "aws",
"external_id": "o-az6b84scgf"
}
}
}
```
**Response (201 Created, 251ms):**
```json
{
"data": {
"type": "organizations",
"id": "ac6271ef-dede-425d-aee7-0109a9a402ba",
"attributes": {
"name": "Prowler org",
"org_type": "aws",
"external_id": "o-az6b84scgf",
"metadata": {},
"root_external_id": null
}
},
"meta": {
"version": "v1"
}
}
```
#### (2) Create Organization Secret
```http
POST /api/v1/organization-secrets
Content-Type: application/vnd.api+json
{
"data": {
"type": "organization-secrets",
"attributes": {
"secret_type": "role",
"secret": {
"role_arn": "arn:aws:iam::741399645537:role/ProwlerScan",
"external_id": "a3b26b14-cbfe-40e8-b54c-dd6e2bfa8780"
}
},
"relationships": {
"organization": {
"data": { "type": "organizations", "id": "ac6271ef-dede-425d-aee7-0109a9a402ba" }
}
}
}
}
```
**Response (201):**
```json
{
"data": {
"type": "organization-secrets",
"id": "dfd50cf3-dcb1-4e56-9239-c57b78734778",
"attributes": {
"secret_type": "role",
"inserted_at": "2026-02-10T11:39:48.135685Z",
"updated_at": "2026-02-10T11:39:48.135700Z"
},
"relationships": {
"organization": {
"data": {
"type": "organizations",
"id": "776bd4af-2e77-47ce-8784-eb76616fb9f5"
}
}
}
},
"meta": {
"version": "v1"
}
}
```
> **Note:** The `secret` field is write-only and never returned in responses.
#### (3) Trigger Discovery
```http
POST /api/v1/organizations/ac6271ef-dede-425d-aee7-0109a9a402ba/discover
```
No request body required.
**Response (202 Accepted, 288ms):**
```json
{
"data": {
"type": "organization-discoveries",
"id": "42883eee-5a67-4426-8872-a638d66ba169",
"attributes": {
"status": "pending",
"result": {},
"error": null,
"inserted_at": "2026-02-10T11:59:43.152443Z",
"updated_at": "2026-02-10T11:59:43.152456Z"
},
"relationships": {
"organization": {
"data": {
"type": "organizations",
"id": "ac6271ef-dede-425d-aee7-0109a9a402ba"
}
}
}
},
"meta": {
"version": "v1"
}
}
```
**Response Headers include:**
```
content-location: /api/v1/organizations/ac6271ef-dede-425d-aee7-0109a9a402ba/discoveries/42883eee-5a67-4426-8872-a638d66ba169
content-type: application/vnd.api+json
```
> Use the `content-location` header value to poll for discovery status.
#### (4) Poll Discovery Status (GET until `succeeded` or `failed`)
```http
GET /api/v1/organizations/ac6271ef-dede-425d-aee7-0109a9a402ba/discoveries/42883eee-5a67-4426-8872-a638d66ba169
```
Poll every 2-5 seconds. Status transitions: `pending` -> `running` -> `succeeded` | `failed`
**Response when `succeeded` (200):**
```json
{
"data": {
"type": "organization-discoveries",
"id": "42883eee-5a67-4426-8872-a638d66ba169",
"attributes": {
"status": "succeeded",
"result": {
"roots": [
{
"id": "r-3iiw",
"arn": "arn:aws:organizations::741399645537:root/o-az6b84scgf/r-3iiw",
"name": "Root",
"policy_types": [
{ "Type": "AISERVICES_OPT_OUT_POLICY", "Status": "ENABLED" },
{ "Type": "SERVICE_CONTROL_POLICY", "Status": "ENABLED" },
{ "Type": "TAG_POLICY", "Status": "ENABLED" }
]
}
],
"accounts": [
{
"id": "106908755756",
"arn": "arn:aws:organizations::741399645537:account/o-az6b84scgf/106908755756",
"name": "prowler-dev",
"email": "ops+106908755756@prowler.com",
"status": "ACTIVE",
"parent_id": "ou-3iiw-4gs9ihzb",
"joined_method": "CREATED",
"joined_timestamp": "2022-02-17T14:59:38.887000+00:00",
"registration": {
"provider_exists": true,
"provider_id": "ea6cba06-14c2-4e18-9848-6fb5969e4e9e",
"organization_relation": "link_required",
"organizational_unit_relation": "link_required",
"provider_secret_state": "already_exists",
"apply_status": "ready",
"blocked_reasons": []
}
},
{
"id": "489604170099",
"arn": "arn:aws:organizations::741399645537:account/o-az6b84scgf/489604170099",
"name": "prowler-demo",
"email": "ops+489604170099@prowler.com",
"status": "ACTIVE"
}
],
"organizational_units": [
{
"id": "ou-3iiw-4gs9ihzb",
"name": "...",
"arn": "...",
"parent_id": "r-3iiw"
}
]
},
"error": null
}
}
}
```
> The `registration` object on each account tells the frontend exactly what will happen when applied (see Section 5 for full schema).
---
## 5. Discovery Result Schema
When a discovery succeeds, the `result` attribute contains the full AWS Organization structure, enriched with `registration` state for each account:
```typescript
interface DiscoveryResult {
organization: {
id: string; // AWS org ID (e.g., "o-abc123def4")
arn: string;
feature_set: string; // e.g., "ALL"
management_account_id: string; // e.g., "123456789012"
management_account_arn: string;
management_account_email: string;
};
roots: Array<{
id: string; // e.g., "r-abc1"
arn: string;
name: string; // e.g., "Root"
policy_types: any[];
}>;
organizational_units: Array<{
id: string; // AWS OU ID (e.g., "ou-abc1-12345678")
name: string; // e.g., "Production"
arn: string;
parent_id: string; // Parent OU ID or Root ID
}>;
accounts: Array<DiscoveredAccount>;
}
interface DiscoveredAccount {
id: string; // AWS Account ID (e.g., "123456789012")
name: string; // Account name
arn: string;
email: string;
status: "ACTIVE" | "SUSPENDED" | "PENDING_CLOSURE" | "CLOSED";
joined_method: "INVITED" | "CREATED";
joined_timestamp: string; // ISO datetime
parent_id: string; // Parent OU ID or Root ID
// Enriched by backend when retrieving discovery
registration: {
provider_exists: boolean; // Account already registered as a Prowler provider
provider_id: string | null; // Existing provider UUID if exists
organization_relation:
| "already_linked" // Provider already linked to THIS org
| "link_required" // Provider exists but not linked (or doesn't exist yet)
| "linked_to_other_organization"; // CONFLICT: linked to a different org
organizational_unit_relation:
| "not_applicable" // Account's parent is a root (not an OU)
| "already_linked" // Already linked to the correct OU
| "link_required" // Needs to be linked to an OU
| "linked_to_other_ou" // CONFLICT: linked to a different OU
| "unchanged"; // Provider in an OU but parent isn't a discovered OU
provider_secret_state:
| "already_exists" // Provider already has credentials
| "will_create" // Credentials will be auto-generated from org role
| "manual_required"; // No org role pattern - user must add manually
apply_status: "ready" | "blocked"; // Whether this account can be applied
blocked_reasons: string[]; // e.g., ["organization_conflict", "organizational_unit_conflict"]
};
}
```
### Registration State Decision Logic
| Condition | `organization_relation` | `apply_status` |
|-----------|------------------------|----------------|
| Provider doesn't exist | `link_required` | `ready` |
| Provider exists, not linked to any org | `link_required` | `ready` |
| Provider exists, linked to THIS org | `already_linked` | `ready` |
| Provider exists, linked to ANOTHER org | `linked_to_other_organization` | `blocked` |
### Secret Auto-Generation Logic
The backend derives per-account role ARNs from the org-level role ARN:
- **Org role ARN:** `arn:aws:iam::123456789012:role/ProwlerOrgRole`
- **Derived account role ARN:** `arn:aws:iam::{account_id}:role/ProwlerOrgRole`
This only happens when:
1. The org secret has a `role_arn` containing `:role/`
2. The org secret has an `external_id`
3. The provider doesn't already have a secret
---
## 6. Apply Endpoint Details
### Request (Verified from Bruno)
```http
POST /api/v1/organizations/ac6271ef-dede-425d-aee7-0109a9a402ba/discoveries/42883eee-5a67-4426-8872-a638d66ba169/apply
Content-Type: application/vnd.api+json
{
"data": {
"type": "organization-discoveries",
"attributes": {
"accounts": [
{ "id": "998057895221", "alias": "public-ecr" }
],
"organizational_units": [
{ "id": "ou-3iiw-4gs9ihzb" }
]
}
}
}
```
**Notes:**
- An **empty body** (`{}`), empty arrays (`[]`), or **omitted** `accounts`/`organizational_units` fields will apply **ALL** discovered accounts and OUs.
- If you **don't want to register any** providers or OUs, simply **don't call this endpoint**.
- To apply a **subset**, explicitly list only the desired account IDs and OU IDs.
- `alias` is optional per account (falls back to the AWS account name).
- The backend automatically includes ancestor OUs even if not explicitly selected.
### Response (200 OK)
> Derived from `OrganizationDiscoveryApplyResultSerializer` in `cloud/v1/serializers.py` (prowler-cloud).
```json
{
"data": {
"type": "organization-discovery-apply-results",
"id": "42883eee-5a67-4426-8872-a638d66ba169",
"attributes": {
"providers_created_count": 1,
"providers_linked_count": 0,
"providers_applied_count": 1,
"organizational_units_created_count": 1
},
"relationships": {
"providers": {
"data": [
{
"type": "providers",
"id": "a1983865-cb1d-4d63-a81b-e9737566a1f7"
}
],
"meta": {
"count": 1
}
},
"organizational_units": {
"data": [
{
"type": "organizational-units",
"id": "af2d994b-c42e-4ed3-bc1b-b2a7b20140f0"
}
],
"meta": {
"count": 1
}
}
}
},
"meta": {
"version": "v1"
}
}
```
**Relationship semantics:**
- `providers` contains **all applied providers** (newly created + existing ones that were linked), sourced from `providers_applied` IDs.
- `organizational_units` contains only **newly created OUs**, sourced from `organizational_units_created` IDs.
- Counts break down the providers: `providers_created_count` (new), `providers_linked_count` (existing, newly linked to org/OU), `providers_applied_count` (total = created + linked).
### What Apply Does (Atomically)
1. Creates/updates OUs in depth-first order (ancestors before children)
2. For each selected account:
- `get_or_create` Provider (with `provider=aws`, `uid=account_id`)
- Links provider to the Organization (`OrganizationProvider`)
- Links provider to its parent OU if applicable (`OrganizationalUnitProvider`)
- Auto-creates a `ProviderSecret` with derived role ARN (if org secret has a valid role pattern and provider doesn't already have a secret)
3. Sets the organization's `root_external_id` from the discovery result
4. Returns counts and IDs of all created/linked resources
---
## 7. Error Handling
### Validation Errors (400)
```json
{
"errors": [{
"detail": "Organization with this type and external ID already exists.",
"source": { "pointer": "/data/attributes/external_id" }
}]
}
```
### Conflict Errors (409)
```json
{
"detail": "One or more providers already belong to another organization."
}
```
### Discovery Prerequisite Error (400)
```json
{
"errors": [{
"detail": "Organization secret is required to run discovery."
}]
}
```
### Apply Prerequisite Error (400)
```json
{
"errors": [{
"detail": "Discovery must be in SUCCEEDED status to apply."
}]
}
```
### Apply Conflict Errors (400)
```json
{
"errors": [{
"detail": "One or more providers already belong to another organization.",
"source": { "pointer": "/data/attributes/accounts" }
}]
}
```
### Provider Unavailable (400) - from PR #2836
When the backend detects an AWS account has been closed/suspended:
```json
{
"detail": "Provider aws is unavailable.",
"status": "400",
"code": "provider_unavailable"
}
```
---
## 8. Existing Frontend Architecture
### Current Provider Form Structure
```
ui/
├── app/(prowler)/providers/(set-up-provider)/
│ ├── layout.tsx # Sidebar stepper + content area
│ ├── connect-account/page.tsx # Step 1 page
│ ├── add-credentials/page.tsx # Step 2 page
│ ├── test-connection/page.tsx # Step 3 page
│ └── update-credentials/page.tsx
├── components/providers/
│ ├── add-provider-button.tsx # Entry point button
│ ├── radio-group-provider.tsx # Provider type radio selector
│ ├── workflow/
│ │ ├── workflow-add-provider.tsx # Main stepper display (3 steps)
│ │ ├── vertical-steps.tsx # Custom stepper with Framer Motion
│ │ └── forms/
│ │ ├── connect-account-form.tsx # Step 1: Choose provider + enter UID
│ │ ├── test-connection-form.tsx # Step 3: Test & scan
│ │ ├── add-via-role-form.tsx # AWS role-based credentials
│ │ └── add-via-credentials-form.tsx # AWS static credentials
├── actions/providers/
│ └── providers.ts # Server actions (addProvider, etc.)
├── hooks/
│ └── use-credentials-form.ts # Form schema + state management
└── types/
└── providers.ts # TypeScript interfaces
```
### Tech Stack
- **Next.js 16** (App Router)
- **React 19**
- **shadcn/ui** - Component system (all new components)
- **Tailwind CSS 4** - Styling
- **React Hook Form + Zod** - Form management + validation
- **Framer Motion** - Animations (stepper progress)
- **Lucide React** - Icons
> **IMPORTANT - Component Library:** HeroUI (`components/ui/`) is **legacy**. All new components for this feature **MUST** use **shadcn** (`components/shadcn/`) with the new Figma styling tokens. If any existing components in the provider workflow (e.g., radio groups, inputs, buttons, steppers) are currently HeroUI-based, they should be rebuilt in shadcn as part of this work. Do not import from `@heroui/*` for new code.
### Key Integration Points
1. **`connect-account-form.tsx`**: This is where the connection method radio buttons need to be added (Single vs. Organizations) after AWS is selected.
2. **`workflow-add-provider.tsx` / `vertical-steps.tsx`**: The stepper may need additional steps for the organizations flow.
3. **`actions/providers/providers.ts`**: New server actions needed for organization API calls.
4. **`types/providers.ts`**: New TypeScript interfaces needed for organization types.
### SaaS vs OSS Detection
The frontend needs to detect whether the user is on SaaS or OSS to enable/disable the Organizations option. Check existing patterns in the codebase for how this distinction is made (likely via environment variable or feature flag).
---
## 9. Testing Strategy
### Local Testing Limitations
This feature **cannot be fully tested locally**. The Organizations API relies on AWS IAM role assumption (STS `AssumeRole`) to discover accounts and OUs, which requires real AWS credentials and cross-account trust relationships that don't exist in a local dev environment.
**What CAN be tested locally:**
- UI component rendering, form validation, and stepper navigation
- Mock API responses to verify UI state transitions (pending/running/succeeded/failed)
- Error handling UI (validation errors, conflict states, blocked accounts)
- Select/deselect behavior on the discovered accounts review screen
**What CANNOT be tested locally:**
- End-to-end flow from organization creation through discovery to apply
- Real discovery results with actual AWS account data
- Role assumption and secret auto-generation
- Provider connection testing after apply
### E2E Testing in Cloud DEV (Required Before Merge)
Due to the multi-step flow and multiple chained API requests, this feature **must be E2E tested in the cloud DEV environment** before merging. This is non-negotiable — the full flow (create org -> create secret -> discover -> review -> apply -> test connection) must be validated against real AWS Organizations data.
**Deploying UI to cloud DEV:**
- Same process as deploying the API, but the deployment file starts with `ui-` (instead of `api-`).
- Ask the team for the exact deployment steps if unfamiliar.
**E2E test scenarios to cover:**
1. **Happy path:** Create org -> add secret -> trigger discovery -> review accounts -> select subset -> apply -> verify providers created
2. **Apply all:** Trigger apply with empty body -> verify all discovered accounts get registered
3. **Blocked accounts:** Verify accounts with `organization_relation: "linked_to_other_organization"` show as blocked and cannot be selected
4. **Already linked accounts:** Verify accounts with `already_linked` status show correctly (no duplicate creation)
5. **Discovery failure:** Verify UI handles `status: "failed"` gracefully (e.g., invalid role ARN, missing permissions)
6. **Secret auto-generation:** After apply, verify that providers have auto-generated credentials derived from the org role ARN
7. **Delete organization:** Verify soft-delete returns 202, resources disappear from UI immediately
8. **OSS gate:** Verify the "Add Multiple Accounts" option is disabled in OSS with the "Get Prowler Cloud" CTA badge
---
## 10. PR References
### PR #2826 - `feat(api): add organization support` (MERGED)
- **What:** Full Organizations API - CRUD for organizations, OUs, secrets, discovery, and apply
- **Files:** 29 changed, +5,063 / -91
- **Key files:**
- `api/src/backend/cloud/models.py` - All new models
- `api/src/backend/cloud/v1/views.py` - All views/endpoints
- `api/src/backend/cloud/v1/serializers.py` - All serializers
- `api/src/backend/cloud/v1/urls.py` - URL routing
- `api/src/backend/cloud/services/organization_discovery.py` - Discovery + apply logic
- `api/src/backend/cloud/services/organizations.py` - OU hierarchy validation
- `api/src/backend/tasks/cloud_tasks.py` - Celery task definitions
- `api/src/backend/tasks/jobs/cloud/organizations/discovery.py` - Async discovery job
- `api/src/backend/tasks/jobs/cloud/organizations/deletion.py` - Async deletion with rollback
### PR #2836 - `feat(api): handle provider available field logic` (OPEN)
- **What:** Auto-detects closed/suspended AWS accounts and marks providers as `available=false`
- **Frontend impact:** The `provider.available` field may now change automatically. Providers marked as unavailable will not run scheduled scans. Error code `provider_unavailable` (HTTP 400) should be handled.
- **Key files:**
- `api/src/backend/tasks/jobs/cloud/provider/available.py` - Availability check using AWS Organizations DescribeAccount
- `api/src/backend/tasks/jobs/scan.py` - Raises `ProviderNotAvailableError` during scan
- `api/src/backend/cloud/exceptions.py` - `ProviderNotAvailableError` exception (HTTP 400)
---
## Appendix: Quick API Reference Card
```
# 1. Create Organization
POST /api/v1/organizations
Body: { data: { type: "organizations", attributes: { name, org_type: "aws", external_id } } }
=> 201 { data.id = org_id }
# 2. Create Secret (linked to org via relationship)
POST /api/v1/organization-secrets
Body: { data: { type: "organization-secrets", attributes: { secret_type: "role", secret: { role_arn, external_id } }, relationships: { organization: { data: { type: "organizations", id: org_id } } } } }
=> 201 { data.id = secret_id }
# 3. Trigger Discovery (no body)
POST /api/v1/organizations/{org_id}/discover
=> 202 { data.id = discovery_id }
Headers: content-location = /api/v1/organizations/{org_id}/discoveries/{discovery_id}
# 4. Poll Discovery (repeat until status != "pending" && status != "running")
GET /api/v1/organizations/{org_id}/discoveries/{discovery_id}
=> 200 { data.attributes.status, data.attributes.result }
# 5. Apply Discovery (select accounts + OUs, or empty body/[] for all)
# If you don't want to register anything, DON'T call this endpoint.
POST /api/v1/organizations/{org_id}/discoveries/{discovery_id}/apply
Body: { data: { type: "organization-discoveries", attributes: { accounts: [{ id, alias? }], organizational_units: [{ id }] } } }
=> 200 { type: "organization-discovery-apply-results", attributes: { counts }, relationships: { providers, organizational_units } }
# Extras:
GET /api/v1/organizational-units?filter[organization_id]={org_id}
DELETE /api/v1/organizations/{org_id} => 202 + Task resource + content-location header (soft-delete, instant in UI)
```
@@ -0,0 +1,411 @@
# AWS Organizations Bulk Account Connection - Implementation Plan
## Context
Prowler SaaS users currently add AWS accounts one at a time. This feature adds a modal-based wizard to bulk discover and connect all accounts in an AWS Organization. In OSS mode, the option appears disabled with a "Get Prowler Cloud" CTA. The backend API (PR #2826) is already merged.
**Spec:** `ui/docs/aws-organizations-bulk-connect.md`
**Figma:** OSS disabled `10248:3258` | Validate Connection `10160:2965` | Launch Scan `10004:102323`
---
## 1. Architecture: Modal-Based Wizard
The entire Organizations flow lives in a **modal dialog** (not separate pages). This is a self-contained wizard opened from the existing connect-account page.
**User journey:**
1. `/providers/connect-account` → Select AWS → See method selector (inline in existing form)
2. Click "Add Multiple Accounts With AWS Organizations" → **Opens `OrgWizardModal`**
3. Modal handles all remaining steps internally, closes on completion
**Modal internal steps** (4-step stepper):
| Step | Name | Content |
|------|------|---------|
| 1 ✓ | Link a Cloud Provider | Pre-completed (AWS + Organizations chosen on the page) |
| 2 | Authenticate Credentials | Org name, AWS Org ID, Role ARN, External ID form |
| 3 | Validate Connection | Discovery polling → Account selection → Apply → Connection testing |
| 4 | Launch Scan | Success summary, scan schedule dropdown, Launch Scan button |
**Step 3 sub-phases:**
- **Phase A — Discovery**: Polls `getDiscovery()` every 3s. Shows spinner + "Discovering your AWS Organization..."
- **Phase B — Account Selection**: TreeView with checkboxes + "Name (optional)" input fields. User selects accounts, enters aliases, clicks "Next"
- **Phase C — Apply + Test**: Calls `applyDiscovery()`, then `checkConnectionProvider()` for each created provider. TreeView updates with status icons (✓/✗). Buttons: "Back", "Skip Connection Validation", "Test Connections"
**Step 4 behavior:**
- Shows org name + UID badge
- "Accounts Connected!" with green check
- Scan schedule dropdown ("Scan Daily every 24 hours")
- "Done" → close modal, navigate to `/providers`
- "Launch Scan" → schedule scans for all providers → close modal → navigate to `/providers` → toast notification
---
## 2. File Structure
### New Files
```
ui/
├── types/organizations.ts # Types + const enums
├── actions/organizations/
│ ├── organizations.ts # Server actions (5 API calls)
│ └── organizations.adapter.ts # buildOrgTreeData(), helpers
├── store/organizations/
│ └── store.ts # Zustand store (multi-step state)
├── components/providers/organizations/
│ ├── org-wizard-modal.tsx # Modal shell + stepper + step router
│ ├── org-wizard-stepper.tsx # 4-step vertical stepper (shadcn-based)
│ ├── aws-method-selector.tsx # "Single Account" vs "Organizations" radio
│ ├── org-setup-form.tsx # Step 2: Org details form
│ ├── org-discovery-loader.tsx # Step 3 Phase A: Discovery polling spinner
│ ├── org-account-selection.tsx # Step 3 Phase B: Tree + checkboxes + names
│ ├── org-connection-test.tsx # Step 3 Phase C: Tree + status icons + test
│ ├── org-launch-scan.tsx # Step 4: Success + scan schedule
│ └── org-account-tree-item.tsx # Custom renderItem for TreeView
```
### Modified Files
```
ui/
├── components/providers/workflow/forms/connect-account-form.tsx # Add method selector for AWS
├── store/index.ts # Export org store
├── types/index.ts # Export org types
```
---
## 3. Types (`ui/types/organizations.ts`)
### Const enums (following `as const` pattern)
```typescript
export const DISCOVERY_STATUS = { PENDING: "pending", RUNNING: "running", SUCCEEDED: "succeeded", FAILED: "failed" } as const;
export const APPLY_STATUS = { READY: "ready", BLOCKED: "blocked" } as const;
export const ORG_RELATION = { ALREADY_LINKED: "already_linked", LINK_REQUIRED: "link_required", LINKED_TO_OTHER: "linked_to_other_organization" } as const;
export const SECRET_STATE = { ALREADY_EXISTS: "already_exists", WILL_CREATE: "will_create", MANUAL_REQUIRED: "manual_required" } as const;
export const ORG_WIZARD_STEP = { SETUP: 0, VALIDATE: 1, LAUNCH: 2 } as const;
// Note: Step 0 in modal = Step 2 in stepper (Step 1 is pre-completed)
```
### Key interfaces (flat, one-level depth)
- `AccountRegistration``{ provider_exists, provider_id, organization_relation, apply_status, blocked_reasons[] }`
- `DiscoveredAccount``{ id, name, email, status, parent_id, registration }`
- `DiscoveredOu``{ id, name, arn, parent_id }`
- `DiscoveredRoot``{ id, arn, name, policy_types }`
- `DiscoveryResult``{ roots[], organizational_units[], accounts[] }`
- `OrganizationResource` — JSON:API `{ id, type: "organizations", attributes }`
- `DiscoveryResource` — JSON:API `{ id, type: "organization-discoveries", attributes }`
- `ApplyResultResource` — JSON:API with counts + relationships (provider IDs)
---
## 4. Server Actions (`ui/actions/organizations/organizations.ts`)
All follow existing pattern: `"use server"`, `getAuthHeaders()`, `handleApiResponse()`.
| Action | Method | Endpoint | Notes |
|--------|--------|----------|-------|
| `createOrganization(formData)` | POST | `/organizations` | Returns org ID |
| `createOrganizationSecret(formData)` | POST | `/organization-secrets` | Includes org relationship |
| `triggerDiscovery(orgId)` | POST | `/organizations/{id}/discover` | No body, returns discovery ID |
| `getDiscovery(orgId, discoveryId)` | GET | `/organizations/{id}/discoveries/{did}` | Poll status |
| `applyDiscovery(formData)` | POST | `/organizations/{id}/discoveries/{did}/apply` | Returns provider IDs + counts |
**Reuse existing utilities:**
- `getAuthHeaders()``ui/lib/helper.ts`
- `handleApiResponse()``ui/lib/server-actions-helper.ts`
- `handleApiError()``ui/lib/server-actions-helper.ts`
- `checkConnectionProvider()``ui/actions/providers/providers.ts` (for testing each created provider)
- `checkTaskStatus()``ui/lib/helper.ts` (polling pattern for connection test tasks)
- `scheduleDaily()` / `scanOnDemand()``ui/actions/scans/scans.ts` (for Launch Scan step)
---
## 5. Adapter (`ui/actions/organizations/organizations.adapter.ts`)
### `buildOrgTreeData(result: DiscoveryResult): TreeDataItem[]`
Transforms flat API arrays into hierarchical `TreeDataItem[]` for the TreeView component:
1. Create root-level nodes from `result.roots[]`
2. Build OU nodes, nest under parent root/OU via `parent_id`
3. Build account leaf nodes, nest under parent OU/root via `parent_id`
4. Set `disabled: true` on accounts where `apply_status === "blocked"`
### `getSelectableAccountIds(result: DiscoveryResult): string[]`
Returns IDs of accounts with `apply_status === "ready"` (pre-selects all selectable accounts).
### `buildAccountLookup(result: DiscoveryResult): Map<string, DiscoveredAccount>`
Lookup map for `org-account-tree-item.tsx` to access registration data by account ID.
### `getOuIdsForSelectedAccounts(result: DiscoveryResult, selectedAccountIds: string[]): string[]`
Given selected account IDs, returns the set of OU IDs needed (ancestor OUs for selected accounts).
---
## 6. Zustand Store (`ui/store/organizations/store.ts`)
```typescript
interface OrgSetupState {
// Identity
organizationId: string | null;
organizationName: string | null;
organizationExternalId: string | null;
discoveryId: string | null;
// Discovery
discoveryResult: DiscoveryResult | null;
// Selection + aliases
selectedAccountIds: string[];
accountAliases: Record<string, string>; // accountId -> alias
// Apply result
createdProviderIds: string[];
// Connection test results
connectionResults: Record<string, "pending" | "success" | "error">; // providerId -> status
// 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;
setConnectionResult: (providerId: string, status: "pending" | "success" | "error") => void;
reset: () => void;
}
```
Uses `persist` middleware with `sessionStorage`. Pattern matches `ui/store/ui/store.ts`.
---
## 7. Component Details
### 7.1 `org-wizard-modal.tsx` — Modal Shell
A large Dialog (shadcn `Dialog` from `@/components/shadcn/dialog`) containing:
- Header: "Adding A Cloud Provider" + info link + X close button
- Two-column layout: Left = `OrgWizardStepper`, Right = step content
- Footer: Back/Next/action buttons (vary by step)
- Internal state: `currentStep` (0=Setup, 1=Validate, 2=Launch)
- On close (X or "Done"): calls `orgStore.reset()`, closes dialog
Props: `open: boolean`, `onOpenChange: (open: boolean) => void`
### 7.2 `org-wizard-stepper.tsx` — 4-Step Stepper
Custom vertical stepper (shadcn-based, NOT HeroUI) with 4 steps:
1. "Link a Cloud Provider" — always completed (green check)
2. "Authenticate Credentials" — active/completed based on `currentStep`
3. "Validate Connection" — active/completed/error based on state
4. "Launch Scan" — active when reached
Icons: folder-git-2, key-round, rocket, Prowler logo (matching Figma)
Step 3 shows error icon when connection tests have failures.
### 7.3 `aws-method-selector.tsx` — Method Selection
Renders inside `connect-account-form.tsx` when `providerType === "aws"`.
Two radio-style cards:
1. **"Add A Single AWS Cloud Account"** — `Box` icon, always enabled
2. **"Add Multiple Accounts With AWS Organizations"** — `Building2` icon
- SaaS (`NEXT_PUBLIC_IS_CLOUD_ENV === "true"`): enabled
- OSS: disabled + gradient "Get Prowler Cloud" CTA badge
### 7.4 `connect-account-form.tsx` — Modification
Add AWS method selection as conditional within `prevStep === 2`:
```
prevStep === 1: RadioGroupProvider — unchanged
prevStep === 2 && providerType === "aws" && !awsMethod: AwsMethodSelector + OrgWizardModal
prevStep === 2 && providerType !== "aws": UID/Alias input — unchanged
prevStep === 2 && awsMethod === "single": UID/Alias input (existing)
```
New local state: `awsMethod: "single" | null`, `isOrgModalOpen: boolean`
When "Organizations" is selected → `setIsOrgModalOpen(true)` (opens the wizard modal)
When "Single Account" is selected → `setAwsMethod("single")` (shows UID input)
### 7.5 `org-setup-form.tsx` — Step 2: Organization Details
React Hook Form + Zod schema:
- `organizationName`: `z.string().min(3)`
- `awsOrgId`: `z.string().regex(/^o-[a-z0-9]{10,32}$/)`
- `roleArn`: `z.string().regex(/^arn:aws:iam::\d{12}:role\//)`
- `externalId`: `z.string().min(1)`
**Submit flow (sequential chain):**
1. `createOrganization()` → get `orgId`
2. `createOrganizationSecret()` with `orgId` → get `secretId`
3. `triggerDiscovery(orgId)` → get `discoveryId`
4. Store all in Zustand: `setOrganization(orgId, name, externalId)`, discoveryId
5. Advance to step 3 (Validate Connection)
Shows loading state during 3-call chain. If any fails, shows error on the relevant field.
### 7.6 `org-discovery-loader.tsx` — Step 3 Phase A: Discovery Polling
On mount, polls `getDiscovery(orgId, discoveryId)` every 3s, max 60 retries (3 min).
- Shows spinner + "Discovering your AWS Organization..."
- On `succeeded`: stores result in Zustand, transitions to Phase B
- On `failed`: shows error message + "Retry" button
### 7.7 `org-account-selection.tsx` — Step 3 Phase B: Selection
Renders:
- Org info header (AWS badge + org name + UID badge)
- `TreeView` from `@/components/shadcn/tree-view` with:
- `showCheckboxes={true}`, `enableSelectChildren={true}`, `expandAll={true}`
- Custom `renderItem` → shows account ID + "Name (optional)" input field
- Pre-selects all `ready` accounts
- Blocked accounts shown as disabled with reason tooltip
Buttons: "Back" (go to step 2), "Next" (advance to Phase C / Apply)
### 7.8 `org-connection-test.tsx` — Step 3 Phase C: Apply + Test Connections
**On mount:**
1. Calls `applyDiscovery()` with selected account IDs + aliases from Zustand
2. Stores returned `createdProviderIds` in Zustand
3. Automatically tests all connections: for each provider ID, calls `checkConnectionProvider()` then polls `checkTaskStatus()`
4. Updates TreeView status icons in real-time via `connectionResults` in store
**TreeView display** (from Figma):
- Table headers: "Account ID" | "Name (optional)"
- Each node: expand arrow + status icon (✓ green / ✗ red / spinner) + folder/shield icon + ID + name field
- Uses `TreeDataItem.status` and `TreeDataItem.isLoading` for built-in spinner/icon support
**Error state** (from Figma `10160:2965`):
- Red error banner: "There was a problem connecting to some accounts. Ensure the Prowler StackSet has successfully deployed then retry testing connections."
- "Test Connections" button retries all failed connections
- "Skip Connection Validation" link advances to step 4 anyway
**Success:** All connections pass → automatically advance to step 4
Buttons: "Back", "Skip Connection Validation" (text link), "Test Connections" (primary)
### 7.9 `org-launch-scan.tsx` — Step 4: Launch Scan
From Figma (`10004:102323`):
- Org info header (AWS badge + org name + UID badge)
- Green check + "Accounts Connected!"
- "Your accounts are connected to Prowler and ready to Scan!"
- "Select a Prowler scan schedule for these accounts."
- Dropdown: "Scan Daily (every 24 hours)" (default selected)
**"Done" button:**
- Close modal, navigate to `/providers`, revalidate
- No scan triggered
**"Launch Scan" button:**
- For each provider ID in `createdProviderIds`:
- Call `scheduleDaily()` (from `ui/actions/scans/scans.ts`)
- Close modal, navigate to `/providers`
- Show toast: "Scan launched for N accounts"
- Revalidate `/providers` and `/scans`
### 7.10 `org-account-tree-item.tsx` — Custom Tree Renderer
Used in both Phase B (selection) and Phase C (testing) via `renderItem` prop.
**Phase B (selection mode):**
- Shows: account ID text + "Name (optional)" input field
- Checkboxes handled by TreeView's built-in checkbox support
**Phase C (testing mode):**
- Shows: status icon (✓/✗/spinner) + account ID + "Name (optional)" input (read-only or editable)
- OUs show aggregate status (all children pass = ✓, any fail = ✗)
- Uses `item.status` ("success"/"error") and `item.isLoading` from TreeDataItem
Account data lookup via closure over `Map<string, DiscoveredAccount>` from adapter.
---
## 8. SaaS/OSS Gating
Single check in `AwsMethodSelector`:
```typescript
const isCloudEnv = process.env.NEXT_PUBLIC_IS_CLOUD_ENV === "true";
```
- OSS: Organizations option disabled with gradient "Get Prowler Cloud" CTA
- SaaS: Organizations option fully functional
- No server-side gating needed
---
## 9. Implementation Order
| # | Task | Depends On |
|---|------|-----------|
| 1 | Types (`types/organizations.ts`) | — |
| 2 | Server actions (`actions/organizations/organizations.ts`) | Types |
| 3 | Adapter (`actions/organizations/organizations.adapter.ts`) | Types |
| 4 | Zustand store (`store/organizations/store.ts`) | Types |
| 5 | `AwsMethodSelector` component | — |
| 6 | `OrgWizardStepper` component | — |
| 7 | `OrgSetupForm` component | Actions, Store |
| 8 | `OrgDiscoveryLoader` component | Actions, Store |
| 9 | `OrgAccountTreeItem` component | Adapter, Types |
| 10 | `OrgAccountSelection` component | TreeView, TreeItem, Store |
| 11 | `OrgConnectionTest` component | Actions, Store, TreeView |
| 12 | `OrgLaunchScan` component | Scan actions, Store |
| 13 | `OrgWizardModal` (shell + step router) | All above components |
| 14 | Modify `connect-account-form.tsx` | AwsMethodSelector, OrgWizardModal |
| 15 | Wire exports (`store/index.ts`, `types/index.ts`) | All |
---
## 10. Verification
### Local testing (UI states — no real API)
1. `pnpm run dev` → verify no errors
2. `pnpm run typecheck` → all types compile
3. `pnpm run lint:fix` → no lint errors
4. `/providers/connect-account`:
- Select AWS → method selector appears
- OSS mode: Organizations option disabled + "Get Prowler Cloud" badge
- Click "Single Account" → existing UID form (unchanged)
- Click "Organizations" (SaaS) → modal opens
- Non-AWS providers → go straight to UID form (no method selector)
5. Modal stepper: verify 4 steps render, step transitions work
6. Org setup form: validation works (Zod errors on invalid inputs)
7. Mock discovery states: pending spinner, succeeded tree, failed error
8. TreeView: checkboxes toggle, blocked accounts disabled, names editable
9. Close modal (X or Done) → state resets
### Cloud DEV E2E testing (required before merge)
1. Happy path: create org → secret → discover → select → apply → test connections → launch scan
2. Partial failure: some connections fail → error banner → "Test Connections" retry → "Skip" works
3. All pass: auto-advance to Launch Scan
4. Discovery failure: shows error + "Retry" button
5. Blocked accounts: cannot be selected in tree
6. Launch Scan: modal closes → `/providers` → toast → providers visible in table
7. OSS gate: method selector shows disabled option with CTA
### Key utilities to reuse
| Utility | Path | Used For |
|---------|------|----------|
| `getAuthHeaders()` | `ui/lib/helper.ts` | All API calls |
| `handleApiResponse()` | `ui/lib/server-actions-helper.ts` | Response parsing |
| `checkConnectionProvider()` | `ui/actions/providers/providers.ts` | Testing each provider |
| `checkTaskStatus()` | `ui/lib/helper.ts:257` | Polling connection test tasks |
| `scheduleDaily()` | `ui/actions/scans/scans.ts:141` | Launch Scan step |
| `useFormServerErrors` | `ui/hooks/use-form-server-errors.ts` | Map API errors to form fields |
| `TreeView` | `ui/components/shadcn/tree-view/` | Account hierarchy + selection |
| `Dialog` | `ui/components/shadcn/dialog` | Modal container |
| `useUIStore` pattern | `ui/store/ui/store.ts` | Zustand + persist reference |
| `addProvider` pattern | `ui/actions/providers/providers.ts` | JSON:API server action reference |