From 025d4d8086d4ea639df95dee50c6bf240e6127ed Mon Sep 17 00:00:00 2001 From: Kunal Dawar Date: Mon, 24 Aug 2026 14:09:49 +0530 Subject: [PATCH 1/6] feat: add Forms management commands --- Makefile | 2 +- README.md | 1 + docs/auth0_forms.md | 20 + docs/auth0_forms_create.md | 68 ++ docs/auth0_forms_delete.md | 59 ++ docs/auth0_forms_export.md | 55 + docs/auth0_forms_import.md | 60 ++ docs/auth0_forms_list.md | 58 ++ docs/auth0_forms_open.md | 47 + docs/auth0_forms_show.md | 55 + docs/auth0_forms_update.md | 60 ++ docs/index.md | 1 + internal/auth/auth.go | 2 +- internal/auth0/auth0.go | 4 +- internal/auth0/form.go | 53 +- internal/auth0/mock/form_mock.go | 106 +- internal/cli/forms.go | 1013 +++++++++++++++++++ internal/cli/forms_envelope.go | 398 ++++++++ internal/cli/forms_envelope_test.go | 206 ++++ internal/cli/forms_test.go | 527 ++++++++++ internal/cli/root.go | 1 + internal/cli/terraform.go | 2 +- internal/cli/terraform_fetcher.go | 26 +- internal/cli/terraform_fetcher_test.go | 42 +- internal/display/forms.go | 237 +++++ internal/display/forms_test.go | 50 + test/integration/fixtures/update-form.json | 10 + test/integration/forms-test-cases.yaml | 128 +++ test/integration/scripts/cleanup-forms.sh | 16 + test/integration/scripts/get-form-id.sh | 13 + test/integration/scripts/run-test-suites.sh | 29 +- test/integration/scripts/test-cleanup.sh | 1 + 32 files changed, 3217 insertions(+), 133 deletions(-) create mode 100644 docs/auth0_forms.md create mode 100644 docs/auth0_forms_create.md create mode 100644 docs/auth0_forms_delete.md create mode 100644 docs/auth0_forms_export.md create mode 100644 docs/auth0_forms_import.md create mode 100644 docs/auth0_forms_list.md create mode 100644 docs/auth0_forms_open.md create mode 100644 docs/auth0_forms_show.md create mode 100644 docs/auth0_forms_update.md create mode 100644 internal/cli/forms.go create mode 100644 internal/cli/forms_envelope.go create mode 100644 internal/cli/forms_envelope_test.go create mode 100644 internal/cli/forms_test.go create mode 100644 internal/display/forms.go create mode 100644 internal/display/forms_test.go create mode 100644 test/integration/fixtures/update-form.json create mode 100644 test/integration/forms-test-cases.yaml create mode 100755 test/integration/scripts/cleanup-forms.sh create mode 100755 test/integration/scripts/get-form-id.sh diff --git a/Makefile b/Makefile index 6434cb882..5adda9fa3 100644 --- a/Makefile +++ b/Makefile @@ -166,7 +166,7 @@ test-unit: ## Run unit tests ${call print, "Running unit tests"} @go test -v -race ${GO_PACKAGES} -coverprofile="coverage-unit-tests.out" -test-integration: install-with-cover $(GO_BIN)/auth0 $(GO_BIN)/commander ## Run integration tests. To run a specific test pass the FILTER var. Usage: `make test-integration FILTER="attack protection"` +test-integration: install-with-cover $(GO_BIN)/auth0 $(GO_BIN)/commander ## Run integration tests. To run a specific test pass the FILTER var. Usage: `make test-integration FILTER="attack protection"`. To run a single suite file pass the FILE var. Usage: `make test-integration FILE="./test/integration/forms-test-cases.yaml"` ${call print, "Running integration tests"} @mkdir -p "coverage" @PATH=$(GO_BIN):$$PATH \ diff --git a/README.md b/README.md index 2c867757e..41f3c17cb 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 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 - [auth0 logs](https://auth0.github.io/auth0-cli/auth0_logs.html) - View tenant logs diff --git a/docs/auth0_forms.md b/docs/auth0_forms.md new file mode 100644 index 000000000..c40b08c34 --- /dev/null +++ b/docs/auth0_forms.md @@ -0,0 +1,20 @@ +--- +layout: default +has_toc: false +has_children: true +--- +# auth0 forms + +Forms are customizable screens you can insert into a flow to collect input from users during authentication and other journeys. + +## Commands + +- [auth0 forms create](auth0_forms_create.md) - Create a new form +- [auth0 forms delete](auth0_forms_delete.md) - Delete a form +- [auth0 forms export](auth0_forms_export.md) - Export a form +- [auth0 forms import](auth0_forms_import.md) - Import a form +- [auth0 forms list](auth0_forms_list.md) - List your forms +- [auth0 forms open](auth0_forms_open.md) - Open a form in the Auth0 Dashboard +- [auth0 forms show](auth0_forms_show.md) - Show a form +- [auth0 forms update](auth0_forms_update.md) - Update a form + diff --git a/docs/auth0_forms_create.md b/docs/auth0_forms_create.md new file mode 100644 index 000000000..8df3842d8 --- /dev/null +++ b/docs/auth0_forms_create.md @@ -0,0 +1,68 @@ +--- +layout: default +parent: auth0 forms +has_toc: false +--- +# auth0 forms create + +Create a new form. + +Interactive behavior: `auth0 forms create` asks only for the name and creates a minimal scaffold; it does not open an editor. You can then refine the form in the dashboard builder. + +Pass `--edit` to open an editor and author the form graph before it is created, or supply the whole body via `--file` (or piped stdin) with optional `--name` and `--language-*` overrides. Run `auth0 forms create --example > form.json` to generate an accepted file payload. + +## Usage +``` +auth0 forms create [flags] +``` + +## Examples + +``` + auth0 forms create + auth0 forms create --name "My Form" + auth0 forms create --name "My Form" --edit + auth0 forms create --example > form.json + auth0 forms create --file ./form.json + auth0 forms create --file ./form.json --name "My Form" --language-primary en + cat form.json | auth0 forms create -f - +``` + + +## Flags + +``` + --edit Open an editor to author the form graph after entering the name. + --example Print an example form JSON body and exit. + -f, --file string Path to a JSON file with the form body. Use '-' to read from stdin. + --json Output in json format. + --json-compact Output in compact json format. + --language-default string Default language of the Form (e.g. en). + --language-primary string Primary language of the Form (e.g. en). + --name string Name of the Form. +``` + + +## 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 forms create](auth0_forms_create.md) - Create a new form +- [auth0 forms delete](auth0_forms_delete.md) - Delete a form +- [auth0 forms export](auth0_forms_export.md) - Export a form +- [auth0 forms import](auth0_forms_import.md) - Import a form +- [auth0 forms list](auth0_forms_list.md) - List your forms +- [auth0 forms open](auth0_forms_open.md) - Open a form in the Auth0 Dashboard +- [auth0 forms show](auth0_forms_show.md) - Show a form +- [auth0 forms update](auth0_forms_update.md) - Update a form + + diff --git a/docs/auth0_forms_delete.md b/docs/auth0_forms_delete.md new file mode 100644 index 000000000..a8f923bae --- /dev/null +++ b/docs/auth0_forms_delete.md @@ -0,0 +1,59 @@ +--- +layout: default +parent: auth0 forms +has_toc: false +--- +# auth0 forms delete + +Delete a form. + +To delete interactively, use `auth0 forms delete` with no arguments. + +To delete non-interactively, supply the form id and the `--force` flag to skip confirmation. + +## Usage +``` +auth0 forms delete [flags] +``` + +## Examples + +``` + auth0 forms delete + auth0 forms rm + auth0 forms delete + auth0 forms delete --force + auth0 forms 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 forms create](auth0_forms_create.md) - Create a new form +- [auth0 forms delete](auth0_forms_delete.md) - Delete a form +- [auth0 forms export](auth0_forms_export.md) - Export a form +- [auth0 forms import](auth0_forms_import.md) - Import a form +- [auth0 forms list](auth0_forms_list.md) - List your forms +- [auth0 forms open](auth0_forms_open.md) - Open a form in the Auth0 Dashboard +- [auth0 forms show](auth0_forms_show.md) - Show a form +- [auth0 forms update](auth0_forms_update.md) - Update a form + + diff --git a/docs/auth0_forms_export.md b/docs/auth0_forms_export.md new file mode 100644 index 000000000..f33f52d35 --- /dev/null +++ b/docs/auth0_forms_export.md @@ -0,0 +1,55 @@ +--- +layout: default +parent: auth0 forms +has_toc: false +--- +# auth0 forms export + +Export a form as JSON. Writes to stdout by default (pipe-friendly) or to a file with `--output`. The output uses the same envelope as the Auth0 Dashboard (`version`, `form`, `flows`, `connections`), bundling the flows and vault connections the form references with portable `#FLOW-N#`/`#CONN-N#` placeholders, so it can be imported by the CLI or opened in the Dashboard. + +## Usage +``` +auth0 forms export [flags] +``` + +## Examples + +``` + auth0 forms export + auth0 forms export --output ./form.json + auth0 forms export --json-compact + auth0 forms export | auth0 forms import -f - +``` + + +## Flags + +``` + --json-compact Output in compact json format. + -o, --output string Path to write the exported form. Writes to stdout when omitted. +``` + + +## 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 forms create](auth0_forms_create.md) - Create a new form +- [auth0 forms delete](auth0_forms_delete.md) - Delete a form +- [auth0 forms export](auth0_forms_export.md) - Export a form +- [auth0 forms import](auth0_forms_import.md) - Import a form +- [auth0 forms list](auth0_forms_list.md) - List your forms +- [auth0 forms open](auth0_forms_open.md) - Open a form in the Auth0 Dashboard +- [auth0 forms show](auth0_forms_show.md) - Show a form +- [auth0 forms update](auth0_forms_update.md) - Update a form + + diff --git a/docs/auth0_forms_import.md b/docs/auth0_forms_import.md new file mode 100644 index 000000000..1378bf828 --- /dev/null +++ b/docs/auth0_forms_import.md @@ -0,0 +1,60 @@ +--- +layout: default +parent: auth0 forms +has_toc: false +--- +# auth0 forms import + +Import a form from a JSON file (or piped stdin). Without `--id` a new form is created; with `--id` the existing form is replaced. + +Both a flat form graph and the Dashboard envelope (`version`, `form`, `flows`, `connections`) are accepted. For an envelope, the bundled flows are created and each `#CONN-N#` connection placeholder is mapped to an existing vault connection, either interactively or with `--connection`. + +## Usage +``` +auth0 forms import [flags] +``` + +## Examples + +``` + auth0 forms import --file ./form.json + auth0 forms import --file ./form.json --id + auth0 forms import --file ./form.json --connection '#CONN-1#=ac_123' + cat form.json | auth0 forms import -f - +``` + + +## Flags + +``` + --connection stringToString Map an exported connection placeholder to an existing vault connection ID, e.g. --connection '#CONN-1#=ac_123'. Repeatable. (default []) + -f, --file string Path to a JSON file with the form body. Use '-' to read from stdin. + --id string Id of an existing Form to replace. When omitted, a new form is created. + --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 forms create](auth0_forms_create.md) - Create a new form +- [auth0 forms delete](auth0_forms_delete.md) - Delete a form +- [auth0 forms export](auth0_forms_export.md) - Export a form +- [auth0 forms import](auth0_forms_import.md) - Import a form +- [auth0 forms list](auth0_forms_list.md) - List your forms +- [auth0 forms open](auth0_forms_open.md) - Open a form in the Auth0 Dashboard +- [auth0 forms show](auth0_forms_show.md) - Show a form +- [auth0 forms update](auth0_forms_update.md) - Update a form + + diff --git a/docs/auth0_forms_list.md b/docs/auth0_forms_list.md new file mode 100644 index 000000000..ca5602716 --- /dev/null +++ b/docs/auth0_forms_list.md @@ -0,0 +1,58 @@ +--- +layout: default +parent: auth0 forms +has_toc: false +--- +# auth0 forms list + +List your existing forms. To create one, run: `auth0 forms create`. + +## Usage +``` +auth0 forms list [flags] +``` + +## Examples + +``` + auth0 forms list + auth0 forms ls + auth0 forms ls --number 100 + auth0 forms ls --json + auth0 forms ls --csv +``` + + +## Flags + +``` + --csv Output in csv format. + --json Output in json format. + --json-compact Output in compact json format. + -n, --number int Number of forms 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 forms create](auth0_forms_create.md) - Create a new form +- [auth0 forms delete](auth0_forms_delete.md) - Delete a form +- [auth0 forms export](auth0_forms_export.md) - Export a form +- [auth0 forms import](auth0_forms_import.md) - Import a form +- [auth0 forms list](auth0_forms_list.md) - List your forms +- [auth0 forms open](auth0_forms_open.md) - Open a form in the Auth0 Dashboard +- [auth0 forms show](auth0_forms_show.md) - Show a form +- [auth0 forms update](auth0_forms_update.md) - Update a form + + diff --git a/docs/auth0_forms_open.md b/docs/auth0_forms_open.md new file mode 100644 index 000000000..a312960f1 --- /dev/null +++ b/docs/auth0_forms_open.md @@ -0,0 +1,47 @@ +--- +layout: default +parent: auth0 forms +has_toc: false +--- +# auth0 forms open + +Open a form's page in the Auth0 Dashboard form builder. + +## Usage +``` +auth0 forms open [flags] +``` + +## Examples + +``` + auth0 forms open + auth0 forms 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 forms create](auth0_forms_create.md) - Create a new form +- [auth0 forms delete](auth0_forms_delete.md) - Delete a form +- [auth0 forms export](auth0_forms_export.md) - Export a form +- [auth0 forms import](auth0_forms_import.md) - Import a form +- [auth0 forms list](auth0_forms_list.md) - List your forms +- [auth0 forms open](auth0_forms_open.md) - Open a form in the Auth0 Dashboard +- [auth0 forms show](auth0_forms_show.md) - Show a form +- [auth0 forms update](auth0_forms_update.md) - Update a form + + diff --git a/docs/auth0_forms_show.md b/docs/auth0_forms_show.md new file mode 100644 index 000000000..aeee9aa4e --- /dev/null +++ b/docs/auth0_forms_show.md @@ -0,0 +1,55 @@ +--- +layout: default +parent: auth0 forms +has_toc: false +--- +# auth0 forms show + +Display information about a form. + +## Usage +``` +auth0 forms show [flags] +``` + +## Examples + +``` + auth0 forms show + auth0 forms show + auth0 forms show --json + auth0 forms show --json-compact +``` + + +## 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 forms create](auth0_forms_create.md) - Create a new form +- [auth0 forms delete](auth0_forms_delete.md) - Delete a form +- [auth0 forms export](auth0_forms_export.md) - Export a form +- [auth0 forms import](auth0_forms_import.md) - Import a form +- [auth0 forms list](auth0_forms_list.md) - List your forms +- [auth0 forms open](auth0_forms_open.md) - Open a form in the Auth0 Dashboard +- [auth0 forms show](auth0_forms_show.md) - Show a form +- [auth0 forms update](auth0_forms_update.md) - Update a form + + diff --git a/docs/auth0_forms_update.md b/docs/auth0_forms_update.md new file mode 100644 index 000000000..d35a366fa --- /dev/null +++ b/docs/auth0_forms_update.md @@ -0,0 +1,60 @@ +--- +layout: default +parent: auth0 forms +has_toc: false +--- +# auth0 forms update + +Update a form. + +Passing `--file` (or piped stdin) replaces every top-level field present in the file. Passing only scalar flags such as `--name` performs a merge that preserves the form's graph fields (nodes, style, translations). Server-managed fields such as `id`, `created_at`, and `updated_at` are removed before the update request is sent. + +## Usage +``` +auth0 forms update [flags] +``` + +## Examples + +``` + auth0 forms update --name "New Name" + auth0 forms update --file ./form.json + cat form.json | auth0 forms update -f - +``` + + +## Flags + +``` + -f, --file string Path to a JSON file with the form body. Use '-' to read from stdin. + --json Output in json format. + --json-compact Output in compact json format. + --language-default string Default language of the Form (e.g. en). + --language-primary string Primary language of the Form (e.g. en). + --name string Name of the Form. +``` + + +## 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 forms create](auth0_forms_create.md) - Create a new form +- [auth0 forms delete](auth0_forms_delete.md) - Delete a form +- [auth0 forms export](auth0_forms_export.md) - Export a form +- [auth0 forms import](auth0_forms_import.md) - Import a form +- [auth0 forms list](auth0_forms_list.md) - List your forms +- [auth0 forms open](auth0_forms_open.md) - Open a form in the Auth0 Dashboard +- [auth0 forms show](auth0_forms_show.md) - Show a form +- [auth0 forms update](auth0_forms_update.md) - Update a form + + diff --git a/docs/index.md b/docs/index.md index df88b74f0..cc5de36f9 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 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 - [auth0 logs](auth0_logs.md) - View tenant logs diff --git a/internal/auth/auth.go b/internal/auth/auth.go index 89c5245f9..24c512f8f 100644 --- a/internal/auth/auth.go +++ b/internal/auth/auth.go @@ -133,7 +133,7 @@ var RequiredScopes = []string{ "read:phone_templates", "create:email_templates", "read:email_templates", "update:email_templates", "create:email_provider", "read:email_provider", "update:email_provider", "delete:email_provider", - "read:flows", "read:forms", "read:flows_vault_connections", + "read:flows", "create:flows", "read:forms", "create:forms", "update:forms", "delete:forms", "read:flows_vault_connections", "read:connections", "update:connections", "read:connections_options", "update:connections_options", "read:client_keys", "read:logs", "read:tenant_settings", "update:tenant_settings", "read:custom_domains", "create:custom_domains", "update:custom_domains", "delete:custom_domains", diff --git a/internal/auth0/auth0.go b/internal/auth0/auth0.go index f566abae5..16d54aa2a 100644 --- a/internal/auth0/auth0.go +++ b/internal/auth0/auth0.go @@ -22,7 +22,6 @@ type API struct { EventStream EventStreamAPI Flow FlowAPI FlowVaultConnection FlowVaultConnectionAPI - Form FormAPI Log LogAPI LogStream LogStreamAPI Organization OrganizationAPI @@ -56,7 +55,6 @@ func NewAPI(m *management.Management) *API { EventStream: m.EventStream, Flow: m.Flow, FlowVaultConnection: m.Flow.Vault, - Form: m.Form, Log: m.Log, LogStream: m.LogStream, Organization: m.Organization, @@ -80,6 +78,7 @@ type APIV3 struct { ClientGrant ClientGrantAPIV3 ClientGrantOrganization ClientGrantOrganizationAPIV3 Events EventsAPIV3 + Form FormAPIV3 PhoneNotificationTemplate PhoneNotificationTemplateAPI Session SessionAPIV3 RefreshToken RefreshTokenAPIV3 @@ -95,6 +94,7 @@ func NewAPIV3(m *managementv3.Management) *APIV3 { ClientGrant: m.ClientGrants, ClientGrantOrganization: m.ClientGrants.Organizations, Events: m.Events, + Form: m.Forms, PhoneNotificationTemplate: m.Branding.Phone.Templates, Session: m.Sessions, RefreshToken: m.RefreshTokens, diff --git a/internal/auth0/form.go b/internal/auth0/form.go index 20d562b42..f6dea7cf1 100644 --- a/internal/auth0/form.go +++ b/internal/auth0/form.go @@ -5,22 +5,43 @@ package auth0 import ( "context" - "github.com/auth0/go-auth0/management" + managementv3 "github.com/auth0/go-auth0/v3/management" + "github.com/auth0/go-auth0/v3/management/core" + "github.com/auth0/go-auth0/v3/management/option" ) -type FormAPI interface { - // Create a new form. - Create(ctx context.Context, r *management.Form, opts ...management.RequestOption) error - - // Read form details. - Read(ctx context.Context, id string, opts ...management.RequestOption) (r *management.Form, err error) - - // Update an existing action. - Update(ctx context.Context, id string, r *management.Form, opts ...management.RequestOption) error - - // Delete an action. - Delete(ctx context.Context, id string, opts ...management.RequestOption) error - - // List form. - List(ctx context.Context, opts ...management.RequestOption) (r *management.FormList, err error) +// FormSummaryPage aliases the paginated forms 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 FormSummaryPage = core.Page[*int, *managementv3.FormSummary, *managementv3.ListFormsOffsetPaginatedResponseContent] + +// FormAPIV3 is the V3 SDK interface for the /forms endpoint. +type FormAPIV3 interface { + // List forms. + // + // Required scope: `read:forms`. + List( + ctx context.Context, + request *managementv3.ListFormsRequestParameters, + opts ...option.RequestOption, + ) (*FormSummaryPage, error) + + // Get retrieves a form by its ID. + // + // Required scope: `read:forms`. + Get( + ctx context.Context, + id string, + request *managementv3.GetFormRequestParameters, + opts ...option.RequestOption, + ) (*managementv3.GetFormResponseContent, error) + + // Delete a form. + // + // Required scope: `delete:forms`. + Delete( + ctx context.Context, + id string, + opts ...option.RequestOption, + ) error } diff --git a/internal/auth0/mock/form_mock.go b/internal/auth0/mock/form_mock.go index 73161402a..aa69be870 100644 --- a/internal/auth0/mock/form_mock.go +++ b/internal/auth0/mock/form_mock.go @@ -8,54 +8,37 @@ import ( context "context" reflect "reflect" - management "github.com/auth0/go-auth0/management" + 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" ) -// MockFormAPI is a mock of FormAPI interface. -type MockFormAPI struct { +// MockFormAPIV3 is a mock of FormAPIV3 interface. +type MockFormAPIV3 struct { ctrl *gomock.Controller - recorder *MockFormAPIMockRecorder + recorder *MockFormAPIV3MockRecorder } -// MockFormAPIMockRecorder is the mock recorder for MockFormAPI. -type MockFormAPIMockRecorder struct { - mock *MockFormAPI +// MockFormAPIV3MockRecorder is the mock recorder for MockFormAPIV3. +type MockFormAPIV3MockRecorder struct { + mock *MockFormAPIV3 } -// NewMockFormAPI creates a new mock instance. -func NewMockFormAPI(ctrl *gomock.Controller) *MockFormAPI { - mock := &MockFormAPI{ctrl: ctrl} - mock.recorder = &MockFormAPIMockRecorder{mock} +// NewMockFormAPIV3 creates a new mock instance. +func NewMockFormAPIV3(ctrl *gomock.Controller) *MockFormAPIV3 { + mock := &MockFormAPIV3{ctrl: ctrl} + mock.recorder = &MockFormAPIV3MockRecorder{mock} return mock } // EXPECT returns an object that allows the caller to indicate expected use. -func (m *MockFormAPI) EXPECT() *MockFormAPIMockRecorder { +func (m *MockFormAPIV3) EXPECT() *MockFormAPIV3MockRecorder { return m.recorder } -// Create mocks base method. -func (m *MockFormAPI) Create(ctx context.Context, r *management.Form, opts ...management.RequestOption) error { - m.ctrl.T.Helper() - varargs := []interface{}{ctx, r} - for _, a := range opts { - varargs = append(varargs, a) - } - ret := m.ctrl.Call(m, "Create", varargs...) - ret0, _ := ret[0].(error) - return ret0 -} - -// Create indicates an expected call of Create. -func (mr *MockFormAPIMockRecorder) Create(ctx, r interface{}, opts ...interface{}) *gomock.Call { - mr.mock.ctrl.T.Helper() - varargs := append([]interface{}{ctx, r}, opts...) - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Create", reflect.TypeOf((*MockFormAPI)(nil).Create), varargs...) -} - // Delete mocks base method. -func (m *MockFormAPI) Delete(ctx context.Context, id string, opts ...management.RequestOption) error { +func (m *MockFormAPIV3) Delete(ctx context.Context, id string, opts ...option.RequestOption) error { m.ctrl.T.Helper() varargs := []interface{}{ctx, id} for _, a := range opts { @@ -67,67 +50,48 @@ func (m *MockFormAPI) Delete(ctx context.Context, id string, opts ...management. } // Delete indicates an expected call of Delete. -func (mr *MockFormAPIMockRecorder) Delete(ctx, id interface{}, opts ...interface{}) *gomock.Call { +func (mr *MockFormAPIV3MockRecorder) 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((*MockFormAPI)(nil).Delete), varargs...) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Delete", reflect.TypeOf((*MockFormAPIV3)(nil).Delete), varargs...) } -// List mocks base method. -func (m *MockFormAPI) List(ctx context.Context, opts ...management.RequestOption) (*management.FormList, error) { +// Get mocks base method. +func (m *MockFormAPIV3) Get(ctx context.Context, id string, request *management.GetFormRequestParameters, opts ...option.RequestOption) (*management.GetFormResponseContent, error) { m.ctrl.T.Helper() - varargs := []interface{}{ctx} + varargs := []interface{}{ctx, id, request} for _, a := range opts { varargs = append(varargs, a) } - ret := m.ctrl.Call(m, "List", varargs...) - ret0, _ := ret[0].(*management.FormList) + ret := m.ctrl.Call(m, "Get", varargs...) + ret0, _ := ret[0].(*management.GetFormResponseContent) ret1, _ := ret[1].(error) return ret0, ret1 } -// List indicates an expected call of List. -func (mr *MockFormAPIMockRecorder) List(ctx interface{}, opts ...interface{}) *gomock.Call { +// Get indicates an expected call of Get. +func (mr *MockFormAPIV3MockRecorder) Get(ctx, id, request interface{}, opts ...interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() - varargs := append([]interface{}{ctx}, opts...) - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "List", reflect.TypeOf((*MockFormAPI)(nil).List), varargs...) + varargs := append([]interface{}{ctx, id, request}, opts...) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Get", reflect.TypeOf((*MockFormAPIV3)(nil).Get), varargs...) } -// Read mocks base method. -func (m *MockFormAPI) Read(ctx context.Context, id string, opts ...management.RequestOption) (*management.Form, error) { +// List mocks base method. +func (m *MockFormAPIV3) List(ctx context.Context, request *management.ListFormsRequestParameters, opts ...option.RequestOption) (*auth0.FormSummaryPage, error) { m.ctrl.T.Helper() - varargs := []interface{}{ctx, id} + varargs := []interface{}{ctx, request} for _, a := range opts { varargs = append(varargs, a) } - ret := m.ctrl.Call(m, "Read", varargs...) - ret0, _ := ret[0].(*management.Form) + ret := m.ctrl.Call(m, "List", varargs...) + ret0, _ := ret[0].(*auth0.FormSummaryPage) ret1, _ := ret[1].(error) return ret0, ret1 } -// Read indicates an expected call of Read. -func (mr *MockFormAPIMockRecorder) Read(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, "Read", reflect.TypeOf((*MockFormAPI)(nil).Read), varargs...) -} - -// Update mocks base method. -func (m *MockFormAPI) Update(ctx context.Context, id string, r *management.Form, opts ...management.RequestOption) error { - m.ctrl.T.Helper() - varargs := []interface{}{ctx, id, r} - for _, a := range opts { - varargs = append(varargs, a) - } - ret := m.ctrl.Call(m, "Update", varargs...) - ret0, _ := ret[0].(error) - return ret0 -} - -// Update indicates an expected call of Update. -func (mr *MockFormAPIMockRecorder) Update(ctx, id, r interface{}, opts ...interface{}) *gomock.Call { +// List indicates an expected call of List. +func (mr *MockFormAPIV3MockRecorder) List(ctx, request interface{}, opts ...interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() - varargs := append([]interface{}{ctx, id, r}, opts...) - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Update", reflect.TypeOf((*MockFormAPI)(nil).Update), varargs...) + varargs := append([]interface{}{ctx, request}, opts...) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "List", reflect.TypeOf((*MockFormAPIV3)(nil).List), varargs...) } diff --git a/internal/cli/forms.go b/internal/cli/forms.go new file mode 100644 index 000000000..f2a5cedc4 --- /dev/null +++ b/internal/cli/forms.go @@ -0,0 +1,1013 @@ +package cli + +import ( + "bytes" + "context" + "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" +) + +// formCreateSkeleton seeds the editor for interactive form creation. The name is +// prompted separately, so the seed only carries the empty graph containers, which +// are all valid on their own. +const formCreateSkeleton = `{ + "start": {}, + "nodes": [], + "ending": {} +} +` + +const formCreateExample = `{ + "name": "Customer Profile Form", + "languages": { + "primary": "en", + "default": "en" + }, + "start": { + "next_node": "step_profile", + "coordinates": { + "x": 0, + "y": 0 + } + }, + "nodes": [ + { + "id": "step_profile", + "type": "STEP", + "coordinates": { + "x": 300, + "y": 0 + }, + "alias": "Collect profile", + "config": { + "components": [ + { + "id": "full_name", + "category": "FIELD", + "type": "TEXT", + "label": "Full name", + "required": true, + "sensitive": false, + "config": { + "multiline": false + } + }, + { + "id": "continue_button", + "category": "BLOCK", + "type": "NEXT_BUTTON", + "config": { + "text": "Continue" + } + } + ], + "next_node": "$ending" + } + } + ], + "ending": { + "resume_flow": true, + "coordinates": { + "x": 600, + "y": 0 + } + } +} +` + +// formServerManagedFields cannot be sent in create or update request bodies. +var formServerManagedFields = []string{ + "id", + "created_at", + "updated_at", + "embedded_at", + "submitted_at", + "flow_count", + "links", +} + +var ( + formID = Argument{ + Name: "Id", + Help: "Id of the Form.", + } + + formName = Flag{ + Name: "Name", + LongForm: "name", + Help: "Name of the Form.", + } + + formFile = Flag{ + Name: "File", + LongForm: "file", + ShortForm: "f", + Help: "Path to a JSON file with the form body. Use '-' to read from stdin.", + } + + formLanguagePrimary = Flag{ + Name: "Language Primary", + LongForm: "language-primary", + Help: "Primary language of the Form (e.g. en).", + } + + formLanguageDefault = Flag{ + Name: "Language Default", + LongForm: "language-default", + Help: "Default language of the Form (e.g. en).", + } + + formOutput = Flag{ + Name: "Output", + LongForm: "output", + ShortForm: "o", + Help: "Path to write the exported form. Writes to stdout when omitted.", + } + + formImportID = Flag{ + Name: "Id", + LongForm: "id", + Help: "Id of an existing Form to replace. When omitted, a new form is created.", + } + + formEdit = Flag{ + Name: "Edit", + LongForm: "edit", + Help: "Open an editor to author the form graph after entering the name.", + } + + formExample = Flag{ + Name: "Example", + LongForm: "example", + Help: "Print an example form JSON body and exit.", + } +) + +func formsCmd(cli *cli) *cobra.Command { + cmd := &cobra.Command{ + Use: "forms", + Short: "Manage Forms", + Long: "Forms are customizable screens you can insert into a flow to collect input " + + "from users during authentication and other journeys.", + } + + cmd.SetUsageTemplate(resourceUsageTemplate()) + cmd.AddCommand(listFormsCmd(cli)) + cmd.AddCommand(showFormCmd(cli)) + cmd.AddCommand(createFormCmd(cli)) + cmd.AddCommand(updateFormCmd(cli)) + cmd.AddCommand(deleteFormCmd(cli)) + cmd.AddCommand(exportFormCmd(cli)) + cmd.AddCommand(importFormCmd(cli)) + cmd.AddCommand(openFormCmd(cli)) + + return cmd +} + +func listFormsCmd(cli *cli) *cobra.Command { + var inputs struct { + Number int + } + + cmd := &cobra.Command{ + Use: "list", + Aliases: []string{"ls"}, + Args: cobra.NoArgs, + Short: "List your forms", + Long: "List your existing forms. To create one, run: `auth0 forms create`.", + Example: ` auth0 forms list + auth0 forms ls + auth0 forms ls --number 100 + auth0 forms ls --json + auth0 forms ls --csv`, + RunE: func(cmd *cobra.Command, args []string) error { + params := &managementv3.ListFormsRequestParameters{} + + var forms []*managementv3.FormSummary + if err := ansi.Waiting(func() (err error) { + forms, err = collectForms(cmd.Context(), cli, params, inputs.Number) + return err + }); err != nil { + return fmt.Errorf("failed to list forms: %w", err) + } + + return cli.renderer.FormsList(forms) + }, + } + + cmd.Flags().IntVarP(&inputs.Number, "number", "n", 100, "Number of forms 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 showFormCmd(cli *cli) *cobra.Command { + var inputs struct { + ID string + } + + cmd := &cobra.Command{ + Use: "show", + Args: cobra.MaximumNArgs(1), + Short: "Show a form", + Long: "Display information about a form.", + Example: ` auth0 forms show + auth0 forms show + auth0 forms show --json + auth0 forms show --json-compact`, + RunE: func(cmd *cobra.Command, args []string) error { + if len(args) == 0 { + if err := formID.Pick(cmd, &inputs.ID, cli.formPickerOptions); err != nil { + return err + } + } else { + inputs.ID = args[0] + } + + form, err := cli.formRawGet(cmd.Context(), inputs.ID) + if err != nil { + return fmt.Errorf("failed to read form with ID %q: %w", inputs.ID, err) + } + + return cli.renderer.FormShowRaw(form) + }, + } + + 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 createFormCmd(cli *cli) *cobra.Command { + var inputs struct { + Name string + File string + LanguagePrimary string + LanguageDefault string + Edit bool + Example bool + } + + cmd := &cobra.Command{ + Use: "create", + Args: cobra.NoArgs, + Short: "Create a new form", + Long: "Create a new form.\n\n" + + "Interactive behavior: `auth0 forms create` asks only for the name and creates a minimal " + + "scaffold; it does not open an editor. You can then refine the form in the dashboard builder.\n\n" + + "Pass `--edit` to open an editor and author the form graph before it is created, or supply " + + "the whole body via `--file` (or piped stdin) with optional `--name` and `--language-*` " + + "overrides. Run `auth0 forms create --example > form.json` to generate an accepted file payload.", + Example: ` auth0 forms create + auth0 forms create --name "My Form" + auth0 forms create --name "My Form" --edit + auth0 forms create --example > form.json + auth0 forms create --file ./form.json + auth0 forms create --file ./form.json --name "My Form" --language-primary en + cat form.json | auth0 forms create -f -`, + RunE: func(cmd *cobra.Command, args []string) error { + if inputs.Example { + cli.renderer.FormExport(formCreateExample) + return nil + } + + body, err := readFormBody(inputs.File) + if err != nil { + return err + } + + rawBody := json.RawMessage(body) + if body == nil { + // No file or piped body: the name is a required scalar, so prompt for + // it explicitly (only when interactive and --name was not supplied). + if err := formName.Ask(cmd, &inputs.Name, nil); err != nil { + return err + } + if inputs.Name == "" { + return errors.New("a form 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 := editFormJSON(cli, formCreateSkeleton, &rawBody); err != nil { + return err + } + } else { + rawBody = json.RawMessage(formCreateSkeleton) + } + } + + rawBody, err = applyRawFormOverrides( + rawBody, + inputs.Name, + inputs.LanguagePrimary, + inputs.LanguageDefault, + ) + if err != nil { + return fmt.Errorf("failed to parse form body: %w", err) + } + + name, err := rawFormStringField(rawBody, "name") + if err != nil { + return fmt.Errorf("failed to parse form body: %w", err) + } + if name == "" { + return errors.New("a form name is required; set it in the body or with --name") + } + + created, err := cli.formRawCreate(cmd.Context(), rawBody) + if err != nil { + return fmt.Errorf("failed to create form: %w", err) + } + if err := cli.renderer.FormCreateRaw(created); err != nil { + return err + } + + id, err := rawFormStringField(created, "id") + if err != nil { + return fmt.Errorf("failed to parse created form: %w", err) + } + formNextStepsHint(cli, id) + return nil + }, + } + + formName.RegisterString(cmd, &inputs.Name, "") + formFile.RegisterString(cmd, &inputs.File, "") + formLanguagePrimary.RegisterString(cmd, &inputs.LanguagePrimary, "") + formLanguageDefault.RegisterString(cmd, &inputs.LanguageDefault, "") + formEdit.RegisterBool(cmd, &inputs.Edit, false) + formExample.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 updateFormCmd(cli *cli) *cobra.Command { + var inputs struct { + ID string + Name string + File string + LanguagePrimary string + LanguageDefault string + } + + cmd := &cobra.Command{ + Use: "update", + Args: cobra.MaximumNArgs(1), + Short: "Update a form", + Long: "Update a form.\n\n" + + "Passing `--file` (or piped stdin) replaces every top-level field present in the file. " + + "Passing only scalar flags such as `--name` performs a merge that preserves the form's " + + "graph fields (nodes, style, translations). Server-managed fields such as `id`, " + + "`created_at`, and `updated_at` are removed before the update request is sent.", + Example: ` auth0 forms update --name "New Name" + auth0 forms update --file ./form.json + cat form.json | auth0 forms update -f -`, + RunE: func(cmd *cobra.Command, args []string) error { + if len(args) > 0 { + inputs.ID = args[0] + } else { + if err := formID.Pick(cmd, &inputs.ID, cli.formPickerOptions); err != nil { + return err + } + } + + body, err := readFormBody(inputs.File) + if err != nil { + return err + } + + var rawBody json.RawMessage + + switch { + case body != nil: + // File / stdin: whole-file overwrite of present top-level fields. + rawBody, err = applyRawFormOverrides( + body, + inputs.Name, + inputs.LanguagePrimary, + inputs.LanguageDefault, + ) + if err != nil { + return fmt.Errorf("failed to parse form body: %w", err) + } + case inputs.Name != "" || inputs.LanguagePrimary != "" || inputs.LanguageDefault != "": + primary := inputs.LanguagePrimary + def := inputs.LanguageDefault + if primary != "" || def != "" { + // The API replaces the languages object, so retain the value that was + // not explicitly overridden. This scalar read is safe through v3. + var current *managementv3.GetFormResponseContent + if err := ansi.Waiting(func() (err error) { + current, err = cli.apiv3.Form.Get( + cmd.Context(), + inputs.ID, + &managementv3.GetFormRequestParameters{}, + ) + return err + }); err != nil { + return fmt.Errorf("failed to read form with ID %q: %w", inputs.ID, err) + } + languages := current.GetLanguages() + if primary == "" { + primary = languages.GetPrimary() + } + if def == "" { + def = languages.GetDefault() + } + } + + rawBody, err = applyRawFormOverrides(json.RawMessage(`{}`), inputs.Name, primary, def) + if err != nil { + return fmt.Errorf("failed to build form update: %w", err) + } + case canPrompt(cmd): + // Editor fallback: pre-load the exact wire body and full-replace. + current, err := cli.formRawGet(cmd.Context(), inputs.ID) + if err != nil { + return fmt.Errorf("failed to read form 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 form with ID %q: %w", inputs.ID, err) + } + + if err := editFormJSON(cli, seed.String(), &rawBody); err != nil { + return err + } + default: + return errors.New("nothing to update; supply --file, pipe JSON via stdin, or a scalar flag such as --name") + } + + updated, err := cli.formRawUpdate(cmd.Context(), inputs.ID, rawBody) + if err != nil { + return fmt.Errorf("failed to update form with ID %q: %w", inputs.ID, err) + } + if err := cli.renderer.FormUpdateRaw(updated); err != nil { + return err + } + formNextStepsHint(cli, inputs.ID) + return nil + }, + } + + formName.RegisterStringU(cmd, &inputs.Name, "") + formFile.RegisterStringU(cmd, &inputs.File, "") + formLanguagePrimary.RegisterStringU(cmd, &inputs.LanguagePrimary, "") + formLanguageDefault.RegisterStringU(cmd, &inputs.LanguageDefault, "") + 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 deleteFormCmd(cli *cli) *cobra.Command { + cmd := &cobra.Command{ + Use: "delete", + Aliases: []string{"rm"}, + Args: cobra.ArbitraryArgs, + Short: "Delete a form", + Long: "Delete a form.\n\n" + + "To delete interactively, use `auth0 forms delete` with no arguments.\n\n" + + "To delete non-interactively, supply the form id and the `--force` flag to skip confirmation.", + Example: ` auth0 forms delete + auth0 forms rm + auth0 forms delete + auth0 forms delete --force + auth0 forms delete `, + RunE: func(cmd *cobra.Command, args []string) error { + var ids []string + if len(args) == 0 { + if err := formID.PickMany(cmd, &ids, cli.formPickerOptions); 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 form(s)", ids, func(_ int, id string) error { + if id == "" { + return nil + } + if err := cli.apiv3.Form.Delete(cmd.Context(), id); err != nil { + return fmt.Errorf("failed to delete form with ID %q: %w", id, err) + } + return nil + }) + }, + } + + cmd.Flags().BoolVar(&cli.force, "force", false, "Skip confirmation.") + + return cmd +} + +func exportFormCmd(cli *cli) *cobra.Command { + var inputs struct { + ID string + Output string + Compact bool + } + + cmd := &cobra.Command{ + Use: "export", + Args: cobra.MaximumNArgs(1), + Short: "Export a form", + Long: "Export a form as JSON. Writes to stdout by default (pipe-friendly) or to a file " + + "with `--output`. The output uses the same envelope as the Auth0 Dashboard " + + "(`version`, `form`, `flows`, `connections`), bundling the flows and vault connections " + + "the form references with portable `#FLOW-N#`/`#CONN-N#` placeholders, so it can be " + + "imported by the CLI or opened in the Dashboard.", + Example: ` auth0 forms export + auth0 forms export --output ./form.json + auth0 forms export --json-compact + auth0 forms export | auth0 forms import -f -`, + RunE: func(cmd *cobra.Command, args []string) error { + if len(args) == 0 { + if err := formID.Pick(cmd, &inputs.ID, cli.formPickerOptions); err != nil { + return err + } + } else { + inputs.ID = args[0] + } + + form, err := cli.formRawGet(cmd.Context(), inputs.ID) + if err != nil { + return fmt.Errorf("failed to read form with ID %q: %w", inputs.ID, err) + } + + env, err := cli.buildFormEnvelope(cmd.Context(), form) + if err != nil { + return err + } + + var data []byte + if inputs.Compact { + data, err = json.Marshal(env) + } else { + data, err = json.MarshalIndent(env, "", " ") + } + if err != nil { + return fmt.Errorf("failed to marshal form: %w", err) + } + + if inputs.Output != "" { + if err := os.WriteFile(inputs.Output, data, 0600); err != nil { + return fmt.Errorf("failed to write form to %q: %w", inputs.Output, err) + } + cli.renderer.Infof("Exported form %s to %s", inputs.ID, inputs.Output) + return nil + } + + cli.renderer.FormExport(string(data)) + return nil + }, + } + + formOutput.RegisterString(cmd, &inputs.Output, "") + cmd.Flags().BoolVar(&inputs.Compact, "json-compact", false, "Output in compact json format.") + + return cmd +} + +func importFormCmd(cli *cli) *cobra.Command { + var inputs struct { + ID string + File string + Connections map[string]string + } + + cmd := &cobra.Command{ + Use: "import", + Args: cobra.NoArgs, + Short: "Import a form", + Long: "Import a form from a JSON file (or piped stdin). Without `--id` a new form is " + + "created; with `--id` the existing form is replaced.\n\n" + + "Both a flat form graph and the Dashboard envelope (`version`, `form`, `flows`, " + + "`connections`) are accepted. For an envelope, the bundled flows are created and each " + + "`#CONN-N#` connection placeholder is mapped to an existing vault connection, either " + + "interactively or with `--connection`.", + Example: ` auth0 forms import --file ./form.json + auth0 forms import --file ./form.json --id + 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) + if err != nil { + return err + } + if body == nil { + return errors.New("no form body provided; supply --file or pipe JSON via stdin") + } + + if isFormEnvelope(body) { + resolved, err := cli.resolveFormEnvelope(cmd, body, inputs.Connections) + if err != nil { + return err + } + body = resolved + } + + // Parse just enough to validate the JSON and read the name. The body is + // created/updated as raw JSON so STEP/ROUTER node config is preserved + // (the typed request models drop it via the lossy FormNode union). + var meta struct { + Name string `json:"name"` + } + if err := json.Unmarshal(body, &meta); err != nil { + return fmt.Errorf("failed to parse form body: %w", err) + } + + if inputs.ID == "" { + if meta.Name == "" { + return errors.New("a form name is required in the imported body") + } + + raw, err := cli.formRawCreate(cmd.Context(), body) + if err != nil { + return fmt.Errorf("failed to create form: %w", err) + } + + return cli.renderer.FormCreateRaw(raw) + } + + raw, err := cli.formRawUpdate(cmd.Context(), inputs.ID, body) + if err != nil { + return fmt.Errorf("failed to update form with ID %q: %w", inputs.ID, err) + } + + return cli.renderer.FormUpdateRaw(raw) + }, + } + + formFile.RegisterString(cmd, &inputs.File, "") + formImportID.RegisterString(cmd, &inputs.ID, "") + cmd.Flags().StringToStringVar(&inputs.Connections, "connection", nil, + "Map an exported connection placeholder to an existing vault connection ID, "+ + "e.g. --connection '#CONN-1#=ac_123'. Repeatable.") + 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 openFormCmd(cli *cli) *cobra.Command { + var inputs struct { + ID string + } + + cmd := &cobra.Command{ + Use: "open", + Args: cobra.MaximumNArgs(1), + Short: "Open a form in the Auth0 Dashboard", + Long: "Open a form's page in the Auth0 Dashboard form builder.", + Example: ` auth0 forms open + auth0 forms open `, + RunE: func(cmd *cobra.Command, args []string) error { + if len(args) == 0 { + if err := formID.Pick(cmd, &inputs.ID, cli.formPickerOptions); err != nil { + return err + } + } else { + inputs.ID = args[0] + } + + openFormEditURL(cli, inputs.ID) + + return nil + }, + } + + 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 + } +} + +// formNextStepsHint prints follow-up commands after a form is created or updated. +// It stays quiet in JSON output modes so scripted consumers get a clean stream. +func formNextStepsHint(cli *cli, id string) { + if id == "" || cli.json || cli.jsonCompact { + return + } + cli.renderer.Infof("Inspect it with: %s", ansi.Faint("auth0 forms show "+id)) + 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) { + var form map[string]json.RawMessage + if err := json.Unmarshal(body, &form); err != nil { + return nil, err + } + if form == nil { + return nil, errors.New("form body must be a JSON object") + } + + if name != "" { + encoded, err := json.Marshal(name) + if err != nil { + return nil, err + } + form["name"] = encoded + } + + if primary != "" || def != "" { + languages := make(map[string]json.RawMessage) + if existing := form["languages"]; len(existing) > 0 && string(existing) != "null" { + if err := json.Unmarshal(existing, &languages); err != nil { + return nil, fmt.Errorf("parse languages: %w", err) + } + } + if primary != "" { + encoded, err := json.Marshal(primary) + if err != nil { + return nil, err + } + languages["primary"] = encoded + } + if def != "" { + encoded, err := json.Marshal(def) + if err != nil { + return nil, err + } + languages["default"] = encoded + } + encoded, err := json.Marshal(languages) + if err != nil { + return nil, err + } + form["languages"] = encoded + } + + 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) +} + +// 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) +} + +// 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) + 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 +} + +// collectForms pages through the forms list, collecting up to `limit` results +// (all results when limit <= 0). +func collectForms(ctx context.Context, cli *cli, params *managementv3.ListFormsRequestParameters, limit int) ([]*managementv3.FormSummary, error) { + page, err := cli.apiv3.Form.List(ctx, params) + if err != nil { + return nil, err + } + + var out []*managementv3.FormSummary + 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 (c *cli) formPickerOptions(ctx context.Context) (pickerOptions, error) { + forms, err := collectForms(ctx, c, &managementv3.ListFormsRequestParameters{}, 0) + if err != nil { + return nil, err + } + + var opts pickerOptions + for _, f := range forms { + 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 forms to choose from. Create one by running: `auth0 forms create`") + } + + return opts, nil +} diff --git a/internal/cli/forms_envelope.go b/internal/cli/forms_envelope.go new file mode 100644 index 000000000..3d336ee51 --- /dev/null +++ b/internal/cli/forms_envelope.go @@ -0,0 +1,398 @@ +package cli + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "sort" + + "github.com/auth0/go-auth0/management" + "github.com/spf13/cobra" + + "github.com/auth0/auth0-cli/internal/ansi" + "github.com/auth0/auth0-cli/internal/prompt" +) + +// formEnvelopeVersion is the schema version the Auth0 Dashboard form builder +// stamps on exported forms. We emit the same value so exports interop. +const formEnvelopeVersion = "4.0.0" + +// formEnvelope mirrors the export shape produced by the Auth0 Dashboard form +// builder: the form graph plus the flows and vault connections it references, +// with real resource IDs replaced by portable #FLOW-N#/#CONN-N# placeholders. +type formEnvelope struct { + Version string `json:"version"` + Form json.RawMessage `json:"form"` + Flows map[string]json.RawMessage `json:"flows,omitempty"` + Connections map[string]envelopeConn `json:"connections,omitempty"` +} + +// envelopeConn is the connection descriptor emitted alongside a form. Vault +// connection secrets are never exported, so on import the placeholder is mapped +// to an existing connection rather than recreated. +type envelopeConn struct { + ID string `json:"id"` + AppID string `json:"app_id,omitempty"` + Name string `json:"name,omitempty"` +} + +// isFormEnvelope reports whether the given body is a Dashboard-style envelope +// rather than a flat form graph. An envelope always carries a top-level "form" +// object, whereas a flat body carries the form fields such as name and nodes +// at the top level. +func isFormEnvelope(body []byte) bool { + var probe struct { + Form json.RawMessage `json:"form"` + } + if err := json.Unmarshal(body, &probe); err != nil { + return false + } + return len(probe.Form) > 0 +} + +// substituteIDs replaces every JSON string value that exactly matches a key in +// `replacements` with its mapped value, walking the whole tree. IDs are opaque +// unique tokens, so exact full-string matching is safe and order-independent. +func substituteIDs(raw json.RawMessage, replacements map[string]string) (json.RawMessage, error) { + if len(replacements) == 0 { + return raw, nil + } + + var tree interface{} + if err := json.Unmarshal(raw, &tree); err != nil { + return nil, err + } + + return json.Marshal(walkReplace(tree, replacements)) +} + +func walkReplace(node interface{}, replacements map[string]string) interface{} { + switch v := node.(type) { + case map[string]interface{}: + for key, val := range v { + v[key] = walkReplace(val, replacements) + } + return v + case []interface{}: + for i, val := range v { + v[i] = walkReplace(val, replacements) + } + return v + case string: + if replaced, ok := replacements[v]; ok { + return replaced + } + return v + default: + return node + } +} + +// collectConnectionIDs returns every value stored under a "connection_id" key +// anywhere in the given flow JSON, de-duplicated and sorted for stable ordering. +func collectConnectionIDs(raw json.RawMessage) ([]string, error) { + var tree interface{} + if err := json.Unmarshal(raw, &tree); err != nil { + return nil, err + } + + seen := map[string]bool{} + var walk func(node interface{}) + walk = func(node interface{}) { + switch v := node.(type) { + case map[string]interface{}: + for key, val := range v { + if key == "connection_id" { + if s, ok := val.(string); ok && s != "" { + seen[s] = true + } + } + walk(val) + } + case []interface{}: + for _, val := range v { + walk(val) + } + } + } + walk(tree) + + out := make([]string, 0, len(seen)) + for id := range seen { + out = append(out, id) + } + sort.Strings(out) + + return out, nil +} + +// collectFlowIDs returns the flow IDs referenced by the form's FLOW nodes, in +// node order and de-duplicated. Working from the raw form map avoids the v3 +// SDK's lossy FormNode union. +func collectFlowIDs(formMap map[string]interface{}) []string { + nodes, ok := formMap["nodes"].([]interface{}) + if !ok { + return nil + } + + var ids []string + seen := map[string]bool{} + for _, n := range nodes { + node, ok := n.(map[string]interface{}) + if !ok || node["type"] != "FLOW" { + continue + } + config, ok := node["config"].(map[string]interface{}) + if !ok { + continue + } + id, ok := config["flow_id"].(string) + if !ok || id == "" || seen[id] { + continue + } + seen[id] = true + ids = append(ids, id) + } + + return ids +} + +// vaultConnectionPickerOptions lists the tenant's flow vault connections as +// selectable options for mapping envelope connection placeholders on import. +func (c *cli) vaultConnectionPickerOptions(ctx context.Context) (pickerOptions, error) { + var list *management.FlowVaultConnectionList + if err := ansi.Waiting(func() (err error) { + list, err = c.api.FlowVaultConnection.GetConnectionList(ctx) + return err + }); err != nil { + return nil, err + } + + var opts pickerOptions + for _, conn := range list.Connections { + label := fmt.Sprintf("%s %s", conn.GetName(), ansi.Faint("("+conn.GetID()+")")) + opts = append(opts, pickerOption{value: conn.GetID(), label: label}) + } + + if len(opts) == 0 { + return nil, errors.New("there are currently no vault connections to map to. Create one in the Auth0 Dashboard first") + } + + return opts, nil +} + +// resolveConnectionPlaceholders maps each #CONN-N# placeholder in the envelope +// to a real vault connection ID. It uses the provided mapping first and falls +// back to an interactive picker; without a terminal an unmapped placeholder is +// an error that tells the user to pass --connection. +func (c *cli) resolveConnectionPlaceholders( + cmd *cobra.Command, + env *formEnvelope, + mapping map[string]string, +) (map[string]string, error) { + placeholders := make([]string, 0, len(env.Connections)) + for ph := range env.Connections { + placeholders = append(placeholders, ph) + } + sort.Strings(placeholders) + + var options pickerOptions + resolved := make(map[string]string, len(placeholders)) + for _, ph := range placeholders { + if id := mapping[ph]; id != "" { + resolved[ph] = id + continue + } + + if !canPrompt(cmd) { + return nil, fmt.Errorf( + "cannot resolve connection %s: pass --connection '%s=' or run without --no-input", + ph, ph, + ) + } + + if options == nil { + opts, err := c.vaultConnectionPickerOptions(cmd.Context()) + if err != nil { + return nil, err + } + options = opts + } + + var label string + message := fmt.Sprintf("Select the vault connection for %s (%s):", ph, env.Connections[ph].Name) + if err := prompt.AskOne( + prompt.SelectInput("connection", message, "", options.labels(), options.defaultLabel(), true), + &label, + ); err != nil { + return nil, err + } + resolved[ph] = options.getValue(label) + } + + return resolved, nil +} + +// resolveFormEnvelope turns a Dashboard-style envelope into a flat form body +// ready for create/update: it maps connection placeholders to existing vault +// connections, creates the bundled flows (substituting the resolved connection +// IDs into them), and swaps the form's #FLOW-N# references for the new flow IDs. +func (c *cli) resolveFormEnvelope( + cmd *cobra.Command, + body []byte, + mapping map[string]string, +) (json.RawMessage, error) { + var env formEnvelope + if err := json.Unmarshal(body, &env); err != nil { + return nil, fmt.Errorf("failed to parse form body: %w", err) + } + if len(env.Form) == 0 { + return nil, errors.New("the imported envelope has no \"form\" object") + } + + connReplacements, err := c.resolveConnectionPlaceholders(cmd, &env, mapping) + if err != nil { + return nil, err + } + + // Create flows in placeholder order for a deterministic sequence. + placeholders := make([]string, 0, len(env.Flows)) + for ph := range env.Flows { + placeholders = append(placeholders, ph) + } + sort.Strings(placeholders) + + flowReplacements := make(map[string]string, len(placeholders)) + for _, ph := range placeholders { + flowRaw, err := substituteIDs(env.Flows[ph], connReplacements) + if err != nil { + return nil, err + } + + flow := &management.Flow{} + if err := json.Unmarshal(flowRaw, flow); err != nil { + return nil, fmt.Errorf("failed to parse flow %s: %w", ph, err) + } + if err := ansi.Waiting(func() error { + return c.api.Flow.Create(cmd.Context(), flow) + }); err != nil { + return nil, fmt.Errorf("failed to create flow %s: %w", ph, err) + } + flowReplacements[ph] = flow.GetID() + } + + return substituteIDs(env.Form, flowReplacements) +} + +// buildFormEnvelope turns a fetched form (raw wire JSON) into a Dashboard-style +// envelope: it reads every flow the form's FLOW nodes reference and every vault +// connection those flows reference, then swaps the real IDs for #FLOW-N#/#CONN-N# +// placeholders so the export is portable across tenants. The form is handled as +// raw JSON so STEP/ROUTER node config survives the round-trip. +func (c *cli) buildFormEnvelope( + ctx context.Context, + formRaw json.RawMessage, +) (*formEnvelope, error) { + var formMap map[string]interface{} + if err := json.Unmarshal(formRaw, &formMap); err != nil { + return nil, fmt.Errorf("failed to parse form: %w", err) + } + + // Referenced flow IDs, in node order, de-duplicated. + flowIDs := collectFlowIDs(formMap) + + // Read each flow and gather the connections its actions reference. + flowsByID := make(map[string]json.RawMessage, len(flowIDs)) + connSet := map[string]bool{} + for _, id := range flowIDs { + var flow *management.Flow + if err := ansi.Waiting(func() (err error) { + flow, err = c.api.Flow.Read(ctx, id) + return err + }); err != nil { + return nil, fmt.Errorf("failed to read flow with ID %q: %w", id, err) + } + + raw, err := json.Marshal(flow) + if err != nil { + return nil, fmt.Errorf("failed to marshal flow with ID %q: %w", id, err) + } + flowsByID[id] = raw + + connIDs, err := collectConnectionIDs(raw) + if err != nil { + return nil, err + } + for _, cid := range connIDs { + connSet[cid] = true + } + } + + connIDs := make([]string, 0, len(connSet)) + for id := range connSet { + connIDs = append(connIDs, id) + } + sort.Strings(connIDs) + + // Assign placeholders and build the real-ID -> placeholder replacement map. + replacements := make(map[string]string, len(flowIDs)+len(connIDs)) + flowPlaceholder := make(map[string]string, len(flowIDs)) + for i, id := range flowIDs { + ph := fmt.Sprintf("#FLOW-%d#", i+1) + replacements[id] = ph + flowPlaceholder[id] = ph + } + connPlaceholder := make(map[string]string, len(connIDs)) + for i, id := range connIDs { + ph := fmt.Sprintf("#CONN-%d#", i+1) + replacements[id] = ph + connPlaceholder[id] = ph + } + + // Drop volatile fields and swap in placeholders. + for _, field := range formServerManagedFields { + delete(formMap, field) + } + formBody, err := json.Marshal(formMap) + if err != nil { + return nil, err + } + formBody, err = substituteIDs(formBody, replacements) + if err != nil { + return nil, err + } + + env := &formEnvelope{Version: formEnvelopeVersion, Form: formBody} + + if len(flowsByID) > 0 { + env.Flows = make(map[string]json.RawMessage, len(flowsByID)) + for id, raw := range flowsByID { + substituted, err := substituteIDs(raw, replacements) + if err != nil { + return nil, err + } + env.Flows[flowPlaceholder[id]] = substituted + } + } + + if len(connIDs) > 0 { + env.Connections = make(map[string]envelopeConn, len(connIDs)) + for _, id := range connIDs { + var conn *management.FlowVaultConnection + if err := ansi.Waiting(func() (err error) { + conn, err = c.api.FlowVaultConnection.GetConnection(ctx, id) + return err + }); err != nil { + return nil, fmt.Errorf("failed to read vault connection with ID %q: %w", id, err) + } + env.Connections[connPlaceholder[id]] = envelopeConn{ + ID: conn.GetID(), + AppID: conn.GetAppID(), + Name: conn.GetName(), + } + } + } + + return env, nil +} diff --git a/internal/cli/forms_envelope_test.go b/internal/cli/forms_envelope_test.go new file mode 100644 index 000000000..33f8cfaea --- /dev/null +++ b/internal/cli/forms_envelope_test.go @@ -0,0 +1,206 @@ +package cli + +import ( + "context" + "encoding/json" + "testing" + + "github.com/auth0/go-auth0/management" + "github.com/golang/mock/gomock" + "github.com/spf13/cobra" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/auth0/auth0-cli/internal/auth0" + "github.com/auth0/auth0-cli/internal/auth0/mock" +) + +func TestIsFormEnvelope(t *testing.T) { + tests := []struct { + name string + body string + want bool + }{ + {name: "envelope with form object", body: `{"version":"4.0.0","form":{"name":"x"}}`, want: true}, + {name: "flat form graph", body: `{"name":"x","nodes":[]}`, want: false}, + {name: "form as non-object is still detected", body: `{"form":{}}`, want: true}, + {name: "invalid json", body: `not-json`, want: false}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + assert.Equal(t, test.want, isFormEnvelope([]byte(test.body))) + }) + } +} + +func TestSubstituteIDs(t *testing.T) { + t.Run("replaces exact string matches anywhere in the tree", func(t *testing.T) { + in := json.RawMessage(`{"flow_id":"fl_1","nested":{"connection_id":"ac_1","keep":"fl_1x"},"list":["fl_1","other"]}`) + out, err := substituteIDs(in, map[string]string{"fl_1": "#FLOW-1#", "ac_1": "#CONN-1#"}) + require.NoError(t, err) + + var got map[string]interface{} + require.NoError(t, json.Unmarshal(out, &got)) + assert.Equal(t, "#FLOW-1#", got["flow_id"]) + nested := got["nested"].(map[string]interface{}) + assert.Equal(t, "#CONN-1#", nested["connection_id"]) + assert.Equal(t, "fl_1x", nested["keep"]) // Substring must not be replaced. + list := got["list"].([]interface{}) + assert.Equal(t, "#FLOW-1#", list[0]) + assert.Equal(t, "other", list[1]) + }) + + t.Run("returns the input unchanged when there are no replacements", func(t *testing.T) { + in := json.RawMessage(`{"a":"b"}`) + out, err := substituteIDs(in, nil) + require.NoError(t, err) + assert.Equal(t, in, out) + }) +} + +func TestCollectConnectionIDs(t *testing.T) { + raw := json.RawMessage(`{ + "name": "flow", + "actions": [ + {"params": {"connection_id": "ac_2"}}, + {"params": {"connection_id": "ac_1"}}, + {"params": {"connection_id": "ac_1"}}, + {"params": {"other": "x"}} + ] + }`) + + got, err := collectConnectionIDs(raw) + require.NoError(t, err) + assert.Equal(t, []string{"ac_1", "ac_2"}, got) // De-duplicated and sorted. +} + +func TestBuildFormEnvelope(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + form := json.RawMessage(`{ + "id": "ap_form1", + "name": "Test Form", + "nodes": [ + {"id": "step_1", "type": "STEP", "config": {"next_node": "flow_1", "components": [{"id": "job_title", "type": "TEXT", "category": "FIELD"}]}}, + {"id": "flow_1", "type": "FLOW", "config": {"flow_id": "fl_real", "next_node": "$ending"}} + ], + "start": {"next_node": "step_1"}, + "ending": {} + }`) + + flowMock := mock.NewMockFlowAPI(ctrl) + flowMock.EXPECT().Read(gomock.Any(), "fl_real").Return(&management.Flow{ + Name: auth0.String("My Flow"), + Actions: []interface{}{ + map[string]interface{}{ + "type": "AUTH0", + "params": map[string]interface{}{"connection_id": "ac_real"}, + }, + }, + }, nil) + + connMock := mock.NewMockFlowVaultConnectionAPI(ctrl) + connMock.EXPECT().GetConnection(gomock.Any(), "ac_real").Return(&management.FlowVaultConnection{ + ID: auth0.String("ac_real"), + AppID: auth0.String("AUTH0"), + Name: auth0.String("My Connection"), + }, nil) + + cli := &cli{api: &auth0.API{Flow: flowMock, FlowVaultConnection: connMock}} + + env, err := cli.buildFormEnvelope(context.Background(), form) + require.NoError(t, err) + + assert.Equal(t, formEnvelopeVersion, env.Version) + + // Connection descriptor keeps the real values under the placeholder key. + require.Contains(t, env.Connections, "#CONN-1#") + assert.Equal(t, "ac_real", env.Connections["#CONN-1#"].ID) + assert.Equal(t, "AUTH0", env.Connections["#CONN-1#"].AppID) + assert.Equal(t, "My Connection", env.Connections["#CONN-1#"].Name) + + // The flow node's flow_id is replaced with the placeholder, and volatile + // fields are dropped from the form block. + var formMap map[string]interface{} + require.NoError(t, json.Unmarshal(env.Form, &formMap)) + assert.NotContains(t, formMap, "id") + nodes := formMap["nodes"].([]interface{}) + flowNode := nodes[1].(map[string]interface{}) + flowConfig := flowNode["config"].(map[string]interface{}) + assert.Equal(t, "#FLOW-1#", flowConfig["flow_id"]) + + // STEP node config (components) is preserved, not dropped by the SDK's union. + stepNode := nodes[0].(map[string]interface{}) + stepConfig := stepNode["config"].(map[string]interface{}) + components := stepConfig["components"].([]interface{}) + require.Len(t, components, 1) + assert.Equal(t, "job_title", components[0].(map[string]interface{})["id"]) + + // The flow's connection_id is replaced with the placeholder. + require.Contains(t, env.Flows, "#FLOW-1#") + var flowMap map[string]interface{} + require.NoError(t, json.Unmarshal(env.Flows["#FLOW-1#"], &flowMap)) + action := flowMap["actions"].([]interface{})[0].(map[string]interface{}) + params := action["params"].(map[string]interface{}) + assert.Equal(t, "#CONN-1#", params["connection_id"]) +} + +func TestResolveFormEnvelope(t *testing.T) { + envelope := []byte(`{ + "version": "4.0.0", + "form": { + "name": "Test Form", + "nodes": [ + {"id": "flow_1", "type": "FLOW", "config": {"flow_id": "#FLOW-1#"}} + ] + }, + "flows": { + "#FLOW-1#": { + "name": "My Flow", + "actions": [{"params": {"connection_id": "#CONN-1#"}}] + } + }, + "connections": { + "#CONN-1#": {"id": "ac_placeholder", "app_id": "AUTH0", "name": "REPLACE_WITH_M2M_CONNECTION"} + } + }`) + + t.Run("maps connections, creates flows, and substitutes flow IDs", func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + flowMock := mock.NewMockFlowAPI(ctrl) + flowMock.EXPECT().Create(gomock.Any(), gomock.Any()).DoAndReturn( + func(_ context.Context, r *management.Flow, _ ...management.RequestOption) error { + // The connection placeholder is resolved before the flow is created. + action := r.Actions[0].(map[string]interface{}) + params := action["params"].(map[string]interface{}) + assert.Equal(t, "ac_mapped", params["connection_id"]) + r.ID = auth0.String("fl_created") + return nil + }) + + cli := &cli{api: &auth0.API{Flow: flowMock}} + + body, err := cli.resolveFormEnvelope(&cobra.Command{}, envelope, map[string]string{"#CONN-1#": "ac_mapped"}) + require.NoError(t, err) + + var formMap map[string]interface{} + require.NoError(t, json.Unmarshal(body, &formMap)) + node := formMap["nodes"].([]interface{})[0].(map[string]interface{}) + config := node["config"].(map[string]interface{}) + assert.Equal(t, "fl_created", config["flow_id"]) + }) + + t.Run("errors when a connection cannot be resolved without a terminal", func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + cli := &cli{api: &auth0.API{Flow: mock.NewMockFlowAPI(ctrl)}} + + _, err := cli.resolveFormEnvelope(&cobra.Command{}, envelope, nil) + assert.ErrorContains(t, err, "cannot resolve connection #CONN-1#") + }) +} diff --git a/internal/cli/forms_test.go b/internal/cli/forms_test.go new file mode 100644 index 000000000..8f8610efb --- /dev/null +++ b/internal/cli/forms_test.go @@ -0,0 +1,527 @@ +package cli + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "io" + "net/http" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/auth0/go-auth0/management" + managementv3 "github.com/auth0/go-auth0/v3/management" + "github.com/auth0/go-auth0/v3/management/core" + "github.com/golang/mock/gomock" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "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" +) + +func TestApplyRawFormOverrides(t *testing.T) { + body := json.RawMessage(`{ + "name":"Original", + "languages":{"primary":"en","default":"fr"}, + "start":{}, + "nodes":[ + {"id":"step_1","type":"STEP","config":{"components":[{"id":"field_1","category":"FIELD","type":"TEXT"}]}}, + {"id":"router_1","type":"ROUTER","config":{"rules":[{"id":"rule_1","condition":{"operator":"AND"}}]}} + ], + "ending":null + }`) + + got, err := applyRawFormOverrides(body, "Renamed", "de", "") + require.NoError(t, err) + + var form map[string]json.RawMessage + require.NoError(t, json.Unmarshal(got, &form)) + + assert.JSONEq(t, `"Renamed"`, string(form["name"])) + assert.JSONEq(t, `{"primary":"de","default":"fr"}`, string(form["languages"])) + assert.JSONEq(t, `{}`, string(form["start"])) + assert.JSONEq(t, `null`, string(form["ending"])) + assert.Contains(t, string(form["nodes"]), `"components"`) + assert.Contains(t, string(form["nodes"]), `"condition"`) +} + +func TestApplyRawFormOverridesRejectsNonObject(t *testing.T) { + _, err := applyRawFormOverrides(json.RawMessage(`[]`), "", "", "") + assert.ErrorContains(t, err, "cannot unmarshal array") +} + +func TestCreateFormCmdUsesRawClientForSimpleScaffold(t *testing.T) { + httpClient := &formHTTPClientStub{ + response: json.RawMessage(`{"id":"ap_simple","name":"Simple Form","start":{},"nodes":[],"ending":{}}`), + } + stdout := &bytes.Buffer{} + c := &cli{ + api: &auth0.API{HTTPClient: httpClient}, + renderer: &display.Renderer{ + MessageWriter: io.Discard, + ResultWriter: stdout, + }, + } + + cmd := createFormCmd(c) + cmd.SetArgs([]string{"--name", "Simple Form"}) + + require.NoError(t, cmd.Execute()) + assert.Equal(t, http.MethodPost, httpClient.method) + require.IsType(t, json.RawMessage{}, httpClient.payload) + assert.JSONEq(t, `{"name":"Simple Form","start":{},"nodes":[],"ending":{}}`, string(httpClient.payload.(json.RawMessage))) + assert.Contains(t, stdout.String(), "Simple Form") +} + +func TestCreateFormCmdUsesRawClientForRichFile(t *testing.T) { + body := []byte(`{ + "name":"Rich Form", + "nodes":[{"id":"step_1","type":"STEP","config":{"components":[{"id":"field_1","category":"FIELD","type":"TEXT"}]}}] + }`) + path := filepath.Join(t.TempDir(), "form.json") + require.NoError(t, os.WriteFile(path, body, 0600)) + + httpClient := &formHTTPClientStub{ + response: json.RawMessage(`{ + "id":"ap_rich", + "name":"Rich Form", + "nodes":[{"id":"step_1","type":"STEP","config":{"components":[{"id":"field_1","category":"FIELD","type":"TEXT"}]}}] + }`), + } + stdout := &bytes.Buffer{} + c := &cli{ + api: &auth0.API{HTTPClient: httpClient}, + renderer: &display.Renderer{ + MessageWriter: io.Discard, + ResultWriter: stdout, + }, + } + + cmd := createFormCmd(c) + cmd.SetArgs([]string{"--file", path}) + + require.NoError(t, cmd.Execute()) + assert.Equal(t, http.MethodPost, httpClient.method) + require.IsType(t, json.RawMessage{}, httpClient.payload) + assert.Contains(t, string(httpClient.payload.(json.RawMessage)), `"components"`) + assert.Contains(t, stdout.String(), "1 nodes") +} + +func TestShowFormCmdUsesRawClient(t *testing.T) { + httpClient := &formHTTPClientStub{ + response: json.RawMessage(`{ + "id":"ap_rich", + "name":"Rich Form", + "flow_count":2, + "links":{"self":"https://example.test/forms/ap_rich"}, + "nodes":[{"id":"router_1","type":"ROUTER","config":{"rules":[{"id":"rule_1","condition":{"operator":"AND"}}]}}] + }`), + } + stdout := &bytes.Buffer{} + c := &cli{ + api: &auth0.API{HTTPClient: httpClient}, + renderer: &display.Renderer{ + MessageWriter: io.Discard, + ResultWriter: stdout, + }, + } + + cmd := showFormCmd(c) + cmd.SetArgs([]string{"ap_rich"}) + + require.NoError(t, cmd.Execute()) + assert.Equal(t, http.MethodGet, httpClient.method) + assert.Contains(t, stdout.String(), "1 nodes") +} + +func TestUpdateFormCmdUsesRawClientForScalarUpdate(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + formAPI := mock.NewMockFormAPIV3(ctrl) + formAPI.EXPECT(). + Get(gomock.Any(), "ap_simple", gomock.Any()). + Return(&managementv3.GetFormResponseContent{ + ID: "ap_simple", + Name: "Original", + Languages: &managementv3.FormLanguages{ + Primary: auth0.String("en"), + Default: auth0.String("fr"), + }, + }, nil) + httpClient := &formHTTPClientStub{ + response: json.RawMessage(`{"id":"ap_simple","name":"Renamed","languages":{"primary":"de","default":"fr"}}`), + } + + stdout := &bytes.Buffer{} + c := &cli{ + api: &auth0.API{HTTPClient: httpClient}, + apiv3: &auth0.APIV3{Form: formAPI}, + renderer: &display.Renderer{ + MessageWriter: io.Discard, + ResultWriter: stdout, + }, + } + + cmd := updateFormCmd(c) + cmd.SetArgs([]string{"ap_simple", "--name", "Renamed", "--language-primary", "de"}) + + require.NoError(t, cmd.Execute()) + assert.Equal(t, http.MethodPatch, httpClient.method) + require.IsType(t, json.RawMessage{}, httpClient.payload) + assert.JSONEq(t, `{"name":"Renamed","languages":{"primary":"de","default":"fr"}}`, string(httpClient.payload.(json.RawMessage))) + assert.Contains(t, stdout.String(), "Renamed") +} + +func TestUpdateFormCmdUsesRawClientForRichFile(t *testing.T) { + body := []byte(`{ + "id":"ap_rich", + "name":"Rich Form", + "nodes":[{"id":"router_1","type":"ROUTER","config":{"rules":[{"id":"rule_1","condition":{"operator":"AND"}}]}}], + "ending":null, + "created_at":"2026-08-24T00:00:00Z", + "updated_at":"2026-08-24T00:00:00Z", + "flow_count":0, + "links":{} + }`) + path := filepath.Join(t.TempDir(), "form.json") + require.NoError(t, os.WriteFile(path, body, 0600)) + + httpClient := &formHTTPClientStub{ + response: json.RawMessage(`{ + "id":"ap_rich", + "name":"Rich Form", + "nodes":[{"id":"router_1","type":"ROUTER","config":{"rules":[{"id":"rule_1","condition":{"operator":"AND"}}]}}], + "ending":null + }`), + } + stdout := &bytes.Buffer{} + c := &cli{ + api: &auth0.API{HTTPClient: httpClient}, + renderer: &display.Renderer{ + MessageWriter: io.Discard, + ResultWriter: stdout, + }, + } + + cmd := updateFormCmd(c) + cmd.SetArgs([]string{"ap_rich", "--file", path}) + + require.NoError(t, cmd.Execute()) + assert.Equal(t, http.MethodPatch, httpClient.method) + require.IsType(t, json.RawMessage{}, httpClient.payload) + payload := httpClient.payload.(json.RawMessage) + assert.Contains(t, string(payload), `"condition"`) + assert.Contains(t, string(payload), `"ending":null`) + var form map[string]json.RawMessage + require.NoError(t, json.Unmarshal(payload, &form)) + assert.NotContains(t, form, "id") + assert.NotContains(t, form, "created_at") + assert.NotContains(t, form, "updated_at") + assert.NotContains(t, form, "flow_count") + assert.NotContains(t, form, "links") + assert.Contains(t, stdout.String(), "1 nodes") +} + +func TestReadFormBody(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) + 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")) + assert.ErrorContains(t, err, "failed to read form file") + }) + + t.Run("reads from stdin when file is '-'", func(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "stdin.json") + want := []byte(`{"name":"Piped Form"}`) + assert.NoError(t, os.WriteFile(path, want, 0600)) + + f, err := os.Open(path) + assert.NoError(t, err) + defer f.Close() + + original := iostream.Input + iostream.Input = f + defer func() { iostream.Input = original }() + + got, err := readFormBody("-") + assert.NoError(t, err) + assert.Equal(t, want, got) + }) +} + +func TestFormPickerOptions(t *testing.T) { + tests := []struct { + name string + forms []*managementv3.FormSummary + apiError error + assertOutput func(t testing.TB, options pickerOptions) + assertError func(t testing.TB, err error) + }{ + { + name: "happy path", + forms: []*managementv3.FormSummary{ + {ID: "some-id-1", Name: "some-name-1"}, + {ID: "some-id-2", Name: "some-name-2"}, + }, + assertOutput: func(t testing.TB, options pickerOptions) { + assert.Len(t, options, 2) + assert.Equal(t, "some-name-1 (some-id-1)", options[0].label) + assert.Equal(t, "some-id-1", options[0].value) + assert.Equal(t, "some-name-2 (some-id-2)", options[1].label) + assert.Equal(t, "some-id-2", options[1].value) + }, + assertError: func(t testing.TB, err error) { + t.Fail() + }, + }, + { + name: "no forms", + forms: []*managementv3.FormSummary{}, + assertOutput: func(t testing.TB, options pickerOptions) { + t.Fail() + }, + assertError: func(t testing.TB, err error) { + assert.ErrorContains(t, err, "there are currently no forms to choose from. Create one by running: `auth0 forms create`") + }, + }, + { + name: "API error", + apiError: errors.New("error"), + assertOutput: func(t testing.TB, options pickerOptions) { + t.Fail() + }, + assertError: func(t testing.TB, err error) { + assert.Error(t, err) + }, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + formAPI := mock.NewMockFormAPIV3(ctrl) + if test.apiError != nil { + formAPI.EXPECT().List(gomock.Any(), gomock.Any()).Return(nil, test.apiError) + } else { + formAPI.EXPECT().List(gomock.Any(), gomock.Any()).Return( + &auth0.FormSummaryPage{ + Results: test.forms, + NextPageFunc: func(_ context.Context) (*auth0.FormSummaryPage, error) { + return nil, core.ErrNoPages + }, + }, nil) + } + + cli := &cli{ + apiv3: &auth0.APIV3{Form: formAPI}, + } + + options, err := cli.formPickerOptions(context.Background()) + + if err != nil { + test.assertError(t, err) + } else { + test.assertOutput(t, options) + } + }) + } +} + +func TestCollectForms(t *testing.T) { + t.Run("pages across responses until exhausted", func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + secondPage := &auth0.FormSummaryPage{ + Results: []*managementv3.FormSummary{{ID: "id-3", Name: "Form 3"}}, + NextPageFunc: func(_ context.Context) (*auth0.FormSummaryPage, error) { + return nil, core.ErrNoPages + }, + } + firstPage := &auth0.FormSummaryPage{ + Results: []*managementv3.FormSummary{ + {ID: "id-1", Name: "Form 1"}, + {ID: "id-2", Name: "Form 2"}, + }, + NextPageFunc: func(_ context.Context) (*auth0.FormSummaryPage, error) { + return secondPage, nil + }, + } + + formAPI := mock.NewMockFormAPIV3(ctrl) + formAPI.EXPECT().List(gomock.Any(), gomock.Any()).Return(firstPage, nil) + + cli := &cli{apiv3: &auth0.APIV3{Form: formAPI}} + + forms, err := collectForms(context.Background(), cli, &managementv3.ListFormsRequestParameters{}, 0) + assert.NoError(t, err) + assert.Len(t, forms, 3) + assert.Equal(t, "id-3", forms[2].GetID()) + }) + + t.Run("stops at the requested limit without paging further", func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + firstPage := &auth0.FormSummaryPage{ + Results: []*managementv3.FormSummary{ + {ID: "id-1", Name: "Form 1"}, + {ID: "id-2", Name: "Form 2"}, + }, + NextPageFunc: func(_ context.Context) (*auth0.FormSummaryPage, error) { + t.Fatal("should not page past the limit") + return nil, nil + }, + } + + formAPI := mock.NewMockFormAPIV3(ctrl) + formAPI.EXPECT().List(gomock.Any(), gomock.Any()).Return(firstPage, nil) + + cli := &cli{apiv3: &auth0.APIV3{Form: formAPI}} + + forms, err := collectForms(context.Background(), cli, &managementv3.ListFormsRequestParameters{}, 1) + assert.NoError(t, err) + assert.Len(t, forms, 1) + assert.Equal(t, "id-1", forms[0].GetID()) + }) + + t.Run("returns the list error", func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + formAPI := mock.NewMockFormAPIV3(ctrl) + formAPI.EXPECT().List(gomock.Any(), gomock.Any()).Return(nil, errors.New("boom")) + + cli := &cli{apiv3: &auth0.APIV3{Form: formAPI}} + + _, err := collectForms(context.Background(), cli, &managementv3.ListFormsRequestParameters{}, 0) + assert.EqualError(t, err, "boom") + }) +} + +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{} + response json.RawMessage +} + +func (s *formHTTPClientStub) NewRequest( + ctx context.Context, + method string, + uri string, + payload interface{}, + _ ...management.RequestOption, +) (*http.Request, error) { + s.method = method + s.payload = payload + return http.NewRequestWithContext(ctx, method, uri, nil) +} + +func (s *formHTTPClientStub) Do(_ *http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: http.StatusOK, + Header: make(http.Header), + Body: io.NopCloser(strings.NewReader(string(s.response))), + }, nil +} + +func (s *formHTTPClientStub) Request( + context.Context, + string, + string, + interface{}, + ...management.RequestOption, +) error { + return nil +} + +func (s *formHTTPClientStub) URI(path ...string) string { + return "https://example.test/api/v2/" + strings.Join(path, "/") +} diff --git a/internal/cli/root.go b/internal/cli/root.go index 0d818c088..cc7596a37 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -280,6 +280,7 @@ func addSubCommands(rootCmd *cobra.Command, cli *cli) { rootCmd.AddCommand(apiCmd(cli)) rootCmd.AddCommand(terraformCmd(cli)) rootCmd.AddCommand(eventStreamsCmd(cli)) + rootCmd.AddCommand(formsCmd(cli)) rootCmd.AddCommand(networkACLCmd(cli)) rootCmd.AddCommand(tenantSettingsCmd(cli)) rootCmd.AddCommand(tokenExchangeCmd(cli)) diff --git a/internal/cli/terraform.go b/internal/cli/terraform.go index 5ddee1b59..d7c45ee5d 100644 --- a/internal/cli/terraform.go +++ b/internal/cli/terraform.go @@ -98,7 +98,7 @@ func (i *terraformInputs) parseResourceFetchers(api *auth0.API, apiv3 *auth0.API case "auth0_flow_vault_connection": fetchers = append(fetchers, &flowVaultConnectionResourceFetcher{api}) case "auth0_form": - fetchers = append(fetchers, &formResourceFetcher{api}) + fetchers = append(fetchers, &formResourceFetcher{apiv3}) case "auth0_guardian": fetchers = append(fetchers, &guardianResourceFetcher{}) case "auth0_log_stream": diff --git a/internal/cli/terraform_fetcher.go b/internal/cli/terraform_fetcher.go index 05655e5f2..a999c64d9 100644 --- a/internal/cli/terraform_fetcher.go +++ b/internal/cli/terraform_fetcher.go @@ -2,11 +2,13 @@ package cli import ( "context" + "errors" "net/http" "strings" "github.com/auth0/go-auth0/management" managementv3 "github.com/auth0/go-auth0/v3/management" + "github.com/auth0/go-auth0/v3/management/core" "github.com/google/uuid" "github.com/auth0/auth0-cli/internal/auth0" @@ -86,7 +88,7 @@ type ( } formResourceFetcher struct { - api *auth0.API + apiv3 *auth0.APIV3 } guardianResourceFetcher struct{} @@ -432,16 +434,26 @@ func (f *flowVaultConnectionResourceFetcher) FetchData(ctx context.Context) (imp func (f *formResourceFetcher) FetchData(ctx context.Context) (importDataList, error) { var data importDataList - forms, err := f.api.Form.List(ctx) + page, err := f.apiv3.Form.List(ctx, &managementv3.ListFormsRequestParameters{}) if err != nil { return data, err } - for _, form := range forms.Forms { - data = append(data, importDataItem{ - ResourceName: "auth0_form." + sanitizeResourceName(form.GetName()), - ImportID: form.GetID(), - }) + for page != nil { + for _, form := range page.Results { + data = append(data, importDataItem{ + ResourceName: "auth0_form." + sanitizeResourceName(form.GetName()), + ImportID: form.GetID(), + }) + } + + page, err = page.GetNextPage(ctx) + if errors.Is(err, core.ErrNoPages) { + break + } + if err != nil { + return data, err + } } return data, nil diff --git a/internal/cli/terraform_fetcher_test.go b/internal/cli/terraform_fetcher_test.go index 574763c3f..6c46984dc 100644 --- a/internal/cli/terraform_fetcher_test.go +++ b/internal/cli/terraform_fetcher_test.go @@ -1076,29 +1076,27 @@ func TestFormResourceFetcher_FetchData(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() - formAPI := mock.NewMockFormAPI(ctrl) + formAPI := mock.NewMockFormAPIV3(ctrl) formAPI.EXPECT(). List(gomock.Any(), gomock.Any()).Return( - &management.FormList{ - List: management.List{ - Start: 0, - Limit: 1, - Total: 2, - }, - Forms: []*management.Form{ + &auth0.FormSummaryPage{ + Results: []*managementv3.FormSummary{ { - ID: auth0.String("form_id1"), - Name: auth0.String("Form 1"), + ID: "form_id1", + Name: "Form 1", }, { - ID: auth0.String("form_id2"), - Name: auth0.String("Form 2"), + ID: "form_id2", + Name: "Form 2", }, }, + NextPageFunc: func(_ context.Context) (*auth0.FormSummaryPage, error) { + return nil, core.ErrNoPages + }, }, nil) fetcher := formResourceFetcher{ - api: &auth0.API{ + apiv3: &auth0.APIV3{ Form: formAPI, }, } @@ -1123,20 +1121,18 @@ func TestFormResourceFetcher_FetchData(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() - formAPI := mock.NewMockFormAPI(ctrl) + formAPI := mock.NewMockFormAPIV3(ctrl) formAPI.EXPECT(). List(gomock.Any(), gomock.Any()).Return( - &management.FormList{ - List: management.List{ - Start: 0, - Limit: 0, - Total: 0, + &auth0.FormSummaryPage{ + Results: []*managementv3.FormSummary{}, + NextPageFunc: func(_ context.Context) (*auth0.FormSummaryPage, error) { + return nil, core.ErrNoPages }, - Forms: []*management.Form{}, }, nil) fetcher := formResourceFetcher{ - api: &auth0.API{ + apiv3: &auth0.APIV3{ Form: formAPI, }, } @@ -1150,13 +1146,13 @@ func TestFormResourceFetcher_FetchData(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() - formAPI := mock.NewMockFormAPI(ctrl) + formAPI := mock.NewMockFormAPIV3(ctrl) formAPI.EXPECT(). List(gomock.Any(), gomock.Any()). Return(nil, fmt.Errorf("failed to read form")) fetcher := formResourceFetcher{ - api: &auth0.API{ + apiv3: &auth0.APIV3{ Form: formAPI, }, } diff --git a/internal/display/forms.go b/internal/display/forms.go new file mode 100644 index 000000000..45839ecbb --- /dev/null +++ b/internal/display/forms.go @@ -0,0 +1,237 @@ +package display + +import ( + "encoding/json" + "fmt" + "strings" + "time" + + managementv3 "github.com/auth0/go-auth0/v3/management" + + "github.com/auth0/auth0-cli/internal/ansi" +) + +type formView struct { + ID string + Name string + LanguagePrimary string + LanguageDefault string + NodeCount int + TranslationLang int + HasStyle bool + CreatedAt string + UpdatedAt string + SubmittedAt string + + raw interface{} +} + +func (v *formView) AsTableHeader() []string { + return []string{"ID", "Name", "Submitted", "Updated"} +} + +func (v *formView) AsTableRow() []string { + return []string{ansi.Faint(v.ID), v.Name, v.SubmittedAt, v.UpdatedAt} +} + +func (v *formView) KeyValues() [][]string { + kvs := [][]string{ + {"ID", ansi.Faint(v.ID)}, + {"NAME", v.Name}, + {"LANGUAGES", formLanguageSummary(v.LanguagePrimary, v.LanguageDefault)}, + {"NODES", fmt.Sprintf("%d nodes", v.NodeCount)}, + {"TRANSLATIONS", fmt.Sprintf("%d languages", v.TranslationLang)}, + {"STYLE", boolToPresence(v.HasStyle)}, + } + + kvs = append(kvs, + []string{"CREATED AT", v.CreatedAt}, + []string{"UPDATED AT", v.UpdatedAt}, + ) + + if v.SubmittedAt != "" { + kvs = append(kvs, []string{"SUBMITTED AT", v.SubmittedAt}) + } + + return kvs +} + +func (v *formView) Object() interface{} { + return v.raw +} + +// formSummaryView renders a single row in the forms list. +type formSummaryView struct { + ID string + Name string + SubmittedAt string + UpdatedAt string + + raw interface{} +} + +func (v *formSummaryView) AsTableHeader() []string { + return []string{"ID", "Name", "Submitted", "Updated"} +} + +func (v *formSummaryView) AsTableRow() []string { + return []string{ansi.Faint(v.ID), v.Name, v.SubmittedAt, v.UpdatedAt} +} + +func (v *formSummaryView) Object() interface{} { + return v.raw +} + +// FormsList renders the list of forms. +func (r *Renderer) FormsList(forms []*managementv3.FormSummary) error { + resource := "forms" + + r.Heading(resource) + + if len(forms) == 0 { + r.EmptyState(resource, "Use 'auth0 forms create' to add one") + return nil + } + + var res []View + for _, f := range forms { + res = append(res, makeFormSummaryView(f)) + } + + r.Results(res) + + return nil +} + +// FormShowRaw renders a full-fidelity form response read through the v1 HTTP +// client, avoiding the v3 SDK's lossy form-node unions. +func (r *Renderer) FormShowRaw(form json.RawMessage) error { + return r.renderRawForm("form", form) +} + +// FormCreateRaw renders a full-fidelity create response. +func (r *Renderer) FormCreateRaw(form json.RawMessage) error { + return r.renderRawForm("form created", form) +} + +// FormUpdateRaw renders a full-fidelity update response. +func (r *Renderer) FormUpdateRaw(form json.RawMessage) error { + return r.renderRawForm("form updated", form) +} + +func (r *Renderer) renderRawForm(heading string, form json.RawMessage) error { + view, err := makeFormViewFromRaw(form) + if err != nil { + return fmt.Errorf("failed to parse form response: %w", err) + } + r.Heading(heading) + r.Result(view) + return nil +} + +func makeFormSummaryView(f *managementv3.FormSummary) *formSummaryView { + return &formSummaryView{ + ID: f.GetID(), + Name: f.GetName(), + SubmittedAt: f.GetSubmittedAt(), + UpdatedAt: timeAgo(f.GetUpdatedAt()), + raw: mergeExtraProperties(f, f.GetExtraProperties()), + } +} + +func makeFormViewFromRaw(raw json.RawMessage) (*formView, error) { + var form struct { + ID string `json:"id"` + Name string `json:"name"` + Languages struct { + Primary string `json:"primary"` + Default string `json:"default"` + } `json:"languages"` + Nodes []json.RawMessage `json:"nodes"` + Translations map[string]json.RawMessage `json:"translations"` + Style json.RawMessage `json:"style"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + SubmittedAt string `json:"submitted_at"` + } + if err := json.Unmarshal(raw, &form); err != nil { + return nil, err + } + + return &formView{ + ID: form.ID, + Name: form.Name, + LanguagePrimary: form.Languages.Primary, + LanguageDefault: form.Languages.Default, + NodeCount: len(form.Nodes), + TranslationLang: len(form.Translations), + HasStyle: rawJSONPresent(form.Style), + CreatedAt: rawTimeAgo(form.CreatedAt), + UpdatedAt: rawTimeAgo(form.UpdatedAt), + SubmittedAt: form.SubmittedAt, + raw: raw, + }, nil +} + +func rawJSONPresent(raw json.RawMessage) bool { + value := strings.TrimSpace(string(raw)) + return value != "" && value != "null" +} + +func rawTimeAgo(value time.Time) string { + if value.IsZero() { + return "" + } + return timeAgo(value) +} + +// FormExport writes a form body verbatim (uncolored) to the result writer so it +// stays pipe- and import-friendly. +func (r *Renderer) FormExport(body string) { + fmt.Fprintln(r.ResultWriter, body) +} + +func formLanguageSummary(primary, def string) string { + switch { + case primary == "" && def == "": + return "-" + case def == "": + return fmt.Sprintf("primary: %s", primary) + case primary == "": + return fmt.Sprintf("default: %s", def) + default: + return fmt.Sprintf("primary: %s, default: %s", primary, def) + } +} + +func boolToPresence(present bool) string { + if present { + return "set" + } + return "none" +} + +// mergeExtraProperties rebuilds the full API wire object for JSON output. The +// generated SDK captures fields it does not model (such as flow_count and links +// on forms) into an extra-properties map that its own MarshalJSON drops, so +// re-marshaling the typed value alone would silently lose them. Marshaling the +// typed value and overlaying the extras keeps --json faithful to the API. +func mergeExtraProperties(obj interface{}, extra map[string]interface{}) interface{} { + if len(extra) == 0 { + return obj + } + data, err := json.Marshal(obj) + if err != nil { + return obj + } + var merged map[string]interface{} + if err := json.Unmarshal(data, &merged); err != nil { + return obj + } + for key, value := range extra { + if _, ok := merged[key]; !ok { + merged[key] = value + } + } + return merged +} diff --git a/internal/display/forms_test.go b/internal/display/forms_test.go new file mode 100644 index 000000000..fa08d85c6 --- /dev/null +++ b/internal/display/forms_test.go @@ -0,0 +1,50 @@ +package display + +import ( + "bytes" + "encoding/json" + "io" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestFormShowRawPreservesRichGraphJSON(t *testing.T) { + stdout := &bytes.Buffer{} + renderer := &Renderer{ + MessageWriter: io.Discard, + ResultWriter: stdout, + Format: OutputFormatJSON, + } + body := json.RawMessage(`{ + "id":"ap_rich", + "name":"Rich Form", + "flow_count":2, + "links":{"self":"https://example.test/forms/ap_rich"}, + "nodes":[ + {"id":"step_1","type":"STEP","config":{"components":[{"id":"field_1","category":"FIELD","type":"TEXT"}]}}, + {"id":"router_1","type":"ROUTER","config":{"rules":[{"id":"rule_1","condition":{"operator":"AND"}}]}} + ] + }`) + + require.NoError(t, renderer.FormShowRaw(body)) + + var got map[string]interface{} + require.NoError(t, json.Unmarshal(stdout.Bytes(), &got)) + assert.Equal(t, float64(2), got["flow_count"]) + assert.Contains(t, got, "links") + + nodes := got["nodes"].([]interface{}) + step := nodes[0].(map[string]interface{}) + assert.Contains(t, step["config"].(map[string]interface{}), "components") + router := nodes[1].(map[string]interface{}) + rules := router["config"].(map[string]interface{})["rules"].([]interface{}) + assert.Contains(t, rules[0].(map[string]interface{}), "condition") +} + +func TestFormShowRawRejectsInvalidJSON(t *testing.T) { + renderer := &Renderer{MessageWriter: io.Discard, ResultWriter: io.Discard} + err := renderer.FormShowRaw(json.RawMessage(`not-json`)) + assert.ErrorContains(t, err, "failed to parse form response") +} diff --git a/test/integration/fixtures/update-form.json b/test/integration/fixtures/update-form.json new file mode 100644 index 000000000..008d6630c --- /dev/null +++ b/test/integration/fixtures/update-form.json @@ -0,0 +1,10 @@ +{ + "name": "integration-test-form-fixture-updated", + "languages": { + "primary": "en", + "default": "en" + }, + "start": {}, + "nodes": [], + "ending": {} +} diff --git a/test/integration/forms-test-cases.yaml b/test/integration/forms-test-cases.yaml new file mode 100644 index 000000000..7478a81df --- /dev/null +++ b/test/integration/forms-test-cases.yaml @@ -0,0 +1,128 @@ +config: + inherit-env: true + retries: 1 + +tests: + 001 - it successfully lists all forms (json): + command: auth0 forms list --json + exit-code: 0 + + 002 - it successfully creates a form via --name: + command: auth0 forms create --name integration-test-form-created --no-input + exit-code: 0 + stdout: + contains: + - ID + - NAME + - integration-test-form-created + + 003 - it successfully creates a form and outputs in json: + command: auth0 forms create --name integration-test-form-json --no-input --json + exit-code: 0 + stdout: + json: + name: "integration-test-form-json" + + 004 - it successfully creates a form from the embedded example: + command: auth0 forms create --example | auth0 forms create -f - --name integration-test-form-example --no-input --json + exit-code: 0 + stdout: + json: + name: "integration-test-form-example" + languages.primary: "en" + languages.default: "en" + + 005 - it fails to create a form without a name: + command: echo '{"start":{},"nodes":[],"ending":{}}' | auth0 forms create -f - --no-input + exit-code: 1 + stderr: + contains: + - form name is required + + 006 - it fails to create a form from invalid json: + command: echo 'not-json' | auth0 forms create -f - --no-input + exit-code: 1 + stderr: + contains: + - parse form body + + 007 - it successfully lists all forms with data: + command: auth0 forms list + exit-code: 0 + stdout: + contains: + - ID + - NAME + - UPDATED + + 008 - given a test form, it successfully shows the form details: + command: auth0 forms show $(./test/integration/scripts/get-form-id.sh) + exit-code: 0 + stdout: + contains: + - ID + - NAME + - integration-test-form + + 009 - given a test form, it successfully shows the form details (json): + command: auth0 forms show $(./test/integration/scripts/get-form-id.sh) --json + exit-code: 0 + stdout: + json: + name: "integration-test-form" + + 010 - given a test form, it successfully updates the form name: + command: auth0 forms update $(./test/integration/scripts/get-form-id.sh) --name integration-test-form-updated --json + exit-code: 0 + stdout: + json: + name: "integration-test-form-updated" + + 011 - given a test form, it successfully updates the form from a fixture file: + command: auth0 forms update $(./test/integration/scripts/get-form-id.sh) -f ./test/integration/fixtures/update-form.json --json + exit-code: 0 + stdout: + json: + name: "integration-test-form-fixture-updated" + languages.primary: "en" + languages.default: "en" + + 012 - given a test form, it successfully exports the form as an envelope: + command: auth0 forms export $(./test/integration/scripts/get-form-id.sh) + exit-code: 0 + stdout: + contains: + - '"version"' + - '"form"' + - '"name"' + + 013 - given a test form, it successfully round-trips export to import: + command: auth0 forms export $(./test/integration/scripts/get-form-id.sh) | auth0 forms import --id $(./test/integration/scripts/get-form-id.sh) -f - --json + exit-code: 0 + stdout: + json: + name: "integration-test-form-fixture-updated" + + 014 - given a test form, it prints the builder URL for open: + command: auth0 forms open $(./test/integration/scripts/get-form-id.sh) --no-input + exit-code: 0 + stderr: + contains: + - forms.auth0.com + - /edit + + 015 - agent mode refuses to delete a form without force: + command: AUTH0_AGENT_MODE=true auth0 forms delete $(./test/integration/scripts/get-form-id.sh) + exit-code: 1 + stderr: + contains: + - destructive command + - --force + + 016 - given a test form, it successfully deletes the form: + command: auth0 forms delete $(./test/integration/scripts/get-form-id.sh) --force + exit-code: 0 + + 017 - it cleans up all forms created by this suite: + command: ./test/integration/scripts/cleanup-forms.sh + exit-code: 0 diff --git a/test/integration/scripts/cleanup-forms.sh b/test/integration/scripts/cleanup-forms.sh new file mode 100755 index 000000000..ec2a4816c --- /dev/null +++ b/test/integration/scripts/cleanup-forms.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 forms list --json --no-input | jq -r '.[] | select(.name | startswith("integration-test-")) | .id') + +if (( ${#ids[@]} > 0 )); then + auth0 forms delete --force "${ids[@]}" +fi + +rm -f ./test/integration/identifiers/form-id diff --git a/test/integration/scripts/get-form-id.sh b/test/integration/scripts/get-form-id.sh new file mode 100755 index 000000000..2a9f67960 --- /dev/null +++ b/test/integration/scripts/get-form-id.sh @@ -0,0 +1,13 @@ +#! /bin/bash + +FILE=./test/integration/identifiers/form-id +if [ -f "$FILE" ]; then + cat $FILE + exit 0 +fi + +form=$( auth0 forms create --name "integration-test-form" --json --no-input ) + +mkdir -p ./test/integration/identifiers +echo "$form" | jq -r '.["id"]' > $FILE +cat $FILE diff --git a/test/integration/scripts/run-test-suites.sh b/test/integration/scripts/run-test-suites.sh index 4b0e7ea28..f637862d1 100644 --- a/test/integration/scripts/run-test-suites.sh +++ b/test/integration/scripts/run-test-suites.sh @@ -14,21 +14,28 @@ auth0 login \ set +e -# The quickstart integration tests are excluded from the default suite, so run -# each remaining test-cases file individually (in alphabetical order, matching -# --dir) instead of the whole directory. exit_code=0 -for suite in ./test/integration/*.yaml; do - if [[ "$(basename "$suite")" == "quickstarts-test-cases.yaml" ]]; then - echo "Skipping $suite" - continue - fi - - commander test --filter "$FILTER" "$suite" +if [[ -n "${FILE}" ]]; then + commander test --filter "$FILTER" "${FILE}" if [[ $? -ne 0 ]]; then exit_code=1 fi -done +else + # The quickstart integration tests are excluded from the default suite, so run + # each remaining test-cases file individually (in alphabetical order, matching + # --dir) instead of the whole directory. + for suite in ./test/integration/*.yaml; do + if [[ "$(basename "$suite")" == "quickstarts-test-cases.yaml" ]]; then + echo "Skipping $suite" + continue + fi + + commander test --filter "$FILTER" "$suite" + if [[ $? -ne 0 ]]; then + exit_code=1 + fi + done +fi bash ./test/integration/scripts/test-cleanup.sh diff --git a/test/integration/scripts/test-cleanup.sh b/test/integration/scripts/test-cleanup.sh index 61ff1d57f..079e76f6d 100755 --- a/test/integration/scripts/test-cleanup.sh +++ b/test/integration/scripts/test-cleanup.sh @@ -32,6 +32,7 @@ delete_resources "actions" "integration-test-" "id" 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 "logs streams" "integration-test-" "id" auth0 domains delete $(./test/integration/scripts/get-custom-domain-id.sh) --no-input From 32e453163413fe8a97a4e1893bb7ff8084b92663 Mon Sep 17 00:00:00 2001 From: Kunal Dawar Date: Mon, 24 Aug 2026 22:03:31 +0530 Subject: [PATCH 2/6] 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 From 5917a9e6369d29ee69aed3de1145a648b619d6f4 Mon Sep 17 00:00:00 2001 From: ramya18101 Date: Mon, 31 Aug 2026 16:12:40 +0530 Subject: [PATCH 3/6] enhance flow and flow vault connection management commands --- docs/auth0_flows_create.md | 19 +- docs/auth0_flows_update.md | 13 +- docs/auth0_flows_vault.md | 2 +- docs/auth0_flows_vault_connections_create.md | 21 +- docs/auth0_flows_vault_connections_update.md | 13 +- docs/auth0_flows_vault_open.md | 2 +- internal/auth/auth.go | 4 +- internal/cli/flows.go | 664 +++--------------- internal/cli/flows_test.go | 99 +-- internal/cli/flows_vault.go | 626 +++++++++++++++++ internal/cli/flows_vault_test.go | 38 + internal/prompt/prompt.go | 7 +- test/integration/fixtures/create-flow.json | 16 + test/integration/flows-test-cases.yaml | 133 +--- test/integration/flows-vault-test-cases.yaml | 100 +++ .../scripts/get-vault-connection-id.sh | 2 +- 16 files changed, 920 insertions(+), 839 deletions(-) create mode 100644 internal/cli/flows_vault.go create mode 100644 internal/cli/flows_vault_test.go create mode 100644 test/integration/fixtures/create-flow.json create mode 100644 test/integration/flows-vault-test-cases.yaml diff --git a/docs/auth0_flows_create.md b/docs/auth0_flows_create.md index a8581a6ff..4d83609a0 100644 --- a/docs/auth0_flows_create.md +++ b/docs/auth0_flows_create.md @@ -7,7 +7,7 @@ has_toc: false 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. +Asks for the name, then whether to edit the actions graph before creating. Supply the body via `--actions-file` with an optional `--name` override. Run `auth0 flows create --actions-template > flow.json` to generate an actions template. ## Usage ``` @@ -19,22 +19,19 @@ auth0 flows create [flags] ``` 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 - + auth0 flows create --actions-template > flow.json + auth0 flows create --name "My Flow" --actions-file ./flow.json ``` ## 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. + -f, --actions-file string Path to a JSON file containing the flow actions body. Run with --actions-template to see the expected format. + --actions-template Print the actions template for --actions-file and exit. + --json Output in json format. + --json-compact Output in compact json format. + --name string Name of the Flow. ``` diff --git a/docs/auth0_flows_update.md b/docs/auth0_flows_update.md index 6fb50e75b..d62d60fa1 100644 --- a/docs/auth0_flows_update.md +++ b/docs/auth0_flows_update.md @@ -7,7 +7,7 @@ has_toc: false 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. +Passing `--actions-file` replaces the flow's actions graph. Passing only `--name` renames the flow without touching its actions. ## Usage ``` @@ -18,18 +18,17 @@ auth0 flows update [flags] ``` auth0 flows update --name "New Name" - auth0 flows update --file ./flow.json - cat flow.json | auth0 flows update -f - + auth0 flows update --actions-file ./flow.json ``` ## 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. + -f, --actions-file string Path to a JSON file containing the flow actions body. Run with --actions-template to see the expected format. + --json Output in json format. + --json-compact Output in compact json format. + --name string Name of the Flow. ``` diff --git a/docs/auth0_flows_vault.md b/docs/auth0_flows_vault.md index f97ae503e..5c1d73774 100644 --- a/docs/auth0_flows_vault.md +++ b/docs/auth0_flows_vault.md @@ -9,6 +9,6 @@ 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 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_create.md b/docs/auth0_flows_vault_connections_create.md index 3edca9d2a..4762aa43e 100644 --- a/docs/auth0_flows_vault_connections_create.md +++ b/docs/auth0_flows_vault_connections_create.md @@ -7,7 +7,7 @@ has_toc: false 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. +Prompts for name and app id, then asks whether to add setup credentials. Use `--setup-file` to supply credentials non-interactively. Run `--setup-template --app-id ` to print the setup credentials template for a given app. ## Usage ``` @@ -18,22 +18,21 @@ auth0 flows vault connections create [flags] ``` 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 - + auth0 flows vault connections create --name "My Connection" --app-id SLACK + auth0 flows vault connections create --name "My Connection" --app-id SLACK --setup-file ./setup.json + auth0 flows vault connections create --setup-template --app-id SLACK > setup.json ``` ## 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. + --app-id string Identifier of the app the Vault connection integrates with (e.g. HTTP, SLACK). + --json Output in json format. + --json-compact Output in compact json format. + --name string Name of the Vault connection. + -f, --setup-file string Path to a JSON file containing the vault connection setup credentials. Run with --setup-template --app-id to see the expected setup schema for a given app. + --setup-template Print the setup credentials template for the given --app-id and exit. ``` diff --git a/docs/auth0_flows_vault_connections_update.md b/docs/auth0_flows_vault_connections_update.md index c9f4012df..d0d2028ac 100644 --- a/docs/auth0_flows_vault_connections_update.md +++ b/docs/auth0_flows_vault_connections_update.md @@ -7,7 +7,7 @@ has_toc: false 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. +Use `--setup-file` to replace setup credentials, or `--name` to rename. Run `auth0 flows vault connections create --setup-template --app-id ` to see the setup schema. ## Usage ``` @@ -18,18 +18,17 @@ auth0 flows vault connections update [flags] ``` 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 - + auth0 flows vault connections update --setup-file ./setup.json ``` ## 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. + --json Output in json format. + --json-compact Output in compact json format. + --name string Name of the Vault connection. + -f, --setup-file string Path to a JSON file containing the vault connection setup credentials. Run with --setup-template --app-id to see the expected setup schema for a given app. ``` diff --git a/docs/auth0_flows_vault_open.md b/docs/auth0_flows_vault_open.md index afe0c1774..69c2a64a2 100644 --- a/docs/auth0_flows_vault_open.md +++ b/docs/auth0_flows_vault_open.md @@ -36,7 +36,7 @@ auth0 flows vault open [flags] ## Related Commands -- [auth0 flows vault connections](auth0_flows_vault_connections.md) - Manage Flow vault connections +- [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/internal/auth/auth.go b/internal/auth/auth.go index 24c512f8f..7b444a6c4 100644 --- a/internal/auth/auth.go +++ b/internal/auth/auth.go @@ -133,7 +133,9 @@ var RequiredScopes = []string{ "read:phone_templates", "create:email_templates", "read:email_templates", "update:email_templates", "create:email_provider", "read:email_provider", "update:email_provider", "delete:email_provider", - "read:flows", "create:flows", "read:forms", "create:forms", "update:forms", "delete:forms", "read:flows_vault_connections", + "read:flows", "create:flows", "update:flows", "delete:flows", + "read:forms", "create:forms", "update:forms", "delete:forms", + "read:flows_vault_connections", "create:flows_vault_connections", "update:flows_vault_connections", "delete:flows_vault_connections", "read:connections", "update:connections", "read:connections_options", "update:connections_options", "read:client_keys", "read:logs", "read:tenant_settings", "update:tenant_settings", "read:custom_domains", "create:custom_domains", "update:custom_domains", "delete:custom_domains", diff --git a/internal/cli/flows.go b/internal/cli/flows.go index d0d99e49c..0ceaf3772 100644 --- a/internal/cli/flows.go +++ b/internal/cli/flows.go @@ -1,12 +1,12 @@ package cli import ( - "bytes" "context" "encoding/json" "errors" "fmt" "net/http" + "os" managementv3 "github.com/auth0/go-auth0/v3/management" "github.com/auth0/go-auth0/v3/management/core" @@ -16,15 +16,13 @@ import ( "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. +// flowCreateSkeleton seeds the editor for interactive flow creation. const flowCreateSkeleton = `{ "actions": [] } ` const flowCreateExample = `{ - "name": "Enrich Profile", "actions": [ { "id": "step_http", @@ -42,24 +40,6 @@ const flowCreateExample = `{ } ` -// 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", @@ -71,16 +51,6 @@ var ( 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", @@ -88,22 +58,16 @@ var ( } flowFile = Flag{ - Name: "File", - LongForm: "file", + Name: "Actions File", + LongForm: "actions-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.", + Help: "Path to a JSON file containing the flow actions body. Run with --actions-template to see the expected format.", } - flowExample = Flag{ - Name: "Example", - LongForm: "example", - Help: "Print an example flow JSON body and exit.", + flowActionsTemplate = Flag{ + Name: "Actions Template", + LongForm: "actions-template", + Help: "Print the actions template for --actions-file and exit.", } flowHydrate = Flag{ @@ -111,48 +75,8 @@ var ( 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", @@ -265,10 +189,9 @@ func showFlowCmd(cli *cli) *cobra.Command { func createFlowCmd(cli *cli) *cobra.Command { var inputs struct { - Name string - File string - Edit bool - Example bool + Name string + File string + ActionsTemplate bool } cmd := &cobra.Command{ @@ -276,40 +199,39 @@ func createFlowCmd(cli *cli) *cobra.Command { 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.", + "Asks for the name, then whether to edit the actions graph before creating. " + + "Supply the body via `--actions-file` with an optional `--name` override. " + + "Run `auth0 flows create --actions-template > flow.json` to generate an actions template.", 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 -`, + auth0 flows create --actions-template > flow.json + auth0 flows create --name "My Flow" --actions-file ./flow.json`, RunE: func(cmd *cobra.Command, args []string) error { - if inputs.Example { + if inputs.ActionsTemplate { + cli.renderer.Warnf("Flow actions body. Save to a file and pass with --actions-file.") cli.renderer.FlowExport(flowCreateExample) return nil } - body, err := readBodyInput(inputs.File, "flow") - if err != nil { + if err := flowName.Ask(cmd, &inputs.Name, nil); err != nil { return err } - rawBody := json.RawMessage(body) - if body == nil { - if err := flowName.Ask(cmd, &inputs.Name, nil); err != nil { - return err + var rawBody json.RawMessage + if inputs.File != "" { + fileBody, err := os.ReadFile(inputs.File) + if err != nil { + return fmt.Errorf("failed to read flow file %q: %w", inputs.File, err) } - if inputs.Name == "" { - return errors.New("a flow name is required; supply --name, provide --file, or pipe JSON via stdin") + if err := json.Unmarshal(fileBody, &rawBody); err != nil { + return fmt.Errorf("failed to parse flow body: %w", err) } - if inputs.Edit { - if !canPrompt(cmd) { - return errors.New("the --edit flag requires an interactive terminal") - } + } else { + var editActions bool + if err := prompt.AskBool("Do you want to edit the actions now?", &editActions, false); err != nil { + return err + } + if editActions { if err := editJSONBody(cli, "flow", flowCreateSkeleton, &rawBody); err != nil { return err } @@ -318,7 +240,7 @@ func createFlowCmd(cli *cli) *cobra.Command { } } - rawBody, err = applyRawNameOverride(rawBody, inputs.Name) + rawBody, err := applyRawNameOverride(rawBody, inputs.Name) if err != nil { return fmt.Errorf("failed to parse flow body: %w", err) } @@ -341,8 +263,7 @@ func createFlowCmd(cli *cli) *cobra.Command { flowName.RegisterString(cmd, &inputs.Name, "") flowFile.RegisterString(cmd, &inputs.File, "") - flowEdit.RegisterBool(cmd, &inputs.Edit, false) - flowExample.RegisterBool(cmd, &inputs.Example, false) + flowActionsTemplate.RegisterBool(cmd, &inputs.ActionsTemplate, 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.") @@ -361,12 +282,10 @@ func updateFlowCmd(cli *cli) *cobra.Command { 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.", + "Passing `--actions-file` replaces the flow's actions graph. " + + "Passing only `--name` renames the flow without touching its actions.", Example: ` auth0 flows update --name "New Name" - auth0 flows update --file ./flow.json - cat flow.json | auth0 flows update -f -`, + auth0 flows update --actions-file ./flow.json`, RunE: func(cmd *cobra.Command, args []string) error { if len(args) > 0 { inputs.ID = args[0] @@ -376,40 +295,61 @@ func updateFlowCmd(cli *cli) *cobra.Command { } } - body, err := readBodyInput(inputs.File, "flow") + 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) + } + + currentName, err := rawJSONStringField(current, "name") if err != nil { + return fmt.Errorf("failed to read flow name: %w", err) + } + + if err := flowName.AskU(cmd, &inputs.Name, ¤tName); err != nil { return err } + if inputs.Name == "" { + inputs.Name = currentName + } var rawBody json.RawMessage - - switch { - case body != nil: - rawBody, err = applyRawNameOverride(body, inputs.Name) + if inputs.File != "" { + fileBody, err := os.ReadFile(inputs.File) if err != nil { - return fmt.Errorf("failed to parse flow body: %w", err) + return fmt.Errorf("failed to read flow file %q: %w", inputs.File, err) } - case inputs.Name != "": - rawBody, err = applyRawNameOverride(json.RawMessage(`{}`), inputs.Name) - if err != nil { - return fmt.Errorf("failed to build flow update: %w", err) + if err := json.Unmarshal(fileBody, &rawBody); err != nil { + return fmt.Errorf("failed to parse flow body: %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) + } else { + var updateActions bool + if err := prompt.AskBool("Do you want to update the actions?", &updateActions, false); err != nil { + return 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 updateActions { + var currentBody map[string]json.RawMessage + if err := json.Unmarshal(current, ¤tBody); err != nil { + return fmt.Errorf("failed to parse flow with ID %q: %w", inputs.ID, err) + } + actions := currentBody["actions"] + if actions == nil { + actions = json.RawMessage(`[]`) + } + seedBytes, err := json.MarshalIndent(map[string]json.RawMessage{"actions": actions}, "", " ") + if err != nil { + return fmt.Errorf("failed to build flow actions seed: %w", err) + } + if err := editJSONBody(cli, "flow", string(seedBytes), &rawBody); err != nil { + return err + } + } else { + rawBody = json.RawMessage(`{}`) } + } - 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") + rawBody, err = applyRawNameOverride(rawBody, inputs.Name) + if err != nil { + return fmt.Errorf("failed to parse flow body: %w", err) } updated, err := cli.flowRawUpdate(cmd.Context(), inputs.ID, rawBody) @@ -658,310 +598,6 @@ func deleteFlowExecutionCmd(cli *cli) *cobra.Command { 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 { @@ -994,64 +630,6 @@ func openFlowCmd(cli *cli) *cobra.Command { 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 @@ -1065,62 +643,13 @@ func (c *cli) flowRawCreate(ctx context.Context, body json.RawMessage) (json.Raw } 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) + return c.rawJSONRequest(ctx, http.MethodPatch, c.api.HTTPClient.URI("flows", id), body) } 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) { @@ -1177,33 +706,6 @@ func collectFlowExecutions(ctx context.Context, cli *cli, flowID string, params 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 { diff --git a/internal/cli/flows_test.go b/internal/cli/flows_test.go index 785ab4dfd..b69813bae 100644 --- a/internal/cli/flows_test.go +++ b/internal/cli/flows_test.go @@ -52,34 +52,6 @@ func TestApplyRawNameOverrideRejectsNonObject(t *testing.T) { 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{ @@ -176,7 +148,7 @@ func TestCreateFlowCmdFromFilePreservesActions(t *testing.T) { c := newRawTestCLI(stub, stdout) cmd := createFlowCmd(c) - cmd.SetArgs([]string{"--file", path}) + cmd.SetArgs([]string{"--actions-file", path}) require.NoError(t, cmd.Execute()) assert.Equal(t, http.MethodPost, stub.method) @@ -201,72 +173,3 @@ func TestUpdateFlowCmdNameOnlyMergePreservesActions(t *testing.T) { // 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/flows_vault.go b/internal/cli/flows_vault.go new file mode 100644 index 000000000..220cdf7ee --- /dev/null +++ b/internal/cli/flows_vault.go @@ -0,0 +1,626 @@ +package cli + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "os" + "strings" + + 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" +) + +var ( + 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).", + } + + 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: "Setup File", + LongForm: "setup-file", + ShortForm: "f", + Help: "Path to a JSON file containing the vault connection setup credentials. Run with --setup-template --app-id to see the expected setup schema for a given app.", + } + + vaultConnectionSetupTemplate = Flag{ + Name: "Setup Template", + LongForm: "setup-template", + Help: "Print the setup credentials template for the given --app-id and exit.", + } +) + +// vaultConnectionCreateSkeleton is the fallback editor seed when the app_id is not recognized. +const vaultConnectionCreateSkeleton = `{ + "setup": { + "type": "BEARER", + "token": "REPLACE_WITH_YOUR_TOKEN" + } +} +` + +// vaultConnectionSkeletons maps each known Vault app_id to an editor seed that +// matches the setup type(s) the Management API accepts for that app. +var vaultConnectionSkeletons = map[string]string{ + "ACTIVECAMPAIGN": "{\n \"setup\": {\n \"type\": \"API_KEY\",\n \"api_key\": \"REPLACE_WITH_API_KEY\",\n \"base_url\": \"https://REPLACE_WITH_YOUR_INSTANCE.api-us1.com\"\n }\n}\n", + "AIRTABLE": "{\n \"setup\": {\n \"type\": \"API_KEY\",\n \"api_key\": \"REPLACE_WITH_API_KEY\"\n }\n}\n", + "AUTH0": "{\n \"setup\": {\n \"type\": \"OAUTH_APP\",\n \"client_id\": \"REPLACE_WITH_CLIENT_ID\",\n \"client_secret\": \"REPLACE_WITH_CLIENT_SECRET\",\n \"domain\": \"REPLACE_WITH_YOUR_DOMAIN.auth0.com\"\n }\n}\n", + "BIGQUERY": "{\n \"setup\": {\n \"type\": \"OAUTH_JWT\",\n \"project_id\": \"REPLACE_WITH_PROJECT_ID\",\n \"private_key\": \"REPLACE_WITH_PRIVATE_KEY\",\n \"client_email\": \"REPLACE@PROJECT.iam.gserviceaccount.com\"\n }\n}\n", + "CLEARBIT": "{\n \"setup\": {\n \"type\": \"API_KEY\",\n \"secret_key\": \"REPLACE_WITH_SECRET_KEY\"\n }\n}\n", + "DOCUSIGN": "{\n \"setup\": {\n \"type\": \"OAUTH_CODE\",\n \"code\": \"REPLACE_WITH_AUTHORIZATION_CODE\"\n }\n}\n", + "GOOGLE_SHEETS": "{\n \"setup\": {\n \"type\": \"OAUTH_CODE\",\n \"code\": \"REPLACE_WITH_AUTHORIZATION_CODE\"\n }\n}\n", + "HTTP": "{\n \"setup\": {\n \"type\": \"BEARER\",\n \"token\": \"REPLACE_WITH_YOUR_TOKEN\"\n }\n}\n", + "HUBSPOT": "{\n \"setup\": {\n \"type\": \"API_KEY\",\n \"api_key\": \"REPLACE_WITH_API_KEY\"\n }\n}\n", + "JWT": "{\n \"setup\": {\n \"type\": \"JWT\",\n \"algorithm\": \"RS256\"\n }\n}\n", + "MAILCHIMP": "{\n \"setup\": {\n \"type\": \"API_KEY\",\n \"secret_key\": \"REPLACE_WITH_SECRET_KEY\"\n }\n}\n", + "MAILJET": "{\n \"setup\": {\n \"type\": \"API_KEY\",\n \"api_key\": \"REPLACE_WITH_API_KEY\",\n \"secret_key\": \"REPLACE_WITH_SECRET_KEY\"\n }\n}\n", + "PIPEDRIVE": "{\n \"setup\": {\n \"type\": \"TOKEN\",\n \"token\": \"REPLACE_WITH_YOUR_TOKEN\"\n }\n}\n", + "SALESFORCE": "{\n \"setup\": {\n \"type\": \"OAUTH_CODE\",\n \"code\": \"REPLACE_WITH_AUTHORIZATION_CODE\"\n }\n}\n", + "SENDGRID": "{\n \"setup\": {\n \"type\": \"API_KEY\",\n \"api_key\": \"REPLACE_WITH_API_KEY\"\n }\n}\n", + "SLACK": "{\n \"setup\": {\n \"type\": \"WEBHOOK\",\n \"url\": \"https://hooks.slack.com/services/REPLACE_WITH_YOUR_WEBHOOK_URL\"\n }\n}\n", + "STRIPE": "{\n \"setup\": {\n \"type\": \"KEY_PAIR\",\n \"private_key\": \"sk_REPLACE_WITH_PRIVATE_KEY\",\n \"public_key\": \"pk_REPLACE_WITH_PUBLIC_KEY\"\n }\n}\n", + "TELEGRAM": "{\n \"setup\": {\n \"type\": \"TOKEN\",\n \"token\": \"REPLACE_WITH_BOT_TOKEN\"\n }\n}\n", + "TWILIO": "{\n \"setup\": {\n \"type\": \"API_KEY\",\n \"account_id\": \"REPLACE_WITH_ACCOUNT_ID\",\n \"api_key\": \"REPLACE_WITH_API_KEY\"\n }\n}\n", + "WHATSAPP": "{\n \"setup\": {\n \"type\": \"TOKEN\",\n \"token\": \"REPLACE_WITH_YOUR_TOKEN\"\n }\n}\n", + "ZAPIER": "{\n \"setup\": {\n \"type\": \"WEBHOOK\",\n \"url\": \"https://hooks.zapier.com/hooks/catch/REPLACE_WITH_YOUR_WEBHOOK_PATH\"\n }\n}\n", +} + +func vaultConnectionSeedForApp(appID string) string { + if s, ok := vaultConnectionSkeletons[strings.ToUpper(appID)]; ok { + return s + } + return vaultConnectionCreateSkeleton +} + +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.flowsVaultConnectionPickerOptions); 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 + SetupTemplate bool + } + + cmd := &cobra.Command{ + Use: "create", + Args: cobra.NoArgs, + Short: "Create a new vault connection", + Long: "Create a new vault connection.\n\n" + + "Prompts for name and app id, then asks whether to add setup credentials. " + + "Use `--setup-file` to supply credentials non-interactively. " + + "Run `--setup-template --app-id ` to print the setup credentials template for a given app.", + Example: ` auth0 flows vault connections create + auth0 flows vault connections create --name "My Connection" --app-id SLACK + auth0 flows vault connections create --name "My Connection" --app-id SLACK --setup-file ./setup.json + auth0 flows vault connections create --setup-template --app-id SLACK > setup.json`, + RunE: func(cmd *cobra.Command, args []string) error { + inputs.AppID = strings.ToUpper(inputs.AppID) + + if inputs.SetupTemplate { + if err := vaultConnectionAppID.Pick(cmd, &inputs.AppID, cli.vaultAppIDPickerOptions); err != nil { + return err + } + if inputs.AppID == "" { + return errors.New("an app id is required") + } + cli.renderer.Warnf("Setup schema for %s. Save to a file and pass with --setup-file.", inputs.AppID) + cli.renderer.FlowExport(vaultConnectionSeedForApp(inputs.AppID)) + return nil + } + + if err := vaultConnectionName.Ask(cmd, &inputs.Name, nil); err != nil { + return err + } + if inputs.Name == "" { + return errors.New("a name is required") + } + if err := vaultConnectionAppID.Pick(cmd, &inputs.AppID, cli.vaultAppIDPickerOptions); err != nil { + return err + } + if inputs.AppID == "" { + return errors.New("an app id is required") + } + + var setupBody json.RawMessage + if inputs.File != "" { + fileBody, err := os.ReadFile(inputs.File) + if err != nil { + return fmt.Errorf("failed to read setup file %q: %w", inputs.File, err) + } + if err = json.Unmarshal(fileBody, &setupBody); err != nil { + return fmt.Errorf("setup file is not valid JSON: %w\n\nRun 'auth0 flows vault connections create --setup-template --app-id ' to see the expected setup schema", err) + } + } else { + var addSetup bool + if err := prompt.AskBool("Do you want to add setup credentials now?", &addSetup, false); err != nil { + return err + } + + if addSetup { + if err := editJSONBody(cli, "vault connection", vaultConnectionSeedForApp(inputs.AppID), &setupBody); err != nil { + return err + } + } else { + setupBody = json.RawMessage(`{}`) + } + } + + m := make(map[string]json.RawMessage) + if err := json.Unmarshal(setupBody, &m); err != nil { + return fmt.Errorf("failed to parse vault connection body: %w", err) + } + nameJSON, _ := json.Marshal(inputs.Name) + appIDJSON, _ := json.Marshal(inputs.AppID) + m["name"] = nameJSON + m["app_id"] = appIDJSON + rawBody, err := json.Marshal(m) + if err != nil { + return fmt.Errorf("failed to build vault connection body: %w", err) + } + + created, err := cli.vaultConnectionRawCreate(cmd.Context(), rawBody) + if err != nil { + if strings.Contains(err.Error(), "oneOf") { + cli.renderer.Warnf("The setup payload does not match the expected schema for %s.\nRun 'auth0 flows vault connections create --setup-template --app-id %s' to see the correct setup format.", inputs.AppID, inputs.AppID) + } + 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, "") + vaultConnectionSetupTemplate.RegisterBool(cmd, &inputs.SetupTemplate, 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" + + "Use `--setup-file` to replace setup credentials, or `--name` to rename. " + + "Run `auth0 flows vault connections create --setup-template --app-id ` to see the setup schema.", + Example: ` auth0 flows vault connections update --name "New Name" + auth0 flows vault connections update --setup-file ./setup.json`, + 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.flowsVaultConnectionPickerOptions); err != nil { + return err + } + } + + current, 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) + } + + currentName, err := rawJSONStringField(current, "name") + if err != nil { + return fmt.Errorf("failed to read vault connection name: %w", err) + } + + if err := vaultConnectionName.AskU(cmd, &inputs.Name, ¤tName); err != nil { + return err + } + if inputs.Name == "" { + inputs.Name = currentName + } + + var appID string + var rawBody json.RawMessage + if inputs.File != "" { + fileBody, err := os.ReadFile(inputs.File) + if err != nil { + return fmt.Errorf("failed to read setup file %q: %w", inputs.File, err) + } + var setupBody json.RawMessage + if err := json.Unmarshal(fileBody, &setupBody); err != nil { + return fmt.Errorf("setup file is not valid JSON: %w", err) + } + m := make(map[string]json.RawMessage) + if err := json.Unmarshal(setupBody, &m); err != nil { + return fmt.Errorf("failed to parse setup body: %w", err) + } + nameJSON, _ := json.Marshal(inputs.Name) + m["name"] = nameJSON + rawBody, err = json.Marshal(m) + if err != nil { + return fmt.Errorf("failed to build update body: %w", err) + } + } else { + var updateSetup bool + if err := prompt.AskBool("Do you want to update the setup details?", &updateSetup, false); err != nil { + return err + } + + if updateSetup { + appID, err = rawJSONStringField(current, "app_id") + if err != nil { + return fmt.Errorf("failed to read app_id from vault connection %q: %w", inputs.ID, err) + } + if err := editJSONBody(cli, "vault connection", vaultConnectionSeedForApp(appID), &rawBody); err != nil { + return err + } + } else { + rawBody = json.RawMessage(`{}`) + } + + rawBody, err = applyRawNameOverride(rawBody, inputs.Name) + if err != nil { + return fmt.Errorf("failed to apply name override: %w", err) + } + } + + updated, err := cli.vaultConnectionRawUpdate(cmd.Context(), inputs.ID, rawBody) + if err != nil { + if strings.Contains(err.Error(), "oneOf") { + hint := "" + if appID != "" { + hint = appID + } + cli.renderer.Warnf("The setup payload does not match the expected schema for %s.\nRun 'auth0 flows vault connections create --setup-template --app-id %s' to see the correct setup format.", hint, hint) + } + 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.flowsVaultConnectionPickerOptions); 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 +} + +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 +} + +// knownVaultAppIDs is the static list of app IDs recognised by the Flows Vault, +// derived from the FlowsVaultConnectionAppID* enums in the v3 SDK. +var knownVaultAppIDs = []string{ + "ACTIVECAMPAIGN", + "AIRTABLE", + "AUTH0", + "BIGQUERY", + "CLEARBIT", + "DOCUSIGN", + "GOOGLE_SHEETS", + "HTTP", + "HUBSPOT", + "JWT", + "MAILCHIMP", + "MAILJET", + "PIPEDRIVE", + "SALESFORCE", + "SENDGRID", + "SLACK", + "STRIPE", + "TELEGRAM", + "TWILIO", + "WHATSAPP", + "ZAPIER", +} + +// vaultAppIDPickerOptions returns the static list of Vault app IDs for vault connection creation. +func (c *cli) vaultAppIDPickerOptions(_ context.Context) (pickerOptions, error) { + var opts pickerOptions + for _, id := range knownVaultAppIDs { + opts = append(opts, pickerOption{value: id, label: id}) + } + return opts, nil +} + +func (c *cli) flowsVaultConnectionPickerOptions(ctx context.Context) (pickerOptions, error) { + connections, err := collectVaultConnections(ctx, c, &managementv3.ListFlowsVaultConnectionsRequestParameters{}, 0) + if err != nil { + return nil, err + } + + var opts pickerOptions + for _, conn := range connections { + label := fmt.Sprintf("%s %s", conn.GetName(), ansi.Faint("("+conn.GetID()+")")) + opts = append(opts, pickerOption{value: conn.GetID(), label: label}) + } + + if len(opts) == 0 { + return nil, errors.New("there are no vault connections to choose from; create one with `auth0 flows vault connections create`") + } + + return opts, nil +} + +// 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 ---. + +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) { + return c.rawJSONRequest(ctx, http.MethodPatch, c.api.HTTPClient.URI("flows", "vault", "connections", id), body) +} + +// --- Paging + pickers ---. + +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 +} diff --git a/internal/cli/flows_vault_test.go b/internal/cli/flows_vault_test.go new file mode 100644 index 000000000..065ddfbe0 --- /dev/null +++ b/internal/cli/flows_vault_test.go @@ -0,0 +1,38 @@ +package cli + +import ( + "bytes" + "encoding/json" + "net/http" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestCreateVaultConnectionCmdUsesRawClient(t *testing.T) { + body := []byte(`{"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{"--setup-file", path, "--name", "Renamed", "--app-id", "HTTP"}) + + 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, `"HTTP"`) + assert.Contains(t, sent, `"setup"`) + // The rendered output must never echo the setup secrets back. + assert.NotContains(t, stdout.String(), "secret") +} diff --git a/internal/prompt/prompt.go b/internal/prompt/prompt.go index 8e17168c9..1cc8e53ab 100644 --- a/internal/prompt/prompt.go +++ b/internal/prompt/prompt.go @@ -36,14 +36,17 @@ func AskMultiSelect(message string, response interface{}, options ...string) err } func AskBool(message string, value *bool, defaultValue bool) error { + *value = defaultValue prompt := &survey.Confirm{ Message: message, Default: defaultValue, } - err := askOne(prompt, value) + if err := askOne(prompt, value); err != nil { + *value = defaultValue + } - return err + return nil } // Confirm prompts the user with a yes/no question and returns their response. diff --git a/test/integration/fixtures/create-flow.json b/test/integration/fixtures/create-flow.json new file mode 100644 index 000000000..3774df785 --- /dev/null +++ b/test/integration/fixtures/create-flow.json @@ -0,0 +1,16 @@ +{ + "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" + } + } + ] +} diff --git a/test/integration/flows-test-cases.yaml b/test/integration/flows-test-cases.yaml index a82f50665..d583fc918 100644 --- a/test/integration/flows-test-cases.yaml +++ b/test/integration/flows-test-cases.yaml @@ -23,28 +23,21 @@ tests: 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 + 004 - it successfully creates a flow from a fixture file: + command: auth0 flows create --actions-file ./test/integration/fixtures/create-flow.json --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 + command: auth0 flows create --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: + 006 - it successfully lists all flows with data: command: auth0 flows list exit-code: 0 stdout: @@ -53,7 +46,7 @@ tests: - NAME - UPDATED - 008 - given a test flow, it successfully shows the flow details: + 007 - 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: @@ -62,28 +55,28 @@ tests: - NAME - integration-test-flow - 009 - given a test flow, it successfully shows the flow details (json): + 008 - 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: + 009 - 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 + 010 - given a test flow, it successfully updates the flow from a fixture file: + command: auth0 flows update $(./test/integration/scripts/get-flow-id.sh) --actions-file ./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: + 011 - 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: @@ -92,107 +85,15 @@ tests: - /flows/ - /edit - 013 - given a test flow, it successfully lists its executions (json): + 012 - 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: + 013 - 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: + 014 - 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: @@ -200,14 +101,10 @@ tests: - destructive command - --force - 029 - given a test flow, it successfully deletes the flow: + 015 - 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: + 016 - it cleans up all flows created by this suite: command: ./test/integration/scripts/cleanup-flows.sh exit-code: 0 diff --git a/test/integration/flows-vault-test-cases.yaml b/test/integration/flows-vault-test-cases.yaml new file mode 100644 index 000000000..29d607e1b --- /dev/null +++ b/test/integration/flows-vault-test-cases.yaml @@ -0,0 +1,100 @@ +config: + inherit-env: true + retries: 1 + +tests: + 001 - it successfully lists all vault connections (json): + command: auth0 flows vault connections list --json + exit-code: 0 + + 002 - it prints the setup template for a vault connection: + command: auth0 flows vault connections create --setup-template --app-id HTTP + exit-code: 0 + stdout: + contains: + - '"setup"' + - '"type"' + + 003 - 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 + + 004 - it fails to create a vault connection without required flags: + command: auth0 flows vault connections create --no-input + exit-code: 1 + stderr: + contains: + - app id is required + + 005 - it successfully creates a vault connection from a fixture file (json): + command: auth0 flows vault connections create --setup-file ./test/integration/fixtures/vault-connection.json --name integration-test-connection-created --app-id HTTP --no-input --json + exit-code: 0 + stdout: + json: + name: "integration-test-connection-created" + + 006 - it successfully lists all vault connections with data: + command: auth0 flows vault connections list + exit-code: 0 + stdout: + contains: + - ID + - NAME + - APP + + 007 - 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 + + 008 - 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 + + 009 - 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" + + 010 - 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" + + 011 - 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" + + 012 - 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 + + 013 - 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 + + 014 - it cleans up all vault connections created by this suite: + command: ./test/integration/scripts/cleanup-vault-connections.sh + exit-code: 0 diff --git a/test/integration/scripts/get-vault-connection-id.sh b/test/integration/scripts/get-vault-connection-id.sh index 929b5e2e5..c9e6375e6 100755 --- a/test/integration/scripts/get-vault-connection-id.sh +++ b/test/integration/scripts/get-vault-connection-id.sh @@ -7,7 +7,7 @@ if [ -f "$FILE" ]; then fi connection=$( auth0 flows vault connections create \ - --file ./test/integration/fixtures/vault-connection.json \ + --setup-file ./test/integration/fixtures/vault-connection.json \ --name "integration-test-connection" \ --json --no-input ) From a81ddf9242573bec06699982a0e4233d81c9f5c2 Mon Sep 17 00:00:00 2001 From: Kunal Dawar Date: Wed, 2 Sep 2026 16:58:10 +0530 Subject: [PATCH 4/6] fix: require --name for flows and vault, reject name in body files, add read:flows_executions scope --- docs/auth0_flows_create.md | 2 +- docs/auth0_flows_update.md | 2 +- docs/auth0_flows_vault_connections_create.md | 2 +- docs/auth0_flows_vault_connections_update.md | 2 +- internal/auth/auth.go | 1 + internal/auth/scopes_test.go | 3 ++ internal/cli/flows.go | 32 ++++++++++------- internal/cli/flows_test.go | 36 +++++++++++++++++-- internal/cli/flows_vault.go | 19 ++++++++-- internal/cli/flows_vault_test.go | 17 +++++++++ internal/cli/utils_shared.go | 20 +++++++++++ test/integration/fixtures/update-flow.json | 16 +++++++-- .../fixtures/update-vault-connection.json | 5 ++- .../fixtures/vault-connection.json | 2 -- test/integration/flows-test-cases.yaml | 4 +-- test/integration/flows-vault-test-cases.yaml | 8 ++--- .../scripts/get-vault-connection-id.sh | 1 + 17 files changed, 140 insertions(+), 32 deletions(-) diff --git a/docs/auth0_flows_create.md b/docs/auth0_flows_create.md index 4d83609a0..b5d5484f2 100644 --- a/docs/auth0_flows_create.md +++ b/docs/auth0_flows_create.md @@ -7,7 +7,7 @@ has_toc: false Create a new flow. -Asks for the name, then whether to edit the actions graph before creating. Supply the body via `--actions-file` with an optional `--name` override. Run `auth0 flows create --actions-template > flow.json` to generate an actions template. +A name is required, supplied with `--name` or the interactive prompt. The actions graph is authored interactively or supplied with `--actions-file`; the file must contain only the actions body, not a name. Run `auth0 flows create --actions-template > flow.json` to generate an actions template. ## Usage ``` diff --git a/docs/auth0_flows_update.md b/docs/auth0_flows_update.md index d62d60fa1..379f40961 100644 --- a/docs/auth0_flows_update.md +++ b/docs/auth0_flows_update.md @@ -7,7 +7,7 @@ has_toc: false Update a flow. -Passing `--actions-file` replaces the flow's actions graph. Passing only `--name` renames the flow without touching its actions. +Passing `--actions-file` replaces the flow's actions graph; the file must contain only the actions body, not a name. Passing `--name` renames the flow; omit it to keep the current name. ## Usage ``` diff --git a/docs/auth0_flows_vault_connections_create.md b/docs/auth0_flows_vault_connections_create.md index 4762aa43e..a306a847e 100644 --- a/docs/auth0_flows_vault_connections_create.md +++ b/docs/auth0_flows_vault_connections_create.md @@ -7,7 +7,7 @@ has_toc: false Create a new vault connection. -Prompts for name and app id, then asks whether to add setup credentials. Use `--setup-file` to supply credentials non-interactively. Run `--setup-template --app-id ` to print the setup credentials template for a given app. +A name is required, supplied with `--name` or the interactive prompt, along with an app id via `--app-id`. Setup credentials are authored interactively or supplied with `--setup-file`; the file must contain only the setup body, not a name. Run `--setup-template --app-id ` to print the setup credentials template for a given app. ## Usage ``` diff --git a/docs/auth0_flows_vault_connections_update.md b/docs/auth0_flows_vault_connections_update.md index d0d2028ac..f1a35d07e 100644 --- a/docs/auth0_flows_vault_connections_update.md +++ b/docs/auth0_flows_vault_connections_update.md @@ -7,7 +7,7 @@ has_toc: false Update a vault connection. -Use `--setup-file` to replace setup credentials, or `--name` to rename. Run `auth0 flows vault connections create --setup-template --app-id ` to see the setup schema. +Passing `--setup-file` replaces the connection's setup credentials; the file must contain only the setup body, not a name. Passing `--name` renames the connection; omit it to keep the current name. Run `auth0 flows vault connections create --setup-template --app-id ` to see the setup schema. ## Usage ``` diff --git a/internal/auth/auth.go b/internal/auth/auth.go index 7b444a6c4..3aea29b83 100644 --- a/internal/auth/auth.go +++ b/internal/auth/auth.go @@ -134,6 +134,7 @@ var RequiredScopes = []string{ "create:email_templates", "read:email_templates", "update:email_templates", "create:email_provider", "read:email_provider", "update:email_provider", "delete:email_provider", "read:flows", "create:flows", "update:flows", "delete:flows", + "read:flows_executions", "read:forms", "create:forms", "update:forms", "delete:forms", "read:flows_vault_connections", "create:flows_vault_connections", "update:flows_vault_connections", "delete:flows_vault_connections", "read:connections", "update:connections", "read:connections_options", "update:connections_options", diff --git a/internal/auth/scopes_test.go b/internal/auth/scopes_test.go index 6d013ed64..de1c7453c 100644 --- a/internal/auth/scopes_test.go +++ b/internal/auth/scopes_test.go @@ -33,6 +33,9 @@ func TestRequiredScopes(t *testing.T) { t.Run("Verify special scopes", func(t *testing.T) { list := []string{ "read:branding", "update:branding", + "read:flows", "create:flows", "update:flows", "delete:flows", + "read:flows_executions", + "read:flows_vault_connections", "create:flows_vault_connections", "update:flows_vault_connections", "delete:flows_vault_connections", "read:connections", "update:connections", "read:connections_options", "update:connections_options", "read:email_templates", "update:email_templates", "read:custom_domains", "create:custom_domains", "update:custom_domains", "delete:custom_domains", diff --git a/internal/cli/flows.go b/internal/cli/flows.go index 0ceaf3772..fe726a9b8 100644 --- a/internal/cli/flows.go +++ b/internal/cli/flows.go @@ -199,8 +199,9 @@ func createFlowCmd(cli *cli) *cobra.Command { Args: cobra.NoArgs, Short: "Create a new flow", Long: "Create a new flow.\n\n" + - "Asks for the name, then whether to edit the actions graph before creating. " + - "Supply the body via `--actions-file` with an optional `--name` override. " + + "A name is required, supplied with `--name` or the interactive prompt. The actions graph " + + "is authored interactively or supplied with `--actions-file`; the file must contain only the " + + "actions body, not a name. " + "Run `auth0 flows create --actions-template > flow.json` to generate an actions template.", Example: ` auth0 flows create auth0 flows create --name "My Flow" @@ -216,6 +217,9 @@ func createFlowCmd(cli *cli) *cobra.Command { 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") + } var rawBody json.RawMessage if inputs.File != "" { @@ -226,6 +230,9 @@ func createFlowCmd(cli *cli) *cobra.Command { if err := json.Unmarshal(fileBody, &rawBody); err != nil { return fmt.Errorf("failed to parse flow body: %w", err) } + if err := rejectRawNameField(rawBody, "actions file"); err != nil { + return err + } } else { var editActions bool if err := prompt.AskBool("Do you want to edit the actions now?", &editActions, false); err != nil { @@ -235,6 +242,9 @@ func createFlowCmd(cli *cli) *cobra.Command { if err := editJSONBody(cli, "flow", flowCreateSkeleton, &rawBody); err != nil { return err } + if err := rejectRawNameField(rawBody, "flow actions"); err != nil { + return err + } } else { rawBody = json.RawMessage(flowCreateSkeleton) } @@ -245,14 +255,6 @@ func createFlowCmd(cli *cli) *cobra.Command { 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) @@ -282,8 +284,8 @@ func updateFlowCmd(cli *cli) *cobra.Command { Args: cobra.MaximumNArgs(1), Short: "Update a flow", Long: "Update a flow.\n\n" + - "Passing `--actions-file` replaces the flow's actions graph. " + - "Passing only `--name` renames the flow without touching its actions.", + "Passing `--actions-file` replaces the flow's actions graph; the file must contain only the " + + "actions body, not a name. Passing `--name` renames the flow; omit it to keep the current name.", Example: ` auth0 flows update --name "New Name" auth0 flows update --actions-file ./flow.json`, RunE: func(cmd *cobra.Command, args []string) error { @@ -321,6 +323,9 @@ func updateFlowCmd(cli *cli) *cobra.Command { if err := json.Unmarshal(fileBody, &rawBody); err != nil { return fmt.Errorf("failed to parse flow body: %w", err) } + if err := rejectRawNameField(rawBody, "actions file"); err != nil { + return err + } } else { var updateActions bool if err := prompt.AskBool("Do you want to update the actions?", &updateActions, false); err != nil { @@ -342,6 +347,9 @@ func updateFlowCmd(cli *cli) *cobra.Command { if err := editJSONBody(cli, "flow", string(seedBytes), &rawBody); err != nil { return err } + if err := rejectRawNameField(rawBody, "flow actions"); err != nil { + return err + } } else { rawBody = json.RawMessage(`{}`) } diff --git a/internal/cli/flows_test.go b/internal/cli/flows_test.go index b69813bae..768288a9a 100644 --- a/internal/cli/flows_test.go +++ b/internal/cli/flows_test.go @@ -137,7 +137,7 @@ func TestCreateFlowCmdScaffoldFromName(t *testing.T) { } 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"}}]}`) + body := []byte(`{"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)) @@ -148,15 +148,47 @@ func TestCreateFlowCmdFromFilePreservesActions(t *testing.T) { c := newRawTestCLI(stub, stdout) cmd := createFlowCmd(c) - cmd.SetArgs([]string{"--actions-file", path}) + cmd.SetArgs([]string{"--actions-file", path, "--name", "Rich Flow"}) 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"`) + // The name comes from --name and is injected into the body, not read from the file. + assert.Contains(t, string(stub.payload.(json.RawMessage)), `"Rich Flow"`) assert.Contains(t, stdout.String(), "1 actions") } +func TestCreateFlowCmdRejectsNameInActionsFile(t *testing.T) { + body := []byte(`{"name":"Rich Flow","actions":[]}`) + path := filepath.Join(t.TempDir(), "flow.json") + require.NoError(t, os.WriteFile(path, body, 0600)) + + stub := &formHTTPClientStub{} + stdout := &bytes.Buffer{} + c := newRawTestCLI(stub, stdout) + + cmd := createFlowCmd(c) + cmd.SetArgs([]string{"--actions-file", path, "--name", "Rich Flow"}) + + err := cmd.Execute() + require.Error(t, err) + assert.Contains(t, err.Error(), "must not contain a top-level \"name\" field") +} + +func TestCreateFlowCmdRequiresName(t *testing.T) { + stub := &formHTTPClientStub{} + stdout := &bytes.Buffer{} + c := newRawTestCLI(stub, stdout) + + cmd := createFlowCmd(c) + cmd.SetArgs([]string{}) + + err := cmd.Execute() + require.Error(t, err) + assert.Contains(t, err.Error(), "flow name is required") +} + func TestUpdateFlowCmdNameOnlyMergePreservesActions(t *testing.T) { stub := &formHTTPClientStub{ response: json.RawMessage(`{"id":"flow_3","name":"New Name","actions":[{"id":"a1","type":"HTTP"}]}`), diff --git a/internal/cli/flows_vault.go b/internal/cli/flows_vault.go index 220cdf7ee..b5f696d9f 100644 --- a/internal/cli/flows_vault.go +++ b/internal/cli/flows_vault.go @@ -215,8 +215,8 @@ func createVaultConnectionCmd(cli *cli) *cobra.Command { Args: cobra.NoArgs, Short: "Create a new vault connection", Long: "Create a new vault connection.\n\n" + - "Prompts for name and app id, then asks whether to add setup credentials. " + - "Use `--setup-file` to supply credentials non-interactively. " + + "A name is required, supplied with `--name` or the interactive prompt, along with an app id via `--app-id`. " + + "Setup credentials are authored interactively or supplied with `--setup-file`; the file must contain only the setup body, not a name. " + "Run `--setup-template --app-id ` to print the setup credentials template for a given app.", Example: ` auth0 flows vault connections create auth0 flows vault connections create --name "My Connection" --app-id SLACK @@ -259,6 +259,9 @@ func createVaultConnectionCmd(cli *cli) *cobra.Command { if err = json.Unmarshal(fileBody, &setupBody); err != nil { return fmt.Errorf("setup file is not valid JSON: %w\n\nRun 'auth0 flows vault connections create --setup-template --app-id ' to see the expected setup schema", err) } + if err := rejectRawNameField(setupBody, "setup file"); err != nil { + return err + } } else { var addSetup bool if err := prompt.AskBool("Do you want to add setup credentials now?", &addSetup, false); err != nil { @@ -269,6 +272,9 @@ func createVaultConnectionCmd(cli *cli) *cobra.Command { if err := editJSONBody(cli, "vault connection", vaultConnectionSeedForApp(inputs.AppID), &setupBody); err != nil { return err } + if err := rejectRawNameField(setupBody, "setup body"); err != nil { + return err + } } else { setupBody = json.RawMessage(`{}`) } @@ -321,7 +327,8 @@ func updateVaultConnectionCmd(cli *cli) *cobra.Command { Args: cobra.MaximumNArgs(1), Short: "Update a vault connection", Long: "Update a vault connection.\n\n" + - "Use `--setup-file` to replace setup credentials, or `--name` to rename. " + + "Passing `--setup-file` replaces the connection's setup credentials; the file must contain only the setup body, not a name. " + + "Passing `--name` renames the connection; omit it to keep the current name. " + "Run `auth0 flows vault connections create --setup-template --app-id ` to see the setup schema.", Example: ` auth0 flows vault connections update --name "New Name" auth0 flows vault connections update --setup-file ./setup.json`, @@ -362,6 +369,9 @@ func updateVaultConnectionCmd(cli *cli) *cobra.Command { if err := json.Unmarshal(fileBody, &setupBody); err != nil { return fmt.Errorf("setup file is not valid JSON: %w", err) } + if err := rejectRawNameField(setupBody, "setup file"); err != nil { + return err + } m := make(map[string]json.RawMessage) if err := json.Unmarshal(setupBody, &m); err != nil { return fmt.Errorf("failed to parse setup body: %w", err) @@ -386,6 +396,9 @@ func updateVaultConnectionCmd(cli *cli) *cobra.Command { if err := editJSONBody(cli, "vault connection", vaultConnectionSeedForApp(appID), &rawBody); err != nil { return err } + if err := rejectRawNameField(rawBody, "setup body"); err != nil { + return err + } } else { rawBody = json.RawMessage(`{}`) } diff --git a/internal/cli/flows_vault_test.go b/internal/cli/flows_vault_test.go index 065ddfbe0..b62fa4a78 100644 --- a/internal/cli/flows_vault_test.go +++ b/internal/cli/flows_vault_test.go @@ -36,3 +36,20 @@ func TestCreateVaultConnectionCmdUsesRawClient(t *testing.T) { // The rendered output must never echo the setup secrets back. assert.NotContains(t, stdout.String(), "secret") } + +func TestCreateVaultConnectionCmdRejectsNameInSetupFile(t *testing.T) { + body := []byte(`{"name":"Renamed","setup":{"type":"BEARER","token":"secret"}}`) + path := filepath.Join(t.TempDir(), "conn.json") + require.NoError(t, os.WriteFile(path, body, 0600)) + + stub := &formHTTPClientStub{} + stdout := &bytes.Buffer{} + c := newRawTestCLI(stub, stdout) + + cmd := createVaultConnectionCmd(c) + cmd.SetArgs([]string{"--setup-file", path, "--name", "Renamed", "--app-id", "HTTP"}) + + err := cmd.Execute() + require.Error(t, err) + assert.Contains(t, err.Error(), "must not contain a top-level \"name\" field") +} diff --git a/internal/cli/utils_shared.go b/internal/cli/utils_shared.go index af6656679..ba9b67640 100644 --- a/internal/cli/utils_shared.go +++ b/internal/cli/utils_shared.go @@ -577,6 +577,26 @@ func applyRawNameOverride(body json.RawMessage, name string) (json.RawMessage, e return json.Marshal(obj) } +// rejectRawNameField returns an error when the raw JSON body carries a top-level +// "name" field. The name is owned by the --name flag, so a name embedded in an +// actions/setup file is rejected rather than silently ignored. Bodies that are +// not JSON objects are left for downstream validation to report. +func rejectRawNameField(body json.RawMessage, source string) error { + if len(body) == 0 { + return nil + } + + var obj map[string]json.RawMessage + if err := json.Unmarshal(body, &obj); err != nil { + return nil + } + if _, ok := obj["name"]; ok { + return fmt.Errorf("the %s must not contain a top-level \"name\" field; set the name with --name instead", source) + } + + return nil +} + // 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 diff --git a/test/integration/fixtures/update-flow.json b/test/integration/fixtures/update-flow.json index b9c4b91de..a368d682a 100644 --- a/test/integration/fixtures/update-flow.json +++ b/test/integration/fixtures/update-flow.json @@ -1,4 +1,16 @@ { - "name": "integration-test-flow-fixture-updated", - "actions": [] + "actions": [ + { + "id": "step_http", + "type": "HTTP", + "action": "SEND_REQUEST", + "allow_failure": false, + "mask_output": false, + "params": { + "method": "GET", + "url": "https://api.example.com/updated", + "content_type": "JSON" + } + } + ] } diff --git a/test/integration/fixtures/update-vault-connection.json b/test/integration/fixtures/update-vault-connection.json index 0bfeff00d..b54830c01 100644 --- a/test/integration/fixtures/update-vault-connection.json +++ b/test/integration/fixtures/update-vault-connection.json @@ -1,3 +1,6 @@ { - "name": "integration-test-connection-fixture-updated" + "setup": { + "type": "BEARER", + "token": "integration-test-token-updated" + } } diff --git a/test/integration/fixtures/vault-connection.json b/test/integration/fixtures/vault-connection.json index 663bcdd62..ed97d4080 100644 --- a/test/integration/fixtures/vault-connection.json +++ b/test/integration/fixtures/vault-connection.json @@ -1,6 +1,4 @@ { - "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 index d583fc918..f990c2ccd 100644 --- a/test/integration/flows-test-cases.yaml +++ b/test/integration/flows-test-cases.yaml @@ -69,12 +69,12 @@ tests: json: name: "integration-test-flow-updated" - 010 - given a test flow, it successfully updates the flow from a fixture file: + 010 - given a test flow, it successfully updates the flow actions from a fixture file: command: auth0 flows update $(./test/integration/scripts/get-flow-id.sh) --actions-file ./test/integration/fixtures/update-flow.json --json exit-code: 0 stdout: json: - name: "integration-test-flow-fixture-updated" + name: "integration-test-flow-updated" 011 - given a test flow, it prints the builder URL for open: command: auth0 flows open $(./test/integration/scripts/get-flow-id.sh) --no-input diff --git a/test/integration/flows-vault-test-cases.yaml b/test/integration/flows-vault-test-cases.yaml index 29d607e1b..17c3e2b7d 100644 --- a/test/integration/flows-vault-test-cases.yaml +++ b/test/integration/flows-vault-test-cases.yaml @@ -23,8 +23,8 @@ tests: - forms.auth0.com - /vault/apps/HTTP/edit - 004 - it fails to create a vault connection without required flags: - command: auth0 flows vault connections create --no-input + 004 - it fails to create a vault connection without an app id: + command: auth0 flows vault connections create --name integration-test-connection-noapp --no-input exit-code: 1 stderr: contains: @@ -76,12 +76,12 @@ tests: json: name: "integration-test-connection-updated" - 011 - given a test vault connection, it successfully updates the connection from a fixture file: + 011 - given a test vault connection, it successfully updates the connection setup 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" + name: "integration-test-connection-updated" 012 - 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) diff --git a/test/integration/scripts/get-vault-connection-id.sh b/test/integration/scripts/get-vault-connection-id.sh index c9e6375e6..cf9658401 100755 --- a/test/integration/scripts/get-vault-connection-id.sh +++ b/test/integration/scripts/get-vault-connection-id.sh @@ -9,6 +9,7 @@ fi connection=$( auth0 flows vault connections create \ --setup-file ./test/integration/fixtures/vault-connection.json \ --name "integration-test-connection" \ + --app-id HTTP \ --json --no-input ) mkdir -p ./test/integration/identifiers From 06a3c800326d7d48200e9bdcc0ecdc473fe401cc Mon Sep 17 00:00:00 2001 From: ramya18101 Date: Wed, 2 Sep 2026 17:11:09 +0530 Subject: [PATCH 5/6] refactor: remove forms management commands and related files --- README.md | 1 - docs/auth0_forms.md | 20 - docs/auth0_forms_create.md | 68 -- docs/auth0_forms_delete.md | 59 -- docs/auth0_forms_export.md | 55 -- docs/auth0_forms_import.md | 60 -- docs/auth0_forms_list.md | 58 -- docs/auth0_forms_open.md | 47 -- docs/auth0_forms_show.md | 55 -- docs/auth0_forms_update.md | 60 -- docs/index.md | 1 - internal/auth0/auth0.go | 4 +- internal/auth0/form.go | 53 +- internal/auth0/mock/form_mock.go | 106 ++- internal/cli/flows_test.go | 72 -- internal/cli/flows_vault_test.go | 59 +- internal/cli/forms.go | 847 --------------------- internal/cli/forms_envelope.go | 398 ---------- internal/cli/forms_envelope_test.go | 206 ----- internal/cli/forms_test.go | 459 ----------- internal/cli/root.go | 2 +- internal/cli/terraform.go | 2 +- internal/cli/terraform_fetcher.go | 26 +- internal/cli/terraform_fetcher_test.go | 42 +- internal/cli/utils_shared.go | 39 - internal/display/flows.go | 34 + internal/display/forms.go | 237 ------ internal/display/forms_test.go | 50 -- test/integration/fixtures/update-form.json | 10 - test/integration/forms-test-cases.yaml | 128 ---- test/integration/scripts/cleanup-forms.sh | 16 - test/integration/scripts/get-form-id.sh | 13 - 32 files changed, 213 insertions(+), 3074 deletions(-) delete mode 100644 docs/auth0_forms.md delete mode 100644 docs/auth0_forms_create.md delete mode 100644 docs/auth0_forms_delete.md delete mode 100644 docs/auth0_forms_export.md delete mode 100644 docs/auth0_forms_import.md delete mode 100644 docs/auth0_forms_list.md delete mode 100644 docs/auth0_forms_open.md delete mode 100644 docs/auth0_forms_show.md delete mode 100644 docs/auth0_forms_update.md delete mode 100644 internal/cli/forms.go delete mode 100644 internal/cli/forms_envelope.go delete mode 100644 internal/cli/forms_envelope_test.go delete mode 100644 internal/cli/forms_test.go delete mode 100644 internal/display/forms.go delete mode 100644 internal/display/forms_test.go delete mode 100644 test/integration/fixtures/update-form.json delete mode 100644 test/integration/forms-test-cases.yaml delete mode 100755 test/integration/scripts/cleanup-forms.sh delete mode 100755 test/integration/scripts/get-form-id.sh diff --git a/README.md b/README.md index b4857bef6..daacf5d96 100644 --- a/README.md +++ b/README.md @@ -274,7 +274,6 @@ Select **y** to proceed with your default tenant, or **N** to choose a different - [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 - [auth0 logs](https://auth0.github.io/auth0-cli/auth0_logs.html) - View tenant logs diff --git a/docs/auth0_forms.md b/docs/auth0_forms.md deleted file mode 100644 index c40b08c34..000000000 --- a/docs/auth0_forms.md +++ /dev/null @@ -1,20 +0,0 @@ ---- -layout: default -has_toc: false -has_children: true ---- -# auth0 forms - -Forms are customizable screens you can insert into a flow to collect input from users during authentication and other journeys. - -## Commands - -- [auth0 forms create](auth0_forms_create.md) - Create a new form -- [auth0 forms delete](auth0_forms_delete.md) - Delete a form -- [auth0 forms export](auth0_forms_export.md) - Export a form -- [auth0 forms import](auth0_forms_import.md) - Import a form -- [auth0 forms list](auth0_forms_list.md) - List your forms -- [auth0 forms open](auth0_forms_open.md) - Open a form in the Auth0 Dashboard -- [auth0 forms show](auth0_forms_show.md) - Show a form -- [auth0 forms update](auth0_forms_update.md) - Update a form - diff --git a/docs/auth0_forms_create.md b/docs/auth0_forms_create.md deleted file mode 100644 index 8df3842d8..000000000 --- a/docs/auth0_forms_create.md +++ /dev/null @@ -1,68 +0,0 @@ ---- -layout: default -parent: auth0 forms -has_toc: false ---- -# auth0 forms create - -Create a new form. - -Interactive behavior: `auth0 forms create` asks only for the name and creates a minimal scaffold; it does not open an editor. You can then refine the form in the dashboard builder. - -Pass `--edit` to open an editor and author the form graph before it is created, or supply the whole body via `--file` (or piped stdin) with optional `--name` and `--language-*` overrides. Run `auth0 forms create --example > form.json` to generate an accepted file payload. - -## Usage -``` -auth0 forms create [flags] -``` - -## Examples - -``` - auth0 forms create - auth0 forms create --name "My Form" - auth0 forms create --name "My Form" --edit - auth0 forms create --example > form.json - auth0 forms create --file ./form.json - auth0 forms create --file ./form.json --name "My Form" --language-primary en - cat form.json | auth0 forms create -f - -``` - - -## Flags - -``` - --edit Open an editor to author the form graph after entering the name. - --example Print an example form JSON body and exit. - -f, --file string Path to a JSON file with the form body. Use '-' to read from stdin. - --json Output in json format. - --json-compact Output in compact json format. - --language-default string Default language of the Form (e.g. en). - --language-primary string Primary language of the Form (e.g. en). - --name string Name of the Form. -``` - - -## 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 forms create](auth0_forms_create.md) - Create a new form -- [auth0 forms delete](auth0_forms_delete.md) - Delete a form -- [auth0 forms export](auth0_forms_export.md) - Export a form -- [auth0 forms import](auth0_forms_import.md) - Import a form -- [auth0 forms list](auth0_forms_list.md) - List your forms -- [auth0 forms open](auth0_forms_open.md) - Open a form in the Auth0 Dashboard -- [auth0 forms show](auth0_forms_show.md) - Show a form -- [auth0 forms update](auth0_forms_update.md) - Update a form - - diff --git a/docs/auth0_forms_delete.md b/docs/auth0_forms_delete.md deleted file mode 100644 index a8f923bae..000000000 --- a/docs/auth0_forms_delete.md +++ /dev/null @@ -1,59 +0,0 @@ ---- -layout: default -parent: auth0 forms -has_toc: false ---- -# auth0 forms delete - -Delete a form. - -To delete interactively, use `auth0 forms delete` with no arguments. - -To delete non-interactively, supply the form id and the `--force` flag to skip confirmation. - -## Usage -``` -auth0 forms delete [flags] -``` - -## Examples - -``` - auth0 forms delete - auth0 forms rm - auth0 forms delete - auth0 forms delete --force - auth0 forms 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 forms create](auth0_forms_create.md) - Create a new form -- [auth0 forms delete](auth0_forms_delete.md) - Delete a form -- [auth0 forms export](auth0_forms_export.md) - Export a form -- [auth0 forms import](auth0_forms_import.md) - Import a form -- [auth0 forms list](auth0_forms_list.md) - List your forms -- [auth0 forms open](auth0_forms_open.md) - Open a form in the Auth0 Dashboard -- [auth0 forms show](auth0_forms_show.md) - Show a form -- [auth0 forms update](auth0_forms_update.md) - Update a form - - diff --git a/docs/auth0_forms_export.md b/docs/auth0_forms_export.md deleted file mode 100644 index f33f52d35..000000000 --- a/docs/auth0_forms_export.md +++ /dev/null @@ -1,55 +0,0 @@ ---- -layout: default -parent: auth0 forms -has_toc: false ---- -# auth0 forms export - -Export a form as JSON. Writes to stdout by default (pipe-friendly) or to a file with `--output`. The output uses the same envelope as the Auth0 Dashboard (`version`, `form`, `flows`, `connections`), bundling the flows and vault connections the form references with portable `#FLOW-N#`/`#CONN-N#` placeholders, so it can be imported by the CLI or opened in the Dashboard. - -## Usage -``` -auth0 forms export [flags] -``` - -## Examples - -``` - auth0 forms export - auth0 forms export --output ./form.json - auth0 forms export --json-compact - auth0 forms export | auth0 forms import -f - -``` - - -## Flags - -``` - --json-compact Output in compact json format. - -o, --output string Path to write the exported form. Writes to stdout when omitted. -``` - - -## 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 forms create](auth0_forms_create.md) - Create a new form -- [auth0 forms delete](auth0_forms_delete.md) - Delete a form -- [auth0 forms export](auth0_forms_export.md) - Export a form -- [auth0 forms import](auth0_forms_import.md) - Import a form -- [auth0 forms list](auth0_forms_list.md) - List your forms -- [auth0 forms open](auth0_forms_open.md) - Open a form in the Auth0 Dashboard -- [auth0 forms show](auth0_forms_show.md) - Show a form -- [auth0 forms update](auth0_forms_update.md) - Update a form - - diff --git a/docs/auth0_forms_import.md b/docs/auth0_forms_import.md deleted file mode 100644 index 1378bf828..000000000 --- a/docs/auth0_forms_import.md +++ /dev/null @@ -1,60 +0,0 @@ ---- -layout: default -parent: auth0 forms -has_toc: false ---- -# auth0 forms import - -Import a form from a JSON file (or piped stdin). Without `--id` a new form is created; with `--id` the existing form is replaced. - -Both a flat form graph and the Dashboard envelope (`version`, `form`, `flows`, `connections`) are accepted. For an envelope, the bundled flows are created and each `#CONN-N#` connection placeholder is mapped to an existing vault connection, either interactively or with `--connection`. - -## Usage -``` -auth0 forms import [flags] -``` - -## Examples - -``` - auth0 forms import --file ./form.json - auth0 forms import --file ./form.json --id - auth0 forms import --file ./form.json --connection '#CONN-1#=ac_123' - cat form.json | auth0 forms import -f - -``` - - -## Flags - -``` - --connection stringToString Map an exported connection placeholder to an existing vault connection ID, e.g. --connection '#CONN-1#=ac_123'. Repeatable. (default []) - -f, --file string Path to a JSON file with the form body. Use '-' to read from stdin. - --id string Id of an existing Form to replace. When omitted, a new form is created. - --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 forms create](auth0_forms_create.md) - Create a new form -- [auth0 forms delete](auth0_forms_delete.md) - Delete a form -- [auth0 forms export](auth0_forms_export.md) - Export a form -- [auth0 forms import](auth0_forms_import.md) - Import a form -- [auth0 forms list](auth0_forms_list.md) - List your forms -- [auth0 forms open](auth0_forms_open.md) - Open a form in the Auth0 Dashboard -- [auth0 forms show](auth0_forms_show.md) - Show a form -- [auth0 forms update](auth0_forms_update.md) - Update a form - - diff --git a/docs/auth0_forms_list.md b/docs/auth0_forms_list.md deleted file mode 100644 index ca5602716..000000000 --- a/docs/auth0_forms_list.md +++ /dev/null @@ -1,58 +0,0 @@ ---- -layout: default -parent: auth0 forms -has_toc: false ---- -# auth0 forms list - -List your existing forms. To create one, run: `auth0 forms create`. - -## Usage -``` -auth0 forms list [flags] -``` - -## Examples - -``` - auth0 forms list - auth0 forms ls - auth0 forms ls --number 100 - auth0 forms ls --json - auth0 forms ls --csv -``` - - -## Flags - -``` - --csv Output in csv format. - --json Output in json format. - --json-compact Output in compact json format. - -n, --number int Number of forms 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 forms create](auth0_forms_create.md) - Create a new form -- [auth0 forms delete](auth0_forms_delete.md) - Delete a form -- [auth0 forms export](auth0_forms_export.md) - Export a form -- [auth0 forms import](auth0_forms_import.md) - Import a form -- [auth0 forms list](auth0_forms_list.md) - List your forms -- [auth0 forms open](auth0_forms_open.md) - Open a form in the Auth0 Dashboard -- [auth0 forms show](auth0_forms_show.md) - Show a form -- [auth0 forms update](auth0_forms_update.md) - Update a form - - diff --git a/docs/auth0_forms_open.md b/docs/auth0_forms_open.md deleted file mode 100644 index a312960f1..000000000 --- a/docs/auth0_forms_open.md +++ /dev/null @@ -1,47 +0,0 @@ ---- -layout: default -parent: auth0 forms -has_toc: false ---- -# auth0 forms open - -Open a form's page in the Auth0 Dashboard form builder. - -## Usage -``` -auth0 forms open [flags] -``` - -## Examples - -``` - auth0 forms open - auth0 forms 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 forms create](auth0_forms_create.md) - Create a new form -- [auth0 forms delete](auth0_forms_delete.md) - Delete a form -- [auth0 forms export](auth0_forms_export.md) - Export a form -- [auth0 forms import](auth0_forms_import.md) - Import a form -- [auth0 forms list](auth0_forms_list.md) - List your forms -- [auth0 forms open](auth0_forms_open.md) - Open a form in the Auth0 Dashboard -- [auth0 forms show](auth0_forms_show.md) - Show a form -- [auth0 forms update](auth0_forms_update.md) - Update a form - - diff --git a/docs/auth0_forms_show.md b/docs/auth0_forms_show.md deleted file mode 100644 index aeee9aa4e..000000000 --- a/docs/auth0_forms_show.md +++ /dev/null @@ -1,55 +0,0 @@ ---- -layout: default -parent: auth0 forms -has_toc: false ---- -# auth0 forms show - -Display information about a form. - -## Usage -``` -auth0 forms show [flags] -``` - -## Examples - -``` - auth0 forms show - auth0 forms show - auth0 forms show --json - auth0 forms show --json-compact -``` - - -## 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 forms create](auth0_forms_create.md) - Create a new form -- [auth0 forms delete](auth0_forms_delete.md) - Delete a form -- [auth0 forms export](auth0_forms_export.md) - Export a form -- [auth0 forms import](auth0_forms_import.md) - Import a form -- [auth0 forms list](auth0_forms_list.md) - List your forms -- [auth0 forms open](auth0_forms_open.md) - Open a form in the Auth0 Dashboard -- [auth0 forms show](auth0_forms_show.md) - Show a form -- [auth0 forms update](auth0_forms_update.md) - Update a form - - diff --git a/docs/auth0_forms_update.md b/docs/auth0_forms_update.md deleted file mode 100644 index d35a366fa..000000000 --- a/docs/auth0_forms_update.md +++ /dev/null @@ -1,60 +0,0 @@ ---- -layout: default -parent: auth0 forms -has_toc: false ---- -# auth0 forms update - -Update a form. - -Passing `--file` (or piped stdin) replaces every top-level field present in the file. Passing only scalar flags such as `--name` performs a merge that preserves the form's graph fields (nodes, style, translations). Server-managed fields such as `id`, `created_at`, and `updated_at` are removed before the update request is sent. - -## Usage -``` -auth0 forms update [flags] -``` - -## Examples - -``` - auth0 forms update --name "New Name" - auth0 forms update --file ./form.json - cat form.json | auth0 forms update -f - -``` - - -## Flags - -``` - -f, --file string Path to a JSON file with the form body. Use '-' to read from stdin. - --json Output in json format. - --json-compact Output in compact json format. - --language-default string Default language of the Form (e.g. en). - --language-primary string Primary language of the Form (e.g. en). - --name string Name of the Form. -``` - - -## 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 forms create](auth0_forms_create.md) - Create a new form -- [auth0 forms delete](auth0_forms_delete.md) - Delete a form -- [auth0 forms export](auth0_forms_export.md) - Export a form -- [auth0 forms import](auth0_forms_import.md) - Import a form -- [auth0 forms list](auth0_forms_list.md) - List your forms -- [auth0 forms open](auth0_forms_open.md) - Open a form in the Auth0 Dashboard -- [auth0 forms show](auth0_forms_show.md) - Show a form -- [auth0 forms update](auth0_forms_update.md) - Update a form - - diff --git a/docs/index.md b/docs/index.md index 018e54344..c08e6ed14 100644 --- a/docs/index.md +++ b/docs/index.md @@ -98,7 +98,6 @@ The help for any command can also be emitted as JSON by combining `--help` with - [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 - [auth0 logs](auth0_logs.md) - View tenant logs diff --git a/internal/auth0/auth0.go b/internal/auth0/auth0.go index 1ec8df22f..de30fc442 100644 --- a/internal/auth0/auth0.go +++ b/internal/auth0/auth0.go @@ -22,6 +22,7 @@ type API struct { EventStream EventStreamAPI Flow FlowAPI FlowVaultConnection FlowVaultConnectionAPI + Form FormAPI Log LogAPI LogStream LogStreamAPI Organization OrganizationAPI @@ -55,6 +56,7 @@ func NewAPI(m *management.Management) *API { EventStream: m.EventStream, Flow: m.Flow, FlowVaultConnection: m.Flow.Vault, + Form: m.Form, Log: m.Log, LogStream: m.LogStream, Organization: m.Organization, @@ -78,7 +80,6 @@ type APIV3 struct { ClientGrant ClientGrantAPIV3 ClientGrantOrganization ClientGrantOrganizationAPIV3 Events EventsAPIV3 - Form FormAPIV3 Flow FlowAPIV3 FlowExecution FlowExecutionAPIV3 FlowVaultConnection FlowVaultConnectionAPIV3 @@ -97,7 +98,6 @@ func NewAPIV3(m *managementv3.Management) *APIV3 { ClientGrant: m.ClientGrants, ClientGrantOrganization: m.ClientGrants.Organizations, Events: m.Events, - Form: m.Forms, Flow: m.Flows, FlowExecution: m.Flows.Executions, FlowVaultConnection: m.Flows.Vault.Connections, diff --git a/internal/auth0/form.go b/internal/auth0/form.go index f6dea7cf1..20d562b42 100644 --- a/internal/auth0/form.go +++ b/internal/auth0/form.go @@ -5,43 +5,22 @@ 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" + "github.com/auth0/go-auth0/management" ) -// FormSummaryPage aliases the paginated forms 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 FormSummaryPage = core.Page[*int, *managementv3.FormSummary, *managementv3.ListFormsOffsetPaginatedResponseContent] - -// FormAPIV3 is the V3 SDK interface for the /forms endpoint. -type FormAPIV3 interface { - // List forms. - // - // Required scope: `read:forms`. - List( - ctx context.Context, - request *managementv3.ListFormsRequestParameters, - opts ...option.RequestOption, - ) (*FormSummaryPage, error) - - // Get retrieves a form by its ID. - // - // Required scope: `read:forms`. - Get( - ctx context.Context, - id string, - request *managementv3.GetFormRequestParameters, - opts ...option.RequestOption, - ) (*managementv3.GetFormResponseContent, error) - - // Delete a form. - // - // Required scope: `delete:forms`. - Delete( - ctx context.Context, - id string, - opts ...option.RequestOption, - ) error +type FormAPI interface { + // Create a new form. + Create(ctx context.Context, r *management.Form, opts ...management.RequestOption) error + + // Read form details. + Read(ctx context.Context, id string, opts ...management.RequestOption) (r *management.Form, err error) + + // Update an existing action. + Update(ctx context.Context, id string, r *management.Form, opts ...management.RequestOption) error + + // Delete an action. + Delete(ctx context.Context, id string, opts ...management.RequestOption) error + + // List form. + List(ctx context.Context, opts ...management.RequestOption) (r *management.FormList, err error) } diff --git a/internal/auth0/mock/form_mock.go b/internal/auth0/mock/form_mock.go index aa69be870..73161402a 100644 --- a/internal/auth0/mock/form_mock.go +++ b/internal/auth0/mock/form_mock.go @@ -8,37 +8,54 @@ 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" + management "github.com/auth0/go-auth0/management" gomock "github.com/golang/mock/gomock" ) -// MockFormAPIV3 is a mock of FormAPIV3 interface. -type MockFormAPIV3 struct { +// MockFormAPI is a mock of FormAPI interface. +type MockFormAPI struct { ctrl *gomock.Controller - recorder *MockFormAPIV3MockRecorder + recorder *MockFormAPIMockRecorder } -// MockFormAPIV3MockRecorder is the mock recorder for MockFormAPIV3. -type MockFormAPIV3MockRecorder struct { - mock *MockFormAPIV3 +// MockFormAPIMockRecorder is the mock recorder for MockFormAPI. +type MockFormAPIMockRecorder struct { + mock *MockFormAPI } -// NewMockFormAPIV3 creates a new mock instance. -func NewMockFormAPIV3(ctrl *gomock.Controller) *MockFormAPIV3 { - mock := &MockFormAPIV3{ctrl: ctrl} - mock.recorder = &MockFormAPIV3MockRecorder{mock} +// NewMockFormAPI creates a new mock instance. +func NewMockFormAPI(ctrl *gomock.Controller) *MockFormAPI { + mock := &MockFormAPI{ctrl: ctrl} + mock.recorder = &MockFormAPIMockRecorder{mock} return mock } // EXPECT returns an object that allows the caller to indicate expected use. -func (m *MockFormAPIV3) EXPECT() *MockFormAPIV3MockRecorder { +func (m *MockFormAPI) EXPECT() *MockFormAPIMockRecorder { return m.recorder } +// Create mocks base method. +func (m *MockFormAPI) Create(ctx context.Context, r *management.Form, opts ...management.RequestOption) error { + m.ctrl.T.Helper() + varargs := []interface{}{ctx, r} + for _, a := range opts { + varargs = append(varargs, a) + } + ret := m.ctrl.Call(m, "Create", varargs...) + ret0, _ := ret[0].(error) + return ret0 +} + +// Create indicates an expected call of Create. +func (mr *MockFormAPIMockRecorder) Create(ctx, r interface{}, opts ...interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + varargs := append([]interface{}{ctx, r}, opts...) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Create", reflect.TypeOf((*MockFormAPI)(nil).Create), varargs...) +} + // Delete mocks base method. -func (m *MockFormAPIV3) Delete(ctx context.Context, id string, opts ...option.RequestOption) error { +func (m *MockFormAPI) Delete(ctx context.Context, id string, opts ...management.RequestOption) error { m.ctrl.T.Helper() varargs := []interface{}{ctx, id} for _, a := range opts { @@ -50,48 +67,67 @@ func (m *MockFormAPIV3) Delete(ctx context.Context, id string, opts ...option.Re } // Delete indicates an expected call of Delete. -func (mr *MockFormAPIV3MockRecorder) Delete(ctx, id interface{}, opts ...interface{}) *gomock.Call { +func (mr *MockFormAPIMockRecorder) 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((*MockFormAPIV3)(nil).Delete), varargs...) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Delete", reflect.TypeOf((*MockFormAPI)(nil).Delete), varargs...) } -// Get mocks base method. -func (m *MockFormAPIV3) Get(ctx context.Context, id string, request *management.GetFormRequestParameters, opts ...option.RequestOption) (*management.GetFormResponseContent, error) { +// List mocks base method. +func (m *MockFormAPI) List(ctx context.Context, opts ...management.RequestOption) (*management.FormList, error) { m.ctrl.T.Helper() - varargs := []interface{}{ctx, id, request} + varargs := []interface{}{ctx} for _, a := range opts { varargs = append(varargs, a) } - ret := m.ctrl.Call(m, "Get", varargs...) - ret0, _ := ret[0].(*management.GetFormResponseContent) + ret := m.ctrl.Call(m, "List", varargs...) + ret0, _ := ret[0].(*management.FormList) ret1, _ := ret[1].(error) return ret0, ret1 } -// Get indicates an expected call of Get. -func (mr *MockFormAPIV3MockRecorder) Get(ctx, id, request interface{}, opts ...interface{}) *gomock.Call { +// List indicates an expected call of List. +func (mr *MockFormAPIMockRecorder) List(ctx interface{}, opts ...interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() - varargs := append([]interface{}{ctx, id, request}, opts...) - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Get", reflect.TypeOf((*MockFormAPIV3)(nil).Get), varargs...) + varargs := append([]interface{}{ctx}, opts...) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "List", reflect.TypeOf((*MockFormAPI)(nil).List), varargs...) } -// List mocks base method. -func (m *MockFormAPIV3) List(ctx context.Context, request *management.ListFormsRequestParameters, opts ...option.RequestOption) (*auth0.FormSummaryPage, error) { +// Read mocks base method. +func (m *MockFormAPI) Read(ctx context.Context, id string, opts ...management.RequestOption) (*management.Form, error) { m.ctrl.T.Helper() - varargs := []interface{}{ctx, request} + varargs := []interface{}{ctx, id} for _, a := range opts { varargs = append(varargs, a) } - ret := m.ctrl.Call(m, "List", varargs...) - ret0, _ := ret[0].(*auth0.FormSummaryPage) + ret := m.ctrl.Call(m, "Read", varargs...) + ret0, _ := ret[0].(*management.Form) ret1, _ := ret[1].(error) return ret0, ret1 } -// List indicates an expected call of List. -func (mr *MockFormAPIV3MockRecorder) List(ctx, request interface{}, opts ...interface{}) *gomock.Call { +// Read indicates an expected call of Read. +func (mr *MockFormAPIMockRecorder) Read(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, "Read", reflect.TypeOf((*MockFormAPI)(nil).Read), varargs...) +} + +// Update mocks base method. +func (m *MockFormAPI) Update(ctx context.Context, id string, r *management.Form, opts ...management.RequestOption) error { + m.ctrl.T.Helper() + varargs := []interface{}{ctx, id, r} + for _, a := range opts { + varargs = append(varargs, a) + } + ret := m.ctrl.Call(m, "Update", varargs...) + ret0, _ := ret[0].(error) + return ret0 +} + +// Update indicates an expected call of Update. +func (mr *MockFormAPIMockRecorder) Update(ctx, id, r 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((*MockFormAPIV3)(nil).List), varargs...) + varargs := append([]interface{}{ctx, id, r}, opts...) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Update", reflect.TypeOf((*MockFormAPI)(nil).Update), varargs...) } diff --git a/internal/cli/flows_test.go b/internal/cli/flows_test.go index b69813bae..d09d9dbe7 100644 --- a/internal/cli/flows_test.go +++ b/internal/cli/flows_test.go @@ -1,32 +1,15 @@ 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"}]}`) @@ -118,58 +101,3 @@ func TestFormatBuilderPageURL(t *testing.T) { }) } } - -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{"--actions-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))) -} diff --git a/internal/cli/flows_vault_test.go b/internal/cli/flows_vault_test.go index 065ddfbe0..05106be62 100644 --- a/internal/cli/flows_vault_test.go +++ b/internal/cli/flows_vault_test.go @@ -2,22 +2,79 @@ package cli import ( "bytes" + "context" "encoding/json" + "io" "net/http" "os" "path/filepath" + "strings" "testing" + "github.com/auth0/go-auth0/management" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + + "github.com/auth0/auth0-cli/internal/auth0" + "github.com/auth0/auth0-cli/internal/display" ) +type rawHTTPClientStub struct { + method string + payload interface{} + response json.RawMessage +} + +func (s *rawHTTPClientStub) NewRequest( + ctx context.Context, + method string, + uri string, + payload interface{}, + _ ...management.RequestOption, +) (*http.Request, error) { + s.method = method + s.payload = payload + return http.NewRequestWithContext(ctx, method, uri, nil) +} + +func (s *rawHTTPClientStub) Do(_ *http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: http.StatusOK, + Header: make(http.Header), + Body: io.NopCloser(strings.NewReader(string(s.response))), + }, nil +} + +func (s *rawHTTPClientStub) Request( + context.Context, + string, + string, + interface{}, + ...management.RequestOption, +) error { + return nil +} + +func (s *rawHTTPClientStub) URI(path ...string) string { + return "https://example.test/api/v2/" + strings.Join(path, "/") +} + +func newRawTestCLI(stub *rawHTTPClientStub, stdout *bytes.Buffer) *cli { + return &cli{ + api: &auth0.API{HTTPClient: stub}, + renderer: &display.Renderer{ + MessageWriter: io.Discard, + ResultWriter: stdout, + }, + } +} + func TestCreateVaultConnectionCmdUsesRawClient(t *testing.T) { body := []byte(`{"setup":{"type":"BEARER","token":"secret"}}`) path := filepath.Join(t.TempDir(), "conn.json") require.NoError(t, os.WriteFile(path, body, 0600)) - stub := &formHTTPClientStub{ + stub := &rawHTTPClientStub{ response: json.RawMessage(`{"id":"ac_1","app_id":"HTTP","name":"Renamed","ready":true}`), } stdout := &bytes.Buffer{} diff --git a/internal/cli/forms.go b/internal/cli/forms.go deleted file mode 100644 index b86d83094..000000000 --- a/internal/cli/forms.go +++ /dev/null @@ -1,847 +0,0 @@ -package cli - -import ( - "bytes" - "context" - "encoding/json" - "errors" - "fmt" - "net/http" - "os" - - 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" -) - -// formCreateSkeleton seeds the editor for interactive form creation. The name is -// prompted separately, so the seed only carries the empty graph containers, which -// are all valid on their own. -const formCreateSkeleton = `{ - "start": {}, - "nodes": [], - "ending": {} -} -` - -const formCreateExample = `{ - "name": "Customer Profile Form", - "languages": { - "primary": "en", - "default": "en" - }, - "start": { - "next_node": "step_profile", - "coordinates": { - "x": 0, - "y": 0 - } - }, - "nodes": [ - { - "id": "step_profile", - "type": "STEP", - "coordinates": { - "x": 300, - "y": 0 - }, - "alias": "Collect profile", - "config": { - "components": [ - { - "id": "full_name", - "category": "FIELD", - "type": "TEXT", - "label": "Full name", - "required": true, - "sensitive": false, - "config": { - "multiline": false - } - }, - { - "id": "continue_button", - "category": "BLOCK", - "type": "NEXT_BUTTON", - "config": { - "text": "Continue" - } - } - ], - "next_node": "$ending" - } - } - ], - "ending": { - "resume_flow": true, - "coordinates": { - "x": 600, - "y": 0 - } - } -} -` - -// formServerManagedFields cannot be sent in create or update request bodies. -var formServerManagedFields = []string{ - "id", - "created_at", - "updated_at", - "embedded_at", - "submitted_at", - "flow_count", - "links", -} - -var ( - formID = Argument{ - Name: "Id", - Help: "Id of the Form.", - } - - formName = Flag{ - Name: "Name", - LongForm: "name", - Help: "Name of the Form.", - } - - formFile = Flag{ - Name: "File", - LongForm: "file", - ShortForm: "f", - Help: "Path to a JSON file with the form body. Use '-' to read from stdin.", - } - - formLanguagePrimary = Flag{ - Name: "Language Primary", - LongForm: "language-primary", - Help: "Primary language of the Form (e.g. en).", - } - - formLanguageDefault = Flag{ - Name: "Language Default", - LongForm: "language-default", - Help: "Default language of the Form (e.g. en).", - } - - formOutput = Flag{ - Name: "Output", - LongForm: "output", - ShortForm: "o", - Help: "Path to write the exported form. Writes to stdout when omitted.", - } - - formImportID = Flag{ - Name: "Id", - LongForm: "id", - Help: "Id of an existing Form to replace. When omitted, a new form is created.", - } - - formEdit = Flag{ - Name: "Edit", - LongForm: "edit", - Help: "Open an editor to author the form graph after entering the name.", - } - - formExample = Flag{ - Name: "Example", - LongForm: "example", - Help: "Print an example form JSON body and exit.", - } -) - -func formsCmd(cli *cli) *cobra.Command { - cmd := &cobra.Command{ - Use: "forms", - Short: "Manage Forms", - Long: "Forms are customizable screens you can insert into a flow to collect input " + - "from users during authentication and other journeys.", - } - - cmd.SetUsageTemplate(resourceUsageTemplate()) - cmd.AddCommand(listFormsCmd(cli)) - cmd.AddCommand(showFormCmd(cli)) - cmd.AddCommand(createFormCmd(cli)) - cmd.AddCommand(updateFormCmd(cli)) - cmd.AddCommand(deleteFormCmd(cli)) - cmd.AddCommand(exportFormCmd(cli)) - cmd.AddCommand(importFormCmd(cli)) - cmd.AddCommand(openFormCmd(cli)) - - return cmd -} - -func listFormsCmd(cli *cli) *cobra.Command { - var inputs struct { - Number int - } - - cmd := &cobra.Command{ - Use: "list", - Aliases: []string{"ls"}, - Args: cobra.NoArgs, - Short: "List your forms", - Long: "List your existing forms. To create one, run: `auth0 forms create`.", - Example: ` auth0 forms list - auth0 forms ls - auth0 forms ls --number 100 - auth0 forms ls --json - auth0 forms ls --csv`, - RunE: func(cmd *cobra.Command, args []string) error { - params := &managementv3.ListFormsRequestParameters{} - - var forms []*managementv3.FormSummary - if err := ansi.Waiting(func() (err error) { - forms, err = collectForms(cmd.Context(), cli, params, inputs.Number) - return err - }); err != nil { - return fmt.Errorf("failed to list forms: %w", err) - } - - return cli.renderer.FormsList(forms) - }, - } - - cmd.Flags().IntVarP(&inputs.Number, "number", "n", 100, "Number of forms 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 showFormCmd(cli *cli) *cobra.Command { - var inputs struct { - ID string - } - - cmd := &cobra.Command{ - Use: "show", - Args: cobra.MaximumNArgs(1), - Short: "Show a form", - Long: "Display information about a form.", - Example: ` auth0 forms show - auth0 forms show - auth0 forms show --json - auth0 forms show --json-compact`, - RunE: func(cmd *cobra.Command, args []string) error { - if len(args) == 0 { - if err := formID.Pick(cmd, &inputs.ID, cli.formPickerOptions); err != nil { - return err - } - } else { - inputs.ID = args[0] - } - - form, err := cli.formRawGet(cmd.Context(), inputs.ID) - if err != nil { - return fmt.Errorf("failed to read form with ID %q: %w", inputs.ID, err) - } - - return cli.renderer.FormShowRaw(form) - }, - } - - 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 createFormCmd(cli *cli) *cobra.Command { - var inputs struct { - Name string - File string - LanguagePrimary string - LanguageDefault string - Edit bool - Example bool - } - - cmd := &cobra.Command{ - Use: "create", - Args: cobra.NoArgs, - Short: "Create a new form", - Long: "Create a new form.\n\n" + - "Interactive behavior: `auth0 forms create` asks only for the name and creates a minimal " + - "scaffold; it does not open an editor. You can then refine the form in the dashboard builder.\n\n" + - "Pass `--edit` to open an editor and author the form graph before it is created, or supply " + - "the whole body via `--file` (or piped stdin) with optional `--name` and `--language-*` " + - "overrides. Run `auth0 forms create --example > form.json` to generate an accepted file payload.", - Example: ` auth0 forms create - auth0 forms create --name "My Form" - auth0 forms create --name "My Form" --edit - auth0 forms create --example > form.json - auth0 forms create --file ./form.json - auth0 forms create --file ./form.json --name "My Form" --language-primary en - cat form.json | auth0 forms create -f -`, - RunE: func(cmd *cobra.Command, args []string) error { - if inputs.Example { - cli.renderer.FormExport(formCreateExample) - return nil - } - - body, err := readBodyInput(inputs.File, "form") - if err != nil { - return err - } - - rawBody := json.RawMessage(body) - if body == nil { - // No file or piped body: the name is a required scalar, so prompt for - // it explicitly (only when interactive and --name was not supplied). - if err := formName.Ask(cmd, &inputs.Name, nil); err != nil { - return err - } - if inputs.Name == "" { - return errors.New("a form 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, "form", formCreateSkeleton, &rawBody); err != nil { - return err - } - } else { - rawBody = json.RawMessage(formCreateSkeleton) - } - } - - rawBody, err = applyRawFormOverrides( - rawBody, - inputs.Name, - inputs.LanguagePrimary, - inputs.LanguageDefault, - ) - if err != nil { - return fmt.Errorf("failed to parse form body: %w", err) - } - - name, err := rawJSONStringField(rawBody, "name") - if err != nil { - return fmt.Errorf("failed to parse form body: %w", err) - } - if name == "" { - return errors.New("a form name is required; set it in the body or with --name") - } - - created, err := cli.formRawCreate(cmd.Context(), rawBody) - if err != nil { - return fmt.Errorf("failed to create form: %w", err) - } - if err := cli.renderer.FormCreateRaw(created); err != nil { - return err - } - - id, err := rawJSONStringField(created, "id") - if err != nil { - return fmt.Errorf("failed to parse created form: %w", err) - } - formNextStepsHint(cli, id) - return nil - }, - } - - formName.RegisterString(cmd, &inputs.Name, "") - formFile.RegisterString(cmd, &inputs.File, "") - formLanguagePrimary.RegisterString(cmd, &inputs.LanguagePrimary, "") - formLanguageDefault.RegisterString(cmd, &inputs.LanguageDefault, "") - formEdit.RegisterBool(cmd, &inputs.Edit, false) - formExample.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 updateFormCmd(cli *cli) *cobra.Command { - var inputs struct { - ID string - Name string - File string - LanguagePrimary string - LanguageDefault string - } - - cmd := &cobra.Command{ - Use: "update", - Args: cobra.MaximumNArgs(1), - Short: "Update a form", - Long: "Update a form.\n\n" + - "Passing `--file` (or piped stdin) replaces every top-level field present in the file. " + - "Passing only scalar flags such as `--name` performs a merge that preserves the form's " + - "graph fields (nodes, style, translations). Server-managed fields such as `id`, " + - "`created_at`, and `updated_at` are removed before the update request is sent.", - Example: ` auth0 forms update --name "New Name" - auth0 forms update --file ./form.json - cat form.json | auth0 forms update -f -`, - RunE: func(cmd *cobra.Command, args []string) error { - if len(args) > 0 { - inputs.ID = args[0] - } else { - if err := formID.Pick(cmd, &inputs.ID, cli.formPickerOptions); err != nil { - return err - } - } - - body, err := readBodyInput(inputs.File, "form") - if err != nil { - return err - } - - var rawBody json.RawMessage - - switch { - case body != nil: - // File / stdin: whole-file overwrite of present top-level fields. - rawBody, err = applyRawFormOverrides( - body, - inputs.Name, - inputs.LanguagePrimary, - inputs.LanguageDefault, - ) - if err != nil { - return fmt.Errorf("failed to parse form body: %w", err) - } - case inputs.Name != "" || inputs.LanguagePrimary != "" || inputs.LanguageDefault != "": - primary := inputs.LanguagePrimary - def := inputs.LanguageDefault - if primary != "" || def != "" { - // The API replaces the languages object, so retain the value that was - // not explicitly overridden. This scalar read is safe through v3. - var current *managementv3.GetFormResponseContent - if err := ansi.Waiting(func() (err error) { - current, err = cli.apiv3.Form.Get( - cmd.Context(), - inputs.ID, - &managementv3.GetFormRequestParameters{}, - ) - return err - }); err != nil { - return fmt.Errorf("failed to read form with ID %q: %w", inputs.ID, err) - } - languages := current.GetLanguages() - if primary == "" { - primary = languages.GetPrimary() - } - if def == "" { - def = languages.GetDefault() - } - } - - rawBody, err = applyRawFormOverrides(json.RawMessage(`{}`), inputs.Name, primary, def) - if err != nil { - return fmt.Errorf("failed to build form update: %w", err) - } - case canPrompt(cmd): - // Editor fallback: pre-load the exact wire body and full-replace. - current, err := cli.formRawGet(cmd.Context(), inputs.ID) - if err != nil { - return fmt.Errorf("failed to read form 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 form with ID %q: %w", inputs.ID, err) - } - - if err := editJSONBody(cli, "form", seed.String(), &rawBody); err != nil { - return err - } - default: - return errors.New("nothing to update; supply --file, pipe JSON via stdin, or a scalar flag such as --name") - } - - updated, err := cli.formRawUpdate(cmd.Context(), inputs.ID, rawBody) - if err != nil { - return fmt.Errorf("failed to update form with ID %q: %w", inputs.ID, err) - } - if err := cli.renderer.FormUpdateRaw(updated); err != nil { - return err - } - formNextStepsHint(cli, inputs.ID) - return nil - }, - } - - formName.RegisterStringU(cmd, &inputs.Name, "") - formFile.RegisterStringU(cmd, &inputs.File, "") - formLanguagePrimary.RegisterStringU(cmd, &inputs.LanguagePrimary, "") - formLanguageDefault.RegisterStringU(cmd, &inputs.LanguageDefault, "") - 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 deleteFormCmd(cli *cli) *cobra.Command { - cmd := &cobra.Command{ - Use: "delete", - Aliases: []string{"rm"}, - Args: cobra.ArbitraryArgs, - Short: "Delete a form", - Long: "Delete a form.\n\n" + - "To delete interactively, use `auth0 forms delete` with no arguments.\n\n" + - "To delete non-interactively, supply the form id and the `--force` flag to skip confirmation.", - Example: ` auth0 forms delete - auth0 forms rm - auth0 forms delete - auth0 forms delete --force - auth0 forms delete `, - RunE: func(cmd *cobra.Command, args []string) error { - var ids []string - if len(args) == 0 { - if err := formID.PickMany(cmd, &ids, cli.formPickerOptions); 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 form(s)", ids, func(_ int, id string) error { - if id == "" { - return nil - } - if err := cli.apiv3.Form.Delete(cmd.Context(), id); err != nil { - return fmt.Errorf("failed to delete form with ID %q: %w", id, err) - } - return nil - }) - }, - } - - cmd.Flags().BoolVar(&cli.force, "force", false, "Skip confirmation.") - - return cmd -} - -func exportFormCmd(cli *cli) *cobra.Command { - var inputs struct { - ID string - Output string - Compact bool - } - - cmd := &cobra.Command{ - Use: "export", - Args: cobra.MaximumNArgs(1), - Short: "Export a form", - Long: "Export a form as JSON. Writes to stdout by default (pipe-friendly) or to a file " + - "with `--output`. The output uses the same envelope as the Auth0 Dashboard " + - "(`version`, `form`, `flows`, `connections`), bundling the flows and vault connections " + - "the form references with portable `#FLOW-N#`/`#CONN-N#` placeholders, so it can be " + - "imported by the CLI or opened in the Dashboard.", - Example: ` auth0 forms export - auth0 forms export --output ./form.json - auth0 forms export --json-compact - auth0 forms export | auth0 forms import -f -`, - RunE: func(cmd *cobra.Command, args []string) error { - if len(args) == 0 { - if err := formID.Pick(cmd, &inputs.ID, cli.formPickerOptions); err != nil { - return err - } - } else { - inputs.ID = args[0] - } - - form, err := cli.formRawGet(cmd.Context(), inputs.ID) - if err != nil { - return fmt.Errorf("failed to read form with ID %q: %w", inputs.ID, err) - } - - env, err := cli.buildFormEnvelope(cmd.Context(), form) - if err != nil { - return err - } - - var data []byte - if inputs.Compact { - data, err = json.Marshal(env) - } else { - data, err = json.MarshalIndent(env, "", " ") - } - if err != nil { - return fmt.Errorf("failed to marshal form: %w", err) - } - - if inputs.Output != "" { - if err := os.WriteFile(inputs.Output, data, 0600); err != nil { - return fmt.Errorf("failed to write form to %q: %w", inputs.Output, err) - } - cli.renderer.Infof("Exported form %s to %s", inputs.ID, inputs.Output) - return nil - } - - cli.renderer.FormExport(string(data)) - return nil - }, - } - - formOutput.RegisterString(cmd, &inputs.Output, "") - cmd.Flags().BoolVar(&inputs.Compact, "json-compact", false, "Output in compact json format.") - - return cmd -} - -func importFormCmd(cli *cli) *cobra.Command { - var inputs struct { - ID string - File string - Connections map[string]string - } - - cmd := &cobra.Command{ - Use: "import", - Args: cobra.NoArgs, - Short: "Import a form", - Long: "Import a form from a JSON file (or piped stdin). Without `--id` a new form is " + - "created; with `--id` the existing form is replaced.\n\n" + - "Both a flat form graph and the Dashboard envelope (`version`, `form`, `flows`, " + - "`connections`) are accepted. For an envelope, the bundled flows are created and each " + - "`#CONN-N#` connection placeholder is mapped to an existing vault connection, either " + - "interactively or with `--connection`.", - Example: ` auth0 forms import --file ./form.json - auth0 forms import --file ./form.json --id - 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 := readBodyInput(inputs.File, "form") - if err != nil { - return err - } - if body == nil { - return errors.New("no form body provided; supply --file or pipe JSON via stdin") - } - - if isFormEnvelope(body) { - resolved, err := cli.resolveFormEnvelope(cmd, body, inputs.Connections) - if err != nil { - return err - } - body = resolved - } - - // Parse just enough to validate the JSON and read the name. The body is - // created/updated as raw JSON so STEP/ROUTER node config is preserved - // (the typed request models drop it via the lossy FormNode union). - var meta struct { - Name string `json:"name"` - } - if err := json.Unmarshal(body, &meta); err != nil { - return fmt.Errorf("failed to parse form body: %w", err) - } - - if inputs.ID == "" { - if meta.Name == "" { - return errors.New("a form name is required in the imported body") - } - - raw, err := cli.formRawCreate(cmd.Context(), body) - if err != nil { - return fmt.Errorf("failed to create form: %w", err) - } - - return cli.renderer.FormCreateRaw(raw) - } - - raw, err := cli.formRawUpdate(cmd.Context(), inputs.ID, body) - if err != nil { - return fmt.Errorf("failed to update form with ID %q: %w", inputs.ID, err) - } - - return cli.renderer.FormUpdateRaw(raw) - }, - } - - formFile.RegisterString(cmd, &inputs.File, "") - formImportID.RegisterString(cmd, &inputs.ID, "") - cmd.Flags().StringToStringVar(&inputs.Connections, "connection", nil, - "Map an exported connection placeholder to an existing vault connection ID, "+ - "e.g. --connection '#CONN-1#=ac_123'. Repeatable.") - 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 openFormCmd(cli *cli) *cobra.Command { - var inputs struct { - ID string - } - - cmd := &cobra.Command{ - Use: "open", - Args: cobra.MaximumNArgs(1), - Short: "Open a form in the Auth0 Dashboard", - Long: "Open a form's page in the Auth0 Dashboard form builder.", - Example: ` auth0 forms open - auth0 forms open `, - RunE: func(cmd *cobra.Command, args []string) error { - if len(args) == 0 { - if err := formID.Pick(cmd, &inputs.ID, cli.formPickerOptions); err != nil { - return err - } - } else { - inputs.ID = args[0] - } - - openFormEditURL(cli, inputs.ID) - - return nil - }, - } - - return cmd -} - -// openFormEditURL opens the form's builder page in a browser, or prints the URL -// when interactivity is disabled. -func openFormEditURL(cli *cli, id string) { - openBuilderURL(cli, fmt.Sprintf("forms/%s/edit", id)) -} - -// formNextStepsHint prints follow-up commands after a form is created or updated. -// It stays quiet in JSON output modes so scripted consumers get a clean stream. -func formNextStepsHint(cli *cli, id string) { - if id == "" || cli.json || cli.jsonCompact { - return - } - cli.renderer.Infof("Inspect it with: %s", ansi.Faint("auth0 forms show "+id)) - cli.renderer.Infof("Edit it in the dashboard with: %s", ansi.Faint("auth0 forms open "+id)) -} - -// 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) { - var form map[string]json.RawMessage - if err := json.Unmarshal(body, &form); err != nil { - return nil, err - } - if form == nil { - return nil, errors.New("form body must be a JSON object") - } - - if name != "" { - encoded, err := json.Marshal(name) - if err != nil { - return nil, err - } - form["name"] = encoded - } - - if primary != "" || def != "" { - languages := make(map[string]json.RawMessage) - if existing := form["languages"]; len(existing) > 0 && string(existing) != "null" { - if err := json.Unmarshal(existing, &languages); err != nil { - return nil, fmt.Errorf("parse languages: %w", err) - } - } - if primary != "" { - encoded, err := json.Marshal(primary) - if err != nil { - return nil, err - } - languages["primary"] = encoded - } - if def != "" { - encoded, err := json.Marshal(def) - if err != nil { - return nil, err - } - languages["default"] = encoded - } - encoded, err := json.Marshal(languages) - if err != nil { - return nil, err - } - form["languages"] = encoded - } - - return json.Marshal(form) -} - -// 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.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.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) { - cleanBody, err := stripRawFields(body, formServerManagedFields) - if err != nil { - return nil, err - } - - return c.rawJSONRequest(ctx, http.MethodPatch, c.api.HTTPClient.URI("forms", id), cleanBody) -} - -// collectForms pages through the forms list, collecting up to `limit` results -// (all results when limit <= 0). -func collectForms(ctx context.Context, cli *cli, params *managementv3.ListFormsRequestParameters, limit int) ([]*managementv3.FormSummary, error) { - page, err := cli.apiv3.Form.List(ctx, params) - if err != nil { - return nil, err - } - - var out []*managementv3.FormSummary - 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 (c *cli) formPickerOptions(ctx context.Context) (pickerOptions, error) { - forms, err := collectForms(ctx, c, &managementv3.ListFormsRequestParameters{}, 0) - if err != nil { - return nil, err - } - - var opts pickerOptions - for _, f := range forms { - 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 forms to choose from. Create one by running: `auth0 forms create`") - } - - return opts, nil -} diff --git a/internal/cli/forms_envelope.go b/internal/cli/forms_envelope.go deleted file mode 100644 index 3d336ee51..000000000 --- a/internal/cli/forms_envelope.go +++ /dev/null @@ -1,398 +0,0 @@ -package cli - -import ( - "context" - "encoding/json" - "errors" - "fmt" - "sort" - - "github.com/auth0/go-auth0/management" - "github.com/spf13/cobra" - - "github.com/auth0/auth0-cli/internal/ansi" - "github.com/auth0/auth0-cli/internal/prompt" -) - -// formEnvelopeVersion is the schema version the Auth0 Dashboard form builder -// stamps on exported forms. We emit the same value so exports interop. -const formEnvelopeVersion = "4.0.0" - -// formEnvelope mirrors the export shape produced by the Auth0 Dashboard form -// builder: the form graph plus the flows and vault connections it references, -// with real resource IDs replaced by portable #FLOW-N#/#CONN-N# placeholders. -type formEnvelope struct { - Version string `json:"version"` - Form json.RawMessage `json:"form"` - Flows map[string]json.RawMessage `json:"flows,omitempty"` - Connections map[string]envelopeConn `json:"connections,omitempty"` -} - -// envelopeConn is the connection descriptor emitted alongside a form. Vault -// connection secrets are never exported, so on import the placeholder is mapped -// to an existing connection rather than recreated. -type envelopeConn struct { - ID string `json:"id"` - AppID string `json:"app_id,omitempty"` - Name string `json:"name,omitempty"` -} - -// isFormEnvelope reports whether the given body is a Dashboard-style envelope -// rather than a flat form graph. An envelope always carries a top-level "form" -// object, whereas a flat body carries the form fields such as name and nodes -// at the top level. -func isFormEnvelope(body []byte) bool { - var probe struct { - Form json.RawMessage `json:"form"` - } - if err := json.Unmarshal(body, &probe); err != nil { - return false - } - return len(probe.Form) > 0 -} - -// substituteIDs replaces every JSON string value that exactly matches a key in -// `replacements` with its mapped value, walking the whole tree. IDs are opaque -// unique tokens, so exact full-string matching is safe and order-independent. -func substituteIDs(raw json.RawMessage, replacements map[string]string) (json.RawMessage, error) { - if len(replacements) == 0 { - return raw, nil - } - - var tree interface{} - if err := json.Unmarshal(raw, &tree); err != nil { - return nil, err - } - - return json.Marshal(walkReplace(tree, replacements)) -} - -func walkReplace(node interface{}, replacements map[string]string) interface{} { - switch v := node.(type) { - case map[string]interface{}: - for key, val := range v { - v[key] = walkReplace(val, replacements) - } - return v - case []interface{}: - for i, val := range v { - v[i] = walkReplace(val, replacements) - } - return v - case string: - if replaced, ok := replacements[v]; ok { - return replaced - } - return v - default: - return node - } -} - -// collectConnectionIDs returns every value stored under a "connection_id" key -// anywhere in the given flow JSON, de-duplicated and sorted for stable ordering. -func collectConnectionIDs(raw json.RawMessage) ([]string, error) { - var tree interface{} - if err := json.Unmarshal(raw, &tree); err != nil { - return nil, err - } - - seen := map[string]bool{} - var walk func(node interface{}) - walk = func(node interface{}) { - switch v := node.(type) { - case map[string]interface{}: - for key, val := range v { - if key == "connection_id" { - if s, ok := val.(string); ok && s != "" { - seen[s] = true - } - } - walk(val) - } - case []interface{}: - for _, val := range v { - walk(val) - } - } - } - walk(tree) - - out := make([]string, 0, len(seen)) - for id := range seen { - out = append(out, id) - } - sort.Strings(out) - - return out, nil -} - -// collectFlowIDs returns the flow IDs referenced by the form's FLOW nodes, in -// node order and de-duplicated. Working from the raw form map avoids the v3 -// SDK's lossy FormNode union. -func collectFlowIDs(formMap map[string]interface{}) []string { - nodes, ok := formMap["nodes"].([]interface{}) - if !ok { - return nil - } - - var ids []string - seen := map[string]bool{} - for _, n := range nodes { - node, ok := n.(map[string]interface{}) - if !ok || node["type"] != "FLOW" { - continue - } - config, ok := node["config"].(map[string]interface{}) - if !ok { - continue - } - id, ok := config["flow_id"].(string) - if !ok || id == "" || seen[id] { - continue - } - seen[id] = true - ids = append(ids, id) - } - - return ids -} - -// vaultConnectionPickerOptions lists the tenant's flow vault connections as -// selectable options for mapping envelope connection placeholders on import. -func (c *cli) vaultConnectionPickerOptions(ctx context.Context) (pickerOptions, error) { - var list *management.FlowVaultConnectionList - if err := ansi.Waiting(func() (err error) { - list, err = c.api.FlowVaultConnection.GetConnectionList(ctx) - return err - }); err != nil { - return nil, err - } - - var opts pickerOptions - for _, conn := range list.Connections { - label := fmt.Sprintf("%s %s", conn.GetName(), ansi.Faint("("+conn.GetID()+")")) - opts = append(opts, pickerOption{value: conn.GetID(), label: label}) - } - - if len(opts) == 0 { - return nil, errors.New("there are currently no vault connections to map to. Create one in the Auth0 Dashboard first") - } - - return opts, nil -} - -// resolveConnectionPlaceholders maps each #CONN-N# placeholder in the envelope -// to a real vault connection ID. It uses the provided mapping first and falls -// back to an interactive picker; without a terminal an unmapped placeholder is -// an error that tells the user to pass --connection. -func (c *cli) resolveConnectionPlaceholders( - cmd *cobra.Command, - env *formEnvelope, - mapping map[string]string, -) (map[string]string, error) { - placeholders := make([]string, 0, len(env.Connections)) - for ph := range env.Connections { - placeholders = append(placeholders, ph) - } - sort.Strings(placeholders) - - var options pickerOptions - resolved := make(map[string]string, len(placeholders)) - for _, ph := range placeholders { - if id := mapping[ph]; id != "" { - resolved[ph] = id - continue - } - - if !canPrompt(cmd) { - return nil, fmt.Errorf( - "cannot resolve connection %s: pass --connection '%s=' or run without --no-input", - ph, ph, - ) - } - - if options == nil { - opts, err := c.vaultConnectionPickerOptions(cmd.Context()) - if err != nil { - return nil, err - } - options = opts - } - - var label string - message := fmt.Sprintf("Select the vault connection for %s (%s):", ph, env.Connections[ph].Name) - if err := prompt.AskOne( - prompt.SelectInput("connection", message, "", options.labels(), options.defaultLabel(), true), - &label, - ); err != nil { - return nil, err - } - resolved[ph] = options.getValue(label) - } - - return resolved, nil -} - -// resolveFormEnvelope turns a Dashboard-style envelope into a flat form body -// ready for create/update: it maps connection placeholders to existing vault -// connections, creates the bundled flows (substituting the resolved connection -// IDs into them), and swaps the form's #FLOW-N# references for the new flow IDs. -func (c *cli) resolveFormEnvelope( - cmd *cobra.Command, - body []byte, - mapping map[string]string, -) (json.RawMessage, error) { - var env formEnvelope - if err := json.Unmarshal(body, &env); err != nil { - return nil, fmt.Errorf("failed to parse form body: %w", err) - } - if len(env.Form) == 0 { - return nil, errors.New("the imported envelope has no \"form\" object") - } - - connReplacements, err := c.resolveConnectionPlaceholders(cmd, &env, mapping) - if err != nil { - return nil, err - } - - // Create flows in placeholder order for a deterministic sequence. - placeholders := make([]string, 0, len(env.Flows)) - for ph := range env.Flows { - placeholders = append(placeholders, ph) - } - sort.Strings(placeholders) - - flowReplacements := make(map[string]string, len(placeholders)) - for _, ph := range placeholders { - flowRaw, err := substituteIDs(env.Flows[ph], connReplacements) - if err != nil { - return nil, err - } - - flow := &management.Flow{} - if err := json.Unmarshal(flowRaw, flow); err != nil { - return nil, fmt.Errorf("failed to parse flow %s: %w", ph, err) - } - if err := ansi.Waiting(func() error { - return c.api.Flow.Create(cmd.Context(), flow) - }); err != nil { - return nil, fmt.Errorf("failed to create flow %s: %w", ph, err) - } - flowReplacements[ph] = flow.GetID() - } - - return substituteIDs(env.Form, flowReplacements) -} - -// buildFormEnvelope turns a fetched form (raw wire JSON) into a Dashboard-style -// envelope: it reads every flow the form's FLOW nodes reference and every vault -// connection those flows reference, then swaps the real IDs for #FLOW-N#/#CONN-N# -// placeholders so the export is portable across tenants. The form is handled as -// raw JSON so STEP/ROUTER node config survives the round-trip. -func (c *cli) buildFormEnvelope( - ctx context.Context, - formRaw json.RawMessage, -) (*formEnvelope, error) { - var formMap map[string]interface{} - if err := json.Unmarshal(formRaw, &formMap); err != nil { - return nil, fmt.Errorf("failed to parse form: %w", err) - } - - // Referenced flow IDs, in node order, de-duplicated. - flowIDs := collectFlowIDs(formMap) - - // Read each flow and gather the connections its actions reference. - flowsByID := make(map[string]json.RawMessage, len(flowIDs)) - connSet := map[string]bool{} - for _, id := range flowIDs { - var flow *management.Flow - if err := ansi.Waiting(func() (err error) { - flow, err = c.api.Flow.Read(ctx, id) - return err - }); err != nil { - return nil, fmt.Errorf("failed to read flow with ID %q: %w", id, err) - } - - raw, err := json.Marshal(flow) - if err != nil { - return nil, fmt.Errorf("failed to marshal flow with ID %q: %w", id, err) - } - flowsByID[id] = raw - - connIDs, err := collectConnectionIDs(raw) - if err != nil { - return nil, err - } - for _, cid := range connIDs { - connSet[cid] = true - } - } - - connIDs := make([]string, 0, len(connSet)) - for id := range connSet { - connIDs = append(connIDs, id) - } - sort.Strings(connIDs) - - // Assign placeholders and build the real-ID -> placeholder replacement map. - replacements := make(map[string]string, len(flowIDs)+len(connIDs)) - flowPlaceholder := make(map[string]string, len(flowIDs)) - for i, id := range flowIDs { - ph := fmt.Sprintf("#FLOW-%d#", i+1) - replacements[id] = ph - flowPlaceholder[id] = ph - } - connPlaceholder := make(map[string]string, len(connIDs)) - for i, id := range connIDs { - ph := fmt.Sprintf("#CONN-%d#", i+1) - replacements[id] = ph - connPlaceholder[id] = ph - } - - // Drop volatile fields and swap in placeholders. - for _, field := range formServerManagedFields { - delete(formMap, field) - } - formBody, err := json.Marshal(formMap) - if err != nil { - return nil, err - } - formBody, err = substituteIDs(formBody, replacements) - if err != nil { - return nil, err - } - - env := &formEnvelope{Version: formEnvelopeVersion, Form: formBody} - - if len(flowsByID) > 0 { - env.Flows = make(map[string]json.RawMessage, len(flowsByID)) - for id, raw := range flowsByID { - substituted, err := substituteIDs(raw, replacements) - if err != nil { - return nil, err - } - env.Flows[flowPlaceholder[id]] = substituted - } - } - - if len(connIDs) > 0 { - env.Connections = make(map[string]envelopeConn, len(connIDs)) - for _, id := range connIDs { - var conn *management.FlowVaultConnection - if err := ansi.Waiting(func() (err error) { - conn, err = c.api.FlowVaultConnection.GetConnection(ctx, id) - return err - }); err != nil { - return nil, fmt.Errorf("failed to read vault connection with ID %q: %w", id, err) - } - env.Connections[connPlaceholder[id]] = envelopeConn{ - ID: conn.GetID(), - AppID: conn.GetAppID(), - Name: conn.GetName(), - } - } - } - - return env, nil -} diff --git a/internal/cli/forms_envelope_test.go b/internal/cli/forms_envelope_test.go deleted file mode 100644 index 33f8cfaea..000000000 --- a/internal/cli/forms_envelope_test.go +++ /dev/null @@ -1,206 +0,0 @@ -package cli - -import ( - "context" - "encoding/json" - "testing" - - "github.com/auth0/go-auth0/management" - "github.com/golang/mock/gomock" - "github.com/spf13/cobra" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - - "github.com/auth0/auth0-cli/internal/auth0" - "github.com/auth0/auth0-cli/internal/auth0/mock" -) - -func TestIsFormEnvelope(t *testing.T) { - tests := []struct { - name string - body string - want bool - }{ - {name: "envelope with form object", body: `{"version":"4.0.0","form":{"name":"x"}}`, want: true}, - {name: "flat form graph", body: `{"name":"x","nodes":[]}`, want: false}, - {name: "form as non-object is still detected", body: `{"form":{}}`, want: true}, - {name: "invalid json", body: `not-json`, want: false}, - } - - for _, test := range tests { - t.Run(test.name, func(t *testing.T) { - assert.Equal(t, test.want, isFormEnvelope([]byte(test.body))) - }) - } -} - -func TestSubstituteIDs(t *testing.T) { - t.Run("replaces exact string matches anywhere in the tree", func(t *testing.T) { - in := json.RawMessage(`{"flow_id":"fl_1","nested":{"connection_id":"ac_1","keep":"fl_1x"},"list":["fl_1","other"]}`) - out, err := substituteIDs(in, map[string]string{"fl_1": "#FLOW-1#", "ac_1": "#CONN-1#"}) - require.NoError(t, err) - - var got map[string]interface{} - require.NoError(t, json.Unmarshal(out, &got)) - assert.Equal(t, "#FLOW-1#", got["flow_id"]) - nested := got["nested"].(map[string]interface{}) - assert.Equal(t, "#CONN-1#", nested["connection_id"]) - assert.Equal(t, "fl_1x", nested["keep"]) // Substring must not be replaced. - list := got["list"].([]interface{}) - assert.Equal(t, "#FLOW-1#", list[0]) - assert.Equal(t, "other", list[1]) - }) - - t.Run("returns the input unchanged when there are no replacements", func(t *testing.T) { - in := json.RawMessage(`{"a":"b"}`) - out, err := substituteIDs(in, nil) - require.NoError(t, err) - assert.Equal(t, in, out) - }) -} - -func TestCollectConnectionIDs(t *testing.T) { - raw := json.RawMessage(`{ - "name": "flow", - "actions": [ - {"params": {"connection_id": "ac_2"}}, - {"params": {"connection_id": "ac_1"}}, - {"params": {"connection_id": "ac_1"}}, - {"params": {"other": "x"}} - ] - }`) - - got, err := collectConnectionIDs(raw) - require.NoError(t, err) - assert.Equal(t, []string{"ac_1", "ac_2"}, got) // De-duplicated and sorted. -} - -func TestBuildFormEnvelope(t *testing.T) { - ctrl := gomock.NewController(t) - defer ctrl.Finish() - - form := json.RawMessage(`{ - "id": "ap_form1", - "name": "Test Form", - "nodes": [ - {"id": "step_1", "type": "STEP", "config": {"next_node": "flow_1", "components": [{"id": "job_title", "type": "TEXT", "category": "FIELD"}]}}, - {"id": "flow_1", "type": "FLOW", "config": {"flow_id": "fl_real", "next_node": "$ending"}} - ], - "start": {"next_node": "step_1"}, - "ending": {} - }`) - - flowMock := mock.NewMockFlowAPI(ctrl) - flowMock.EXPECT().Read(gomock.Any(), "fl_real").Return(&management.Flow{ - Name: auth0.String("My Flow"), - Actions: []interface{}{ - map[string]interface{}{ - "type": "AUTH0", - "params": map[string]interface{}{"connection_id": "ac_real"}, - }, - }, - }, nil) - - connMock := mock.NewMockFlowVaultConnectionAPI(ctrl) - connMock.EXPECT().GetConnection(gomock.Any(), "ac_real").Return(&management.FlowVaultConnection{ - ID: auth0.String("ac_real"), - AppID: auth0.String("AUTH0"), - Name: auth0.String("My Connection"), - }, nil) - - cli := &cli{api: &auth0.API{Flow: flowMock, FlowVaultConnection: connMock}} - - env, err := cli.buildFormEnvelope(context.Background(), form) - require.NoError(t, err) - - assert.Equal(t, formEnvelopeVersion, env.Version) - - // Connection descriptor keeps the real values under the placeholder key. - require.Contains(t, env.Connections, "#CONN-1#") - assert.Equal(t, "ac_real", env.Connections["#CONN-1#"].ID) - assert.Equal(t, "AUTH0", env.Connections["#CONN-1#"].AppID) - assert.Equal(t, "My Connection", env.Connections["#CONN-1#"].Name) - - // The flow node's flow_id is replaced with the placeholder, and volatile - // fields are dropped from the form block. - var formMap map[string]interface{} - require.NoError(t, json.Unmarshal(env.Form, &formMap)) - assert.NotContains(t, formMap, "id") - nodes := formMap["nodes"].([]interface{}) - flowNode := nodes[1].(map[string]interface{}) - flowConfig := flowNode["config"].(map[string]interface{}) - assert.Equal(t, "#FLOW-1#", flowConfig["flow_id"]) - - // STEP node config (components) is preserved, not dropped by the SDK's union. - stepNode := nodes[0].(map[string]interface{}) - stepConfig := stepNode["config"].(map[string]interface{}) - components := stepConfig["components"].([]interface{}) - require.Len(t, components, 1) - assert.Equal(t, "job_title", components[0].(map[string]interface{})["id"]) - - // The flow's connection_id is replaced with the placeholder. - require.Contains(t, env.Flows, "#FLOW-1#") - var flowMap map[string]interface{} - require.NoError(t, json.Unmarshal(env.Flows["#FLOW-1#"], &flowMap)) - action := flowMap["actions"].([]interface{})[0].(map[string]interface{}) - params := action["params"].(map[string]interface{}) - assert.Equal(t, "#CONN-1#", params["connection_id"]) -} - -func TestResolveFormEnvelope(t *testing.T) { - envelope := []byte(`{ - "version": "4.0.0", - "form": { - "name": "Test Form", - "nodes": [ - {"id": "flow_1", "type": "FLOW", "config": {"flow_id": "#FLOW-1#"}} - ] - }, - "flows": { - "#FLOW-1#": { - "name": "My Flow", - "actions": [{"params": {"connection_id": "#CONN-1#"}}] - } - }, - "connections": { - "#CONN-1#": {"id": "ac_placeholder", "app_id": "AUTH0", "name": "REPLACE_WITH_M2M_CONNECTION"} - } - }`) - - t.Run("maps connections, creates flows, and substitutes flow IDs", func(t *testing.T) { - ctrl := gomock.NewController(t) - defer ctrl.Finish() - - flowMock := mock.NewMockFlowAPI(ctrl) - flowMock.EXPECT().Create(gomock.Any(), gomock.Any()).DoAndReturn( - func(_ context.Context, r *management.Flow, _ ...management.RequestOption) error { - // The connection placeholder is resolved before the flow is created. - action := r.Actions[0].(map[string]interface{}) - params := action["params"].(map[string]interface{}) - assert.Equal(t, "ac_mapped", params["connection_id"]) - r.ID = auth0.String("fl_created") - return nil - }) - - cli := &cli{api: &auth0.API{Flow: flowMock}} - - body, err := cli.resolveFormEnvelope(&cobra.Command{}, envelope, map[string]string{"#CONN-1#": "ac_mapped"}) - require.NoError(t, err) - - var formMap map[string]interface{} - require.NoError(t, json.Unmarshal(body, &formMap)) - node := formMap["nodes"].([]interface{})[0].(map[string]interface{}) - config := node["config"].(map[string]interface{}) - assert.Equal(t, "fl_created", config["flow_id"]) - }) - - t.Run("errors when a connection cannot be resolved without a terminal", func(t *testing.T) { - ctrl := gomock.NewController(t) - defer ctrl.Finish() - - cli := &cli{api: &auth0.API{Flow: mock.NewMockFlowAPI(ctrl)}} - - _, err := cli.resolveFormEnvelope(&cobra.Command{}, envelope, nil) - assert.ErrorContains(t, err, "cannot resolve connection #CONN-1#") - }) -} diff --git a/internal/cli/forms_test.go b/internal/cli/forms_test.go deleted file mode 100644 index 0d0cf687c..000000000 --- a/internal/cli/forms_test.go +++ /dev/null @@ -1,459 +0,0 @@ -package cli - -import ( - "bytes" - "context" - "encoding/json" - "errors" - "io" - "net/http" - "os" - "path/filepath" - "strings" - "testing" - - "github.com/auth0/go-auth0/management" - managementv3 "github.com/auth0/go-auth0/v3/management" - "github.com/auth0/go-auth0/v3/management/core" - "github.com/golang/mock/gomock" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - - "github.com/auth0/auth0-cli/internal/auth0" - "github.com/auth0/auth0-cli/internal/auth0/mock" - "github.com/auth0/auth0-cli/internal/display" - "github.com/auth0/auth0-cli/internal/iostream" -) - -func TestApplyRawFormOverrides(t *testing.T) { - body := json.RawMessage(`{ - "name":"Original", - "languages":{"primary":"en","default":"fr"}, - "start":{}, - "nodes":[ - {"id":"step_1","type":"STEP","config":{"components":[{"id":"field_1","category":"FIELD","type":"TEXT"}]}}, - {"id":"router_1","type":"ROUTER","config":{"rules":[{"id":"rule_1","condition":{"operator":"AND"}}]}} - ], - "ending":null - }`) - - got, err := applyRawFormOverrides(body, "Renamed", "de", "") - require.NoError(t, err) - - var form map[string]json.RawMessage - require.NoError(t, json.Unmarshal(got, &form)) - - assert.JSONEq(t, `"Renamed"`, string(form["name"])) - assert.JSONEq(t, `{"primary":"de","default":"fr"}`, string(form["languages"])) - assert.JSONEq(t, `{}`, string(form["start"])) - assert.JSONEq(t, `null`, string(form["ending"])) - assert.Contains(t, string(form["nodes"]), `"components"`) - assert.Contains(t, string(form["nodes"]), `"condition"`) -} - -func TestApplyRawFormOverridesRejectsNonObject(t *testing.T) { - _, err := applyRawFormOverrides(json.RawMessage(`[]`), "", "", "") - assert.ErrorContains(t, err, "cannot unmarshal array") -} - -func TestCreateFormCmdUsesRawClientForSimpleScaffold(t *testing.T) { - httpClient := &formHTTPClientStub{ - response: json.RawMessage(`{"id":"ap_simple","name":"Simple Form","start":{},"nodes":[],"ending":{}}`), - } - stdout := &bytes.Buffer{} - c := &cli{ - api: &auth0.API{HTTPClient: httpClient}, - renderer: &display.Renderer{ - MessageWriter: io.Discard, - ResultWriter: stdout, - }, - } - - cmd := createFormCmd(c) - cmd.SetArgs([]string{"--name", "Simple Form"}) - - require.NoError(t, cmd.Execute()) - assert.Equal(t, http.MethodPost, httpClient.method) - require.IsType(t, json.RawMessage{}, httpClient.payload) - assert.JSONEq(t, `{"name":"Simple Form","start":{},"nodes":[],"ending":{}}`, string(httpClient.payload.(json.RawMessage))) - assert.Contains(t, stdout.String(), "Simple Form") -} - -func TestCreateFormCmdUsesRawClientForRichFile(t *testing.T) { - body := []byte(`{ - "name":"Rich Form", - "nodes":[{"id":"step_1","type":"STEP","config":{"components":[{"id":"field_1","category":"FIELD","type":"TEXT"}]}}] - }`) - path := filepath.Join(t.TempDir(), "form.json") - require.NoError(t, os.WriteFile(path, body, 0600)) - - httpClient := &formHTTPClientStub{ - response: json.RawMessage(`{ - "id":"ap_rich", - "name":"Rich Form", - "nodes":[{"id":"step_1","type":"STEP","config":{"components":[{"id":"field_1","category":"FIELD","type":"TEXT"}]}}] - }`), - } - stdout := &bytes.Buffer{} - c := &cli{ - api: &auth0.API{HTTPClient: httpClient}, - renderer: &display.Renderer{ - MessageWriter: io.Discard, - ResultWriter: stdout, - }, - } - - cmd := createFormCmd(c) - cmd.SetArgs([]string{"--file", path}) - - require.NoError(t, cmd.Execute()) - assert.Equal(t, http.MethodPost, httpClient.method) - require.IsType(t, json.RawMessage{}, httpClient.payload) - assert.Contains(t, string(httpClient.payload.(json.RawMessage)), `"components"`) - assert.Contains(t, stdout.String(), "1 nodes") -} - -func TestShowFormCmdUsesRawClient(t *testing.T) { - httpClient := &formHTTPClientStub{ - response: json.RawMessage(`{ - "id":"ap_rich", - "name":"Rich Form", - "flow_count":2, - "links":{"self":"https://example.test/forms/ap_rich"}, - "nodes":[{"id":"router_1","type":"ROUTER","config":{"rules":[{"id":"rule_1","condition":{"operator":"AND"}}]}}] - }`), - } - stdout := &bytes.Buffer{} - c := &cli{ - api: &auth0.API{HTTPClient: httpClient}, - renderer: &display.Renderer{ - MessageWriter: io.Discard, - ResultWriter: stdout, - }, - } - - cmd := showFormCmd(c) - cmd.SetArgs([]string{"ap_rich"}) - - require.NoError(t, cmd.Execute()) - assert.Equal(t, http.MethodGet, httpClient.method) - assert.Contains(t, stdout.String(), "1 nodes") -} - -func TestUpdateFormCmdUsesRawClientForScalarUpdate(t *testing.T) { - ctrl := gomock.NewController(t) - defer ctrl.Finish() - - formAPI := mock.NewMockFormAPIV3(ctrl) - formAPI.EXPECT(). - Get(gomock.Any(), "ap_simple", gomock.Any()). - Return(&managementv3.GetFormResponseContent{ - ID: "ap_simple", - Name: "Original", - Languages: &managementv3.FormLanguages{ - Primary: auth0.String("en"), - Default: auth0.String("fr"), - }, - }, nil) - httpClient := &formHTTPClientStub{ - response: json.RawMessage(`{"id":"ap_simple","name":"Renamed","languages":{"primary":"de","default":"fr"}}`), - } - - stdout := &bytes.Buffer{} - c := &cli{ - api: &auth0.API{HTTPClient: httpClient}, - apiv3: &auth0.APIV3{Form: formAPI}, - renderer: &display.Renderer{ - MessageWriter: io.Discard, - ResultWriter: stdout, - }, - } - - cmd := updateFormCmd(c) - cmd.SetArgs([]string{"ap_simple", "--name", "Renamed", "--language-primary", "de"}) - - require.NoError(t, cmd.Execute()) - assert.Equal(t, http.MethodPatch, httpClient.method) - require.IsType(t, json.RawMessage{}, httpClient.payload) - assert.JSONEq(t, `{"name":"Renamed","languages":{"primary":"de","default":"fr"}}`, string(httpClient.payload.(json.RawMessage))) - assert.Contains(t, stdout.String(), "Renamed") -} - -func TestUpdateFormCmdUsesRawClientForRichFile(t *testing.T) { - body := []byte(`{ - "id":"ap_rich", - "name":"Rich Form", - "nodes":[{"id":"router_1","type":"ROUTER","config":{"rules":[{"id":"rule_1","condition":{"operator":"AND"}}]}}], - "ending":null, - "created_at":"2026-08-24T00:00:00Z", - "updated_at":"2026-08-24T00:00:00Z", - "flow_count":0, - "links":{} - }`) - path := filepath.Join(t.TempDir(), "form.json") - require.NoError(t, os.WriteFile(path, body, 0600)) - - httpClient := &formHTTPClientStub{ - response: json.RawMessage(`{ - "id":"ap_rich", - "name":"Rich Form", - "nodes":[{"id":"router_1","type":"ROUTER","config":{"rules":[{"id":"rule_1","condition":{"operator":"AND"}}]}}], - "ending":null - }`), - } - stdout := &bytes.Buffer{} - c := &cli{ - api: &auth0.API{HTTPClient: httpClient}, - renderer: &display.Renderer{ - MessageWriter: io.Discard, - ResultWriter: stdout, - }, - } - - cmd := updateFormCmd(c) - cmd.SetArgs([]string{"ap_rich", "--file", path}) - - require.NoError(t, cmd.Execute()) - assert.Equal(t, http.MethodPatch, httpClient.method) - require.IsType(t, json.RawMessage{}, httpClient.payload) - payload := httpClient.payload.(json.RawMessage) - assert.Contains(t, string(payload), `"condition"`) - assert.Contains(t, string(payload), `"ending":null`) - var form map[string]json.RawMessage - require.NoError(t, json.Unmarshal(payload, &form)) - assert.NotContains(t, form, "id") - assert.NotContains(t, form, "created_at") - assert.NotContains(t, form, "updated_at") - assert.NotContains(t, form, "flow_count") - assert.NotContains(t, form, "links") - assert.Contains(t, stdout.String(), "1 nodes") -} - -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 := readBodyInput(path, "form") - assert.NoError(t, err) - assert.Equal(t, want, got) - }) - - t.Run("errors on a missing file", func(t *testing.T) { - _, err := readBodyInput(filepath.Join(t.TempDir(), "missing.json"), "form") - assert.ErrorContains(t, err, "failed to read form file") - }) - - t.Run("reads from stdin when file is '-'", func(t *testing.T) { - dir := t.TempDir() - path := filepath.Join(dir, "stdin.json") - want := []byte(`{"name":"Piped Form"}`) - assert.NoError(t, os.WriteFile(path, want, 0600)) - - f, err := os.Open(path) - assert.NoError(t, err) - defer f.Close() - - original := iostream.Input - iostream.Input = f - defer func() { iostream.Input = original }() - - got, err := readBodyInput("-", "form") - assert.NoError(t, err) - assert.Equal(t, want, got) - }) -} - -func TestFormPickerOptions(t *testing.T) { - tests := []struct { - name string - forms []*managementv3.FormSummary - apiError error - assertOutput func(t testing.TB, options pickerOptions) - assertError func(t testing.TB, err error) - }{ - { - name: "happy path", - forms: []*managementv3.FormSummary{ - {ID: "some-id-1", Name: "some-name-1"}, - {ID: "some-id-2", Name: "some-name-2"}, - }, - assertOutput: func(t testing.TB, options pickerOptions) { - assert.Len(t, options, 2) - assert.Equal(t, "some-name-1 (some-id-1)", options[0].label) - assert.Equal(t, "some-id-1", options[0].value) - assert.Equal(t, "some-name-2 (some-id-2)", options[1].label) - assert.Equal(t, "some-id-2", options[1].value) - }, - assertError: func(t testing.TB, err error) { - t.Fail() - }, - }, - { - name: "no forms", - forms: []*managementv3.FormSummary{}, - assertOutput: func(t testing.TB, options pickerOptions) { - t.Fail() - }, - assertError: func(t testing.TB, err error) { - assert.ErrorContains(t, err, "there are currently no forms to choose from. Create one by running: `auth0 forms create`") - }, - }, - { - name: "API error", - apiError: errors.New("error"), - assertOutput: func(t testing.TB, options pickerOptions) { - t.Fail() - }, - assertError: func(t testing.TB, err error) { - assert.Error(t, err) - }, - }, - } - - for _, test := range tests { - t.Run(test.name, func(t *testing.T) { - ctrl := gomock.NewController(t) - defer ctrl.Finish() - - formAPI := mock.NewMockFormAPIV3(ctrl) - if test.apiError != nil { - formAPI.EXPECT().List(gomock.Any(), gomock.Any()).Return(nil, test.apiError) - } else { - formAPI.EXPECT().List(gomock.Any(), gomock.Any()).Return( - &auth0.FormSummaryPage{ - Results: test.forms, - NextPageFunc: func(_ context.Context) (*auth0.FormSummaryPage, error) { - return nil, core.ErrNoPages - }, - }, nil) - } - - cli := &cli{ - apiv3: &auth0.APIV3{Form: formAPI}, - } - - options, err := cli.formPickerOptions(context.Background()) - - if err != nil { - test.assertError(t, err) - } else { - test.assertOutput(t, options) - } - }) - } -} - -func TestCollectForms(t *testing.T) { - t.Run("pages across responses until exhausted", func(t *testing.T) { - ctrl := gomock.NewController(t) - defer ctrl.Finish() - - secondPage := &auth0.FormSummaryPage{ - Results: []*managementv3.FormSummary{{ID: "id-3", Name: "Form 3"}}, - NextPageFunc: func(_ context.Context) (*auth0.FormSummaryPage, error) { - return nil, core.ErrNoPages - }, - } - firstPage := &auth0.FormSummaryPage{ - Results: []*managementv3.FormSummary{ - {ID: "id-1", Name: "Form 1"}, - {ID: "id-2", Name: "Form 2"}, - }, - NextPageFunc: func(_ context.Context) (*auth0.FormSummaryPage, error) { - return secondPage, nil - }, - } - - formAPI := mock.NewMockFormAPIV3(ctrl) - formAPI.EXPECT().List(gomock.Any(), gomock.Any()).Return(firstPage, nil) - - cli := &cli{apiv3: &auth0.APIV3{Form: formAPI}} - - forms, err := collectForms(context.Background(), cli, &managementv3.ListFormsRequestParameters{}, 0) - assert.NoError(t, err) - assert.Len(t, forms, 3) - assert.Equal(t, "id-3", forms[2].GetID()) - }) - - t.Run("stops at the requested limit without paging further", func(t *testing.T) { - ctrl := gomock.NewController(t) - defer ctrl.Finish() - - firstPage := &auth0.FormSummaryPage{ - Results: []*managementv3.FormSummary{ - {ID: "id-1", Name: "Form 1"}, - {ID: "id-2", Name: "Form 2"}, - }, - NextPageFunc: func(_ context.Context) (*auth0.FormSummaryPage, error) { - t.Fatal("should not page past the limit") - return nil, nil - }, - } - - formAPI := mock.NewMockFormAPIV3(ctrl) - formAPI.EXPECT().List(gomock.Any(), gomock.Any()).Return(firstPage, nil) - - cli := &cli{apiv3: &auth0.APIV3{Form: formAPI}} - - forms, err := collectForms(context.Background(), cli, &managementv3.ListFormsRequestParameters{}, 1) - assert.NoError(t, err) - assert.Len(t, forms, 1) - assert.Equal(t, "id-1", forms[0].GetID()) - }) - - t.Run("returns the list error", func(t *testing.T) { - ctrl := gomock.NewController(t) - defer ctrl.Finish() - - formAPI := mock.NewMockFormAPIV3(ctrl) - formAPI.EXPECT().List(gomock.Any(), gomock.Any()).Return(nil, errors.New("boom")) - - cli := &cli{apiv3: &auth0.APIV3{Form: formAPI}} - - _, err := collectForms(context.Background(), cli, &managementv3.ListFormsRequestParameters{}, 0) - assert.EqualError(t, err, "boom") - }) -} - -type formHTTPClientStub struct { - method string - payload interface{} - response json.RawMessage -} - -func (s *formHTTPClientStub) NewRequest( - ctx context.Context, - method string, - uri string, - payload interface{}, - _ ...management.RequestOption, -) (*http.Request, error) { - s.method = method - s.payload = payload - return http.NewRequestWithContext(ctx, method, uri, nil) -} - -func (s *formHTTPClientStub) Do(_ *http.Request) (*http.Response, error) { - return &http.Response{ - StatusCode: http.StatusOK, - Header: make(http.Header), - Body: io.NopCloser(strings.NewReader(string(s.response))), - }, nil -} - -func (s *formHTTPClientStub) Request( - context.Context, - string, - string, - interface{}, - ...management.RequestOption, -) error { - return nil -} - -func (s *formHTTPClientStub) URI(path ...string) string { - return "https://example.test/api/v2/" + strings.Join(path, "/") -} diff --git a/internal/cli/root.go b/internal/cli/root.go index 4339bbb80..4e7eff8a8 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -257,6 +257,7 @@ func addSubCommands(rootCmd *cobra.Command, cli *cli) { // The order of the commands here matters. // Add new commands in a place that reflect its // relevance or relation with other commands. + rootCmd.AddCommand(initCmd(cli)) rootCmd.AddCommand(loginCmd(cli)) rootCmd.AddCommand(logoutCmd(cli)) rootCmd.AddCommand(tenantsCmd(cli)) @@ -280,7 +281,6 @@ func addSubCommands(rootCmd *cobra.Command, cli *cli) { rootCmd.AddCommand(apiCmd(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)) diff --git a/internal/cli/terraform.go b/internal/cli/terraform.go index d7c45ee5d..5ddee1b59 100644 --- a/internal/cli/terraform.go +++ b/internal/cli/terraform.go @@ -98,7 +98,7 @@ func (i *terraformInputs) parseResourceFetchers(api *auth0.API, apiv3 *auth0.API case "auth0_flow_vault_connection": fetchers = append(fetchers, &flowVaultConnectionResourceFetcher{api}) case "auth0_form": - fetchers = append(fetchers, &formResourceFetcher{apiv3}) + fetchers = append(fetchers, &formResourceFetcher{api}) case "auth0_guardian": fetchers = append(fetchers, &guardianResourceFetcher{}) case "auth0_log_stream": diff --git a/internal/cli/terraform_fetcher.go b/internal/cli/terraform_fetcher.go index a999c64d9..05655e5f2 100644 --- a/internal/cli/terraform_fetcher.go +++ b/internal/cli/terraform_fetcher.go @@ -2,13 +2,11 @@ package cli import ( "context" - "errors" "net/http" "strings" "github.com/auth0/go-auth0/management" managementv3 "github.com/auth0/go-auth0/v3/management" - "github.com/auth0/go-auth0/v3/management/core" "github.com/google/uuid" "github.com/auth0/auth0-cli/internal/auth0" @@ -88,7 +86,7 @@ type ( } formResourceFetcher struct { - apiv3 *auth0.APIV3 + api *auth0.API } guardianResourceFetcher struct{} @@ -434,26 +432,16 @@ func (f *flowVaultConnectionResourceFetcher) FetchData(ctx context.Context) (imp func (f *formResourceFetcher) FetchData(ctx context.Context) (importDataList, error) { var data importDataList - page, err := f.apiv3.Form.List(ctx, &managementv3.ListFormsRequestParameters{}) + forms, err := f.api.Form.List(ctx) if err != nil { return data, err } - for page != nil { - for _, form := range page.Results { - data = append(data, importDataItem{ - ResourceName: "auth0_form." + sanitizeResourceName(form.GetName()), - ImportID: form.GetID(), - }) - } - - page, err = page.GetNextPage(ctx) - if errors.Is(err, core.ErrNoPages) { - break - } - if err != nil { - return data, err - } + for _, form := range forms.Forms { + data = append(data, importDataItem{ + ResourceName: "auth0_form." + sanitizeResourceName(form.GetName()), + ImportID: form.GetID(), + }) } return data, nil diff --git a/internal/cli/terraform_fetcher_test.go b/internal/cli/terraform_fetcher_test.go index 6c46984dc..574763c3f 100644 --- a/internal/cli/terraform_fetcher_test.go +++ b/internal/cli/terraform_fetcher_test.go @@ -1076,27 +1076,29 @@ func TestFormResourceFetcher_FetchData(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() - formAPI := mock.NewMockFormAPIV3(ctrl) + formAPI := mock.NewMockFormAPI(ctrl) formAPI.EXPECT(). List(gomock.Any(), gomock.Any()).Return( - &auth0.FormSummaryPage{ - Results: []*managementv3.FormSummary{ + &management.FormList{ + List: management.List{ + Start: 0, + Limit: 1, + Total: 2, + }, + Forms: []*management.Form{ { - ID: "form_id1", - Name: "Form 1", + ID: auth0.String("form_id1"), + Name: auth0.String("Form 1"), }, { - ID: "form_id2", - Name: "Form 2", + ID: auth0.String("form_id2"), + Name: auth0.String("Form 2"), }, }, - NextPageFunc: func(_ context.Context) (*auth0.FormSummaryPage, error) { - return nil, core.ErrNoPages - }, }, nil) fetcher := formResourceFetcher{ - apiv3: &auth0.APIV3{ + api: &auth0.API{ Form: formAPI, }, } @@ -1121,18 +1123,20 @@ func TestFormResourceFetcher_FetchData(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() - formAPI := mock.NewMockFormAPIV3(ctrl) + formAPI := mock.NewMockFormAPI(ctrl) formAPI.EXPECT(). List(gomock.Any(), gomock.Any()).Return( - &auth0.FormSummaryPage{ - Results: []*managementv3.FormSummary{}, - NextPageFunc: func(_ context.Context) (*auth0.FormSummaryPage, error) { - return nil, core.ErrNoPages + &management.FormList{ + List: management.List{ + Start: 0, + Limit: 0, + Total: 0, }, + Forms: []*management.Form{}, }, nil) fetcher := formResourceFetcher{ - apiv3: &auth0.APIV3{ + api: &auth0.API{ Form: formAPI, }, } @@ -1146,13 +1150,13 @@ func TestFormResourceFetcher_FetchData(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() - formAPI := mock.NewMockFormAPIV3(ctrl) + formAPI := mock.NewMockFormAPI(ctrl) formAPI.EXPECT(). List(gomock.Any(), gomock.Any()). Return(nil, fmt.Errorf("failed to read form")) fetcher := formResourceFetcher{ - apiv3: &auth0.APIV3{ + api: &auth0.API{ Form: formAPI, }, } diff --git a/internal/cli/utils_shared.go b/internal/cli/utils_shared.go index af6656679..c2d1c1fde 100644 --- a/internal/cli/utils_shared.go +++ b/internal/cli/utils_shared.go @@ -10,7 +10,6 @@ import ( "io" "net/http" "net/url" - "os" "strconv" "strings" "time" @@ -24,7 +23,6 @@ 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" ) @@ -528,31 +526,6 @@ func (c *cli) rawJSONRequest( 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) { @@ -577,18 +550,6 @@ func applyRawNameOverride(body json.RawMessage, name string) (json.RawMessage, e 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) { diff --git a/internal/display/flows.go b/internal/display/flows.go index c4975dbb9..e7846b307 100644 --- a/internal/display/flows.go +++ b/internal/display/flows.go @@ -374,9 +374,43 @@ func makeFlowVaultConnectionViewFromRaw(raw json.RawMessage) (*flowVaultConnecti }, nil } +func boolToPresence(present bool) string { + if present { + return "set" + } + return "none" +} + func boolToReady(ready bool) string { if ready { return "yes" } return "no" } + +func rawTimeAgo(value time.Time) string { + if value.IsZero() { + return "" + } + return timeAgo(value) +} + +func mergeExtraProperties(obj interface{}, extra map[string]interface{}) interface{} { + if len(extra) == 0 { + return obj + } + data, err := json.Marshal(obj) + if err != nil { + return obj + } + var merged map[string]interface{} + if err := json.Unmarshal(data, &merged); err != nil { + return obj + } + for key, value := range extra { + if _, ok := merged[key]; !ok { + merged[key] = value + } + } + return merged +} diff --git a/internal/display/forms.go b/internal/display/forms.go deleted file mode 100644 index 45839ecbb..000000000 --- a/internal/display/forms.go +++ /dev/null @@ -1,237 +0,0 @@ -package display - -import ( - "encoding/json" - "fmt" - "strings" - "time" - - managementv3 "github.com/auth0/go-auth0/v3/management" - - "github.com/auth0/auth0-cli/internal/ansi" -) - -type formView struct { - ID string - Name string - LanguagePrimary string - LanguageDefault string - NodeCount int - TranslationLang int - HasStyle bool - CreatedAt string - UpdatedAt string - SubmittedAt string - - raw interface{} -} - -func (v *formView) AsTableHeader() []string { - return []string{"ID", "Name", "Submitted", "Updated"} -} - -func (v *formView) AsTableRow() []string { - return []string{ansi.Faint(v.ID), v.Name, v.SubmittedAt, v.UpdatedAt} -} - -func (v *formView) KeyValues() [][]string { - kvs := [][]string{ - {"ID", ansi.Faint(v.ID)}, - {"NAME", v.Name}, - {"LANGUAGES", formLanguageSummary(v.LanguagePrimary, v.LanguageDefault)}, - {"NODES", fmt.Sprintf("%d nodes", v.NodeCount)}, - {"TRANSLATIONS", fmt.Sprintf("%d languages", v.TranslationLang)}, - {"STYLE", boolToPresence(v.HasStyle)}, - } - - kvs = append(kvs, - []string{"CREATED AT", v.CreatedAt}, - []string{"UPDATED AT", v.UpdatedAt}, - ) - - if v.SubmittedAt != "" { - kvs = append(kvs, []string{"SUBMITTED AT", v.SubmittedAt}) - } - - return kvs -} - -func (v *formView) Object() interface{} { - return v.raw -} - -// formSummaryView renders a single row in the forms list. -type formSummaryView struct { - ID string - Name string - SubmittedAt string - UpdatedAt string - - raw interface{} -} - -func (v *formSummaryView) AsTableHeader() []string { - return []string{"ID", "Name", "Submitted", "Updated"} -} - -func (v *formSummaryView) AsTableRow() []string { - return []string{ansi.Faint(v.ID), v.Name, v.SubmittedAt, v.UpdatedAt} -} - -func (v *formSummaryView) Object() interface{} { - return v.raw -} - -// FormsList renders the list of forms. -func (r *Renderer) FormsList(forms []*managementv3.FormSummary) error { - resource := "forms" - - r.Heading(resource) - - if len(forms) == 0 { - r.EmptyState(resource, "Use 'auth0 forms create' to add one") - return nil - } - - var res []View - for _, f := range forms { - res = append(res, makeFormSummaryView(f)) - } - - r.Results(res) - - return nil -} - -// FormShowRaw renders a full-fidelity form response read through the v1 HTTP -// client, avoiding the v3 SDK's lossy form-node unions. -func (r *Renderer) FormShowRaw(form json.RawMessage) error { - return r.renderRawForm("form", form) -} - -// FormCreateRaw renders a full-fidelity create response. -func (r *Renderer) FormCreateRaw(form json.RawMessage) error { - return r.renderRawForm("form created", form) -} - -// FormUpdateRaw renders a full-fidelity update response. -func (r *Renderer) FormUpdateRaw(form json.RawMessage) error { - return r.renderRawForm("form updated", form) -} - -func (r *Renderer) renderRawForm(heading string, form json.RawMessage) error { - view, err := makeFormViewFromRaw(form) - if err != nil { - return fmt.Errorf("failed to parse form response: %w", err) - } - r.Heading(heading) - r.Result(view) - return nil -} - -func makeFormSummaryView(f *managementv3.FormSummary) *formSummaryView { - return &formSummaryView{ - ID: f.GetID(), - Name: f.GetName(), - SubmittedAt: f.GetSubmittedAt(), - UpdatedAt: timeAgo(f.GetUpdatedAt()), - raw: mergeExtraProperties(f, f.GetExtraProperties()), - } -} - -func makeFormViewFromRaw(raw json.RawMessage) (*formView, error) { - var form struct { - ID string `json:"id"` - Name string `json:"name"` - Languages struct { - Primary string `json:"primary"` - Default string `json:"default"` - } `json:"languages"` - Nodes []json.RawMessage `json:"nodes"` - Translations map[string]json.RawMessage `json:"translations"` - Style json.RawMessage `json:"style"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` - SubmittedAt string `json:"submitted_at"` - } - if err := json.Unmarshal(raw, &form); err != nil { - return nil, err - } - - return &formView{ - ID: form.ID, - Name: form.Name, - LanguagePrimary: form.Languages.Primary, - LanguageDefault: form.Languages.Default, - NodeCount: len(form.Nodes), - TranslationLang: len(form.Translations), - HasStyle: rawJSONPresent(form.Style), - CreatedAt: rawTimeAgo(form.CreatedAt), - UpdatedAt: rawTimeAgo(form.UpdatedAt), - SubmittedAt: form.SubmittedAt, - raw: raw, - }, nil -} - -func rawJSONPresent(raw json.RawMessage) bool { - value := strings.TrimSpace(string(raw)) - return value != "" && value != "null" -} - -func rawTimeAgo(value time.Time) string { - if value.IsZero() { - return "" - } - return timeAgo(value) -} - -// FormExport writes a form body verbatim (uncolored) to the result writer so it -// stays pipe- and import-friendly. -func (r *Renderer) FormExport(body string) { - fmt.Fprintln(r.ResultWriter, body) -} - -func formLanguageSummary(primary, def string) string { - switch { - case primary == "" && def == "": - return "-" - case def == "": - return fmt.Sprintf("primary: %s", primary) - case primary == "": - return fmt.Sprintf("default: %s", def) - default: - return fmt.Sprintf("primary: %s, default: %s", primary, def) - } -} - -func boolToPresence(present bool) string { - if present { - return "set" - } - return "none" -} - -// mergeExtraProperties rebuilds the full API wire object for JSON output. The -// generated SDK captures fields it does not model (such as flow_count and links -// on forms) into an extra-properties map that its own MarshalJSON drops, so -// re-marshaling the typed value alone would silently lose them. Marshaling the -// typed value and overlaying the extras keeps --json faithful to the API. -func mergeExtraProperties(obj interface{}, extra map[string]interface{}) interface{} { - if len(extra) == 0 { - return obj - } - data, err := json.Marshal(obj) - if err != nil { - return obj - } - var merged map[string]interface{} - if err := json.Unmarshal(data, &merged); err != nil { - return obj - } - for key, value := range extra { - if _, ok := merged[key]; !ok { - merged[key] = value - } - } - return merged -} diff --git a/internal/display/forms_test.go b/internal/display/forms_test.go deleted file mode 100644 index fa08d85c6..000000000 --- a/internal/display/forms_test.go +++ /dev/null @@ -1,50 +0,0 @@ -package display - -import ( - "bytes" - "encoding/json" - "io" - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func TestFormShowRawPreservesRichGraphJSON(t *testing.T) { - stdout := &bytes.Buffer{} - renderer := &Renderer{ - MessageWriter: io.Discard, - ResultWriter: stdout, - Format: OutputFormatJSON, - } - body := json.RawMessage(`{ - "id":"ap_rich", - "name":"Rich Form", - "flow_count":2, - "links":{"self":"https://example.test/forms/ap_rich"}, - "nodes":[ - {"id":"step_1","type":"STEP","config":{"components":[{"id":"field_1","category":"FIELD","type":"TEXT"}]}}, - {"id":"router_1","type":"ROUTER","config":{"rules":[{"id":"rule_1","condition":{"operator":"AND"}}]}} - ] - }`) - - require.NoError(t, renderer.FormShowRaw(body)) - - var got map[string]interface{} - require.NoError(t, json.Unmarshal(stdout.Bytes(), &got)) - assert.Equal(t, float64(2), got["flow_count"]) - assert.Contains(t, got, "links") - - nodes := got["nodes"].([]interface{}) - step := nodes[0].(map[string]interface{}) - assert.Contains(t, step["config"].(map[string]interface{}), "components") - router := nodes[1].(map[string]interface{}) - rules := router["config"].(map[string]interface{})["rules"].([]interface{}) - assert.Contains(t, rules[0].(map[string]interface{}), "condition") -} - -func TestFormShowRawRejectsInvalidJSON(t *testing.T) { - renderer := &Renderer{MessageWriter: io.Discard, ResultWriter: io.Discard} - err := renderer.FormShowRaw(json.RawMessage(`not-json`)) - assert.ErrorContains(t, err, "failed to parse form response") -} diff --git a/test/integration/fixtures/update-form.json b/test/integration/fixtures/update-form.json deleted file mode 100644 index 008d6630c..000000000 --- a/test/integration/fixtures/update-form.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "name": "integration-test-form-fixture-updated", - "languages": { - "primary": "en", - "default": "en" - }, - "start": {}, - "nodes": [], - "ending": {} -} diff --git a/test/integration/forms-test-cases.yaml b/test/integration/forms-test-cases.yaml deleted file mode 100644 index 7478a81df..000000000 --- a/test/integration/forms-test-cases.yaml +++ /dev/null @@ -1,128 +0,0 @@ -config: - inherit-env: true - retries: 1 - -tests: - 001 - it successfully lists all forms (json): - command: auth0 forms list --json - exit-code: 0 - - 002 - it successfully creates a form via --name: - command: auth0 forms create --name integration-test-form-created --no-input - exit-code: 0 - stdout: - contains: - - ID - - NAME - - integration-test-form-created - - 003 - it successfully creates a form and outputs in json: - command: auth0 forms create --name integration-test-form-json --no-input --json - exit-code: 0 - stdout: - json: - name: "integration-test-form-json" - - 004 - it successfully creates a form from the embedded example: - command: auth0 forms create --example | auth0 forms create -f - --name integration-test-form-example --no-input --json - exit-code: 0 - stdout: - json: - name: "integration-test-form-example" - languages.primary: "en" - languages.default: "en" - - 005 - it fails to create a form without a name: - command: echo '{"start":{},"nodes":[],"ending":{}}' | auth0 forms create -f - --no-input - exit-code: 1 - stderr: - contains: - - form name is required - - 006 - it fails to create a form from invalid json: - command: echo 'not-json' | auth0 forms create -f - --no-input - exit-code: 1 - stderr: - contains: - - parse form body - - 007 - it successfully lists all forms with data: - command: auth0 forms list - exit-code: 0 - stdout: - contains: - - ID - - NAME - - UPDATED - - 008 - given a test form, it successfully shows the form details: - command: auth0 forms show $(./test/integration/scripts/get-form-id.sh) - exit-code: 0 - stdout: - contains: - - ID - - NAME - - integration-test-form - - 009 - given a test form, it successfully shows the form details (json): - command: auth0 forms show $(./test/integration/scripts/get-form-id.sh) --json - exit-code: 0 - stdout: - json: - name: "integration-test-form" - - 010 - given a test form, it successfully updates the form name: - command: auth0 forms update $(./test/integration/scripts/get-form-id.sh) --name integration-test-form-updated --json - exit-code: 0 - stdout: - json: - name: "integration-test-form-updated" - - 011 - given a test form, it successfully updates the form from a fixture file: - command: auth0 forms update $(./test/integration/scripts/get-form-id.sh) -f ./test/integration/fixtures/update-form.json --json - exit-code: 0 - stdout: - json: - name: "integration-test-form-fixture-updated" - languages.primary: "en" - languages.default: "en" - - 012 - given a test form, it successfully exports the form as an envelope: - command: auth0 forms export $(./test/integration/scripts/get-form-id.sh) - exit-code: 0 - stdout: - contains: - - '"version"' - - '"form"' - - '"name"' - - 013 - given a test form, it successfully round-trips export to import: - command: auth0 forms export $(./test/integration/scripts/get-form-id.sh) | auth0 forms import --id $(./test/integration/scripts/get-form-id.sh) -f - --json - exit-code: 0 - stdout: - json: - name: "integration-test-form-fixture-updated" - - 014 - given a test form, it prints the builder URL for open: - command: auth0 forms open $(./test/integration/scripts/get-form-id.sh) --no-input - exit-code: 0 - stderr: - contains: - - forms.auth0.com - - /edit - - 015 - agent mode refuses to delete a form without force: - command: AUTH0_AGENT_MODE=true auth0 forms delete $(./test/integration/scripts/get-form-id.sh) - exit-code: 1 - stderr: - contains: - - destructive command - - --force - - 016 - given a test form, it successfully deletes the form: - command: auth0 forms delete $(./test/integration/scripts/get-form-id.sh) --force - exit-code: 0 - - 017 - it cleans up all forms created by this suite: - command: ./test/integration/scripts/cleanup-forms.sh - exit-code: 0 diff --git a/test/integration/scripts/cleanup-forms.sh b/test/integration/scripts/cleanup-forms.sh deleted file mode 100755 index ec2a4816c..000000000 --- a/test/integration/scripts/cleanup-forms.sh +++ /dev/null @@ -1,16 +0,0 @@ -#!/bin/bash - -set -euo pipefail - -ids=() -while IFS= read -r id; do - if [[ -n "$id" ]]; then - ids+=("$id") - fi -done < <(auth0 forms list --json --no-input | jq -r '.[] | select(.name | startswith("integration-test-")) | .id') - -if (( ${#ids[@]} > 0 )); then - auth0 forms delete --force "${ids[@]}" -fi - -rm -f ./test/integration/identifiers/form-id diff --git a/test/integration/scripts/get-form-id.sh b/test/integration/scripts/get-form-id.sh deleted file mode 100755 index 2a9f67960..000000000 --- a/test/integration/scripts/get-form-id.sh +++ /dev/null @@ -1,13 +0,0 @@ -#! /bin/bash - -FILE=./test/integration/identifiers/form-id -if [ -f "$FILE" ]; then - cat $FILE - exit 0 -fi - -form=$( auth0 forms create --name "integration-test-form" --json --no-input ) - -mkdir -p ./test/integration/identifiers -echo "$form" | jq -r '.["id"]' > $FILE -cat $FILE From 111eb9e604ab5b0729ed611d3d988c60ac543c41 Mon Sep 17 00:00:00 2001 From: ramya18101 Date: Wed, 2 Sep 2026 17:49:46 +0530 Subject: [PATCH 6/6] refactor: update forms references to builder and remove unused mock file --- internal/auth/auth.go | 3 +- internal/auth0/mock/flow_v3_mock.go | 201 ----------------------- internal/cli/flows_test.go | 56 +++++++ internal/cli/flows_vault_test.go | 57 ------- internal/cli/root.go | 1 - internal/cli/utils_shared.go | 8 +- test/integration/scripts/test-cleanup.sh | 1 - 7 files changed, 61 insertions(+), 266 deletions(-) delete mode 100644 internal/auth0/mock/flow_v3_mock.go diff --git a/internal/auth/auth.go b/internal/auth/auth.go index 3aea29b83..87c24ce7d 100644 --- a/internal/auth/auth.go +++ b/internal/auth/auth.go @@ -134,8 +134,7 @@ var RequiredScopes = []string{ "create:email_templates", "read:email_templates", "update:email_templates", "create:email_provider", "read:email_provider", "update:email_provider", "delete:email_provider", "read:flows", "create:flows", "update:flows", "delete:flows", - "read:flows_executions", - "read:forms", "create:forms", "update:forms", "delete:forms", + "read:flows_executions", "read:forms", "read:flows_vault_connections", "create:flows_vault_connections", "update:flows_vault_connections", "delete:flows_vault_connections", "read:connections", "update:connections", "read:connections_options", "update:connections_options", "read:client_keys", "read:logs", "read:tenant_settings", "update:tenant_settings", diff --git a/internal/auth0/mock/flow_v3_mock.go b/internal/auth0/mock/flow_v3_mock.go deleted file mode 100644 index 13751f05c..000000000 --- a/internal/auth0/mock/flow_v3_mock.go +++ /dev/null @@ -1,201 +0,0 @@ -// 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_test.go b/internal/cli/flows_test.go index 7d01cb782..2b845b433 100644 --- a/internal/cli/flows_test.go +++ b/internal/cli/flows_test.go @@ -2,18 +2,74 @@ package cli import ( "bytes" + "context" "encoding/json" + "io" "net/http" "os" "path/filepath" + "strings" "testing" + "github.com/auth0/go-auth0/management" "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" ) +type rawHTTPClientStub struct { + method string + payload interface{} + response json.RawMessage +} + +func (s *rawHTTPClientStub) NewRequest( + ctx context.Context, + method string, + uri string, + payload interface{}, + _ ...management.RequestOption, +) (*http.Request, error) { + s.method = method + s.payload = payload + return http.NewRequestWithContext(ctx, method, uri, nil) +} + +func (s *rawHTTPClientStub) Do(_ *http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: http.StatusOK, + Header: make(http.Header), + Body: io.NopCloser(strings.NewReader(string(s.response))), + }, nil +} + +func (s *rawHTTPClientStub) Request( + context.Context, + string, + string, + interface{}, + ...management.RequestOption, +) error { + return nil +} + +func (s *rawHTTPClientStub) URI(path ...string) string { + return "https://example.test/api/v2/" + strings.Join(path, "/") +} + +func newRawTestCLI(stub *rawHTTPClientStub, 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"}]}`) diff --git a/internal/cli/flows_vault_test.go b/internal/cli/flows_vault_test.go index e8850dd74..8e0aa8b27 100644 --- a/internal/cli/flows_vault_test.go +++ b/internal/cli/flows_vault_test.go @@ -2,73 +2,16 @@ package cli import ( "bytes" - "context" "encoding/json" - "io" "net/http" "os" "path/filepath" - "strings" "testing" - "github.com/auth0/go-auth0/management" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - - "github.com/auth0/auth0-cli/internal/auth0" - "github.com/auth0/auth0-cli/internal/display" ) -type rawHTTPClientStub struct { - method string - payload interface{} - response json.RawMessage -} - -func (s *rawHTTPClientStub) NewRequest( - ctx context.Context, - method string, - uri string, - payload interface{}, - _ ...management.RequestOption, -) (*http.Request, error) { - s.method = method - s.payload = payload - return http.NewRequestWithContext(ctx, method, uri, nil) -} - -func (s *rawHTTPClientStub) Do(_ *http.Request) (*http.Response, error) { - return &http.Response{ - StatusCode: http.StatusOK, - Header: make(http.Header), - Body: io.NopCloser(strings.NewReader(string(s.response))), - }, nil -} - -func (s *rawHTTPClientStub) Request( - context.Context, - string, - string, - interface{}, - ...management.RequestOption, -) error { - return nil -} - -func (s *rawHTTPClientStub) URI(path ...string) string { - return "https://example.test/api/v2/" + strings.Join(path, "/") -} - -func newRawTestCLI(stub *rawHTTPClientStub, stdout *bytes.Buffer) *cli { - return &cli{ - api: &auth0.API{HTTPClient: stub}, - renderer: &display.Renderer{ - MessageWriter: io.Discard, - ResultWriter: stdout, - }, - } -} - func TestCreateVaultConnectionCmdUsesRawClient(t *testing.T) { body := []byte(`{"setup":{"type":"BEARER","token":"secret"}}`) path := filepath.Join(t.TempDir(), "conn.json") diff --git a/internal/cli/root.go b/internal/cli/root.go index 4e7eff8a8..c4d9dd74b 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -257,7 +257,6 @@ func addSubCommands(rootCmd *cobra.Command, cli *cli) { // The order of the commands here matters. // Add new commands in a place that reflect its // relevance or relation with other commands. - rootCmd.AddCommand(initCmd(cli)) rootCmd.AddCommand(loginCmd(cli)) rootCmd.AddCommand(logoutCmd(cli)) rootCmd.AddCommand(tenantsCmd(cli)) diff --git a/internal/cli/utils_shared.go b/internal/cli/utils_shared.go index 8a5455e9d..f3a3d53c4 100644 --- a/internal/cli/utils_shared.go +++ b/internal/cli/utils_shared.go @@ -478,9 +478,9 @@ func collectV3Pages[C comparable, T any, R any]( // 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" +// builderURL is the host for the Auth0 visual builders (Flows and Vault). +// Both live on a dedicated host rather than under the main management dashboard. +const builderURL = "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. @@ -653,5 +653,5 @@ func formatBuilderPageURL(tenant string, cfg *config.Config, path string) string return "" } - return fmt.Sprintf("%s/tenants/%s/%s/%s", formsBuilderURL, region, tenantName, path) + return fmt.Sprintf("%s/tenants/%s/%s/%s", builderURL, region, tenantName, path) } diff --git a/test/integration/scripts/test-cleanup.sh b/test/integration/scripts/test-cleanup.sh index 9c9d1afb9..54400e09c 100755 --- a/test/integration/scripts/test-cleanup.sh +++ b/test/integration/scripts/test-cleanup.sh @@ -32,7 +32,6 @@ delete_resources "actions" "integration-test-" "id" 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"