From 32e453163413fe8a97a4e1893bb7ff8084b92663 Mon Sep 17 00:00:00 2001 From: Kunal Dawar Date: Mon, 24 Aug 2026 22:03:31 +0530 Subject: [PATCH] feat: add Flows management commands Add `auth0 flows` for managing Flows, Flow Executions, and Vault Connections, mirroring the existing Forms commands. - flows list/create/show/update/delete/open - flows executions list/show/delete - flows vault connections list/create/show/update/delete - flows vault open Create/update/show go through the raw Management API to preserve provider-specific config that the typed v3 request bodies drop, while list/delete use the v3 SDK. Consolidate the raw-JSON, body-input, editor, and builder-URL helpers shared by Forms and Flows into utils_shared.go, removing the duplicated readFormBody, editFormJSON, and openFormEditURL helpers from forms.go. Includes generated docs, unit tests, and a commander integration suite. --- README.md | 1 + docs/auth0_flows.md | 20 + docs/auth0_flows_create.md | 63 + docs/auth0_flows_delete.md | 59 + docs/auth0_flows_executions.md | 15 + docs/auth0_flows_executions_delete.md | 50 + docs/auth0_flows_executions_list.md | 53 + docs/auth0_flows_executions_show.md | 48 + docs/auth0_flows_list.md | 60 + docs/auth0_flows_open.md | 47 + docs/auth0_flows_show.md | 54 + docs/auth0_flows_update.md | 58 + docs/auth0_flows_vault.md | 14 + docs/auth0_flows_vault_connections.md | 17 + docs/auth0_flows_vault_connections_create.md | 59 + docs/auth0_flows_vault_connections_delete.md | 55 + docs/auth0_flows_vault_connections_list.md | 53 + docs/auth0_flows_vault_connections_show.md | 51 + docs/auth0_flows_vault_connections_update.md | 55 + docs/auth0_flows_vault_open.md | 42 + docs/index.md | 1 + internal/auth0/auth0.go | 6 + internal/auth0/flow_v3.go | 94 ++ internal/auth0/mock/flow_v3_mock.go | 201 +++ internal/cli/flows.go | 1247 +++++++++++++++++ internal/cli/flows_test.go | 272 ++++ internal/cli/forms.go | 190 +-- internal/cli/forms_test.go | 76 +- internal/cli/root.go | 1 + internal/cli/utils_shared.go | 210 +++ internal/display/flows.go | 382 +++++ test/integration/fixtures/update-flow.json | 4 + .../fixtures/update-vault-connection.json | 3 + .../fixtures/vault-connection.json | 8 + test/integration/flows-test-cases.yaml | 213 +++ test/integration/scripts/cleanup-flows.sh | 16 + .../scripts/cleanup-vault-connections.sh | 16 + test/integration/scripts/get-flow-id.sh | 13 + .../scripts/get-vault-connection-id.sh | 16 + test/integration/scripts/test-cleanup.sh | 2 + 40 files changed, 3595 insertions(+), 250 deletions(-) create mode 100644 docs/auth0_flows.md create mode 100644 docs/auth0_flows_create.md create mode 100644 docs/auth0_flows_delete.md create mode 100644 docs/auth0_flows_executions.md create mode 100644 docs/auth0_flows_executions_delete.md create mode 100644 docs/auth0_flows_executions_list.md create mode 100644 docs/auth0_flows_executions_show.md create mode 100644 docs/auth0_flows_list.md create mode 100644 docs/auth0_flows_open.md create mode 100644 docs/auth0_flows_show.md create mode 100644 docs/auth0_flows_update.md create mode 100644 docs/auth0_flows_vault.md create mode 100644 docs/auth0_flows_vault_connections.md create mode 100644 docs/auth0_flows_vault_connections_create.md create mode 100644 docs/auth0_flows_vault_connections_delete.md create mode 100644 docs/auth0_flows_vault_connections_list.md create mode 100644 docs/auth0_flows_vault_connections_show.md create mode 100644 docs/auth0_flows_vault_connections_update.md create mode 100644 docs/auth0_flows_vault_open.md create mode 100644 internal/auth0/flow_v3.go create mode 100644 internal/auth0/mock/flow_v3_mock.go create mode 100644 internal/cli/flows.go create mode 100644 internal/cli/flows_test.go create mode 100644 internal/display/flows.go create mode 100644 test/integration/fixtures/update-flow.json create mode 100644 test/integration/fixtures/update-vault-connection.json create mode 100644 test/integration/fixtures/vault-connection.json create mode 100644 test/integration/flows-test-cases.yaml create mode 100755 test/integration/scripts/cleanup-flows.sh create mode 100755 test/integration/scripts/cleanup-vault-connections.sh create mode 100755 test/integration/scripts/get-flow-id.sh create mode 100755 test/integration/scripts/get-vault-connection-id.sh diff --git a/README.md b/README.md index 41f3c17cb..b4857bef6 100644 --- a/README.md +++ b/README.md @@ -273,6 +273,7 @@ Select **y** to proceed with your default tenant, or **N** to choose a different - [auth0 completion](https://auth0.github.io/auth0-cli/auth0_completion.html) - Setup autocomplete features for this CLI on your terminal - [auth0 domains](https://auth0.github.io/auth0-cli/auth0_domains.html) - Manage custom domains - [auth0 email](https://auth0.github.io/auth0-cli/auth0_email.html) - Manage email settings +- [auth0 flows](https://auth0.github.io/auth0-cli/auth0_flows.html) - Manage Flows - [auth0 forms](https://auth0.github.io/auth0-cli/auth0_forms.html) - Manage Forms - [auth0 login](https://auth0.github.io/auth0-cli/auth0_login.html) - Authenticate the Auth0 CLI - [auth0 logout](https://auth0.github.io/auth0-cli/auth0_logout.html) - Log out of a tenant's session diff --git a/docs/auth0_flows.md b/docs/auth0_flows.md new file mode 100644 index 000000000..2c9fbfe0b --- /dev/null +++ b/docs/auth0_flows.md @@ -0,0 +1,20 @@ +--- +layout: default +has_toc: false +has_children: true +--- +# auth0 flows + +Flows let you orchestrate custom logic during authentication and other journeys, chaining actions such as HTTP requests and vault-backed integrations. + +## Commands + +- [auth0 flows create](auth0_flows_create.md) - Create a new flow +- [auth0 flows delete](auth0_flows_delete.md) - Delete a flow +- [auth0 flows executions](auth0_flows_executions.md) - Manage Flow executions +- [auth0 flows list](auth0_flows_list.md) - List your flows +- [auth0 flows open](auth0_flows_open.md) - Open a flow in the Auth0 Dashboard +- [auth0 flows show](auth0_flows_show.md) - Show a flow +- [auth0 flows update](auth0_flows_update.md) - Update a flow +- [auth0 flows vault](auth0_flows_vault.md) - Manage Flow vault connections + diff --git a/docs/auth0_flows_create.md b/docs/auth0_flows_create.md new file mode 100644 index 000000000..a8581a6ff --- /dev/null +++ b/docs/auth0_flows_create.md @@ -0,0 +1,63 @@ +--- +layout: default +parent: auth0 flows +has_toc: false +--- +# auth0 flows create + +Create a new flow. + +Interactive behavior: `auth0 flows create` asks only for the name and creates a minimal scaffold; it does not open an editor. Pass `--edit` to open an editor and author the flow actions before it is created, or supply the whole body via `--file` (or piped stdin) with an optional `--name` override. Run `auth0 flows create --example > flow.json` to generate an accepted file payload. + +## Usage +``` +auth0 flows create [flags] +``` + +## Examples + +``` + auth0 flows create + auth0 flows create --name "My Flow" + auth0 flows create --name "My Flow" --edit + auth0 flows create --example > flow.json + auth0 flows create --file ./flow.json + cat flow.json | auth0 flows create -f - +``` + + +## Flags + +``` + --edit Open an editor to author the flow graph after entering the name. + --example Print an example flow JSON body and exit. + -f, --file string Path to a JSON file with the flow body. Use '-' to read from stdin. + --json Output in json format. + --json-compact Output in compact json format. + --name string Name of the Flow. +``` + + +## Inherited Flags + +``` + --agent-mode Output JSON, disable prompts and colors. Auto-enabled for AI agents; set AUTH0_AGENT_MODE=false to disable. + --debug Enable debug mode. + --no-color Disable colors. + --no-input Disable interactivity. + --tenant string Specific tenant to use. +``` + + +## Related Commands + +- [auth0 flows create](auth0_flows_create.md) - Create a new flow +- [auth0 flows delete](auth0_flows_delete.md) - Delete a flow +- [auth0 flows executions](auth0_flows_executions.md) - Manage Flow executions +- [auth0 flows list](auth0_flows_list.md) - List your flows +- [auth0 flows open](auth0_flows_open.md) - Open a flow in the Auth0 Dashboard +- [auth0 flows show](auth0_flows_show.md) - Show a flow +- [auth0 flows update](auth0_flows_update.md) - Update a flow +- [auth0 flows vault](auth0_flows_vault.md) - Manage Flow vault connections + + diff --git a/docs/auth0_flows_delete.md b/docs/auth0_flows_delete.md new file mode 100644 index 000000000..9a767b4ad --- /dev/null +++ b/docs/auth0_flows_delete.md @@ -0,0 +1,59 @@ +--- +layout: default +parent: auth0 flows +has_toc: false +--- +# auth0 flows delete + +Delete a flow. + +To delete interactively, use `auth0 flows delete` with no arguments. + +To delete non-interactively, supply the flow id and the `--force` flag to skip confirmation. + +## Usage +``` +auth0 flows delete [flags] +``` + +## Examples + +``` + auth0 flows delete + auth0 flows rm + auth0 flows delete + auth0 flows delete --force + auth0 flows delete +``` + + +## Flags + +``` + --force Skip confirmation. +``` + + +## Inherited Flags + +``` + --agent-mode Output JSON, disable prompts and colors. Auto-enabled for AI agents; set AUTH0_AGENT_MODE=false to disable. + --debug Enable debug mode. + --no-color Disable colors. + --no-input Disable interactivity. + --tenant string Specific tenant to use. +``` + + +## Related Commands + +- [auth0 flows create](auth0_flows_create.md) - Create a new flow +- [auth0 flows delete](auth0_flows_delete.md) - Delete a flow +- [auth0 flows executions](auth0_flows_executions.md) - Manage Flow executions +- [auth0 flows list](auth0_flows_list.md) - List your flows +- [auth0 flows open](auth0_flows_open.md) - Open a flow in the Auth0 Dashboard +- [auth0 flows show](auth0_flows_show.md) - Show a flow +- [auth0 flows update](auth0_flows_update.md) - Update a flow +- [auth0 flows vault](auth0_flows_vault.md) - Manage Flow vault connections + + diff --git a/docs/auth0_flows_executions.md b/docs/auth0_flows_executions.md new file mode 100644 index 000000000..7344e45d7 --- /dev/null +++ b/docs/auth0_flows_executions.md @@ -0,0 +1,15 @@ +--- +layout: default +has_toc: false +has_children: true +--- +# auth0 flows executions + +Inspect the runtime executions produced when a flow runs. + +## Commands + +- [auth0 flows executions delete](auth0_flows_executions_delete.md) - Delete a flow execution +- [auth0 flows executions list](auth0_flows_executions_list.md) - List a flow's executions +- [auth0 flows executions show](auth0_flows_executions_show.md) - Show a flow execution + diff --git a/docs/auth0_flows_executions_delete.md b/docs/auth0_flows_executions_delete.md new file mode 100644 index 000000000..ab8b43cba --- /dev/null +++ b/docs/auth0_flows_executions_delete.md @@ -0,0 +1,50 @@ +--- +layout: default +parent: auth0 flows executions +has_toc: false +--- +# auth0 flows executions delete + +Delete one or more executions of a flow. + +Supply the flow id followed by the execution ids. Use `--force` to skip confirmation. + +## Usage +``` +auth0 flows executions delete [flags] +``` + +## Examples + +``` + auth0 flows executions delete + auth0 flows executions rm --force + auth0 flows executions delete +``` + + +## Flags + +``` + --force Skip confirmation. +``` + + +## Inherited Flags + +``` + --agent-mode Output JSON, disable prompts and colors. Auto-enabled for AI agents; set AUTH0_AGENT_MODE=false to disable. + --debug Enable debug mode. + --no-color Disable colors. + --no-input Disable interactivity. + --tenant string Specific tenant to use. +``` + + +## Related Commands + +- [auth0 flows executions delete](auth0_flows_executions_delete.md) - Delete a flow execution +- [auth0 flows executions list](auth0_flows_executions_list.md) - List a flow's executions +- [auth0 flows executions show](auth0_flows_executions_show.md) - Show a flow execution + + diff --git a/docs/auth0_flows_executions_list.md b/docs/auth0_flows_executions_list.md new file mode 100644 index 000000000..d4bda76db --- /dev/null +++ b/docs/auth0_flows_executions_list.md @@ -0,0 +1,53 @@ +--- +layout: default +parent: auth0 flows executions +has_toc: false +--- +# auth0 flows executions list + +List the executions produced by a flow. + +## Usage +``` +auth0 flows executions list [flags] +``` + +## Examples + +``` + auth0 flows executions list + auth0 flows executions ls --number 100 + auth0 flows executions list --json +``` + + +## Flags + +``` + --csv Output in csv format. + --from string Cursor id from which to start selection. + --json Output in json format. + --json-compact Output in compact json format. + -n, --number int Number of executions to retrieve. Fetched across pages. (default 100) + --take int Number of executions to retrieve per page. +``` + + +## Inherited Flags + +``` + --agent-mode Output JSON, disable prompts and colors. Auto-enabled for AI agents; set AUTH0_AGENT_MODE=false to disable. + --debug Enable debug mode. + --no-color Disable colors. + --no-input Disable interactivity. + --tenant string Specific tenant to use. +``` + + +## Related Commands + +- [auth0 flows executions delete](auth0_flows_executions_delete.md) - Delete a flow execution +- [auth0 flows executions list](auth0_flows_executions_list.md) - List a flow's executions +- [auth0 flows executions show](auth0_flows_executions_show.md) - Show a flow execution + + diff --git a/docs/auth0_flows_executions_show.md b/docs/auth0_flows_executions_show.md new file mode 100644 index 000000000..74196248d --- /dev/null +++ b/docs/auth0_flows_executions_show.md @@ -0,0 +1,48 @@ +--- +layout: default +parent: auth0 flows executions +has_toc: false +--- +# auth0 flows executions show + +Display information about a flow execution. + +## Usage +``` +auth0 flows executions show [flags] +``` + +## Examples + +``` + auth0 flows executions show + auth0 flows executions show --json +``` + + +## Flags + +``` + --json Output in json format. + --json-compact Output in compact json format. +``` + + +## Inherited Flags + +``` + --agent-mode Output JSON, disable prompts and colors. Auto-enabled for AI agents; set AUTH0_AGENT_MODE=false to disable. + --debug Enable debug mode. + --no-color Disable colors. + --no-input Disable interactivity. + --tenant string Specific tenant to use. +``` + + +## Related Commands + +- [auth0 flows executions delete](auth0_flows_executions_delete.md) - Delete a flow execution +- [auth0 flows executions list](auth0_flows_executions_list.md) - List a flow's executions +- [auth0 flows executions show](auth0_flows_executions_show.md) - Show a flow execution + + diff --git a/docs/auth0_flows_list.md b/docs/auth0_flows_list.md new file mode 100644 index 000000000..a6de50a57 --- /dev/null +++ b/docs/auth0_flows_list.md @@ -0,0 +1,60 @@ +--- +layout: default +parent: auth0 flows +has_toc: false +--- +# auth0 flows list + +List your existing flows. To create one, run: `auth0 flows create`. + +## Usage +``` +auth0 flows list [flags] +``` + +## Examples + +``` + auth0 flows list + auth0 flows ls + auth0 flows ls --number 100 + auth0 flows ls --hydrate + auth0 flows ls --json +``` + + +## Flags + +``` + --csv Output in csv format. + --hydrate Hydrate the response with the number of forms referencing each flow. + --json Output in json format. + --json-compact Output in compact json format. + -n, --number int Number of flows to retrieve. Fetched across pages. (default 100) + --synchronous Filter to synchronous (true) or asynchronous (false) flows. +``` + + +## Inherited Flags + +``` + --agent-mode Output JSON, disable prompts and colors. Auto-enabled for AI agents; set AUTH0_AGENT_MODE=false to disable. + --debug Enable debug mode. + --no-color Disable colors. + --no-input Disable interactivity. + --tenant string Specific tenant to use. +``` + + +## Related Commands + +- [auth0 flows create](auth0_flows_create.md) - Create a new flow +- [auth0 flows delete](auth0_flows_delete.md) - Delete a flow +- [auth0 flows executions](auth0_flows_executions.md) - Manage Flow executions +- [auth0 flows list](auth0_flows_list.md) - List your flows +- [auth0 flows open](auth0_flows_open.md) - Open a flow in the Auth0 Dashboard +- [auth0 flows show](auth0_flows_show.md) - Show a flow +- [auth0 flows update](auth0_flows_update.md) - Update a flow +- [auth0 flows vault](auth0_flows_vault.md) - Manage Flow vault connections + + diff --git a/docs/auth0_flows_open.md b/docs/auth0_flows_open.md new file mode 100644 index 000000000..27409dd4c --- /dev/null +++ b/docs/auth0_flows_open.md @@ -0,0 +1,47 @@ +--- +layout: default +parent: auth0 flows +has_toc: false +--- +# auth0 flows open + +Open a flow's page in the Auth0 Dashboard flow builder. + +## Usage +``` +auth0 flows open [flags] +``` + +## Examples + +``` + auth0 flows open + auth0 flows open +``` + + + + +## Inherited Flags + +``` + --agent-mode Output JSON, disable prompts and colors. Auto-enabled for AI agents; set AUTH0_AGENT_MODE=false to disable. + --debug Enable debug mode. + --no-color Disable colors. + --no-input Disable interactivity. + --tenant string Specific tenant to use. +``` + + +## Related Commands + +- [auth0 flows create](auth0_flows_create.md) - Create a new flow +- [auth0 flows delete](auth0_flows_delete.md) - Delete a flow +- [auth0 flows executions](auth0_flows_executions.md) - Manage Flow executions +- [auth0 flows list](auth0_flows_list.md) - List your flows +- [auth0 flows open](auth0_flows_open.md) - Open a flow in the Auth0 Dashboard +- [auth0 flows show](auth0_flows_show.md) - Show a flow +- [auth0 flows update](auth0_flows_update.md) - Update a flow +- [auth0 flows vault](auth0_flows_vault.md) - Manage Flow vault connections + + diff --git a/docs/auth0_flows_show.md b/docs/auth0_flows_show.md new file mode 100644 index 000000000..53f1fcaa3 --- /dev/null +++ b/docs/auth0_flows_show.md @@ -0,0 +1,54 @@ +--- +layout: default +parent: auth0 flows +has_toc: false +--- +# auth0 flows show + +Display information about a flow. + +## Usage +``` +auth0 flows show [flags] +``` + +## Examples + +``` + auth0 flows show + auth0 flows show + auth0 flows show --json +``` + + +## Flags + +``` + --json Output in json format. + --json-compact Output in compact json format. +``` + + +## Inherited Flags + +``` + --agent-mode Output JSON, disable prompts and colors. Auto-enabled for AI agents; set AUTH0_AGENT_MODE=false to disable. + --debug Enable debug mode. + --no-color Disable colors. + --no-input Disable interactivity. + --tenant string Specific tenant to use. +``` + + +## Related Commands + +- [auth0 flows create](auth0_flows_create.md) - Create a new flow +- [auth0 flows delete](auth0_flows_delete.md) - Delete a flow +- [auth0 flows executions](auth0_flows_executions.md) - Manage Flow executions +- [auth0 flows list](auth0_flows_list.md) - List your flows +- [auth0 flows open](auth0_flows_open.md) - Open a flow in the Auth0 Dashboard +- [auth0 flows show](auth0_flows_show.md) - Show a flow +- [auth0 flows update](auth0_flows_update.md) - Update a flow +- [auth0 flows vault](auth0_flows_vault.md) - Manage Flow vault connections + + diff --git a/docs/auth0_flows_update.md b/docs/auth0_flows_update.md new file mode 100644 index 000000000..6fb50e75b --- /dev/null +++ b/docs/auth0_flows_update.md @@ -0,0 +1,58 @@ +--- +layout: default +parent: auth0 flows +has_toc: false +--- +# auth0 flows update + +Update a flow. + +Passing `--file` (or piped stdin) replaces every top-level field present in the file. Passing only `--name` performs a merge that preserves the flow's actions. Server-managed fields such as `id`, `created_at`, and `updated_at` are removed before the request is sent. + +## Usage +``` +auth0 flows update [flags] +``` + +## Examples + +``` + auth0 flows update --name "New Name" + auth0 flows update --file ./flow.json + cat flow.json | auth0 flows update -f - +``` + + +## Flags + +``` + -f, --file string Path to a JSON file with the flow body. Use '-' to read from stdin. + --json Output in json format. + --json-compact Output in compact json format. + --name string Name of the Flow. +``` + + +## Inherited Flags + +``` + --agent-mode Output JSON, disable prompts and colors. Auto-enabled for AI agents; set AUTH0_AGENT_MODE=false to disable. + --debug Enable debug mode. + --no-color Disable colors. + --no-input Disable interactivity. + --tenant string Specific tenant to use. +``` + + +## Related Commands + +- [auth0 flows create](auth0_flows_create.md) - Create a new flow +- [auth0 flows delete](auth0_flows_delete.md) - Delete a flow +- [auth0 flows executions](auth0_flows_executions.md) - Manage Flow executions +- [auth0 flows list](auth0_flows_list.md) - List your flows +- [auth0 flows open](auth0_flows_open.md) - Open a flow in the Auth0 Dashboard +- [auth0 flows show](auth0_flows_show.md) - Show a flow +- [auth0 flows update](auth0_flows_update.md) - Update a flow +- [auth0 flows vault](auth0_flows_vault.md) - Manage Flow vault connections + + diff --git a/docs/auth0_flows_vault.md b/docs/auth0_flows_vault.md new file mode 100644 index 000000000..f97ae503e --- /dev/null +++ b/docs/auth0_flows_vault.md @@ -0,0 +1,14 @@ +--- +layout: default +has_toc: false +has_children: true +--- +# auth0 flows vault + +Manage the vault connections that store credentials for flow integrations. + +## Commands + +- [auth0 flows vault connections](auth0_flows_vault_connections.md) - Manage Flow vault connections +- [auth0 flows vault open](auth0_flows_vault_open.md) - Open the Vault in the Auth0 Dashboard + diff --git a/docs/auth0_flows_vault_connections.md b/docs/auth0_flows_vault_connections.md new file mode 100644 index 000000000..f271927c5 --- /dev/null +++ b/docs/auth0_flows_vault_connections.md @@ -0,0 +1,17 @@ +--- +layout: default +has_toc: false +has_children: true +--- +# auth0 flows vault connections + +List, inspect, create, update, and delete flow vault connections. + +## Commands + +- [auth0 flows vault connections create](auth0_flows_vault_connections_create.md) - Create a new vault connection +- [auth0 flows vault connections delete](auth0_flows_vault_connections_delete.md) - Delete a vault connection +- [auth0 flows vault connections list](auth0_flows_vault_connections_list.md) - List your vault connections +- [auth0 flows vault connections show](auth0_flows_vault_connections_show.md) - Show a vault connection +- [auth0 flows vault connections update](auth0_flows_vault_connections_update.md) - Update a vault connection + diff --git a/docs/auth0_flows_vault_connections_create.md b/docs/auth0_flows_vault_connections_create.md new file mode 100644 index 000000000..3edca9d2a --- /dev/null +++ b/docs/auth0_flows_vault_connections_create.md @@ -0,0 +1,59 @@ +--- +layout: default +parent: auth0 flows vault connections +has_toc: false +--- +# auth0 flows vault connections create + +Create a new vault connection. + +Interactive behavior: `auth0 flows vault connections create` asks for the name and app id, then opens an editor seeded with a provider-specific `setup` template so you can enter the connection secrets. Alternatively, supply the whole body (including its `setup` secrets) via `--file` (or piped stdin); `--name` and `--app-id` override the corresponding fields after the file is parsed. Run `auth0 flows vault connections create --example` to print a template. + +## Usage +``` +auth0 flows vault connections create [flags] +``` + +## Examples + +``` + auth0 flows vault connections create + auth0 flows vault connections create --file ./connection.json + auth0 flows vault connections create --file ./connection.json --name "My Connection" + auth0 flows vault connections create --example > connection.json + cat connection.json | auth0 flows vault connections create -f - +``` + + +## Flags + +``` + --app-id string Identifier of the app the Vault connection integrates with (e.g. HTTP, SLACK). + --example Print an example flow JSON body and exit. + -f, --file string Path to a JSON file with the vault connection body (including its setup secrets). Use '-' to read from stdin. + --json Output in json format. + --json-compact Output in compact json format. + --name string Name of the Vault connection. +``` + + +## Inherited Flags + +``` + --agent-mode Output JSON, disable prompts and colors. Auto-enabled for AI agents; set AUTH0_AGENT_MODE=false to disable. + --debug Enable debug mode. + --no-color Disable colors. + --no-input Disable interactivity. + --tenant string Specific tenant to use. +``` + + +## Related Commands + +- [auth0 flows vault connections create](auth0_flows_vault_connections_create.md) - Create a new vault connection +- [auth0 flows vault connections delete](auth0_flows_vault_connections_delete.md) - Delete a vault connection +- [auth0 flows vault connections list](auth0_flows_vault_connections_list.md) - List your vault connections +- [auth0 flows vault connections show](auth0_flows_vault_connections_show.md) - Show a vault connection +- [auth0 flows vault connections update](auth0_flows_vault_connections_update.md) - Update a vault connection + + diff --git a/docs/auth0_flows_vault_connections_delete.md b/docs/auth0_flows_vault_connections_delete.md new file mode 100644 index 000000000..d319f3ce9 --- /dev/null +++ b/docs/auth0_flows_vault_connections_delete.md @@ -0,0 +1,55 @@ +--- +layout: default +parent: auth0 flows vault connections +has_toc: false +--- +# auth0 flows vault connections delete + +Delete a vault connection. + +To delete interactively, use `auth0 flows vault connections delete` with no arguments. + +To delete non-interactively, supply the connection id and the `--force` flag. + +## Usage +``` +auth0 flows vault connections delete [flags] +``` + +## Examples + +``` + auth0 flows vault connections delete + auth0 flows vault connections rm + auth0 flows vault connections delete + auth0 flows vault connections delete --force +``` + + +## Flags + +``` + --force Skip confirmation. +``` + + +## Inherited Flags + +``` + --agent-mode Output JSON, disable prompts and colors. Auto-enabled for AI agents; set AUTH0_AGENT_MODE=false to disable. + --debug Enable debug mode. + --no-color Disable colors. + --no-input Disable interactivity. + --tenant string Specific tenant to use. +``` + + +## Related Commands + +- [auth0 flows vault connections create](auth0_flows_vault_connections_create.md) - Create a new vault connection +- [auth0 flows vault connections delete](auth0_flows_vault_connections_delete.md) - Delete a vault connection +- [auth0 flows vault connections list](auth0_flows_vault_connections_list.md) - List your vault connections +- [auth0 flows vault connections show](auth0_flows_vault_connections_show.md) - Show a vault connection +- [auth0 flows vault connections update](auth0_flows_vault_connections_update.md) - Update a vault connection + + diff --git a/docs/auth0_flows_vault_connections_list.md b/docs/auth0_flows_vault_connections_list.md new file mode 100644 index 000000000..e93f01f01 --- /dev/null +++ b/docs/auth0_flows_vault_connections_list.md @@ -0,0 +1,53 @@ +--- +layout: default +parent: auth0 flows vault connections +has_toc: false +--- +# auth0 flows vault connections list + +List your existing vault connections. To create one, run: `auth0 flows vault connections create`. + +## Usage +``` +auth0 flows vault connections list [flags] +``` + +## Examples + +``` + auth0 flows vault connections list + auth0 flows vault connections ls --number 100 + auth0 flows vault connections ls --json +``` + + +## Flags + +``` + --csv Output in csv format. + --json Output in json format. + --json-compact Output in compact json format. + -n, --number int Number of connections to retrieve. Fetched across pages. (default 100) +``` + + +## Inherited Flags + +``` + --agent-mode Output JSON, disable prompts and colors. Auto-enabled for AI agents; set AUTH0_AGENT_MODE=false to disable. + --debug Enable debug mode. + --no-color Disable colors. + --no-input Disable interactivity. + --tenant string Specific tenant to use. +``` + + +## Related Commands + +- [auth0 flows vault connections create](auth0_flows_vault_connections_create.md) - Create a new vault connection +- [auth0 flows vault connections delete](auth0_flows_vault_connections_delete.md) - Delete a vault connection +- [auth0 flows vault connections list](auth0_flows_vault_connections_list.md) - List your vault connections +- [auth0 flows vault connections show](auth0_flows_vault_connections_show.md) - Show a vault connection +- [auth0 flows vault connections update](auth0_flows_vault_connections_update.md) - Update a vault connection + + diff --git a/docs/auth0_flows_vault_connections_show.md b/docs/auth0_flows_vault_connections_show.md new file mode 100644 index 000000000..25f80140a --- /dev/null +++ b/docs/auth0_flows_vault_connections_show.md @@ -0,0 +1,51 @@ +--- +layout: default +parent: auth0 flows vault connections +has_toc: false +--- +# auth0 flows vault connections show + +Display information about a vault connection. Secret values are never returned by the API. + +## Usage +``` +auth0 flows vault connections show [flags] +``` + +## Examples + +``` + auth0 flows vault connections show + auth0 flows vault connections show + auth0 flows vault connections show --json +``` + + +## Flags + +``` + --json Output in json format. + --json-compact Output in compact json format. +``` + + +## Inherited Flags + +``` + --agent-mode Output JSON, disable prompts and colors. Auto-enabled for AI agents; set AUTH0_AGENT_MODE=false to disable. + --debug Enable debug mode. + --no-color Disable colors. + --no-input Disable interactivity. + --tenant string Specific tenant to use. +``` + + +## Related Commands + +- [auth0 flows vault connections create](auth0_flows_vault_connections_create.md) - Create a new vault connection +- [auth0 flows vault connections delete](auth0_flows_vault_connections_delete.md) - Delete a vault connection +- [auth0 flows vault connections list](auth0_flows_vault_connections_list.md) - List your vault connections +- [auth0 flows vault connections show](auth0_flows_vault_connections_show.md) - Show a vault connection +- [auth0 flows vault connections update](auth0_flows_vault_connections_update.md) - Update a vault connection + + diff --git a/docs/auth0_flows_vault_connections_update.md b/docs/auth0_flows_vault_connections_update.md new file mode 100644 index 000000000..c9f4012df --- /dev/null +++ b/docs/auth0_flows_vault_connections_update.md @@ -0,0 +1,55 @@ +--- +layout: default +parent: auth0 flows vault connections +has_toc: false +--- +# auth0 flows vault connections update + +Update a vault connection. + +Passing `--file` (or piped stdin) replaces every top-level field present in the file. Passing only `--name` performs a merge. Server-managed fields such as `id`, `ready`, and `fingerprint` are removed before the request is sent. + +## Usage +``` +auth0 flows vault connections update [flags] +``` + +## Examples + +``` + auth0 flows vault connections update --name "New Name" + auth0 flows vault connections update --file ./connection.json + cat connection.json | auth0 flows vault connections update -f - +``` + + +## Flags + +``` + -f, --file string Path to a JSON file with the vault connection body (including its setup secrets). Use '-' to read from stdin. + --json Output in json format. + --json-compact Output in compact json format. + --name string Name of the Vault connection. +``` + + +## Inherited Flags + +``` + --agent-mode Output JSON, disable prompts and colors. Auto-enabled for AI agents; set AUTH0_AGENT_MODE=false to disable. + --debug Enable debug mode. + --no-color Disable colors. + --no-input Disable interactivity. + --tenant string Specific tenant to use. +``` + + +## Related Commands + +- [auth0 flows vault connections create](auth0_flows_vault_connections_create.md) - Create a new vault connection +- [auth0 flows vault connections delete](auth0_flows_vault_connections_delete.md) - Delete a vault connection +- [auth0 flows vault connections list](auth0_flows_vault_connections_list.md) - List your vault connections +- [auth0 flows vault connections show](auth0_flows_vault_connections_show.md) - Show a vault connection +- [auth0 flows vault connections update](auth0_flows_vault_connections_update.md) - Update a vault connection + + diff --git a/docs/auth0_flows_vault_open.md b/docs/auth0_flows_vault_open.md new file mode 100644 index 000000000..afe0c1774 --- /dev/null +++ b/docs/auth0_flows_vault_open.md @@ -0,0 +1,42 @@ +--- +layout: default +parent: auth0 flows vault +has_toc: false +--- +# auth0 flows vault open + +Open a Vault app's page in the Auth0 Dashboard. This opens the app's Vault page (for example AUTH0, JWT, HTTP, or SLACK), not a specific connection. + +## Usage +``` +auth0 flows vault open [flags] +``` + +## Examples + +``` + auth0 flows vault open + auth0 flows vault open HTTP + auth0 flows vault open SLACK +``` + + + + +## Inherited Flags + +``` + --agent-mode Output JSON, disable prompts and colors. Auto-enabled for AI agents; set AUTH0_AGENT_MODE=false to disable. + --debug Enable debug mode. + --no-color Disable colors. + --no-input Disable interactivity. + --tenant string Specific tenant to use. +``` + + +## Related Commands + +- [auth0 flows vault connections](auth0_flows_vault_connections.md) - Manage Flow vault connections +- [auth0 flows vault open](auth0_flows_vault_open.md) - Open the Vault in the Auth0 Dashboard + + diff --git a/docs/index.md b/docs/index.md index cc5de36f9..018e54344 100644 --- a/docs/index.md +++ b/docs/index.md @@ -97,6 +97,7 @@ The help for any command can also be emitted as JSON by combining `--help` with - [auth0 domains](auth0_domains.md) - Manage custom domains - [auth0 email](auth0_email.md) - Manage email settings and configure email providers - [auth0 event-streams](auth0_event-streams.md) - Manage Event Stream +- [auth0 flows](auth0_flows.md) - Manage Flows - [auth0 forms](auth0_forms.md) - Manage Forms - [auth0 login](auth0_login.md) - Authenticate the Auth0 CLI - [auth0 logout](auth0_logout.md) - Log out of a tenant's session diff --git a/internal/auth0/auth0.go b/internal/auth0/auth0.go index 16d54aa2a..1ec8df22f 100644 --- a/internal/auth0/auth0.go +++ b/internal/auth0/auth0.go @@ -79,6 +79,9 @@ type APIV3 struct { ClientGrantOrganization ClientGrantOrganizationAPIV3 Events EventsAPIV3 Form FormAPIV3 + Flow FlowAPIV3 + FlowExecution FlowExecutionAPIV3 + FlowVaultConnection FlowVaultConnectionAPIV3 PhoneNotificationTemplate PhoneNotificationTemplateAPI Session SessionAPIV3 RefreshToken RefreshTokenAPIV3 @@ -95,6 +98,9 @@ func NewAPIV3(m *managementv3.Management) *APIV3 { ClientGrantOrganization: m.ClientGrants.Organizations, Events: m.Events, Form: m.Forms, + Flow: m.Flows, + FlowExecution: m.Flows.Executions, + FlowVaultConnection: m.Flows.Vault.Connections, PhoneNotificationTemplate: m.Branding.Phone.Templates, Session: m.Sessions, RefreshToken: m.RefreshTokens, diff --git a/internal/auth0/flow_v3.go b/internal/auth0/flow_v3.go new file mode 100644 index 000000000..053f6f012 --- /dev/null +++ b/internal/auth0/flow_v3.go @@ -0,0 +1,94 @@ +//go:generate mockgen -source=flow_v3.go -destination=mock/flow_v3_mock.go -package=mock + +package auth0 + +import ( + "context" + + managementv3 "github.com/auth0/go-auth0/v3/management" + "github.com/auth0/go-auth0/v3/management/core" + "github.com/auth0/go-auth0/v3/management/option" +) + +// FlowSummaryPage aliases the paginated flows list response. The alias keeps the +// interface return type a single identifier so mockgen's source parser can handle +// it (it cannot parse the multi-type-parameter generic inline). +type FlowSummaryPage = core.Page[*int, *managementv3.FlowSummary, *managementv3.ListFlowsOffsetPaginatedResponseContent] + +// FlowExecutionSummaryPage aliases the paginated flow-executions list response. +type FlowExecutionSummaryPage = core.Page[*string, *managementv3.FlowExecutionSummary, *managementv3.ListFlowExecutionsPaginatedResponseContent] + +// FlowsVaultConnectionSummaryPage aliases the paginated vault-connections list response. +type FlowsVaultConnectionSummaryPage = core.Page[*int, *managementv3.FlowsVaultConnectionSummary, *managementv3.ListFlowsVaultConnectionsOffsetPaginatedResponseContent] + +// FlowAPIV3 is the V3 SDK interface for the /flows endpoint. Create, read, and +// update go through the raw HTTP client to preserve the flow action graph that +// the typed request models would drop, so only paging and delete live here. +type FlowAPIV3 interface { + // List flows. + // + // Required scope: `read:flows`. + List( + ctx context.Context, + request *managementv3.ListFlowsRequestParameters, + opts ...option.RequestOption, + ) (*FlowSummaryPage, error) + + // Delete a flow. + // + // Required scope: `delete:flows`. + Delete( + ctx context.Context, + id string, + opts ...option.RequestOption, + ) error +} + +// FlowExecutionAPIV3 is the V3 SDK interface for the /flows/{id}/executions +// endpoint. Executions are runtime-produced, so the surface is read and delete +// only. +type FlowExecutionAPIV3 interface { + // List flow executions. + // + // Required scope: `read:flows_executions`. + List( + ctx context.Context, + flowID string, + request *managementv3.ListFlowExecutionsRequestParameters, + opts ...option.RequestOption, + ) (*FlowExecutionSummaryPage, error) + + // Delete a flow execution. + // + // Required scope: `delete:flows_executions`. + Delete( + ctx context.Context, + flowID string, + executionID string, + opts ...option.RequestOption, + ) error +} + +// FlowVaultConnectionAPIV3 is the V3 SDK interface for the +// /flows/vault/connections endpoint. Create and update go through the raw HTTP +// client because the typed request model is a large per-provider union, so only +// paging and delete live here. +type FlowVaultConnectionAPIV3 interface { + // List vault connections. + // + // Required scope: `read:flows_vault_connections`. + List( + ctx context.Context, + request *managementv3.ListFlowsVaultConnectionsRequestParameters, + opts ...option.RequestOption, + ) (*FlowsVaultConnectionSummaryPage, error) + + // Delete a vault connection. + // + // Required scope: `delete:flows_vault_connections`. + Delete( + ctx context.Context, + id string, + opts ...option.RequestOption, + ) error +} diff --git a/internal/auth0/mock/flow_v3_mock.go b/internal/auth0/mock/flow_v3_mock.go new file mode 100644 index 000000000..13751f05c --- /dev/null +++ b/internal/auth0/mock/flow_v3_mock.go @@ -0,0 +1,201 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: flow_v3.go + +// Package mock is a generated GoMock package. +package mock + +import ( + context "context" + reflect "reflect" + + auth0 "github.com/auth0/auth0-cli/internal/auth0" + management "github.com/auth0/go-auth0/v3/management" + option "github.com/auth0/go-auth0/v3/management/option" + gomock "github.com/golang/mock/gomock" +) + +// MockFlowAPIV3 is a mock of FlowAPIV3 interface. +type MockFlowAPIV3 struct { + ctrl *gomock.Controller + recorder *MockFlowAPIV3MockRecorder +} + +// MockFlowAPIV3MockRecorder is the mock recorder for MockFlowAPIV3. +type MockFlowAPIV3MockRecorder struct { + mock *MockFlowAPIV3 +} + +// NewMockFlowAPIV3 creates a new mock instance. +func NewMockFlowAPIV3(ctrl *gomock.Controller) *MockFlowAPIV3 { + mock := &MockFlowAPIV3{ctrl: ctrl} + mock.recorder = &MockFlowAPIV3MockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockFlowAPIV3) EXPECT() *MockFlowAPIV3MockRecorder { + return m.recorder +} + +// Delete mocks base method. +func (m *MockFlowAPIV3) Delete(ctx context.Context, id string, opts ...option.RequestOption) error { + m.ctrl.T.Helper() + varargs := []interface{}{ctx, id} + for _, a := range opts { + varargs = append(varargs, a) + } + ret := m.ctrl.Call(m, "Delete", varargs...) + ret0, _ := ret[0].(error) + return ret0 +} + +// Delete indicates an expected call of Delete. +func (mr *MockFlowAPIV3MockRecorder) Delete(ctx, id interface{}, opts ...interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + varargs := append([]interface{}{ctx, id}, opts...) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Delete", reflect.TypeOf((*MockFlowAPIV3)(nil).Delete), varargs...) +} + +// List mocks base method. +func (m *MockFlowAPIV3) List(ctx context.Context, request *management.ListFlowsRequestParameters, opts ...option.RequestOption) (*auth0.FlowSummaryPage, error) { + m.ctrl.T.Helper() + varargs := []interface{}{ctx, request} + for _, a := range opts { + varargs = append(varargs, a) + } + ret := m.ctrl.Call(m, "List", varargs...) + ret0, _ := ret[0].(*auth0.FlowSummaryPage) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// List indicates an expected call of List. +func (mr *MockFlowAPIV3MockRecorder) List(ctx, request interface{}, opts ...interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + varargs := append([]interface{}{ctx, request}, opts...) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "List", reflect.TypeOf((*MockFlowAPIV3)(nil).List), varargs...) +} + +// MockFlowExecutionAPIV3 is a mock of FlowExecutionAPIV3 interface. +type MockFlowExecutionAPIV3 struct { + ctrl *gomock.Controller + recorder *MockFlowExecutionAPIV3MockRecorder +} + +// MockFlowExecutionAPIV3MockRecorder is the mock recorder for MockFlowExecutionAPIV3. +type MockFlowExecutionAPIV3MockRecorder struct { + mock *MockFlowExecutionAPIV3 +} + +// NewMockFlowExecutionAPIV3 creates a new mock instance. +func NewMockFlowExecutionAPIV3(ctrl *gomock.Controller) *MockFlowExecutionAPIV3 { + mock := &MockFlowExecutionAPIV3{ctrl: ctrl} + mock.recorder = &MockFlowExecutionAPIV3MockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockFlowExecutionAPIV3) EXPECT() *MockFlowExecutionAPIV3MockRecorder { + return m.recorder +} + +// Delete mocks base method. +func (m *MockFlowExecutionAPIV3) Delete(ctx context.Context, flowID, executionID string, opts ...option.RequestOption) error { + m.ctrl.T.Helper() + varargs := []interface{}{ctx, flowID, executionID} + for _, a := range opts { + varargs = append(varargs, a) + } + ret := m.ctrl.Call(m, "Delete", varargs...) + ret0, _ := ret[0].(error) + return ret0 +} + +// Delete indicates an expected call of Delete. +func (mr *MockFlowExecutionAPIV3MockRecorder) Delete(ctx, flowID, executionID interface{}, opts ...interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + varargs := append([]interface{}{ctx, flowID, executionID}, opts...) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Delete", reflect.TypeOf((*MockFlowExecutionAPIV3)(nil).Delete), varargs...) +} + +// List mocks base method. +func (m *MockFlowExecutionAPIV3) List(ctx context.Context, flowID string, request *management.ListFlowExecutionsRequestParameters, opts ...option.RequestOption) (*auth0.FlowExecutionSummaryPage, error) { + m.ctrl.T.Helper() + varargs := []interface{}{ctx, flowID, request} + for _, a := range opts { + varargs = append(varargs, a) + } + ret := m.ctrl.Call(m, "List", varargs...) + ret0, _ := ret[0].(*auth0.FlowExecutionSummaryPage) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// List indicates an expected call of List. +func (mr *MockFlowExecutionAPIV3MockRecorder) List(ctx, flowID, request interface{}, opts ...interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + varargs := append([]interface{}{ctx, flowID, request}, opts...) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "List", reflect.TypeOf((*MockFlowExecutionAPIV3)(nil).List), varargs...) +} + +// MockFlowVaultConnectionAPIV3 is a mock of FlowVaultConnectionAPIV3 interface. +type MockFlowVaultConnectionAPIV3 struct { + ctrl *gomock.Controller + recorder *MockFlowVaultConnectionAPIV3MockRecorder +} + +// MockFlowVaultConnectionAPIV3MockRecorder is the mock recorder for MockFlowVaultConnectionAPIV3. +type MockFlowVaultConnectionAPIV3MockRecorder struct { + mock *MockFlowVaultConnectionAPIV3 +} + +// NewMockFlowVaultConnectionAPIV3 creates a new mock instance. +func NewMockFlowVaultConnectionAPIV3(ctrl *gomock.Controller) *MockFlowVaultConnectionAPIV3 { + mock := &MockFlowVaultConnectionAPIV3{ctrl: ctrl} + mock.recorder = &MockFlowVaultConnectionAPIV3MockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockFlowVaultConnectionAPIV3) EXPECT() *MockFlowVaultConnectionAPIV3MockRecorder { + return m.recorder +} + +// Delete mocks base method. +func (m *MockFlowVaultConnectionAPIV3) Delete(ctx context.Context, id string, opts ...option.RequestOption) error { + m.ctrl.T.Helper() + varargs := []interface{}{ctx, id} + for _, a := range opts { + varargs = append(varargs, a) + } + ret := m.ctrl.Call(m, "Delete", varargs...) + ret0, _ := ret[0].(error) + return ret0 +} + +// Delete indicates an expected call of Delete. +func (mr *MockFlowVaultConnectionAPIV3MockRecorder) Delete(ctx, id interface{}, opts ...interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + varargs := append([]interface{}{ctx, id}, opts...) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Delete", reflect.TypeOf((*MockFlowVaultConnectionAPIV3)(nil).Delete), varargs...) +} + +// List mocks base method. +func (m *MockFlowVaultConnectionAPIV3) List(ctx context.Context, request *management.ListFlowsVaultConnectionsRequestParameters, opts ...option.RequestOption) (*auth0.FlowsVaultConnectionSummaryPage, error) { + m.ctrl.T.Helper() + varargs := []interface{}{ctx, request} + for _, a := range opts { + varargs = append(varargs, a) + } + ret := m.ctrl.Call(m, "List", varargs...) + ret0, _ := ret[0].(*auth0.FlowsVaultConnectionSummaryPage) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// List indicates an expected call of List. +func (mr *MockFlowVaultConnectionAPIV3MockRecorder) List(ctx, request interface{}, opts ...interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + varargs := append([]interface{}{ctx, request}, opts...) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "List", reflect.TypeOf((*MockFlowVaultConnectionAPIV3)(nil).List), varargs...) +} diff --git a/internal/cli/flows.go b/internal/cli/flows.go new file mode 100644 index 000000000..d0d99e49c --- /dev/null +++ b/internal/cli/flows.go @@ -0,0 +1,1247 @@ +package cli + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + + managementv3 "github.com/auth0/go-auth0/v3/management" + "github.com/auth0/go-auth0/v3/management/core" + "github.com/spf13/cobra" + + "github.com/auth0/auth0-cli/internal/ansi" + "github.com/auth0/auth0-cli/internal/prompt" +) + +// flowCreateSkeleton seeds the editor for interactive flow creation. The name is +// prompted separately, so the seed only carries the empty actions container. +const flowCreateSkeleton = `{ + "actions": [] +} +` + +const flowCreateExample = `{ + "name": "Enrich Profile", + "actions": [ + { + "id": "step_http", + "type": "HTTP", + "action": "SEND_REQUEST", + "allow_failure": false, + "mask_output": false, + "params": { + "method": "GET", + "url": "https://api.example.com/enrich", + "content_type": "JSON" + } + } + ] +} +` + +// flowServerManagedFields cannot be sent in create or update request bodies. +var flowServerManagedFields = []string{ + "id", + "created_at", + "updated_at", + "executed_at", +} + +// vaultConnectionServerManagedFields cannot be sent in update request bodies. +var vaultConnectionServerManagedFields = []string{ + "id", + "created_at", + "updated_at", + "refreshed_at", + "ready", + "fingerprint", +} + +var ( + flowID = Argument{ + Name: "Id", + Help: "Id of the Flow.", + } + + flowExecutionID = Argument{ + Name: "Execution Id", + Help: "Id of the Flow execution.", + } + + vaultConnectionID = Argument{ + Name: "Id", + Help: "Id of the Vault connection.", + } + + vaultAppID = Argument{ + Name: "App Id", + Help: "Identifier of the Vault app to open (e.g. AUTH0, JWT, HTTP, SLACK).", + } + + flowName = Flag{ + Name: "Name", + LongForm: "name", + Help: "Name of the Flow.", + } + + flowFile = Flag{ + Name: "File", + LongForm: "file", + ShortForm: "f", + Help: "Path to a JSON file with the flow body. Use '-' to read from stdin.", + } + + flowEdit = Flag{ + Name: "Edit", + LongForm: "edit", + Help: "Open an editor to author the flow graph after entering the name.", + } + + flowExample = Flag{ + Name: "Example", + LongForm: "example", + Help: "Print an example flow JSON body and exit.", + } + + flowHydrate = Flag{ + Name: "Hydrate", + LongForm: "hydrate", + Help: "Hydrate the response with the number of forms referencing each flow.", + } + + vaultConnectionName = Flag{ + Name: "Name", + LongForm: "name", + Help: "Name of the Vault connection.", + } + + vaultConnectionAppID = Flag{ + Name: "App Id", + LongForm: "app-id", + Help: "Identifier of the app the Vault connection integrates with (e.g. HTTP, SLACK).", + } + + vaultConnectionFile = Flag{ + Name: "File", + LongForm: "file", + ShortForm: "f", + Help: "Path to a JSON file with the vault connection body (including its setup secrets). Use '-' to read from stdin.", + } +) + +const vaultConnectionExample = `{ + "app_id": "HTTP", + "name": "My HTTP Connection", + "setup": { + "type": "BEARER", + "token": "REPLACE_WITH_YOUR_TOKEN" + } +} +` + +// vaultConnectionCreateSkeleton seeds the editor for interactive vault connection +// creation. The name and app id are prompted separately, so the seed only carries +// a provider-specific setup template for the user to edit. +const vaultConnectionCreateSkeleton = `{ + "setup": { + "type": "BEARER", + "token": "REPLACE_WITH_YOUR_TOKEN" + } +} +` + +func flowsCmd(cli *cli) *cobra.Command { + cmd := &cobra.Command{ + Use: "flows", + Short: "Manage Flows", + Long: "Flows let you orchestrate custom logic during authentication and other journeys, " + + "chaining actions such as HTTP requests and vault-backed integrations.", + } + + cmd.SetUsageTemplate(resourceUsageTemplate()) + cmd.AddCommand(listFlowsCmd(cli)) + cmd.AddCommand(showFlowCmd(cli)) + cmd.AddCommand(createFlowCmd(cli)) + cmd.AddCommand(updateFlowCmd(cli)) + cmd.AddCommand(deleteFlowCmd(cli)) + cmd.AddCommand(openFlowCmd(cli)) + cmd.AddCommand(flowExecutionsCmd(cli)) + cmd.AddCommand(flowVaultCmd(cli)) + + return cmd +} + +func listFlowsCmd(cli *cli) *cobra.Command { + var inputs struct { + Number int + Hydrate bool + Synchronous bool + } + + cmd := &cobra.Command{ + Use: "list", + Aliases: []string{"ls"}, + Args: cobra.NoArgs, + Short: "List your flows", + Long: "List your existing flows. To create one, run: `auth0 flows create`.", + Example: ` auth0 flows list + auth0 flows ls + auth0 flows ls --number 100 + auth0 flows ls --hydrate + auth0 flows ls --json`, + RunE: func(cmd *cobra.Command, args []string) error { + params := &managementv3.ListFlowsRequestParameters{} + if inputs.Hydrate { + params.Hydrate = []*managementv3.ListFlowsRequestParametersHydrateEnum{ + managementv3.ListFlowsRequestParametersHydrateEnumFormCount.Ptr(), + } + } + if cmd.Flags().Changed("synchronous") { + params.Synchronous = &inputs.Synchronous + } + + var flows []*managementv3.FlowSummary + if err := ansi.Waiting(func() (err error) { + flows, err = collectFlows(cmd.Context(), cli, params, inputs.Number) + return err + }); err != nil { + return fmt.Errorf("failed to list flows: %w", err) + } + + return cli.renderer.FlowsList(flows) + }, + } + + cmd.Flags().IntVarP(&inputs.Number, "number", "n", 100, "Number of flows to retrieve. Fetched across pages.") + flowHydrate.RegisterBool(cmd, &inputs.Hydrate, false) + cmd.Flags().BoolVar(&inputs.Synchronous, "synchronous", false, "Filter to synchronous (true) or asynchronous (false) flows.") + cmd.Flags().BoolVar(&cli.json, "json", false, "Output in json format.") + cmd.Flags().BoolVar(&cli.jsonCompact, "json-compact", false, "Output in compact json format.") + cmd.Flags().BoolVar(&cli.csv, "csv", false, "Output in csv format.") + cmd.MarkFlagsMutuallyExclusive("json", "json-compact", "csv") + + return cmd +} + +func showFlowCmd(cli *cli) *cobra.Command { + var inputs struct { + ID string + } + + cmd := &cobra.Command{ + Use: "show", + Args: cobra.MaximumNArgs(1), + Short: "Show a flow", + Long: "Display information about a flow.", + Example: ` auth0 flows show + auth0 flows show + auth0 flows show --json`, + RunE: func(cmd *cobra.Command, args []string) error { + if len(args) == 0 { + if err := flowID.Pick(cmd, &inputs.ID, cli.flowPickerOptions); err != nil { + return err + } + } else { + inputs.ID = args[0] + } + + flow, err := cli.flowRawGet(cmd.Context(), inputs.ID) + if err != nil { + return fmt.Errorf("failed to read flow with ID %q: %w", inputs.ID, err) + } + + return cli.renderer.FlowShowRaw(flow) + }, + } + + cmd.Flags().BoolVar(&cli.json, "json", false, "Output in json format.") + cmd.Flags().BoolVar(&cli.jsonCompact, "json-compact", false, "Output in compact json format.") + + return cmd +} + +func createFlowCmd(cli *cli) *cobra.Command { + var inputs struct { + Name string + File string + Edit bool + Example bool + } + + cmd := &cobra.Command{ + Use: "create", + Args: cobra.NoArgs, + Short: "Create a new flow", + Long: "Create a new flow.\n\n" + + "Interactive behavior: `auth0 flows create` asks only for the name and creates a minimal " + + "scaffold; it does not open an editor. Pass `--edit` to open an editor and author the flow " + + "actions before it is created, or supply the whole body via `--file` (or piped stdin) with " + + "an optional `--name` override. Run `auth0 flows create --example > flow.json` to generate " + + "an accepted file payload.", + Example: ` auth0 flows create + auth0 flows create --name "My Flow" + auth0 flows create --name "My Flow" --edit + auth0 flows create --example > flow.json + auth0 flows create --file ./flow.json + cat flow.json | auth0 flows create -f -`, + RunE: func(cmd *cobra.Command, args []string) error { + if inputs.Example { + cli.renderer.FlowExport(flowCreateExample) + return nil + } + + body, err := readBodyInput(inputs.File, "flow") + if err != nil { + return err + } + + rawBody := json.RawMessage(body) + if body == nil { + if err := flowName.Ask(cmd, &inputs.Name, nil); err != nil { + return err + } + if inputs.Name == "" { + return errors.New("a flow name is required; supply --name, provide --file, or pipe JSON via stdin") + } + if inputs.Edit { + if !canPrompt(cmd) { + return errors.New("the --edit flag requires an interactive terminal") + } + if err := editJSONBody(cli, "flow", flowCreateSkeleton, &rawBody); err != nil { + return err + } + } else { + rawBody = json.RawMessage(flowCreateSkeleton) + } + } + + rawBody, err = applyRawNameOverride(rawBody, inputs.Name) + if err != nil { + return fmt.Errorf("failed to parse flow body: %w", err) + } + + name, err := rawJSONStringField(rawBody, "name") + if err != nil { + return fmt.Errorf("failed to parse flow body: %w", err) + } + if name == "" { + return errors.New("a flow name is required; set it in the body or with --name") + } + + created, err := cli.flowRawCreate(cmd.Context(), rawBody) + if err != nil { + return fmt.Errorf("failed to create flow: %w", err) + } + return cli.renderer.FlowCreateRaw(created) + }, + } + + flowName.RegisterString(cmd, &inputs.Name, "") + flowFile.RegisterString(cmd, &inputs.File, "") + flowEdit.RegisterBool(cmd, &inputs.Edit, false) + flowExample.RegisterBool(cmd, &inputs.Example, false) + cmd.Flags().BoolVar(&cli.json, "json", false, "Output in json format.") + cmd.Flags().BoolVar(&cli.jsonCompact, "json-compact", false, "Output in compact json format.") + + return cmd +} + +func updateFlowCmd(cli *cli) *cobra.Command { + var inputs struct { + ID string + Name string + File string + } + + cmd := &cobra.Command{ + Use: "update", + Args: cobra.MaximumNArgs(1), + Short: "Update a flow", + Long: "Update a flow.\n\n" + + "Passing `--file` (or piped stdin) replaces every top-level field present in the file. " + + "Passing only `--name` performs a merge that preserves the flow's actions. Server-managed " + + "fields such as `id`, `created_at`, and `updated_at` are removed before the request is sent.", + Example: ` auth0 flows update --name "New Name" + auth0 flows update --file ./flow.json + cat flow.json | auth0 flows update -f -`, + RunE: func(cmd *cobra.Command, args []string) error { + if len(args) > 0 { + inputs.ID = args[0] + } else { + if err := flowID.Pick(cmd, &inputs.ID, cli.flowPickerOptions); err != nil { + return err + } + } + + body, err := readBodyInput(inputs.File, "flow") + if err != nil { + return err + } + + var rawBody json.RawMessage + + switch { + case body != nil: + rawBody, err = applyRawNameOverride(body, inputs.Name) + if err != nil { + return fmt.Errorf("failed to parse flow body: %w", err) + } + case inputs.Name != "": + rawBody, err = applyRawNameOverride(json.RawMessage(`{}`), inputs.Name) + if err != nil { + return fmt.Errorf("failed to build flow update: %w", err) + } + case canPrompt(cmd): + current, err := cli.flowRawGet(cmd.Context(), inputs.ID) + if err != nil { + return fmt.Errorf("failed to read flow with ID %q: %w", inputs.ID, err) + } + + var seed bytes.Buffer + if err := json.Indent(&seed, current, "", " "); err != nil { + return fmt.Errorf("failed to parse flow with ID %q: %w", inputs.ID, err) + } + + if err := editJSONBody(cli, "flow", seed.String(), &rawBody); err != nil { + return err + } + default: + return errors.New("nothing to update; supply --file, pipe JSON via stdin, or the --name flag") + } + + updated, err := cli.flowRawUpdate(cmd.Context(), inputs.ID, rawBody) + if err != nil { + return fmt.Errorf("failed to update flow with ID %q: %w", inputs.ID, err) + } + return cli.renderer.FlowUpdateRaw(updated) + }, + } + + flowName.RegisterStringU(cmd, &inputs.Name, "") + flowFile.RegisterStringU(cmd, &inputs.File, "") + cmd.Flags().BoolVar(&cli.json, "json", false, "Output in json format.") + cmd.Flags().BoolVar(&cli.jsonCompact, "json-compact", false, "Output in compact json format.") + + return cmd +} + +func deleteFlowCmd(cli *cli) *cobra.Command { + cmd := &cobra.Command{ + Use: "delete", + Aliases: []string{"rm"}, + Args: cobra.ArbitraryArgs, + Short: "Delete a flow", + Long: "Delete a flow.\n\n" + + "To delete interactively, use `auth0 flows delete` with no arguments.\n\n" + + "To delete non-interactively, supply the flow id and the `--force` flag to skip confirmation.", + Example: ` auth0 flows delete + auth0 flows rm + auth0 flows delete + auth0 flows delete --force + auth0 flows delete `, + RunE: func(cmd *cobra.Command, args []string) error { + var ids []string + if len(args) == 0 { + if err := flowID.PickMany(cmd, &ids, cli.flowPickerOptions); err != nil { + return err + } + } else { + ids = args + } + + if !cli.force && cli.agentMode { + return errDestructiveNoConfirm + } + + if !cli.force && canPrompt(cmd) { + if confirmed := prompt.Confirm("Are you sure you want to proceed?"); !confirmed { + return nil + } + } + + return ansi.ProgressBar("Deleting flow(s)", ids, func(_ int, id string) error { + if id == "" { + return nil + } + if err := cli.apiv3.Flow.Delete(cmd.Context(), id); err != nil { + return fmt.Errorf("failed to delete flow with ID %q: %w", id, err) + } + return nil + }) + }, + } + + cmd.Flags().BoolVar(&cli.force, "force", false, "Skip confirmation.") + + return cmd +} + +// --- Executions ---. + +func flowExecutionsCmd(cli *cli) *cobra.Command { + cmd := &cobra.Command{ + Use: "executions", + Short: "Manage Flow executions", + Long: "Inspect the runtime executions produced when a flow runs.", + } + + cmd.SetUsageTemplate(resourceUsageTemplate()) + cmd.AddCommand(listFlowExecutionsCmd(cli)) + cmd.AddCommand(showFlowExecutionCmd(cli)) + cmd.AddCommand(deleteFlowExecutionCmd(cli)) + + return cmd +} + +func listFlowExecutionsCmd(cli *cli) *cobra.Command { + var inputs struct { + FlowID string + Number int + From string + Take int + } + + cmd := &cobra.Command{ + Use: "list", + Aliases: []string{"ls"}, + Args: cobra.MaximumNArgs(1), + Short: "List a flow's executions", + Long: "List the executions produced by a flow.", + Example: ` auth0 flows executions list + auth0 flows executions ls --number 100 + auth0 flows executions list --json`, + RunE: func(cmd *cobra.Command, args []string) error { + if len(args) > 0 { + inputs.FlowID = args[0] + } else { + if err := flowID.Pick(cmd, &inputs.FlowID, cli.flowPickerOptions); err != nil { + return err + } + } + + params := &managementv3.ListFlowExecutionsRequestParameters{} + if inputs.From != "" { + params.From = &inputs.From + } + if cmd.Flags().Changed("take") { + params.Take = &inputs.Take + } + + var executions []*managementv3.FlowExecutionSummary + if err := ansi.Waiting(func() (err error) { + executions, err = collectFlowExecutions(cmd.Context(), cli, inputs.FlowID, params, inputs.Number) + return err + }); err != nil { + return fmt.Errorf("failed to list flow executions: %w", err) + } + + return cli.renderer.FlowExecutionsList(executions) + }, + } + + cmd.Flags().IntVarP(&inputs.Number, "number", "n", 100, "Number of executions to retrieve. Fetched across pages.") + cmd.Flags().StringVar(&inputs.From, "from", "", "Cursor id from which to start selection.") + cmd.Flags().IntVar(&inputs.Take, "take", 0, "Number of executions to retrieve per page.") + cmd.Flags().BoolVar(&cli.json, "json", false, "Output in json format.") + cmd.Flags().BoolVar(&cli.jsonCompact, "json-compact", false, "Output in compact json format.") + cmd.Flags().BoolVar(&cli.csv, "csv", false, "Output in csv format.") + cmd.MarkFlagsMutuallyExclusive("json", "json-compact", "csv") + + return cmd +} + +func showFlowExecutionCmd(cli *cli) *cobra.Command { + var inputs struct { + FlowID string + ExecutionID string + } + + cmd := &cobra.Command{ + Use: "show", + Args: cobra.MaximumNArgs(2), + Short: "Show a flow execution", + Long: "Display information about a flow execution.", + Example: ` auth0 flows executions show + auth0 flows executions show --json`, + RunE: func(cmd *cobra.Command, args []string) error { + if len(args) > 0 { + inputs.FlowID = args[0] + } else { + if err := flowID.Pick(cmd, &inputs.FlowID, cli.flowPickerOptions); err != nil { + return err + } + } + + if len(args) > 1 { + inputs.ExecutionID = args[1] + } else { + if err := flowExecutionID.Pick(cmd, &inputs.ExecutionID, cli.flowExecutionPickerOptions(inputs.FlowID)); err != nil { + return err + } + } + + execution, err := cli.flowExecutionRawGet(cmd.Context(), inputs.FlowID, inputs.ExecutionID) + if err != nil { + return fmt.Errorf("failed to read flow execution with ID %q: %w", inputs.ExecutionID, err) + } + + return cli.renderer.FlowExecutionShowRaw(execution) + }, + } + + cmd.Flags().BoolVar(&cli.json, "json", false, "Output in json format.") + cmd.Flags().BoolVar(&cli.jsonCompact, "json-compact", false, "Output in compact json format.") + + return cmd +} + +func deleteFlowExecutionCmd(cli *cli) *cobra.Command { + var inputs struct { + FlowID string + } + + cmd := &cobra.Command{ + Use: "delete", + Aliases: []string{"rm"}, + Args: cobra.ArbitraryArgs, + Short: "Delete a flow execution", + Long: "Delete one or more executions of a flow.\n\n" + + "Supply the flow id followed by the execution ids. Use `--force` to skip confirmation.", + Example: ` auth0 flows executions delete + auth0 flows executions rm --force + auth0 flows executions delete `, + RunE: func(cmd *cobra.Command, args []string) error { + if len(args) > 0 { + inputs.FlowID = args[0] + } else { + if err := flowID.Pick(cmd, &inputs.FlowID, cli.flowPickerOptions); err != nil { + return err + } + } + + var ids []string + if len(args) > 1 { + ids = args[1:] + } else { + if err := flowExecutionID.PickMany(cmd, &ids, cli.flowExecutionPickerOptions(inputs.FlowID)); err != nil { + return err + } + } + + if !cli.force && cli.agentMode { + return errDestructiveNoConfirm + } + + if !cli.force && canPrompt(cmd) { + if confirmed := prompt.Confirm("Are you sure you want to proceed?"); !confirmed { + return nil + } + } + + return ansi.ProgressBar("Deleting flow execution(s)", ids, func(_ int, id string) error { + if id == "" { + return nil + } + if err := cli.apiv3.FlowExecution.Delete(cmd.Context(), inputs.FlowID, id); err != nil { + return fmt.Errorf("failed to delete flow execution with ID %q: %w", id, err) + } + return nil + }) + }, + } + + cmd.Flags().BoolVar(&cli.force, "force", false, "Skip confirmation.") + + return cmd +} + +// --- Vault connections ---. + +func flowVaultCmd(cli *cli) *cobra.Command { + cmd := &cobra.Command{ + Use: "vault", + Short: "Manage Flow vault connections", + Long: "Manage the vault connections that store credentials for flow integrations.", + } + + cmd.SetUsageTemplate(resourceUsageTemplate()) + cmd.AddCommand(flowVaultConnectionsCmd(cli)) + cmd.AddCommand(openVaultAppCmd(cli)) + + return cmd +} + +func flowVaultConnectionsCmd(cli *cli) *cobra.Command { + cmd := &cobra.Command{ + Use: "connections", + Short: "Manage Flow vault connections", + Long: "List, inspect, create, update, and delete flow vault connections.", + } + + cmd.SetUsageTemplate(resourceUsageTemplate()) + cmd.AddCommand(listVaultConnectionsCmd(cli)) + cmd.AddCommand(showVaultConnectionCmd(cli)) + cmd.AddCommand(createVaultConnectionCmd(cli)) + cmd.AddCommand(updateVaultConnectionCmd(cli)) + cmd.AddCommand(deleteVaultConnectionCmd(cli)) + + return cmd +} + +func listVaultConnectionsCmd(cli *cli) *cobra.Command { + var inputs struct { + Number int + } + + cmd := &cobra.Command{ + Use: "list", + Aliases: []string{"ls"}, + Args: cobra.NoArgs, + Short: "List your vault connections", + Long: "List your existing vault connections. To create one, run: `auth0 flows vault connections create`.", + Example: ` auth0 flows vault connections list + auth0 flows vault connections ls --number 100 + auth0 flows vault connections ls --json`, + RunE: func(cmd *cobra.Command, args []string) error { + params := &managementv3.ListFlowsVaultConnectionsRequestParameters{} + + var connections []*managementv3.FlowsVaultConnectionSummary + if err := ansi.Waiting(func() (err error) { + connections, err = collectVaultConnections(cmd.Context(), cli, params, inputs.Number) + return err + }); err != nil { + return fmt.Errorf("failed to list vault connections: %w", err) + } + + return cli.renderer.FlowVaultConnectionsList(connections) + }, + } + + cmd.Flags().IntVarP(&inputs.Number, "number", "n", 100, "Number of connections to retrieve. Fetched across pages.") + cmd.Flags().BoolVar(&cli.json, "json", false, "Output in json format.") + cmd.Flags().BoolVar(&cli.jsonCompact, "json-compact", false, "Output in compact json format.") + cmd.Flags().BoolVar(&cli.csv, "csv", false, "Output in csv format.") + cmd.MarkFlagsMutuallyExclusive("json", "json-compact", "csv") + + return cmd +} + +func showVaultConnectionCmd(cli *cli) *cobra.Command { + var inputs struct { + ID string + } + + cmd := &cobra.Command{ + Use: "show", + Args: cobra.MaximumNArgs(1), + Short: "Show a vault connection", + Long: "Display information about a vault connection. Secret values are never returned by the API.", + Example: ` auth0 flows vault connections show + auth0 flows vault connections show + auth0 flows vault connections show --json`, + RunE: func(cmd *cobra.Command, args []string) error { + if len(args) == 0 { + if err := vaultConnectionID.Pick(cmd, &inputs.ID, cli.vaultConnectionPickerOptions); err != nil { + return err + } + } else { + inputs.ID = args[0] + } + + connection, err := cli.vaultConnectionRawGet(cmd.Context(), inputs.ID) + if err != nil { + return fmt.Errorf("failed to read vault connection with ID %q: %w", inputs.ID, err) + } + + return cli.renderer.FlowVaultConnectionShowRaw("vault connection", connection) + }, + } + + cmd.Flags().BoolVar(&cli.json, "json", false, "Output in json format.") + cmd.Flags().BoolVar(&cli.jsonCompact, "json-compact", false, "Output in compact json format.") + + return cmd +} + +func createVaultConnectionCmd(cli *cli) *cobra.Command { + var inputs struct { + Name string + AppID string + File string + Example bool + } + + cmd := &cobra.Command{ + Use: "create", + Args: cobra.NoArgs, + Short: "Create a new vault connection", + Long: "Create a new vault connection.\n\n" + + "Interactive behavior: `auth0 flows vault connections create` asks for the name and app id, " + + "then opens an editor seeded with a provider-specific `setup` template so you can enter the " + + "connection secrets. Alternatively, supply the whole body (including its `setup` secrets) via " + + "`--file` (or piped stdin); `--name` and `--app-id` override the corresponding fields after the " + + "file is parsed. Run `auth0 flows vault connections create --example` to print a template.", + Example: ` auth0 flows vault connections create + auth0 flows vault connections create --file ./connection.json + auth0 flows vault connections create --file ./connection.json --name "My Connection" + auth0 flows vault connections create --example > connection.json + cat connection.json | auth0 flows vault connections create -f -`, + RunE: func(cmd *cobra.Command, args []string) error { + if inputs.Example { + cli.renderer.FlowExport(vaultConnectionExample) + return nil + } + + body, err := readBodyInput(inputs.File, "vault connection") + if err != nil { + return err + } + + var rawBody json.RawMessage + if body != nil { + rawBody, err = applyRawVaultConnectionOverrides(body, inputs.Name, inputs.AppID) + if err != nil { + return fmt.Errorf("failed to parse vault connection body: %w", err) + } + } else { + if !canPrompt(cmd) { + return errors.New("no vault connection body provided; supply --file or pipe JSON via stdin") + } + if err := vaultConnectionName.Ask(cmd, &inputs.Name, nil); err != nil { + return err + } + if err := vaultConnectionAppID.Ask(cmd, &inputs.AppID, nil); err != nil { + return err + } + if inputs.Name == "" || inputs.AppID == "" { + return errors.New("a vault connection name and app id are required") + } + if err := editJSONBody(cli, "vault connection", vaultConnectionCreateSkeleton, &rawBody); err != nil { + return err + } + rawBody, err = applyRawVaultConnectionOverrides(rawBody, inputs.Name, inputs.AppID) + if err != nil { + return fmt.Errorf("failed to parse vault connection body: %w", err) + } + } + + created, err := cli.vaultConnectionRawCreate(cmd.Context(), rawBody) + if err != nil { + return fmt.Errorf("failed to create vault connection: %w", err) + } + + return cli.renderer.FlowVaultConnectionShowRaw("vault connection created", created) + }, + } + + vaultConnectionName.RegisterString(cmd, &inputs.Name, "") + vaultConnectionAppID.RegisterString(cmd, &inputs.AppID, "") + vaultConnectionFile.RegisterString(cmd, &inputs.File, "") + flowExample.RegisterBool(cmd, &inputs.Example, false) + cmd.Flags().BoolVar(&cli.json, "json", false, "Output in json format.") + cmd.Flags().BoolVar(&cli.jsonCompact, "json-compact", false, "Output in compact json format.") + + return cmd +} + +func updateVaultConnectionCmd(cli *cli) *cobra.Command { + var inputs struct { + ID string + Name string + File string + } + + cmd := &cobra.Command{ + Use: "update", + Args: cobra.MaximumNArgs(1), + Short: "Update a vault connection", + Long: "Update a vault connection.\n\n" + + "Passing `--file` (or piped stdin) replaces every top-level field present in the file. " + + "Passing only `--name` performs a merge. Server-managed fields such as `id`, `ready`, and " + + "`fingerprint` are removed before the request is sent.", + Example: ` auth0 flows vault connections update --name "New Name" + auth0 flows vault connections update --file ./connection.json + cat connection.json | auth0 flows vault connections update -f -`, + RunE: func(cmd *cobra.Command, args []string) error { + if len(args) > 0 { + inputs.ID = args[0] + } else { + if err := vaultConnectionID.Pick(cmd, &inputs.ID, cli.vaultConnectionPickerOptions); err != nil { + return err + } + } + + body, err := readBodyInput(inputs.File, "vault connection") + if err != nil { + return err + } + + var rawBody json.RawMessage + switch { + case body != nil: + rawBody, err = applyRawNameOverride(body, inputs.Name) + if err != nil { + return fmt.Errorf("failed to parse vault connection body: %w", err) + } + case inputs.Name != "": + rawBody, err = applyRawNameOverride(json.RawMessage(`{}`), inputs.Name) + if err != nil { + return fmt.Errorf("failed to build vault connection update: %w", err) + } + default: + return errors.New("nothing to update; supply --file, pipe JSON via stdin, or the --name flag") + } + + updated, err := cli.vaultConnectionRawUpdate(cmd.Context(), inputs.ID, rawBody) + if err != nil { + return fmt.Errorf("failed to update vault connection with ID %q: %w", inputs.ID, err) + } + + return cli.renderer.FlowVaultConnectionShowRaw("vault connection updated", updated) + }, + } + + vaultConnectionName.RegisterStringU(cmd, &inputs.Name, "") + vaultConnectionFile.RegisterStringU(cmd, &inputs.File, "") + cmd.Flags().BoolVar(&cli.json, "json", false, "Output in json format.") + cmd.Flags().BoolVar(&cli.jsonCompact, "json-compact", false, "Output in compact json format.") + + return cmd +} + +func deleteVaultConnectionCmd(cli *cli) *cobra.Command { + cmd := &cobra.Command{ + Use: "delete", + Aliases: []string{"rm"}, + Args: cobra.ArbitraryArgs, + Short: "Delete a vault connection", + Long: "Delete a vault connection.\n\n" + + "To delete interactively, use `auth0 flows vault connections delete` with no arguments.\n\n" + + "To delete non-interactively, supply the connection id and the `--force` flag.", + Example: ` auth0 flows vault connections delete + auth0 flows vault connections rm + auth0 flows vault connections delete + auth0 flows vault connections delete --force`, + RunE: func(cmd *cobra.Command, args []string) error { + var ids []string + if len(args) == 0 { + if err := vaultConnectionID.PickMany(cmd, &ids, cli.vaultConnectionPickerOptions); err != nil { + return err + } + } else { + ids = args + } + + if !cli.force && cli.agentMode { + return errDestructiveNoConfirm + } + + if !cli.force && canPrompt(cmd) { + if confirmed := prompt.Confirm("Are you sure you want to proceed?"); !confirmed { + return nil + } + } + + return ansi.ProgressBar("Deleting vault connection(s)", ids, func(_ int, id string) error { + if id == "" { + return nil + } + if err := cli.apiv3.FlowVaultConnection.Delete(cmd.Context(), id); err != nil { + return fmt.Errorf("failed to delete vault connection with ID %q: %w", id, err) + } + return nil + }) + }, + } + + cmd.Flags().BoolVar(&cli.force, "force", false, "Skip confirmation.") + + return cmd +} + +// --- Open in Dashboard ---. + +func openFlowCmd(cli *cli) *cobra.Command { + var inputs struct { + ID string + } + + cmd := &cobra.Command{ + Use: "open", + Args: cobra.MaximumNArgs(1), + Short: "Open a flow in the Auth0 Dashboard", + Long: "Open a flow's page in the Auth0 Dashboard flow builder.", + Example: ` auth0 flows open + auth0 flows open `, + RunE: func(cmd *cobra.Command, args []string) error { + if len(args) == 0 { + if err := flowID.Pick(cmd, &inputs.ID, cli.flowPickerOptions); err != nil { + return err + } + } else { + inputs.ID = args[0] + } + + openBuilderURL(cli, fmt.Sprintf("flows/%s/edit", inputs.ID)) + + return nil + }, + } + + return cmd +} + +func openVaultAppCmd(cli *cli) *cobra.Command { + var inputs struct { + AppID string + } + + cmd := &cobra.Command{ + Use: "open", + Args: cobra.MaximumNArgs(1), + Short: "Open the Vault in the Auth0 Dashboard", + Long: "Open a Vault app's page in the Auth0 Dashboard. This opens the app's Vault page " + + "(for example AUTH0, JWT, HTTP, or SLACK), not a specific connection.", + Example: ` auth0 flows vault open + auth0 flows vault open HTTP + auth0 flows vault open SLACK`, + RunE: func(cmd *cobra.Command, args []string) error { + if len(args) == 0 { + if err := vaultAppID.Pick(cmd, &inputs.AppID, cli.vaultAppPickerOptions); err != nil { + return err + } + } else { + inputs.AppID = args[0] + } + + openBuilderURL(cli, fmt.Sprintf("vault/apps/%s/edit", inputs.AppID)) + + return nil + }, + } + + return cmd +} + +// vaultAppPickerOptions offers the distinct app ids among existing vault +// connections, so `auth0 flows vault open` can be run without arguments. +func (c *cli) vaultAppPickerOptions(ctx context.Context) (pickerOptions, error) { + connections, err := collectVaultConnections(ctx, c, &managementv3.ListFlowsVaultConnectionsRequestParameters{}, 0) + if err != nil { + return nil, err + } + + seen := map[string]bool{} + var opts pickerOptions + for _, conn := range connections { + appID := conn.GetAppID() + if appID == "" || seen[appID] { + continue + } + seen[appID] = true + opts = append(opts, pickerOption{value: appID, label: appID}) + } + + if len(opts) == 0 { + return nil, errors.New("there are no vault apps to choose from; supply an app id, e.g. `auth0 flows vault open HTTP`") + } + + return opts, nil +} + +// --- Raw HTTP helpers ---. + +// flowRawGet fetches a flow through the v1 client's HTTP layer without using the +// v3 SDK's lossy flow-action unions. +func (c *cli) flowRawGet(ctx context.Context, id string) (json.RawMessage, error) { + return c.rawJSONRequest(ctx, http.MethodGet, c.api.HTTPClient.URI("flows", id), nil) +} + +func (c *cli) flowRawCreate(ctx context.Context, body json.RawMessage) (json.RawMessage, error) { + return c.rawJSONRequest(ctx, http.MethodPost, c.api.HTTPClient.URI("flows"), body) +} + +func (c *cli) flowRawUpdate(ctx context.Context, id string, body json.RawMessage) (json.RawMessage, error) { + cleanBody, err := stripRawFields(body, flowServerManagedFields) + if err != nil { + return nil, err + } + return c.rawJSONRequest(ctx, http.MethodPatch, c.api.HTTPClient.URI("flows", id), cleanBody) +} + +func (c *cli) flowExecutionRawGet(ctx context.Context, flowID, executionID string) (json.RawMessage, error) { + return c.rawJSONRequest(ctx, http.MethodGet, c.api.HTTPClient.URI("flows", flowID, "executions", executionID), nil) +} + +func (c *cli) vaultConnectionRawGet(ctx context.Context, id string) (json.RawMessage, error) { + return c.rawJSONRequest(ctx, http.MethodGet, c.api.HTTPClient.URI("flows", "vault", "connections", id), nil) +} + +func (c *cli) vaultConnectionRawCreate(ctx context.Context, body json.RawMessage) (json.RawMessage, error) { + return c.rawJSONRequest(ctx, http.MethodPost, c.api.HTTPClient.URI("flows", "vault", "connections"), body) +} + +func (c *cli) vaultConnectionRawUpdate(ctx context.Context, id string, body json.RawMessage) (json.RawMessage, error) { + cleanBody, err := stripRawFields(body, vaultConnectionServerManagedFields) + if err != nil { + return nil, err + } + return c.rawJSONRequest(ctx, http.MethodPatch, c.api.HTTPClient.URI("flows", "vault", "connections", id), cleanBody) +} + +// applyRawVaultConnectionOverrides overlays the --name and --app-id scalar flags +// on a vault connection body. +func applyRawVaultConnectionOverrides(body json.RawMessage, name, appID string) (json.RawMessage, error) { + var obj map[string]json.RawMessage + if err := json.Unmarshal(body, &obj); err != nil { + return nil, err + } + if obj == nil { + return nil, errors.New("vault connection body must be a JSON object") + } + + if name != "" { + encoded, err := json.Marshal(name) + if err != nil { + return nil, err + } + obj["name"] = encoded + } + if appID != "" { + encoded, err := json.Marshal(appID) + if err != nil { + return nil, err + } + obj["app_id"] = encoded + } + + return json.Marshal(obj) +} + +// --- Paging + pickers ---. + +func collectFlows(ctx context.Context, cli *cli, params *managementv3.ListFlowsRequestParameters, limit int) ([]*managementv3.FlowSummary, error) { + page, err := cli.apiv3.Flow.List(ctx, params) + if err != nil { + return nil, err + } + + var out []*managementv3.FlowSummary + for page != nil { + for _, f := range page.Results { + out = append(out, f) + if limit > 0 && len(out) >= limit { + return out, nil + } + } + + page, err = page.GetNextPage(ctx) + if errors.Is(err, core.ErrNoPages) { + break + } + if err != nil { + return out, err + } + } + + return out, nil +} + +func collectFlowExecutions(ctx context.Context, cli *cli, flowID string, params *managementv3.ListFlowExecutionsRequestParameters, limit int) ([]*managementv3.FlowExecutionSummary, error) { + page, err := cli.apiv3.FlowExecution.List(ctx, flowID, params) + if err != nil { + return nil, err + } + + var out []*managementv3.FlowExecutionSummary + for page != nil { + for _, e := range page.Results { + out = append(out, e) + if limit > 0 && len(out) >= limit { + return out, nil + } + } + + page, err = page.GetNextPage(ctx) + if errors.Is(err, core.ErrNoPages) { + break + } + if err != nil { + return out, err + } + } + + return out, nil +} + +func collectVaultConnections(ctx context.Context, cli *cli, params *managementv3.ListFlowsVaultConnectionsRequestParameters, limit int) ([]*managementv3.FlowsVaultConnectionSummary, error) { + page, err := cli.apiv3.FlowVaultConnection.List(ctx, params) + if err != nil { + return nil, err + } + + var out []*managementv3.FlowsVaultConnectionSummary + for page != nil { + for _, c := range page.Results { + out = append(out, c) + if limit > 0 && len(out) >= limit { + return out, nil + } + } + + page, err = page.GetNextPage(ctx) + if errors.Is(err, core.ErrNoPages) { + break + } + if err != nil { + return out, err + } + } + + return out, nil +} + +func (c *cli) flowPickerOptions(ctx context.Context) (pickerOptions, error) { + flows, err := collectFlows(ctx, c, &managementv3.ListFlowsRequestParameters{}, 0) + if err != nil { + return nil, err + } + + var opts pickerOptions + for _, f := range flows { + label := fmt.Sprintf("%s %s", f.GetName(), ansi.Faint("("+f.GetID()+")")) + opts = append(opts, pickerOption{value: f.GetID(), label: label}) + } + + if len(opts) == 0 { + return nil, errors.New("there are currently no flows to choose from. Create one by running: `auth0 flows create`") + } + + return opts, nil +} + +// flowExecutionPickerOptions returns a picker over the executions of a specific +// flow, so it must be bound to the flow id before use. +func (c *cli) flowExecutionPickerOptions(flowID string) pickerOptionsFunc { + return func(ctx context.Context) (pickerOptions, error) { + executions, err := collectFlowExecutions(ctx, c, flowID, &managementv3.ListFlowExecutionsRequestParameters{}, 0) + if err != nil { + return nil, err + } + + var opts pickerOptions + for _, e := range executions { + label := fmt.Sprintf("%s %s", e.GetStatus(), ansi.Faint("("+e.GetID()+")")) + opts = append(opts, pickerOption{value: e.GetID(), label: label}) + } + + if len(opts) == 0 { + return nil, errors.New("this flow has no executions to choose from") + } + + return opts, nil + } +} diff --git a/internal/cli/flows_test.go b/internal/cli/flows_test.go new file mode 100644 index 000000000..785ab4dfd --- /dev/null +++ b/internal/cli/flows_test.go @@ -0,0 +1,272 @@ +package cli + +import ( + "bytes" + "encoding/json" + "io" + "net/http" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/auth0/auth0-cli/internal/auth0" + "github.com/auth0/auth0-cli/internal/config" + "github.com/auth0/auth0-cli/internal/display" +) + +func newRawTestCLI(stub *formHTTPClientStub, stdout *bytes.Buffer) *cli { + return &cli{ + api: &auth0.API{HTTPClient: stub}, + renderer: &display.Renderer{ + MessageWriter: io.Discard, + ResultWriter: stdout, + }, + } +} + +func TestApplyRawNameOverride(t *testing.T) { + body := json.RawMessage(`{"name":"Original","actions":[{"id":"a1","type":"HTTP"}]}`) + + got, err := applyRawNameOverride(body, "Renamed") + require.NoError(t, err) + + var obj map[string]json.RawMessage + require.NoError(t, json.Unmarshal(got, &obj)) + assert.JSONEq(t, `"Renamed"`, string(obj["name"])) + assert.Contains(t, string(obj["actions"]), `"HTTP"`) +} + +func TestApplyRawNameOverrideNoopWhenEmpty(t *testing.T) { + body := json.RawMessage(`{"actions":[]}`) + + got, err := applyRawNameOverride(body, "") + require.NoError(t, err) + assert.Equal(t, body, got) +} + +func TestApplyRawNameOverrideRejectsNonObject(t *testing.T) { + _, err := applyRawNameOverride(json.RawMessage(`[]`), "New") + assert.ErrorContains(t, err, "cannot unmarshal array") +} + +func TestApplyRawVaultConnectionOverrides(t *testing.T) { + body := json.RawMessage(`{"app_id":"HTTP","name":"Original","setup":{"type":"BEARER","token":"secret"}}`) + + got, err := applyRawVaultConnectionOverrides(body, "Renamed", "SLACK") + require.NoError(t, err) + + var obj map[string]json.RawMessage + require.NoError(t, json.Unmarshal(got, &obj)) + assert.JSONEq(t, `"Renamed"`, string(obj["name"])) + assert.JSONEq(t, `"SLACK"`, string(obj["app_id"])) + assert.Contains(t, string(obj["setup"]), `"token"`) +} + +func TestStripRawFields(t *testing.T) { + body := json.RawMessage(`{"id":"f1","name":"Flow","created_at":"x","actions":[]}`) + + got, err := stripRawFields(body, flowServerManagedFields) + require.NoError(t, err) + + var obj map[string]json.RawMessage + require.NoError(t, json.Unmarshal(got, &obj)) + _, hasID := obj["id"] + _, hasCreated := obj["created_at"] + assert.False(t, hasID) + assert.False(t, hasCreated) + assert.Contains(t, string(obj["name"]), "Flow") +} + +func TestFormatBuilderPageURL(t *testing.T) { + cfg := &config.Config{ + Tenants: config.Tenants{ + "example.us.auth0.com": {Name: "example"}, + "my-tenant.eu.auth0.com": {Name: "my-tenant"}, + "dev-tti06f6y.auth0.com": {Name: "dev-tti06f6y"}, + "no-name.us.auth0.com": {Name: ""}, + }, + } + + tests := []struct { + name string + tenant string + path string + expected string + }{ + { + name: "builds a flow edit URL", + tenant: "example.us.auth0.com", + path: "flows/af_123/edit", + expected: "https://forms.auth0.com/tenants/us/example/flows/af_123/edit", + }, + { + name: "builds a vault app URL in a non-us region", + tenant: "my-tenant.eu.auth0.com", + path: "vault/apps/HTTP/edit", + expected: "https://forms.auth0.com/tenants/eu/my-tenant/vault/apps/HTTP/edit", + }, + { + name: "defaults to us for a three-part PUS1 domain", + tenant: "dev-tti06f6y.auth0.com", + path: "vault/apps/JWT/edit", + expected: "https://forms.auth0.com/tenants/us/dev-tti06f6y/vault/apps/JWT/edit", + }, + { + name: "returns empty when the path is missing", + tenant: "example.us.auth0.com", + path: "", + expected: "", + }, + { + name: "returns empty when the tenant is missing", + tenant: "", + path: "flows/af_123/edit", + expected: "", + }, + { + name: "returns empty when the domain has too few parts", + tenant: "invalid", + path: "flows/af_123/edit", + expected: "", + }, + { + name: "returns empty when the tenant name is unknown", + tenant: "no-name.us.auth0.com", + path: "flows/af_123/edit", + expected: "", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + assert.Equal(t, test.expected, formatBuilderPageURL(test.tenant, cfg, test.path)) + }) + } +} + +func TestCreateFlowCmdScaffoldFromName(t *testing.T) { + stub := &formHTTPClientStub{ + response: json.RawMessage(`{"id":"flow_1","name":"My Flow","actions":[]}`), + } + stdout := &bytes.Buffer{} + c := newRawTestCLI(stub, stdout) + + cmd := createFlowCmd(c) + cmd.SetArgs([]string{"--name", "My Flow"}) + + require.NoError(t, cmd.Execute()) + assert.Equal(t, http.MethodPost, stub.method) + require.IsType(t, json.RawMessage{}, stub.payload) + assert.JSONEq(t, `{"name":"My Flow","actions":[]}`, string(stub.payload.(json.RawMessage))) + assert.Contains(t, stdout.String(), "My Flow") +} + +func TestCreateFlowCmdFromFilePreservesActions(t *testing.T) { + body := []byte(`{"name":"Rich Flow","actions":[{"id":"a1","type":"HTTP","action":"SEND_REQUEST","params":{"method":"GET","url":"https://x.test"}}]}`) + path := filepath.Join(t.TempDir(), "flow.json") + require.NoError(t, os.WriteFile(path, body, 0600)) + + stub := &formHTTPClientStub{ + response: json.RawMessage(`{"id":"flow_2","name":"Rich Flow","actions":[{"id":"a1","type":"HTTP"}]}`), + } + stdout := &bytes.Buffer{} + c := newRawTestCLI(stub, stdout) + + cmd := createFlowCmd(c) + cmd.SetArgs([]string{"--file", path}) + + require.NoError(t, cmd.Execute()) + assert.Equal(t, http.MethodPost, stub.method) + require.IsType(t, json.RawMessage{}, stub.payload) + assert.Contains(t, string(stub.payload.(json.RawMessage)), `"SEND_REQUEST"`) + assert.Contains(t, stdout.String(), "1 actions") +} + +func TestUpdateFlowCmdNameOnlyMergePreservesActions(t *testing.T) { + stub := &formHTTPClientStub{ + response: json.RawMessage(`{"id":"flow_3","name":"New Name","actions":[{"id":"a1","type":"HTTP"}]}`), + } + stdout := &bytes.Buffer{} + c := newRawTestCLI(stub, stdout) + + cmd := updateFlowCmd(c) + cmd.SetArgs([]string{"flow_3", "--name", "New Name"}) + + require.NoError(t, cmd.Execute()) + assert.Equal(t, http.MethodPatch, stub.method) + require.IsType(t, json.RawMessage{}, stub.payload) + // A name-only merge must send only the name so the API preserves the actions graph. + assert.JSONEq(t, `{"name":"New Name"}`, string(stub.payload.(json.RawMessage))) +} + +func TestUpdateFlowCmdFileStripsServerManagedFields(t *testing.T) { + body := []byte(`{"id":"flow_4","name":"Flow","created_at":"2020-01-01","actions":[]}`) + path := filepath.Join(t.TempDir(), "flow.json") + require.NoError(t, os.WriteFile(path, body, 0600)) + + stub := &formHTTPClientStub{ + response: json.RawMessage(`{"id":"flow_4","name":"Flow","actions":[]}`), + } + stdout := &bytes.Buffer{} + c := newRawTestCLI(stub, stdout) + + cmd := updateFlowCmd(c) + cmd.SetArgs([]string{"flow_4", "--file", path}) + + require.NoError(t, cmd.Execute()) + require.IsType(t, json.RawMessage{}, stub.payload) + sent := string(stub.payload.(json.RawMessage)) + assert.NotContains(t, sent, `"id"`) + assert.NotContains(t, sent, `"created_at"`) + assert.Contains(t, sent, `"actions"`) +} + +func TestCreateVaultConnectionCmdUsesRawClient(t *testing.T) { + body := []byte(`{"app_id":"HTTP","name":"Conn","setup":{"type":"BEARER","token":"secret"}}`) + path := filepath.Join(t.TempDir(), "conn.json") + require.NoError(t, os.WriteFile(path, body, 0600)) + + stub := &formHTTPClientStub{ + response: json.RawMessage(`{"id":"ac_1","app_id":"HTTP","name":"Renamed","ready":true}`), + } + stdout := &bytes.Buffer{} + c := newRawTestCLI(stub, stdout) + + cmd := createVaultConnectionCmd(c) + cmd.SetArgs([]string{"--file", path, "--name", "Renamed"}) + + require.NoError(t, cmd.Execute()) + assert.Equal(t, http.MethodPost, stub.method) + require.IsType(t, json.RawMessage{}, stub.payload) + sent := string(stub.payload.(json.RawMessage)) + assert.Contains(t, sent, `"Renamed"`) + assert.Contains(t, sent, `"setup"`) + // The rendered output must never echo the setup secrets back. + assert.NotContains(t, stdout.String(), "secret") +} + +func TestUpdateVaultConnectionCmdStripsServerFields(t *testing.T) { + body := []byte(`{"id":"ac_2","name":"Conn","ready":true,"fingerprint":"abc","setup":{"token":"secret"}}`) + path := filepath.Join(t.TempDir(), "conn.json") + require.NoError(t, os.WriteFile(path, body, 0600)) + + stub := &formHTTPClientStub{ + response: json.RawMessage(`{"id":"ac_2","name":"Conn","ready":true}`), + } + stdout := &bytes.Buffer{} + c := newRawTestCLI(stub, stdout) + + cmd := updateVaultConnectionCmd(c) + cmd.SetArgs([]string{"ac_2", "--file", path}) + + require.NoError(t, cmd.Execute()) + require.IsType(t, json.RawMessage{}, stub.payload) + sent := string(stub.payload.(json.RawMessage)) + assert.NotContains(t, sent, `"id"`) + assert.NotContains(t, sent, `"ready"`) + assert.NotContains(t, sent, `"fingerprint"`) + assert.Contains(t, sent, `"setup"`) +} diff --git a/internal/cli/forms.go b/internal/cli/forms.go index f2a5cedc4..b86d83094 100644 --- a/internal/cli/forms.go +++ b/internal/cli/forms.go @@ -6,19 +6,14 @@ import ( "encoding/json" "errors" "fmt" - "io" "net/http" "os" - "strings" managementv3 "github.com/auth0/go-auth0/v3/management" "github.com/auth0/go-auth0/v3/management/core" - "github.com/pkg/browser" "github.com/spf13/cobra" "github.com/auth0/auth0-cli/internal/ansi" - "github.com/auth0/auth0-cli/internal/config" - "github.com/auth0/auth0-cli/internal/iostream" "github.com/auth0/auth0-cli/internal/prompt" ) @@ -290,7 +285,7 @@ func createFormCmd(cli *cli) *cobra.Command { return nil } - body, err := readFormBody(inputs.File) + body, err := readBodyInput(inputs.File, "form") if err != nil { return err } @@ -309,7 +304,7 @@ func createFormCmd(cli *cli) *cobra.Command { if !canPrompt(cmd) { return errors.New("the --edit flag requires an interactive terminal") } - if err := editFormJSON(cli, formCreateSkeleton, &rawBody); err != nil { + if err := editJSONBody(cli, "form", formCreateSkeleton, &rawBody); err != nil { return err } } else { @@ -327,7 +322,7 @@ func createFormCmd(cli *cli) *cobra.Command { return fmt.Errorf("failed to parse form body: %w", err) } - name, err := rawFormStringField(rawBody, "name") + name, err := rawJSONStringField(rawBody, "name") if err != nil { return fmt.Errorf("failed to parse form body: %w", err) } @@ -343,7 +338,7 @@ func createFormCmd(cli *cli) *cobra.Command { return err } - id, err := rawFormStringField(created, "id") + id, err := rawJSONStringField(created, "id") if err != nil { return fmt.Errorf("failed to parse created form: %w", err) } @@ -394,7 +389,7 @@ func updateFormCmd(cli *cli) *cobra.Command { } } - body, err := readFormBody(inputs.File) + body, err := readBodyInput(inputs.File, "form") if err != nil { return err } @@ -455,7 +450,7 @@ func updateFormCmd(cli *cli) *cobra.Command { return fmt.Errorf("failed to parse form with ID %q: %w", inputs.ID, err) } - if err := editFormJSON(cli, seed.String(), &rawBody); err != nil { + if err := editJSONBody(cli, "form", seed.String(), &rawBody); err != nil { return err } default: @@ -625,7 +620,7 @@ func importFormCmd(cli *cli) *cobra.Command { auth0 forms import --file ./form.json --connection '#CONN-1#=ac_123' cat form.json | auth0 forms import -f -`, RunE: func(cmd *cobra.Command, args []string) error { - body, err := readFormBody(inputs.File) + body, err := readBodyInput(inputs.File, "form") if err != nil { return err } @@ -714,76 +709,10 @@ func openFormCmd(cli *cli) *cobra.Command { return cmd } -// formsBuilderURL is the host for the Auth0 Forms visual builder. Forms live on a -// dedicated host rather than under the main management dashboard. -const formsBuilderURL = "https://forms.auth0.com" - // openFormEditURL opens the form's builder page in a browser, or prints the URL // when interactivity is disabled. func openFormEditURL(cli *cli, id string) { - url := formatFormEditURL(cli.Config.DefaultTenant, &cli.Config, id) - if url == "" { - cli.renderer.Warnf("Failed to format the correct URL, please ensure you have run 'auth0 login' and try again.") - return - } - - if cli.noInput { - cli.renderer.Infof("Open the following URL in a browser: %s", url) - return - } - - if err := browser.OpenURL(url); err != nil { - cli.renderer.Warnf("Couldn't open the URL, please do it manually: %s", url) - } -} - -// formatFormEditURL builds the Forms builder URL, deriving the region and tenant -// name the same way formatManageTenantURL does for the management dashboard. -func formatFormEditURL(tenant string, cfg *config.Config, id string) string { - if len(tenant) == 0 || len(id) == 0 { - return "" - } - - s := strings.Split(tenant, ".") - if len(s) < 3 { - return "" - } - - region := "us" // A PUS1 tenant looks like dev-tti06f6y.auth0.com (3 parts). - if len(s) > 3 { - region = s[len(s)-3] - } - - tenantName := cfg.Tenants[tenant].Name - if len(tenantName) == 0 { - return "" - } - - return fmt.Sprintf("%s/tenants/%s/%s/forms/%s/edit", formsBuilderURL, region, tenantName, id) -} - -// editFormJSON opens an editor seeded with `seed` and unmarshals the result into -// `target`. When the buffer is not valid JSON it re-opens the editor with the -// user's edits intact rather than discarding them, so a typo never costs work. -func editFormJSON(cli *cli, seed string, target interface{}) error { - content := seed - for { - var edited string - if err := openCreateEditor(&edited, content, "form.*.json", nil, nil); err != nil { - return err - } - - if err := json.Unmarshal([]byte(edited), target); err != nil { - cli.renderer.Warnf("The form body is not valid JSON: %s", err) - if !prompt.Confirm("Re-open the editor to fix it?") { - return errors.New("aborted; the form was not saved") - } - content = edited - continue - } - - return nil - } + openBuilderURL(cli, fmt.Sprintf("forms/%s/edit", id)) } // formNextStepsHint prints follow-up commands after a form is created or updated. @@ -796,30 +725,6 @@ func formNextStepsHint(cli *cli, id string) { cli.renderer.Infof("Edit it in the dashboard with: %s", ansi.Faint("auth0 forms open "+id)) } -// readFormBody resolves a JSON body from an explicit --file, "-"/piped stdin, and -// returns nil when no such source is available so the caller can decide whether to -// fall back to an editor or error. -func readFormBody(filePath string) ([]byte, error) { - if filePath == "-" { - data, err := io.ReadAll(iostream.Input) - if err != nil { - return nil, fmt.Errorf("failed to read form body from stdin: %w", err) - } - return data, nil - } - if filePath != "" { - data, err := os.ReadFile(filePath) - if err != nil { - return nil, fmt.Errorf("failed to read form file %q: %w", filePath, err) - } - return data, nil - } - if piped := iostream.PipedInput(); len(piped) > 0 { - return piped, nil - } - return nil, nil -} - // applyRawFormOverrides applies scalar flag overrides without deserializing the // form graph into the v3 SDK's lossy union types. func applyRawFormOverrides(body json.RawMessage, name, primary, def string) (json.RawMessage, error) { @@ -870,98 +775,27 @@ func applyRawFormOverrides(body json.RawMessage, name, primary, def string) (jso return json.Marshal(form) } -func rawFormStringField(body json.RawMessage, field string) (string, error) { - var form map[string]json.RawMessage - if err := json.Unmarshal(body, &form); err != nil { - return "", err - } - if form == nil { - return "", errors.New("form body must be a JSON object") - } - - raw, ok := form[field] - if !ok || string(raw) == "null" { - return "", nil - } - var value string - if err := json.Unmarshal(raw, &value); err != nil { - return "", fmt.Errorf("%s must be a string: %w", field, err) - } - return value, nil -} - // formRawGet fetches a form through the v1 client's HTTP layer without using // the v3 SDK's lossy form-node unions. func (c *cli) formRawGet(ctx context.Context, id string) (json.RawMessage, error) { - return c.formRawRequest(ctx, http.MethodGet, c.api.HTTPClient.URI("forms", id), nil) + return c.rawJSONRequest(ctx, http.MethodGet, c.api.HTTPClient.URI("forms", id), nil) } // formRawCreate creates a form from raw JSON, preserving node config that the // typed CreateFormRequestContent would drop. It returns the created form JSON. func (c *cli) formRawCreate(ctx context.Context, body json.RawMessage) (json.RawMessage, error) { - return c.formRawRequest(ctx, http.MethodPost, c.api.HTTPClient.URI("forms"), body) + return c.rawJSONRequest(ctx, http.MethodPost, c.api.HTTPClient.URI("forms"), body) } // formRawUpdate replaces a form from raw JSON, preserving node config that the // typed UpdateFormRequestContent would drop. It returns the updated form JSON. func (c *cli) formRawUpdate(ctx context.Context, id string, body json.RawMessage) (json.RawMessage, error) { - var form map[string]json.RawMessage - if err := json.Unmarshal(body, &form); err != nil { - return nil, err - } - for _, field := range formServerManagedFields { - delete(form, field) - } - cleanBody, err := json.Marshal(form) - if err != nil { - return nil, err - } - - return c.formRawRequest(ctx, http.MethodPatch, c.api.HTTPClient.URI("forms", id), cleanBody) -} - -// formRawRequest sends a raw JSON request to the Management API and returns the -// response body, surfacing API errors the same way the `api` command does. -func (c *cli) formRawRequest( - ctx context.Context, - method string, - uri string, - body json.RawMessage, -) (json.RawMessage, error) { - var payload interface{} - if len(body) > 0 { - payload = body - } - - request, err := c.api.HTTPClient.NewRequest(ctx, method, uri, payload) + cleanBody, err := stripRawFields(body, formServerManagedFields) if err != nil { return nil, err } - var out json.RawMessage - if err := ansi.Waiting(func() error { - response, err := c.api.HTTPClient.Do(request) - if err != nil { - return err - } - defer func() { - _ = response.Body.Close() - }() - - data, err := io.ReadAll(response.Body) - if err != nil { - return err - } - if response.StatusCode >= http.StatusBadRequest { - return newAPIResponseError(response.StatusCode, response.Header, data) - } - out = data - return nil - }); err != nil { - return nil, err - } - - return out, nil + return c.rawJSONRequest(ctx, http.MethodPatch, c.api.HTTPClient.URI("forms", id), cleanBody) } // collectForms pages through the forms list, collecting up to `limit` results diff --git a/internal/cli/forms_test.go b/internal/cli/forms_test.go index 8f8610efb..0d0cf687c 100644 --- a/internal/cli/forms_test.go +++ b/internal/cli/forms_test.go @@ -21,7 +21,6 @@ import ( "github.com/auth0/auth0-cli/internal/auth0" "github.com/auth0/auth0-cli/internal/auth0/mock" - "github.com/auth0/auth0-cli/internal/config" "github.com/auth0/auth0-cli/internal/display" "github.com/auth0/auth0-cli/internal/iostream" ) @@ -230,20 +229,20 @@ func TestUpdateFormCmdUsesRawClientForRichFile(t *testing.T) { assert.Contains(t, stdout.String(), "1 nodes") } -func TestReadFormBody(t *testing.T) { +func TestReadBodyInput(t *testing.T) { t.Run("reads from a file", func(t *testing.T) { dir := t.TempDir() path := filepath.Join(dir, "form.json") want := []byte(`{"name":"My Form"}`) assert.NoError(t, os.WriteFile(path, want, 0600)) - got, err := readFormBody(path) + got, err := readBodyInput(path, "form") assert.NoError(t, err) assert.Equal(t, want, got) }) t.Run("errors on a missing file", func(t *testing.T) { - _, err := readFormBody(filepath.Join(t.TempDir(), "missing.json")) + _, err := readBodyInput(filepath.Join(t.TempDir(), "missing.json"), "form") assert.ErrorContains(t, err, "failed to read form file") }) @@ -261,7 +260,7 @@ func TestReadFormBody(t *testing.T) { iostream.Input = f defer func() { iostream.Input = original }() - got, err := readFormBody("-") + got, err := readBodyInput("-", "form") assert.NoError(t, err) assert.Equal(t, want, got) }) @@ -419,73 +418,6 @@ func TestCollectForms(t *testing.T) { }) } -func TestFormatFormEditURL(t *testing.T) { - cfg := &config.Config{ - Tenants: config.Tenants{ - "example.us.auth0.com": {Name: "example"}, - "my-tenant.eu.auth0.com": {Name: "my-tenant"}, - "dev-tti06f6y.auth0.com": {Name: "dev-tti06f6y"}, - "no-name.us.auth0.com": {Name: ""}, - }, - } - - tests := []struct { - name string - tenant string - id string - expected string - }{ - { - name: "derives the region from a four-part domain", - tenant: "example.us.auth0.com", - id: "ap_123", - expected: "https://forms.auth0.com/tenants/us/example/forms/ap_123/edit", - }, - { - name: "supports non-us regions", - tenant: "my-tenant.eu.auth0.com", - id: "ap_456", - expected: "https://forms.auth0.com/tenants/eu/my-tenant/forms/ap_456/edit", - }, - { - name: "defaults to us for a three-part PUS1 domain", - tenant: "dev-tti06f6y.auth0.com", - id: "ap_789", - expected: "https://forms.auth0.com/tenants/us/dev-tti06f6y/forms/ap_789/edit", - }, - { - name: "returns empty when the tenant is unknown", - tenant: "example.us.auth0.com", - id: "", - expected: "", - }, - { - name: "returns empty when the tenant is missing", - tenant: "", - id: "ap_123", - expected: "", - }, - { - name: "returns empty when the domain has too few parts", - tenant: "invalid", - id: "ap_123", - expected: "", - }, - { - name: "returns empty when the tenant name is unknown", - tenant: "no-name.us.auth0.com", - id: "ap_123", - expected: "", - }, - } - - for _, test := range tests { - t.Run(test.name, func(t *testing.T) { - assert.Equal(t, test.expected, formatFormEditURL(test.tenant, cfg, test.id)) - }) - } -} - type formHTTPClientStub struct { method string payload interface{} diff --git a/internal/cli/root.go b/internal/cli/root.go index cc7596a37..4339bbb80 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -281,6 +281,7 @@ func addSubCommands(rootCmd *cobra.Command, cli *cli) { rootCmd.AddCommand(terraformCmd(cli)) rootCmd.AddCommand(eventStreamsCmd(cli)) rootCmd.AddCommand(formsCmd(cli)) + rootCmd.AddCommand(flowsCmd(cli)) rootCmd.AddCommand(networkACLCmd(cli)) rootCmd.AddCommand(tenantSettingsCmd(cli)) rootCmd.AddCommand(tokenExchangeCmd(cli)) diff --git a/internal/cli/utils_shared.go b/internal/cli/utils_shared.go index eafbff7f9..af6656679 100644 --- a/internal/cli/utils_shared.go +++ b/internal/cli/utils_shared.go @@ -5,9 +5,12 @@ import ( "crypto/rand" "encoding/base64" "encoding/json" + "errors" "fmt" + "io" "net/http" "net/url" + "os" "strconv" "strings" "time" @@ -21,6 +24,7 @@ import ( "github.com/auth0/auth0-cli/internal/auth/authutil" "github.com/auth0/auth0-cli/internal/auth0" "github.com/auth0/auth0-cli/internal/config" + "github.com/auth0/auth0-cli/internal/iostream" "github.com/auth0/auth0-cli/internal/prompt" ) @@ -468,3 +472,209 @@ func collectV3Pages[C comparable, T any, R any]( return items, err } + +// --- Shared raw-JSON + builder helpers ---. +// +// Forms and Flows share a hybrid approach: they use the v3 SDK for list/delete +// but send create/update/show through the raw HTTP layer, because the typed +// request bodies drop provider-specific config in their union types. The helpers +// below are the resource-agnostic pieces of that approach. + +// formsBuilderURL is the host for the Auth0 Forms and Flows visual builders. Both +// live on a dedicated host rather than under the main management dashboard. +const formsBuilderURL = "https://forms.auth0.com" + +// rawJSONRequest sends a raw JSON request to the Management API and returns the +// response body, surfacing API errors the same way the `api` command does. +func (c *cli) rawJSONRequest( + ctx context.Context, + method string, + uri string, + body json.RawMessage, +) (json.RawMessage, error) { + var payload interface{} + if len(body) > 0 { + payload = body + } + + request, err := c.api.HTTPClient.NewRequest(ctx, method, uri, payload) + if err != nil { + return nil, err + } + + var out json.RawMessage + if err := ansi.Waiting(func() error { + response, err := c.api.HTTPClient.Do(request) + if err != nil { + return err + } + defer func() { + _ = response.Body.Close() + }() + + data, err := io.ReadAll(response.Body) + if err != nil { + return err + } + if response.StatusCode >= http.StatusBadRequest { + return newAPIResponseError(response.StatusCode, response.Header, data) + } + out = data + return nil + }); err != nil { + return nil, err + } + + return out, nil +} + +// readBodyInput resolves a JSON body from an explicit --file, "-"/piped stdin, +// and returns nil when no such source is available so the caller can decide +// whether to fall back to an editor or error. `resource` names the object in +// error messages (e.g. "flow", "form", "vault connection"). +func readBodyInput(filePath, resource string) ([]byte, error) { + if filePath == "-" { + data, err := io.ReadAll(iostream.Input) + if err != nil { + return nil, fmt.Errorf("failed to read %s body from stdin: %w", resource, err) + } + return data, nil + } + if filePath != "" { + data, err := os.ReadFile(filePath) + if err != nil { + return nil, fmt.Errorf("failed to read %s file %q: %w", resource, filePath, err) + } + return data, nil + } + if piped := iostream.PipedInput(); len(piped) > 0 { + return piped, nil + } + return nil, nil +} + +// applyRawNameOverride sets the top-level "name" field when a non-empty override +// is supplied, without deserializing the rest of the body. +func applyRawNameOverride(body json.RawMessage, name string) (json.RawMessage, error) { + if name == "" { + return body, nil + } + + var obj map[string]json.RawMessage + if err := json.Unmarshal(body, &obj); err != nil { + return nil, err + } + if obj == nil { + return nil, errors.New("body must be a JSON object") + } + + encoded, err := json.Marshal(name) + if err != nil { + return nil, err + } + obj["name"] = encoded + + return json.Marshal(obj) +} + +// stripRawFields removes the given top-level fields from a JSON object body. +func stripRawFields(body json.RawMessage, fields []string) (json.RawMessage, error) { + var obj map[string]json.RawMessage + if err := json.Unmarshal(body, &obj); err != nil { + return nil, err + } + for _, field := range fields { + delete(obj, field) + } + return json.Marshal(obj) +} + +// rawJSONStringField extracts a top-level string field from a raw JSON object, +// returning an empty string when the field is absent or null. +func rawJSONStringField(body json.RawMessage, field string) (string, error) { + var obj map[string]json.RawMessage + if err := json.Unmarshal(body, &obj); err != nil { + return "", err + } + if obj == nil { + return "", errors.New("body must be a JSON object") + } + + raw, ok := obj[field] + if !ok || string(raw) == "null" { + return "", nil + } + var value string + if err := json.Unmarshal(raw, &value); err != nil { + return "", fmt.Errorf("%s must be a string: %w", field, err) + } + return value, nil +} + +// editJSONBody opens an editor seeded with `seed` and unmarshals the result into +// `target`, re-opening on invalid JSON so a typo never costs the user's edits. +// `resource` names the object in prompts and error messages. +func editJSONBody(cli *cli, resource, seed string, target interface{}) error { + content := seed + for { + var edited string + if err := openCreateEditor(&edited, content, resource+".*.json", nil, nil); err != nil { + return err + } + + if err := json.Unmarshal([]byte(edited), target); err != nil { + cli.renderer.Warnf("The %s body is not valid JSON: %s", resource, err) + if !prompt.Confirm("Re-open the editor to fix it?") { + return fmt.Errorf("aborted; the %s was not saved", resource) + } + content = edited + continue + } + + return nil + } +} + +// openBuilderURL opens a Forms/Flows builder page in a browser, or prints the URL +// when interactivity is disabled. +func openBuilderURL(cli *cli, path string) { + url := formatBuilderPageURL(cli.Config.DefaultTenant, &cli.Config, path) + if url == "" { + cli.renderer.Warnf("Failed to format the correct URL, please ensure you have run 'auth0 login' and try again.") + return + } + + if cli.noInput { + cli.renderer.Infof("Open the following URL in a browser: %s", url) + return + } + + if err := browser.OpenURL(url); err != nil { + cli.renderer.Warnf("Couldn't open the URL, please do it manually: %s", url) + } +} + +// formatBuilderPageURL builds a Forms/Flows builder URL for the given path, +// deriving the region and tenant name from the configured tenant. +func formatBuilderPageURL(tenant string, cfg *config.Config, path string) string { + if len(tenant) == 0 || len(path) == 0 { + return "" + } + + s := strings.Split(tenant, ".") + if len(s) < 3 { + return "" + } + + region := "us" // A PUS1 tenant looks like dev-tti06f6y.auth0.com (3 parts). + if len(s) > 3 { + region = s[len(s)-3] + } + + tenantName := cfg.Tenants[tenant].Name + if len(tenantName) == 0 { + return "" + } + + return fmt.Sprintf("%s/tenants/%s/%s/%s", formsBuilderURL, region, tenantName, path) +} diff --git a/internal/display/flows.go b/internal/display/flows.go new file mode 100644 index 000000000..c4975dbb9 --- /dev/null +++ b/internal/display/flows.go @@ -0,0 +1,382 @@ +package display + +import ( + "encoding/json" + "fmt" + "time" + + managementv3 "github.com/auth0/go-auth0/v3/management" + + "github.com/auth0/auth0-cli/internal/ansi" +) + +// --- Flows ---. + +type flowView struct { + ID string + Name string + ActionCount int + CreatedAt string + UpdatedAt string + ExecutedAt string + + raw interface{} +} + +func (v *flowView) AsTableHeader() []string { + return []string{"ID", "Name", "Executed At", "Updated"} +} + +func (v *flowView) AsTableRow() []string { + return []string{ansi.Faint(v.ID), v.Name, v.ExecutedAt, v.UpdatedAt} +} + +func (v *flowView) KeyValues() [][]string { + return [][]string{ + {"ID", ansi.Faint(v.ID)}, + {"NAME", v.Name}, + {"ACTIONS", fmt.Sprintf("%d actions", v.ActionCount)}, + {"CREATED AT", v.CreatedAt}, + {"UPDATED AT", v.UpdatedAt}, + } +} + +func (v *flowView) Object() interface{} { + return v.raw +} + +type flowSummaryView struct { + ID string + Name string + UpdatedAt string + ExecutedAt string + + raw interface{} +} + +func (v *flowSummaryView) AsTableHeader() []string { + return []string{"ID", "Name", "Executed At", "Updated"} +} + +func (v *flowSummaryView) AsTableRow() []string { + return []string{ansi.Faint(v.ID), v.Name, v.ExecutedAt, v.UpdatedAt} +} + +func (v *flowSummaryView) Object() interface{} { + return v.raw +} + +// FlowsList renders the list of flows. +func (r *Renderer) FlowsList(flows []*managementv3.FlowSummary) error { + resource := "flows" + + r.Heading(resource) + + if len(flows) == 0 { + r.EmptyState(resource, "Use 'auth0 flows create' to add one") + return nil + } + + var res []View + for _, f := range flows { + res = append(res, &flowSummaryView{ + ID: f.GetID(), + Name: f.GetName(), + UpdatedAt: timeAgo(f.GetUpdatedAt()), + ExecutedAt: f.GetExecutedAt(), + raw: mergeExtraProperties(f, f.GetExtraProperties()), + }) + } + + r.Results(res) + + return nil +} + +// FlowShowRaw renders a full-fidelity flow response read through the v1 HTTP +// client, avoiding the v3 SDK's lossy flow-action unions. +func (r *Renderer) FlowShowRaw(flow json.RawMessage) error { + return r.renderRawFlow("flow", flow) +} + +// FlowCreateRaw renders a full-fidelity create response. +func (r *Renderer) FlowCreateRaw(flow json.RawMessage) error { + return r.renderRawFlow("flow created", flow) +} + +// FlowUpdateRaw renders a full-fidelity update response. +func (r *Renderer) FlowUpdateRaw(flow json.RawMessage) error { + return r.renderRawFlow("flow updated", flow) +} + +func (r *Renderer) renderRawFlow(heading string, flow json.RawMessage) error { + view, err := makeFlowViewFromRaw(flow) + if err != nil { + return fmt.Errorf("failed to parse flow response: %w", err) + } + r.Heading(heading) + r.Result(view) + return nil +} + +func makeFlowViewFromRaw(raw json.RawMessage) (*flowView, error) { + var flow struct { + ID string `json:"id"` + Name string `json:"name"` + Actions []json.RawMessage `json:"actions"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + ExecutedAt string `json:"executed_at"` + } + if err := json.Unmarshal(raw, &flow); err != nil { + return nil, err + } + + return &flowView{ + ID: flow.ID, + Name: flow.Name, + ActionCount: len(flow.Actions), + CreatedAt: rawTimeAgo(flow.CreatedAt), + UpdatedAt: rawTimeAgo(flow.UpdatedAt), + ExecutedAt: flow.ExecutedAt, + raw: raw, + }, nil +} + +// FlowExport writes a flow body verbatim (uncolored) to the result writer so it +// stays pipe- and import-friendly. +func (r *Renderer) FlowExport(body string) { + fmt.Fprintln(r.ResultWriter, body) +} + +// --- Flow executions ---. + +type flowExecutionView struct { + ID string + Status string + TraceID string + StartedAt string + EndedAt string + CreatedAt string + UpdatedAt string + + raw interface{} +} + +func (v *flowExecutionView) AsTableHeader() []string { + return []string{"ID", "Status", "Started", "Ended"} +} + +func (v *flowExecutionView) AsTableRow() []string { + return []string{ansi.Faint(v.ID), v.Status, v.StartedAt, v.EndedAt} +} + +func (v *flowExecutionView) KeyValues() [][]string { + kvs := [][]string{ + {"ID", ansi.Faint(v.ID)}, + {"STATUS", v.Status}, + {"TRACE ID", v.TraceID}, + } + if v.StartedAt != "" { + kvs = append(kvs, []string{"STARTED AT", v.StartedAt}) + } + if v.EndedAt != "" { + kvs = append(kvs, []string{"ENDED AT", v.EndedAt}) + } + kvs = append(kvs, + []string{"CREATED AT", v.CreatedAt}, + []string{"UPDATED AT", v.UpdatedAt}, + ) + return kvs +} + +func (v *flowExecutionView) Object() interface{} { + return v.raw +} + +// FlowExecutionsList renders the list of flow executions. +func (r *Renderer) FlowExecutionsList(executions []*managementv3.FlowExecutionSummary) error { + resource := "flow executions" + + r.Heading(resource) + + if len(executions) == 0 { + r.EmptyState(resource, "This flow has not been executed yet") + return nil + } + + var res []View + for _, e := range executions { + res = append(res, &flowExecutionView{ + ID: e.GetID(), + Status: e.GetStatus(), + TraceID: e.GetTraceID(), + StartedAt: rawTimeAgo(e.GetStartedAt()), + EndedAt: rawTimeAgo(e.GetEndedAt()), + CreatedAt: rawTimeAgo(e.GetCreatedAt()), + UpdatedAt: rawTimeAgo(e.GetUpdatedAt()), + raw: mergeExtraProperties(e, e.GetExtraProperties()), + }) + } + + r.Results(res) + + return nil +} + +// FlowExecutionShowRaw renders a full-fidelity execution response read through +// the v1 HTTP client. +func (r *Renderer) FlowExecutionShowRaw(execution json.RawMessage) error { + view, err := makeFlowExecutionViewFromRaw(execution) + if err != nil { + return fmt.Errorf("failed to parse flow execution response: %w", err) + } + r.Heading("flow execution") + r.Result(view) + return nil +} + +func makeFlowExecutionViewFromRaw(raw json.RawMessage) (*flowExecutionView, error) { + var execution struct { + ID string `json:"id"` + Status string `json:"status"` + TraceID string `json:"trace_id"` + StartedAt time.Time `json:"started_at"` + EndedAt time.Time `json:"ended_at"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + } + if err := json.Unmarshal(raw, &execution); err != nil { + return nil, err + } + + return &flowExecutionView{ + ID: execution.ID, + Status: execution.Status, + TraceID: execution.TraceID, + StartedAt: rawTimeAgo(execution.StartedAt), + EndedAt: rawTimeAgo(execution.EndedAt), + CreatedAt: rawTimeAgo(execution.CreatedAt), + UpdatedAt: rawTimeAgo(execution.UpdatedAt), + raw: raw, + }, nil +} + +// --- Flow vault connections ---. + +type flowVaultConnectionView struct { + ID string + Name string + AppID string + Ready bool + AccountName string + CreatedAt string + UpdatedAt string + + raw interface{} +} + +func (v *flowVaultConnectionView) AsTableHeader() []string { + return []string{"ID", "Name", "App", "Ready"} +} + +func (v *flowVaultConnectionView) AsTableRow() []string { + return []string{ansi.Faint(v.ID), v.Name, v.AppID, boolToPresence(v.Ready)} +} + +func (v *flowVaultConnectionView) KeyValues() [][]string { + kvs := [][]string{ + {"ID", ansi.Faint(v.ID)}, + {"NAME", v.Name}, + {"APP ID", v.AppID}, + {"READY", boolToReady(v.Ready)}, + } + if v.AccountName != "" { + kvs = append(kvs, []string{"ACCOUNT NAME", v.AccountName}) + } + kvs = append(kvs, + []string{"CREATED AT", v.CreatedAt}, + []string{"UPDATED AT", v.UpdatedAt}, + ) + return kvs +} + +func (v *flowVaultConnectionView) Object() interface{} { + return v.raw +} + +// FlowVaultConnectionsList renders the list of vault connections. +func (r *Renderer) FlowVaultConnectionsList(connections []*managementv3.FlowsVaultConnectionSummary) error { + resource := "flow vault connections" + + r.Heading(resource) + + if len(connections) == 0 { + r.EmptyState(resource, "Use 'auth0 flows vault connections create' to add one") + return nil + } + + var res []View + for _, c := range connections { + res = append(res, &flowVaultConnectionView{ + ID: c.GetID(), + Name: c.GetName(), + AppID: c.GetAppID(), + Ready: c.GetReady(), + AccountName: c.GetAccountName(), + CreatedAt: rawTimeAgo(c.GetCreatedAt()), + UpdatedAt: rawTimeAgo(c.GetUpdatedAt()), + raw: mergeExtraProperties(c, c.GetExtraProperties()), + }) + } + + r.Results(res) + + return nil +} + +// FlowVaultConnectionShowRaw renders a full-fidelity vault connection response. +// The Management API never returns the write-only `setup` secrets, so nothing is +// masked here; the CLI simply never echoes the create/update body. +func (r *Renderer) FlowVaultConnectionShowRaw(heading string, connection json.RawMessage) error { + view, err := makeFlowVaultConnectionViewFromRaw(connection) + if err != nil { + return fmt.Errorf("failed to parse vault connection response: %w", err) + } + r.Heading(heading) + r.Result(view) + return nil +} + +func makeFlowVaultConnectionViewFromRaw(raw json.RawMessage) (*flowVaultConnectionView, error) { + var connection struct { + ID string `json:"id"` + Name string `json:"name"` + AppID string `json:"app_id"` + Ready bool `json:"ready"` + AccountName string `json:"account_name"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + } + if err := json.Unmarshal(raw, &connection); err != nil { + return nil, err + } + + return &flowVaultConnectionView{ + ID: connection.ID, + Name: connection.Name, + AppID: connection.AppID, + Ready: connection.Ready, + AccountName: connection.AccountName, + CreatedAt: rawTimeAgo(connection.CreatedAt), + UpdatedAt: rawTimeAgo(connection.UpdatedAt), + raw: raw, + }, nil +} + +func boolToReady(ready bool) string { + if ready { + return "yes" + } + return "no" +} diff --git a/test/integration/fixtures/update-flow.json b/test/integration/fixtures/update-flow.json new file mode 100644 index 000000000..b9c4b91de --- /dev/null +++ b/test/integration/fixtures/update-flow.json @@ -0,0 +1,4 @@ +{ + "name": "integration-test-flow-fixture-updated", + "actions": [] +} diff --git a/test/integration/fixtures/update-vault-connection.json b/test/integration/fixtures/update-vault-connection.json new file mode 100644 index 000000000..0bfeff00d --- /dev/null +++ b/test/integration/fixtures/update-vault-connection.json @@ -0,0 +1,3 @@ +{ + "name": "integration-test-connection-fixture-updated" +} diff --git a/test/integration/fixtures/vault-connection.json b/test/integration/fixtures/vault-connection.json new file mode 100644 index 000000000..663bcdd62 --- /dev/null +++ b/test/integration/fixtures/vault-connection.json @@ -0,0 +1,8 @@ +{ + "app_id": "HTTP", + "name": "integration-test-connection", + "setup": { + "type": "BEARER", + "token": "integration-test-token" + } +} diff --git a/test/integration/flows-test-cases.yaml b/test/integration/flows-test-cases.yaml new file mode 100644 index 000000000..a82f50665 --- /dev/null +++ b/test/integration/flows-test-cases.yaml @@ -0,0 +1,213 @@ +config: + inherit-env: true + retries: 1 + +tests: + 001 - it successfully lists all flows (json): + command: auth0 flows list --json + exit-code: 0 + + 002 - it successfully creates a flow via --name: + command: auth0 flows create --name integration-test-flow-created --no-input + exit-code: 0 + stdout: + contains: + - ID + - NAME + - integration-test-flow-created + + 003 - it successfully creates a flow and outputs in json: + command: auth0 flows create --name integration-test-flow-json --no-input --json + exit-code: 0 + stdout: + json: + name: "integration-test-flow-json" + + 004 - it successfully creates a flow from the embedded example: + command: auth0 flows create --example | auth0 flows create -f - --name integration-test-flow-example --no-input --json + exit-code: 0 + stdout: + json: + name: "integration-test-flow-example" + + 005 - it fails to create a flow without a name: + command: echo '{"actions":[]}' | auth0 flows create -f - --no-input + exit-code: 1 + stderr: + contains: + - flow name is required + + 006 - it fails to create a flow from invalid json: + command: echo 'not-json' | auth0 flows create -f - --no-input + exit-code: 1 + stderr: + contains: + - parse flow body + + 007 - it successfully lists all flows with data: + command: auth0 flows list + exit-code: 0 + stdout: + contains: + - ID + - NAME + - UPDATED + + 008 - given a test flow, it successfully shows the flow details: + command: auth0 flows show $(./test/integration/scripts/get-flow-id.sh) + exit-code: 0 + stdout: + contains: + - ID + - NAME + - integration-test-flow + + 009 - given a test flow, it successfully shows the flow details (json): + command: auth0 flows show $(./test/integration/scripts/get-flow-id.sh) --json + exit-code: 0 + stdout: + json: + name: "integration-test-flow" + + 010 - given a test flow, it successfully updates the flow name: + command: auth0 flows update $(./test/integration/scripts/get-flow-id.sh) --name integration-test-flow-updated --json + exit-code: 0 + stdout: + json: + name: "integration-test-flow-updated" + + 011 - given a test flow, it successfully updates the flow from a fixture file: + command: auth0 flows update $(./test/integration/scripts/get-flow-id.sh) -f ./test/integration/fixtures/update-flow.json --json + exit-code: 0 + stdout: + json: + name: "integration-test-flow-fixture-updated" + + 012 - given a test flow, it prints the builder URL for open: + command: auth0 flows open $(./test/integration/scripts/get-flow-id.sh) --no-input + exit-code: 0 + stderr: + contains: + - forms.auth0.com + - /flows/ + - /edit + + 013 - given a test flow, it successfully lists its executions (json): + command: auth0 flows executions list $(./test/integration/scripts/get-flow-id.sh) --json + exit-code: 0 + + 014 - given a test flow, it successfully lists its executions: + command: auth0 flows executions list $(./test/integration/scripts/get-flow-id.sh) + exit-code: 0 + + 015 - it successfully lists all vault connections (json): + command: auth0 flows vault connections list --json + exit-code: 0 + + 016 - it prints an example vault connection body: + command: auth0 flows vault connections create --example + exit-code: 0 + stdout: + contains: + - '"app_id"' + - '"setup"' + + 017 - it prints the vault app builder URL for open: + command: auth0 flows vault open HTTP --no-input + exit-code: 0 + stderr: + contains: + - forms.auth0.com + - /vault/apps/HTTP/edit + + 018 - it fails to create a vault connection without a body: + command: auth0 flows vault connections create --no-input + exit-code: 1 + stderr: + contains: + - vault connection body provided + + 019 - it successfully creates a vault connection from a fixture file (json): + command: auth0 flows vault connections create --file ./test/integration/fixtures/vault-connection.json --name integration-test-connection-created --no-input --json + exit-code: 0 + stdout: + json: + name: "integration-test-connection-created" + + 020 - it successfully lists all vault connections with data: + command: auth0 flows vault connections list + exit-code: 0 + stdout: + contains: + - ID + - NAME + - APP + + 021 - given a test vault connection, it successfully shows the connection details: + command: auth0 flows vault connections show $(./test/integration/scripts/get-vault-connection-id.sh) + exit-code: 0 + stdout: + contains: + - ID + - NAME + - integration-test-connection + + 022 - given a test vault connection, it does not echo the setup secret on show: + command: auth0 flows vault connections show $(./test/integration/scripts/get-vault-connection-id.sh) + exit-code: 0 + stdout: + not-contains: + - integration-test-token + + 023 - given a test vault connection, it successfully shows the connection details (json): + command: auth0 flows vault connections show $(./test/integration/scripts/get-vault-connection-id.sh) --json + exit-code: 0 + stdout: + json: + name: "integration-test-connection" + + 024 - given a test vault connection, it successfully updates the connection name: + command: auth0 flows vault connections update $(./test/integration/scripts/get-vault-connection-id.sh) --name integration-test-connection-updated --json + exit-code: 0 + stdout: + json: + name: "integration-test-connection-updated" + + 025 - given a test vault connection, it successfully updates the connection from a fixture file: + command: auth0 flows vault connections update $(./test/integration/scripts/get-vault-connection-id.sh) -f ./test/integration/fixtures/update-vault-connection.json --json + exit-code: 0 + stdout: + json: + name: "integration-test-connection-fixture-updated" + + 026 - agent mode refuses to delete a vault connection without force: + command: AUTH0_AGENT_MODE=true auth0 flows vault connections delete $(./test/integration/scripts/get-vault-connection-id.sh) + exit-code: 1 + stderr: + contains: + - destructive command + - --force + + 027 - given a test vault connection, it successfully deletes the connection: + command: auth0 flows vault connections delete $(./test/integration/scripts/get-vault-connection-id.sh) --force + exit-code: 0 + + 028 - agent mode refuses to delete a flow without force: + command: AUTH0_AGENT_MODE=true auth0 flows delete $(./test/integration/scripts/get-flow-id.sh) + exit-code: 1 + stderr: + contains: + - destructive command + - --force + + 029 - given a test flow, it successfully deletes the flow: + command: auth0 flows delete $(./test/integration/scripts/get-flow-id.sh) --force + exit-code: 0 + + 030 - it cleans up all vault connections created by this suite: + command: ./test/integration/scripts/cleanup-vault-connections.sh + exit-code: 0 + + 031 - it cleans up all flows created by this suite: + command: ./test/integration/scripts/cleanup-flows.sh + exit-code: 0 diff --git a/test/integration/scripts/cleanup-flows.sh b/test/integration/scripts/cleanup-flows.sh new file mode 100755 index 000000000..320b41402 --- /dev/null +++ b/test/integration/scripts/cleanup-flows.sh @@ -0,0 +1,16 @@ +#!/bin/bash + +set -euo pipefail + +ids=() +while IFS= read -r id; do + if [[ -n "$id" ]]; then + ids+=("$id") + fi +done < <(auth0 flows list --json --no-input | jq -r '.[] | select(.name | startswith("integration-test-")) | .id') + +if (( ${#ids[@]} > 0 )); then + auth0 flows delete --force "${ids[@]}" +fi + +rm -f ./test/integration/identifiers/flow-id diff --git a/test/integration/scripts/cleanup-vault-connections.sh b/test/integration/scripts/cleanup-vault-connections.sh new file mode 100755 index 000000000..588a6ee9b --- /dev/null +++ b/test/integration/scripts/cleanup-vault-connections.sh @@ -0,0 +1,16 @@ +#!/bin/bash + +set -euo pipefail + +ids=() +while IFS= read -r id; do + if [[ -n "$id" ]]; then + ids+=("$id") + fi +done < <(auth0 flows vault connections list --json --no-input | jq -r '.[] | select(.name | startswith("integration-test-")) | .id') + +if (( ${#ids[@]} > 0 )); then + auth0 flows vault connections delete --force "${ids[@]}" +fi + +rm -f ./test/integration/identifiers/vault-connection-id diff --git a/test/integration/scripts/get-flow-id.sh b/test/integration/scripts/get-flow-id.sh new file mode 100755 index 000000000..4877125ce --- /dev/null +++ b/test/integration/scripts/get-flow-id.sh @@ -0,0 +1,13 @@ +#! /bin/bash + +FILE=./test/integration/identifiers/flow-id +if [ -f "$FILE" ]; then + cat $FILE + exit 0 +fi + +flow=$( auth0 flows create --name "integration-test-flow" --json --no-input ) + +mkdir -p ./test/integration/identifiers +echo "$flow" | jq -r '.["id"]' > $FILE +cat $FILE diff --git a/test/integration/scripts/get-vault-connection-id.sh b/test/integration/scripts/get-vault-connection-id.sh new file mode 100755 index 000000000..929b5e2e5 --- /dev/null +++ b/test/integration/scripts/get-vault-connection-id.sh @@ -0,0 +1,16 @@ +#! /bin/bash + +FILE=./test/integration/identifiers/vault-connection-id +if [ -f "$FILE" ]; then + cat $FILE + exit 0 +fi + +connection=$( auth0 flows vault connections create \ + --file ./test/integration/fixtures/vault-connection.json \ + --name "integration-test-connection" \ + --json --no-input ) + +mkdir -p ./test/integration/identifiers +echo "$connection" | jq -r '.["id"]' > $FILE +cat $FILE diff --git a/test/integration/scripts/test-cleanup.sh b/test/integration/scripts/test-cleanup.sh index 079e76f6d..9c9d1afb9 100755 --- a/test/integration/scripts/test-cleanup.sh +++ b/test/integration/scripts/test-cleanup.sh @@ -33,6 +33,8 @@ delete_resources "actions modules" "integration-test-module" "id" delete_resources "token-exchange" "integration-test-" "id" delete_resources "event-streams" "integration-test-" "id" delete_resources "forms" "integration-test-" "id" +delete_resources "flows vault connections" "integration-test-" "id" +delete_resources "flows" "integration-test-" "id" delete_resources "logs streams" "integration-test-" "id" auth0 domains delete $(./test/integration/scripts/get-custom-domain-id.sh) --no-input