From 025d4d8086d4ea639df95dee50c6bf240e6127ed Mon Sep 17 00:00:00 2001 From: Kunal Dawar Date: Mon, 24 Aug 2026 14:09:49 +0530 Subject: [PATCH 1/8] 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 5101b1a5c05cf2c9bbf340cf7318d3ecbe29f401 Mon Sep 17 00:00:00 2001 From: Kunal Dawar Date: Tue, 8 Sep 2026 12:49:05 +0530 Subject: [PATCH 2/8] refactor: adopt the shared --data input framework for forms commands Replace the forms-specific --file/-f flag with the common --data input layer (inline JSON, @file.json, or piped stdin) that create and update validate against the OpenAPI schema, mirroring how the other resource commands work. Forms still send the body as raw JSON so STEP/ROUTER node config the v3 union types would drop is preserved. Also remove now-redundant code: forms now reuse the shared openBuilderURL and formatBuilderPageURL helpers instead of their own copies, and formRawUpdate strips server-managed fields through the shared stripFormServerManagedFields helper. Update the integration test cases and regenerate the command docs. --- docs/auth0_forms_create.md | 14 +- docs/auth0_forms_export.md | 2 +- docs/auth0_forms_import.md | 12 +- docs/auth0_forms_update.md | 13 +- internal/cli/forms.go | 342 ++++++++++++++----------- internal/cli/forms_test.go | 169 +++++------- internal/display/forms.go | 1 - test/integration/forms-test-cases.yaml | 16 +- 8 files changed, 291 insertions(+), 278 deletions(-) diff --git a/docs/auth0_forms_create.md b/docs/auth0_forms_create.md index 8df3842d8..33b7a3fca 100644 --- a/docs/auth0_forms_create.md +++ b/docs/auth0_forms_create.md @@ -9,7 +9,9 @@ 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. +Pass `--edit` to open an editor and author the form graph before it is created, or supply the whole body via `--data` as inline JSON, a file (`@form.json`), or piped stdin. Run `auth0 forms create --schema` to print the accepted payload schema and `auth0 forms create --example > form.json` to generate a starter body. + +`--data` provides the whole payload and cannot be combined with `--name` or the `--language-*` flags; the JSON is validated against the OpenAPI schema before it is sent. ## Usage ``` @@ -23,23 +25,25 @@ auth0 forms create [flags] 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 - + auth0 forms create --schema + auth0 forms create --data '{"name":"My Form"}' + auth0 forms create --data @form.json + cat form.json | auth0 forms create ``` ## Flags ``` + --data string JSON payload for the operation, as a JSON string or file path (@file.json). Can also be piped via stdin. --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. + --schema Print the request payload schema for this command and exit. Use with --json or --json-compact for machine-readable output. ``` diff --git a/docs/auth0_forms_export.md b/docs/auth0_forms_export.md index f33f52d35..d7ccfc1b4 100644 --- a/docs/auth0_forms_export.md +++ b/docs/auth0_forms_export.md @@ -18,7 +18,7 @@ auth0 forms export [flags] auth0 forms export auth0 forms export --output ./form.json auth0 forms export --json-compact - auth0 forms export | auth0 forms import -f - + auth0 forms export | auth0 forms import ``` diff --git a/docs/auth0_forms_import.md b/docs/auth0_forms_import.md index 1378bf828..9ca815c8d 100644 --- a/docs/auth0_forms_import.md +++ b/docs/auth0_forms_import.md @@ -5,7 +5,7 @@ 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. +Import a form from `--data`, given as inline JSON, a file (`@form.json`), 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`. @@ -17,10 +17,10 @@ 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 - + auth0 forms import --data @form.json + auth0 forms import --data @form.json --id + auth0 forms import --data @form.json --connection '#CONN-1#=ac_123' + auth0 forms export | auth0 forms import ``` @@ -28,7 +28,7 @@ auth0 forms import [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. + --data string JSON payload for the operation, as a JSON string or file path (@file.json). Can also be piped via 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. diff --git a/docs/auth0_forms_update.md b/docs/auth0_forms_update.md index d35a366fa..2f52daba8 100644 --- a/docs/auth0_forms_update.md +++ b/docs/auth0_forms_update.md @@ -7,7 +7,9 @@ has_toc: false 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. +Passing `--data` as inline JSON, a file (`@form.json`), or piped stdin replaces every top-level field present in the payload, which is validated against the OpenAPI schema before it is sent. 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. + +`--data` provides the whole payload and cannot be combined with `--name` or the `--language-*` flags. Run `auth0 forms update --schema` to print the accepted payload schema. ## Usage ``` @@ -18,20 +20,23 @@ auth0 forms update [flags] ``` auth0 forms update --name "New Name" - auth0 forms update --file ./form.json - cat form.json | auth0 forms update -f - + auth0 forms update --schema + auth0 forms update --data '{"name":"New Name"}' + auth0 forms update --data @form.json + cat form.json | auth0 forms update ``` ## Flags ``` - -f, --file string Path to a JSON file with the form body. Use '-' to read from stdin. + --data string JSON payload for the operation, as a JSON string or file path (@file.json). Can also be piped via 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. + --schema Print the request payload schema for this command and exit. Use with --json or --json-compact for machine-readable output. ``` diff --git a/internal/cli/forms.go b/internal/cli/forms.go index f2a5cedc4..a6152cbc4 100644 --- a/internal/cli/forms.go +++ b/internal/cli/forms.go @@ -9,15 +9,12 @@ import ( "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" ) @@ -113,13 +110,6 @@ var ( 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", @@ -260,11 +250,12 @@ func showFormCmd(cli *cli) *cobra.Command { func createFormCmd(cli *cli) *cobra.Command { var inputs struct { Name string - File string + Data string LanguagePrimary string LanguageDefault string Edit bool Example bool + Schema bool } cmd := &cobra.Command{ @@ -275,45 +266,56 @@ func createFormCmd(cli *cli) *cobra.Command { "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.", + "the whole body via `--data` as inline JSON, a file (`@form.json`), or piped stdin. Run " + + "`auth0 forms create --schema` to print the accepted payload schema and " + + "`auth0 forms create --example > form.json` to generate a starter body.\n\n" + + "`--data` provides the whole payload and cannot be combined with `--name` or the " + + "`--language-*` flags; the JSON is validated against the OpenAPI schema before it is sent.", 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 -`, + auth0 forms create --schema + auth0 forms create --data '{"name":"My Form"}' + auth0 forms create --data @form.json + cat form.json | auth0 forms create`, RunE: func(cmd *cobra.Command, args []string) error { if inputs.Example { cli.renderer.FormExport(formCreateExample) return nil } - body, err := readFormBody(inputs.File) + // Schema discovery mode: print the request payload and exit. + if inputs.Schema { + return printOperationSchema(cli, http.MethodPost, "/forms") + } + + // JSON input mode (for agents and automation): explicit --data or piped + // stdin. The body is sent verbatim so STEP/ROUTER node config is preserved. + dataStr, provided, err := ResolveData(cmd) if err != nil { return err } + if provided { + return cli.createFormFromJSON(cmd, dataStr) + } - 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") + // Interactive: the name is a required scalar, so prompt for it (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, --data, or pipe JSON via stdin") + } + + rawBody := json.RawMessage(formCreateSkeleton) + if inputs.Edit { + if !canPrompt(cmd) { + return errors.New("the --edit flag requires an interactive terminal") } - 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) + if err := editFormJSON(cli, formCreateSkeleton, &rawBody); err != nil { + return err } } @@ -324,15 +326,7 @@ func createFormCmd(cli *cli) *cobra.Command { 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") + return fmt.Errorf("failed to build form body: %w", err) } created, err := cli.formRawCreate(cmd.Context(), rawBody) @@ -353,24 +347,55 @@ func createFormCmd(cli *cli) *cobra.Command { } formName.RegisterString(cmd, &inputs.Name, "") - formFile.RegisterString(cmd, &inputs.File, "") + dataFlag.RegisterString(cmd, &inputs.Data, "") formLanguagePrimary.RegisterString(cmd, &inputs.LanguagePrimary, "") formLanguageDefault.RegisterString(cmd, &inputs.LanguageDefault, "") formEdit.RegisterBool(cmd, &inputs.Edit, false) formExample.RegisterBool(cmd, &inputs.Example, false) + schemaFlag.RegisterBool(cmd, &inputs.Schema, 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.") + // --data supplies the whole payload, so it cannot be combined with the + // granular input flags. Output flags (--json) and --schema are not affected. + markDataExclusive(cmd) + return cmd } +// createFormFromJSON creates a form from a --data JSON payload. The body is +// validated against the OpenAPI schema and then sent verbatim, so node config the +// v3 SDK's union types would drop is preserved. +func (c *cli) createFormFromJSON(cmd *cobra.Command, dataStr string) error { + payload, err := validateFormData(c, dataStr, http.MethodPost, "/forms", "auth0 forms create", nil) + if err != nil { + return err + } + + created, err := c.formRawCreate(cmd.Context(), payload) + if err != nil { + return fmt.Errorf("failed to create form: %w", err) + } + if err := c.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(c, id) + return nil +} + func updateFormCmd(cli *cli) *cobra.Command { var inputs struct { ID string Name string - File string + Data string LanguagePrimary string LanguageDefault string + Schema bool } cmd := &cobra.Command{ @@ -378,14 +403,26 @@ func updateFormCmd(cli *cli) *cobra.Command { 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.", + "Passing `--data` as inline JSON, a file (`@form.json`), or piped stdin replaces every " + + "top-level field present in the payload, which is validated against the OpenAPI schema " + + "before it is sent. 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.\n\n" + + "`--data` provides the whole payload and cannot be combined with `--name` or the " + + "`--language-*` flags. Run `auth0 forms update --schema` to print the accepted payload schema.", Example: ` auth0 forms update --name "New Name" - auth0 forms update --file ./form.json - cat form.json | auth0 forms update -f -`, + auth0 forms update --schema + auth0 forms update --data '{"name":"New Name"}' + auth0 forms update --data @form.json + cat form.json | auth0 forms update `, RunE: func(cmd *cobra.Command, args []string) error { + // Schema discovery mode: print the request payload and exit. + // This does not require a form ID. + if inputs.Schema { + return printOperationSchema(cli, http.MethodPatch, "/forms/{id}") + } + if len(args) > 0 { inputs.ID = args[0] } else { @@ -394,7 +431,8 @@ func updateFormCmd(cli *cli) *cobra.Command { } } - body, err := readFormBody(inputs.File) + // JSON input mode (for agents and automation): explicit --data or piped stdin. + dataStr, provided, err := ResolveData(cmd) if err != nil { return err } @@ -402,16 +440,20 @@ func updateFormCmd(cli *cli) *cobra.Command { 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, + case provided: + // --data / stdin: whole-payload overwrite. Server-managed fields are + // stripped before validation so an exported form body round-trips, then + // the payload is validated and sent verbatim. + rawBody, err = validateFormData( + cli, + dataStr, + http.MethodPatch, + "/forms/{id}", + "auth0 forms update", + stripFormServerManagedFields, ) if err != nil { - return fmt.Errorf("failed to parse form body: %w", err) + return err } case inputs.Name != "" || inputs.LanguagePrimary != "" || inputs.LanguageDefault != "": primary := inputs.LanguagePrimary @@ -459,7 +501,7 @@ func updateFormCmd(cli *cli) *cobra.Command { return err } default: - return errors.New("nothing to update; supply --file, pipe JSON via stdin, or a scalar flag such as --name") + return errors.New("nothing to update; supply --data, pipe JSON via stdin, or a scalar flag such as --name") } updated, err := cli.formRawUpdate(cmd.Context(), inputs.ID, rawBody) @@ -475,12 +517,17 @@ func updateFormCmd(cli *cli) *cobra.Command { } formName.RegisterStringU(cmd, &inputs.Name, "") - formFile.RegisterStringU(cmd, &inputs.File, "") + dataFlag.RegisterString(cmd, &inputs.Data, "") formLanguagePrimary.RegisterStringU(cmd, &inputs.LanguagePrimary, "") formLanguageDefault.RegisterStringU(cmd, &inputs.LanguageDefault, "") + schemaFlag.RegisterBool(cmd, &inputs.Schema, 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.") + // --data supplies the whole payload, so it cannot be combined with the + // granular input flags. Output flags (--json) and --schema are not affected. + markDataExclusive(cmd) + return cmd } @@ -554,7 +601,7 @@ func exportFormCmd(cli *cli) *cobra.Command { Example: ` auth0 forms export auth0 forms export --output ./form.json auth0 forms export --json-compact - auth0 forms export | auth0 forms import -f -`, + auth0 forms export | auth0 forms import`, RunE: func(cmd *cobra.Command, args []string) error { if len(args) == 0 { if err := formID.Pick(cmd, &inputs.ID, cli.formPickerOptions); err != nil { @@ -606,7 +653,7 @@ func exportFormCmd(cli *cli) *cobra.Command { func importFormCmd(cli *cli) *cobra.Command { var inputs struct { ID string - File string + Data string Connections map[string]string } @@ -614,23 +661,23 @@ func importFormCmd(cli *cli) *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" + + Long: "Import a form from `--data`, given as inline JSON, a file (`@form.json`), 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 -`, + Example: ` auth0 forms import --data @form.json + auth0 forms import --data @form.json --id + auth0 forms import --data @form.json --connection '#CONN-1#=ac_123' + auth0 forms export | auth0 forms import`, RunE: func(cmd *cobra.Command, args []string) error { - body, err := readFormBody(inputs.File) + body, err := readFormData(cmd) if err != nil { return err } if body == nil { - return errors.New("no form body provided; supply --file or pipe JSON via stdin") + return errors.New("no form body provided; supply --data or pipe JSON via stdin") } if isFormEnvelope(body) { @@ -673,7 +720,7 @@ func importFormCmd(cli *cli) *cobra.Command { }, } - formFile.RegisterString(cmd, &inputs.File, "") + dataFlag.RegisterString(cmd, &inputs.Data, "") formImportID.RegisterString(cmd, &inputs.ID, "") cmd.Flags().StringToStringVar(&inputs.Connections, "connection", nil, "Map an exported connection placeholder to an existing vault connection ID, "+ @@ -705,7 +752,7 @@ func openFormCmd(cli *cli) *cobra.Command { inputs.ID = args[0] } - openFormEditURL(cli, inputs.ID) + openBuilderURL(cli, fmt.Sprintf("forms/%s/edit", inputs.ID)) return nil }, @@ -714,54 +761,6 @@ 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. @@ -796,23 +795,22 @@ 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) +// readFormData resolves a JSON body from --data (inline JSON or an @file.json +// reference) or piped stdin, returning nil when no such source is available so the +// caller can decide whether to fall back to an editor or error. Unlike ResolveData +// it does not reject other set flags, so `import` can combine a body with its +// --id and --connection modifiers. +func readFormData(cmd *cobra.Command) ([]byte, error) { + if HasData(cmd) { + value, _ := GetData(cmd) + if value != "" && value[0] == '@' { + data, err := os.ReadFile(value[1:]) + if err != nil { + return nil, fmt.Errorf("failed to read form file %q: %w", value[1:], err) + } + return data, nil } - return data, nil + return []byte(value), nil } if piped := iostream.PipedInput(); len(piped) > 0 { return piped, nil @@ -820,6 +818,63 @@ func readFormBody(filePath string) ([]byte, error) { return nil, nil } +// validateFormData reads a --data JSON payload (inline JSON, an @file.json +// reference, or the resolved stdin string), applies preClean when it is non-nil, +// validates the result against the operation's OpenAPI schema, and returns the raw +// bytes to send verbatim. Forms are sent as raw JSON rather than through the v3 +// SDK's lossy union types, so the payload is validated without being deserialized. +// The preClean hook runs before validation so, for example, an exported form body +// whose server-managed fields are stripped still satisfies the schema's +// additionalProperties constraint. +func validateFormData( + cli *cli, + dataStr, method, schemaPath, schemaCmd string, + preClean func(json.RawMessage) (json.RawMessage, error), +) (json.RawMessage, error) { + handler, err := NewDataJSONHandler(cli) + if err != nil { + return nil, fmt.Errorf("failed to initialize JSON handler: %w", err) + } + + raw, err := handler.readJSONInput(dataStr) + if err != nil { + return nil, fmt.Errorf("failed to read JSON input: %w", err) + } + + if preClean != nil { + raw, err = preClean(raw) + if err != nil { + return nil, fmt.Errorf("failed to parse form body: %w", err) + } + } + + result, err := handler.manager.ValidateRequest(method, schemaPath, raw) + if err != nil { + cli.renderer.Infof("Run '%s --schema' to see the expected schema.", schemaCmd) + return nil, fmt.Errorf("schema validation error: %w", err) + } + if !result.Valid { + cli.renderer.Infof("Run '%s --schema' to see the expected schema.", schemaCmd) + return nil, fmt.Errorf("schema validation failed:\n%s", formatValidationErrors(result.Errors)) + } + + return json.RawMessage(raw), nil +} + +// stripFormServerManagedFields removes the fields the API sets and rejects on +// write (id, timestamps, links) so an exported or previously-read form body can be +// sent back on create or update without a schema-additionalProperties violation. +func stripFormServerManagedFields(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) + } + return json.Marshal(form) +} + // 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) { @@ -905,14 +960,7 @@ func (c *cli) formRawCreate(ctx context.Context, body json.RawMessage) (json.Raw // 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) + cleanBody, err := stripFormServerManagedFields(body) if err != nil { return nil, err } diff --git a/internal/cli/forms_test.go b/internal/cli/forms_test.go index 8f8610efb..8f3c6f1c4 100644 --- a/internal/cli/forms_test.go +++ b/internal/cli/forms_test.go @@ -16,12 +16,12 @@ import ( managementv3 "github.com/auth0/go-auth0/v3/management" "github.com/auth0/go-auth0/v3/management/core" "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" - "github.com/auth0/auth0-cli/internal/config" "github.com/auth0/auth0-cli/internal/display" "github.com/auth0/auth0-cli/internal/iostream" ) @@ -80,13 +80,11 @@ func TestCreateFormCmdUsesRawClientForSimpleScaffold(t *testing.T) { assert.Contains(t, stdout.String(), "Simple Form") } -func TestCreateFormCmdUsesRawClientForRichFile(t *testing.T) { - body := []byte(`{ +func TestFormRawCreatePreservesNodeConfig(t *testing.T) { + body := json.RawMessage(`{ "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(`{ @@ -95,23 +93,17 @@ func TestCreateFormCmdUsesRawClientForRichFile(t *testing.T) { "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, - }, - } + c := &cli{api: &auth0.API{HTTPClient: httpClient}} - cmd := createFormCmd(c) - cmd.SetArgs([]string{"--file", path}) + created, err := c.formRawCreate(context.Background(), body) + require.NoError(t, err) - require.NoError(t, cmd.Execute()) assert.Equal(t, http.MethodPost, httpClient.method) require.IsType(t, json.RawMessage{}, httpClient.payload) + // The body is sent verbatim, so STEP node config the v3 union types would + // drop survives the round-trip. assert.Contains(t, string(httpClient.payload.(json.RawMessage)), `"components"`) - assert.Contains(t, stdout.String(), "1 nodes") + assert.Contains(t, string(created), `"components"`) } func TestShowFormCmdUsesRawClient(t *testing.T) { @@ -180,8 +172,8 @@ func TestUpdateFormCmdUsesRawClientForScalarUpdate(t *testing.T) { assert.Contains(t, stdout.String(), "Renamed") } -func TestUpdateFormCmdUsesRawClientForRichFile(t *testing.T) { - body := []byte(`{ +func TestFormRawUpdatePreservesNodeConfigAndStripsServerManagedFields(t *testing.T) { + body := json.RawMessage(`{ "id":"ap_rich", "name":"Rich Form", "nodes":[{"id":"router_1","type":"ROUTER","config":{"rules":[{"id":"rule_1","condition":{"operator":"AND"}}]}}], @@ -191,8 +183,6 @@ func TestUpdateFormCmdUsesRawClientForRichFile(t *testing.T) { "flow_count":0, "links":{} }`) - path := filepath.Join(t.TempDir(), "form.json") - require.NoError(t, os.WriteFile(path, body, 0600)) httpClient := &formHTTPClientStub{ response: json.RawMessage(`{ @@ -202,24 +192,18 @@ func TestUpdateFormCmdUsesRawClientForRichFile(t *testing.T) { "ending":null }`), } - stdout := &bytes.Buffer{} - c := &cli{ - api: &auth0.API{HTTPClient: httpClient}, - renderer: &display.Renderer{ - MessageWriter: io.Discard, - ResultWriter: stdout, - }, - } + c := &cli{api: &auth0.API{HTTPClient: httpClient}} - cmd := updateFormCmd(c) - cmd.SetArgs([]string{"ap_rich", "--file", path}) + _, err := c.formRawUpdate(context.Background(), "ap_rich", body) + require.NoError(t, err) - require.NoError(t, cmd.Execute()) assert.Equal(t, http.MethodPatch, httpClient.method) require.IsType(t, json.RawMessage{}, httpClient.payload) payload := httpClient.payload.(json.RawMessage) + // Rich ROUTER config and an explicit null ending survive the raw round-trip. assert.Contains(t, string(payload), `"condition"`) assert.Contains(t, string(payload), `"ending":null`) + // Server-managed fields are stripped before the request is sent. var form map[string]json.RawMessage require.NoError(t, json.Unmarshal(payload, &form)) assert.NotContains(t, form, "id") @@ -227,27 +211,48 @@ func TestUpdateFormCmdUsesRawClientForRichFile(t *testing.T) { 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) { +func TestReadFormData(t *testing.T) { + newCmd := func() *cobra.Command { + cmd := &cobra.Command{} + var data string + dataFlag.RegisterString(cmd, &data, "") + return cmd + } + + t.Run("reads inline JSON from --data", func(t *testing.T) { + cmd := newCmd() + require.NoError(t, cmd.Flags().Set("data", `{"name":"My Form"}`)) + + got, err := readFormData(cmd) + assert.NoError(t, err) + assert.Equal(t, []byte(`{"name":"My Form"}`), got) + }) + + t.Run("reads from an @file reference", 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) + cmd := newCmd() + require.NoError(t, cmd.Flags().Set("data", "@"+path)) + + got, err := readFormData(cmd) 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")) + t.Run("errors on a missing @file", func(t *testing.T) { + cmd := newCmd() + require.NoError(t, cmd.Flags().Set("data", "@"+filepath.Join(t.TempDir(), "missing.json"))) + + _, err := readFormData(cmd) assert.ErrorContains(t, err, "failed to read form file") }) - t.Run("reads from stdin when file is '-'", func(t *testing.T) { + t.Run("reads from stdin when --data is not set", func(t *testing.T) { dir := t.TempDir() path := filepath.Join(dir, "stdin.json") want := []byte(`{"name":"Piped Form"}`) @@ -261,10 +266,27 @@ func TestReadFormBody(t *testing.T) { iostream.Input = f defer func() { iostream.Input = original }() - got, err := readFormBody("-") + got, err := readFormData(newCmd()) assert.NoError(t, err) assert.Equal(t, want, got) }) + + t.Run("returns nil when no source is available", func(t *testing.T) { + path := filepath.Join(t.TempDir(), "empty.json") + assert.NoError(t, os.WriteFile(path, nil, 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 := readFormData(newCmd()) + assert.NoError(t, err) + assert.Nil(t, got) + }) } func TestFormPickerOptions(t *testing.T) { @@ -419,73 +441,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/display/forms.go b/internal/display/forms.go index 4d3e1fa9e..bfc972ff3 100644 --- a/internal/display/forms.go +++ b/internal/display/forms.go @@ -196,4 +196,3 @@ func formLanguageSummary(primary, def string) string { return fmt.Sprintf("primary: %s, default: %s", primary, def) } } - diff --git a/test/integration/forms-test-cases.yaml b/test/integration/forms-test-cases.yaml index 7478a81df..6bba240f5 100644 --- a/test/integration/forms-test-cases.yaml +++ b/test/integration/forms-test-cases.yaml @@ -24,7 +24,7 @@ tests: 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 + command: auth0 forms create --example | jq '.name="integration-test-form-example"' | auth0 forms create --no-input --json exit-code: 0 stdout: json: @@ -33,18 +33,20 @@ tests: languages.default: "en" 005 - it fails to create a form without a name: - command: echo '{"start":{},"nodes":[],"ending":{}}' | auth0 forms create -f - --no-input + command: echo '{"start":{},"nodes":[],"ending":{}}' | auth0 forms create --no-input exit-code: 1 stderr: contains: - - form name is required + - schema validation failed + - name 006 - it fails to create a form from invalid json: - command: echo 'not-json' | auth0 forms create -f - --no-input + command: echo 'not-json' | auth0 forms create --no-input exit-code: 1 stderr: contains: - - parse form body + - schema validation failed + - Invalid JSON 007 - it successfully lists all forms with data: command: auth0 forms list @@ -79,7 +81,7 @@ tests: 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 + command: auth0 forms update $(./test/integration/scripts/get-form-id.sh) --data @./test/integration/fixtures/update-form.json --json exit-code: 0 stdout: json: @@ -97,7 +99,7 @@ tests: - '"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 + command: auth0 forms export $(./test/integration/scripts/get-form-id.sh) | auth0 forms import --id $(./test/integration/scripts/get-form-id.sh) --json exit-code: 0 stdout: json: From 11bd8bb2e92471f8e87b83ddf4feaea0853bad8a Mon Sep 17 00:00:00 2001 From: Kunal Dawar Date: Tue, 8 Sep 2026 14:39:52 +0530 Subject: [PATCH 3/8] fix: relax forms --data validation to JSON and required name The published OpenAPI schema cannot faithfully describe a real form graph: its FormNodePointer oneOf is non-exclusive, so the reserved "$ending" pointer that every real form uses matches two branches and fails validation. This is the same lossiness that already makes Forms send raw JSON. Validating the node graph against that schema rejected valid forms, including the shipped --example body. Forms --data now checks only what is trustworthy locally (valid JSON object, and a name on create) and sends the body verbatim; the API validates the graph. Updates the integration assertions for the new messages. --- docs/auth0_forms_create.md | 2 +- internal/cli/forms.go | 59 +++++++++++++++----------- test/integration/forms-test-cases.yaml | 4 +- 3 files changed, 37 insertions(+), 28 deletions(-) diff --git a/docs/auth0_forms_create.md b/docs/auth0_forms_create.md index 33b7a3fca..d964ce43d 100644 --- a/docs/auth0_forms_create.md +++ b/docs/auth0_forms_create.md @@ -11,7 +11,7 @@ Interactive behavior: `auth0 forms create` asks only for the name and creates a Pass `--edit` to open an editor and author the form graph before it is created, or supply the whole body via `--data` as inline JSON, a file (`@form.json`), or piped stdin. Run `auth0 forms create --schema` to print the accepted payload schema and `auth0 forms create --example > form.json` to generate a starter body. -`--data` provides the whole payload and cannot be combined with `--name` or the `--language-*` flags; the JSON is validated against the OpenAPI schema before it is sent. +`--data` provides the whole payload and cannot be combined with `--name` or the `--language-*` flags; it is checked for valid JSON and a form name before it is sent, and the form graph itself is validated by the API. ## Usage ``` diff --git a/internal/cli/forms.go b/internal/cli/forms.go index a6152cbc4..87a3814d7 100644 --- a/internal/cli/forms.go +++ b/internal/cli/forms.go @@ -270,7 +270,8 @@ func createFormCmd(cli *cli) *cobra.Command { "`auth0 forms create --schema` to print the accepted payload schema and " + "`auth0 forms create --example > form.json` to generate a starter body.\n\n" + "`--data` provides the whole payload and cannot be combined with `--name` or the " + - "`--language-*` flags; the JSON is validated against the OpenAPI schema before it is sent.", + "`--language-*` flags; it is checked for valid JSON and a form name before it is sent, " + + "and the form graph itself is validated by the API.", Example: ` auth0 forms create auth0 forms create --name "My Form" auth0 forms create --name "My Form" --edit @@ -364,10 +365,10 @@ func createFormCmd(cli *cli) *cobra.Command { } // createFormFromJSON creates a form from a --data JSON payload. The body is -// validated against the OpenAPI schema and then sent verbatim, so node config the +// checked for valid JSON and a form name, then sent verbatim, so node config the // v3 SDK's union types would drop is preserved. func (c *cli) createFormFromJSON(cmd *cobra.Command, dataStr string) error { - payload, err := validateFormData(c, dataStr, http.MethodPost, "/forms", "auth0 forms create", nil) + payload, err := validateFormData(c, dataStr, "auth0 forms create", true, nil) if err != nil { return err } @@ -442,14 +443,13 @@ func updateFormCmd(cli *cli) *cobra.Command { switch { case provided: // --data / stdin: whole-payload overwrite. Server-managed fields are - // stripped before validation so an exported form body round-trips, then - // the payload is validated and sent verbatim. + // stripped so an exported form body round-trips, then the payload is + // checked for valid JSON and sent verbatim. rawBody, err = validateFormData( cli, dataStr, - http.MethodPatch, - "/forms/{id}", "auth0 forms update", + false, stripFormServerManagedFields, ) if err != nil { @@ -820,21 +820,23 @@ func readFormData(cmd *cobra.Command) ([]byte, error) { // validateFormData reads a --data JSON payload (inline JSON, an @file.json // reference, or the resolved stdin string), applies preClean when it is non-nil, -// validates the result against the operation's OpenAPI schema, and returns the raw -// bytes to send verbatim. Forms are sent as raw JSON rather than through the v3 -// SDK's lossy union types, so the payload is validated without being deserialized. -// The preClean hook runs before validation so, for example, an exported form body -// whose server-managed fields are stripped still satisfies the schema's -// additionalProperties constraint. +// and returns the raw bytes to send verbatim. +// +// Forms are sent as raw JSON rather than through the v3 SDK's lossy union types, +// and the published OpenAPI schema likewise cannot faithfully describe a real form +// graph (for example the reserved "$ending" node pointer trips the schema's +// non-exclusive oneOf). So we validate only what we can trust locally: the payload +// is a JSON object and, when requireName is set (create), it carries a non-empty +// "name". The API validates the node graph server-side. The preClean hook runs +// first so an exported form body whose server-managed fields are stripped is +// checked in the same shape it will be sent. func validateFormData( cli *cli, - dataStr, method, schemaPath, schemaCmd string, + dataStr, schemaCmd string, + requireName bool, preClean func(json.RawMessage) (json.RawMessage, error), ) (json.RawMessage, error) { - handler, err := NewDataJSONHandler(cli) - if err != nil { - return nil, fmt.Errorf("failed to initialize JSON handler: %w", err) - } + handler := &DataJSONHandler{cli: cli} raw, err := handler.readJSONInput(dataStr) if err != nil { @@ -848,14 +850,21 @@ func validateFormData( } } - result, err := handler.manager.ValidateRequest(method, schemaPath, raw) - if err != nil { - cli.renderer.Infof("Run '%s --schema' to see the expected schema.", schemaCmd) - return nil, fmt.Errorf("schema validation error: %w", err) + var form map[string]json.RawMessage + if err := json.Unmarshal(raw, &form); err != nil { + cli.renderer.Infof("Run '%s --schema' to see the accepted schema.", schemaCmd) + return nil, fmt.Errorf("invalid JSON: the form payload must be a JSON object: %w", err) } - if !result.Valid { - cli.renderer.Infof("Run '%s --schema' to see the expected schema.", schemaCmd) - return nil, fmt.Errorf("schema validation failed:\n%s", formatValidationErrors(result.Errors)) + + if requireName { + name, err := rawFormStringField(raw, "name") + if err != nil { + return nil, err + } + if name == "" { + cli.renderer.Infof("Run '%s --schema' to see the accepted schema.", schemaCmd) + return nil, errors.New(`the form payload must include a non-empty "name"`) + } } return json.RawMessage(raw), nil diff --git a/test/integration/forms-test-cases.yaml b/test/integration/forms-test-cases.yaml index 6bba240f5..50322678c 100644 --- a/test/integration/forms-test-cases.yaml +++ b/test/integration/forms-test-cases.yaml @@ -37,7 +37,7 @@ tests: exit-code: 1 stderr: contains: - - schema validation failed + - must include - name 006 - it fails to create a form from invalid json: @@ -45,8 +45,8 @@ tests: exit-code: 1 stderr: contains: - - schema validation failed - Invalid JSON + - JSON object 007 - it successfully lists all forms with data: command: auth0 forms list From 6462e6cedf667ca190c45601e9df6cd2a36cbc4d Mon Sep 17 00:00:00 2001 From: Kunal Dawar Date: Tue, 8 Sep 2026 19:04:06 +0530 Subject: [PATCH 4/8] feat: replace forms create --edit flag with interactive body prompt --- docs/auth0_forms_create.md | 6 ++---- internal/cli/forms.go | 33 +++++++++++++++------------------ 2 files changed, 17 insertions(+), 22 deletions(-) diff --git a/docs/auth0_forms_create.md b/docs/auth0_forms_create.md index d964ce43d..62b8d27d1 100644 --- a/docs/auth0_forms_create.md +++ b/docs/auth0_forms_create.md @@ -7,9 +7,9 @@ has_toc: false 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. +Interactive behavior: `auth0 forms create` asks for the name, then offers to author the form body in an editor. Decline the prompt to create a minimal scaffold and refine it in the dashboard builder instead. -Pass `--edit` to open an editor and author the form graph before it is created, or supply the whole body via `--data` as inline JSON, a file (`@form.json`), or piped stdin. Run `auth0 forms create --schema` to print the accepted payload schema and `auth0 forms create --example > form.json` to generate a starter body. +Alternatively, supply the whole body via `--data` as inline JSON, a file (`@form.json`), or piped stdin. Run `auth0 forms create --schema` to print the accepted payload schema and `auth0 forms create --example > form.json` to generate a starter body. `--data` provides the whole payload and cannot be combined with `--name` or the `--language-*` flags; it is checked for valid JSON and a form name before it is sent, and the form graph itself is validated by the API. @@ -23,7 +23,6 @@ auth0 forms create [flags] ``` 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 --schema auth0 forms create --data '{"name":"My Form"}' @@ -36,7 +35,6 @@ auth0 forms create [flags] ``` --data string JSON payload for the operation, as a JSON string or file path (@file.json). Can also be piped via stdin. - --edit Open an editor to author the form graph after entering the name. --example Print an example form JSON body and exit. --json Output in json format. --json-compact Output in compact json format. diff --git a/internal/cli/forms.go b/internal/cli/forms.go index 87a3814d7..ea6eee449 100644 --- a/internal/cli/forms.go +++ b/internal/cli/forms.go @@ -135,12 +135,6 @@ var ( 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", @@ -253,7 +247,6 @@ func createFormCmd(cli *cli) *cobra.Command { Data string LanguagePrimary string LanguageDefault string - Edit bool Example bool Schema bool } @@ -263,9 +256,10 @@ func createFormCmd(cli *cli) *cobra.Command { 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 " + + "Interactive behavior: `auth0 forms create` asks for the name, then offers to author the " + + "form body in an editor. Decline the prompt to create a minimal scaffold and refine it " + + "in the dashboard builder instead.\n\n" + + "Alternatively, supply " + "the whole body via `--data` as inline JSON, a file (`@form.json`), or piped stdin. Run " + "`auth0 forms create --schema` to print the accepted payload schema and " + "`auth0 forms create --example > form.json` to generate a starter body.\n\n" + @@ -274,7 +268,6 @@ func createFormCmd(cli *cli) *cobra.Command { "and the form graph itself is validated by the API.", 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 --schema auth0 forms create --data '{"name":"My Form"}' @@ -311,12 +304,17 @@ func createFormCmd(cli *cli) *cobra.Command { } rawBody := json.RawMessage(formCreateSkeleton) - 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 + // When the name was gathered interactively, offer to author the body + // now. Declining creates a minimal scaffold to refine in the dashboard + // builder later. + if canPrompt(cmd) { + cli.renderer.Infof("A form body is the JSON graph behind the screen: the fields, " + + "buttons and blocks users see, the steps and routing between them, plus languages " + + "and styling. You can author it now, or skip and design it visually in the dashboard.") + if prompt.ConfirmWithDefault("Do you want to author the form body now?", false) { + if err := editFormJSON(cli, formCreateSkeleton, &rawBody); err != nil { + return err + } } } @@ -351,7 +349,6 @@ func createFormCmd(cli *cli) *cobra.Command { dataFlag.RegisterString(cmd, &inputs.Data, "") formLanguagePrimary.RegisterString(cmd, &inputs.LanguagePrimary, "") formLanguageDefault.RegisterString(cmd, &inputs.LanguageDefault, "") - formEdit.RegisterBool(cmd, &inputs.Edit, false) formExample.RegisterBool(cmd, &inputs.Example, false) schemaFlag.RegisterBool(cmd, &inputs.Schema, false) cmd.Flags().BoolVar(&cli.json, "json", false, "Output in json format.") From fd05714d48719ee08c5889ed6a9054e1991e0bf2 Mon Sep 17 00:00:00 2001 From: Kunal Dawar Date: Wed, 9 Sep 2026 11:31:17 +0530 Subject: [PATCH 5/8] fix: strip server-managed fields on forms create and correct update --data help --- docs/auth0_forms_update.md | 2 +- internal/cli/forms.go | 16 ++++++++++++---- 2 files changed, 13 insertions(+), 5 deletions(-) diff --git a/docs/auth0_forms_update.md b/docs/auth0_forms_update.md index 2f52daba8..359a14abb 100644 --- a/docs/auth0_forms_update.md +++ b/docs/auth0_forms_update.md @@ -7,7 +7,7 @@ has_toc: false Update a form. -Passing `--data` as inline JSON, a file (`@form.json`), or piped stdin replaces every top-level field present in the payload, which is validated against the OpenAPI schema before it is sent. 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. +Passing `--data` as inline JSON, a file (`@form.json`), or piped stdin replaces every top-level field present in the payload. The payload is checked for valid JSON before it is sent, and the form graph itself is validated by the API. 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. `--data` provides the whole payload and cannot be combined with `--name` or the `--language-*` flags. Run `auth0 forms update --schema` to print the accepted payload schema. diff --git a/internal/cli/forms.go b/internal/cli/forms.go index ea6eee449..8da1fe07e 100644 --- a/internal/cli/forms.go +++ b/internal/cli/forms.go @@ -402,8 +402,9 @@ func updateFormCmd(cli *cli) *cobra.Command { Short: "Update a form", Long: "Update a form.\n\n" + "Passing `--data` as inline JSON, a file (`@form.json`), or piped stdin replaces every " + - "top-level field present in the payload, which is validated against the OpenAPI schema " + - "before it is sent. Passing only scalar flags such as `--name` performs a merge that " + + "top-level field present in the payload. The payload is checked for valid JSON before it " + + "is sent, and the form graph itself is validated by the API. 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.\n\n" + @@ -958,9 +959,16 @@ func (c *cli) formRawGet(ctx context.Context, id string) (json.RawMessage, error } // formRawCreate creates a form from raw JSON, preserving node config that the -// typed CreateFormRequestContent would drop. It returns the created form JSON. +// typed CreateFormRequestContent would drop. Server-managed fields are stripped +// first so a body read or exported from the API round-trips into create the same +// way it does into update. 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) + cleanBody, err := stripFormServerManagedFields(body) + if err != nil { + return nil, err + } + + return c.formRawRequest(ctx, http.MethodPost, c.api.HTTPClient.URI("forms"), cleanBody) } // formRawUpdate replaces a form from raw JSON, preserving node config that the From 41e74b87562fd728fd2930ebd73a3df743e4232c Mon Sep 17 00:00:00 2001 From: ramya18101 Date: Wed, 9 Sep 2026 14:34:16 +0530 Subject: [PATCH 6/8] feat: enhance form JSON handling and validation for improved user experience --- internal/cli/data_json.go | 2 +- internal/cli/forms.go | 95 +++++++++++++++++------------ internal/cli/forms_envelope.go | 6 +- internal/cli/forms_envelope_test.go | 29 +++++++++ 4 files changed, 92 insertions(+), 40 deletions(-) diff --git a/internal/cli/data_json.go b/internal/cli/data_json.go index 28b68dd19..ef53552be 100644 --- a/internal/cli/data_json.go +++ b/internal/cli/data_json.go @@ -57,7 +57,7 @@ func (h *DataJSONHandler) ReadAndValidate(inputStr, method, path string) (json.R return nil, fmt.Errorf("schema validation failed:\n%s", formatValidationErrors(result.Errors)) } - return json.RawMessage(jsonData), nil + return jsonData, nil } // readJSONInput reads JSON from various input sources. diff --git a/internal/cli/forms.go b/internal/cli/forms.go index 8da1fe07e..e9616f6bb 100644 --- a/internal/cli/forms.go +++ b/internal/cli/forms.go @@ -1,7 +1,6 @@ package cli import ( - "bytes" "context" "encoding/json" "errors" @@ -20,12 +19,15 @@ import ( ) // 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. +// prompted separately, so the seed carries empty containers for all writable fields. const formCreateSkeleton = `{ - "start": {}, + "messages": {}, + "languages": {}, + "translations": {}, "nodes": [], - "ending": {} + "start": {}, + "ending": {}, + "style": {} } ` @@ -98,6 +100,18 @@ var formServerManagedFields = []string{ "links", } +// formEditorSeed controls the field order in the interactive editor seed. +type formEditorSeed struct { + Name json.RawMessage `json:"name,omitempty"` + Messages json.RawMessage `json:"messages,omitempty"` + Languages json.RawMessage `json:"languages,omitempty"` + Translations json.RawMessage `json:"translations,omitempty"` + Nodes json.RawMessage `json:"nodes,omitempty"` + Start json.RawMessage `json:"start,omitempty"` + Ending json.RawMessage `json:"ending,omitempty"` + Style json.RawMessage `json:"style,omitempty"` +} + var ( formID = Argument{ Name: "Id", @@ -361,9 +375,8 @@ func createFormCmd(cli *cli) *cobra.Command { return cmd } -// createFormFromJSON creates a form from a --data JSON payload. The body is -// checked for valid JSON and a form name, then sent verbatim, so node config the -// v3 SDK's union types would drop is preserved. +// createFormFromJSON creates a form from a --data JSON payload, sent verbatim +// to preserve node config the v3 SDK's union types would drop. func (c *cli) createFormFromJSON(cmd *cobra.Command, dataStr string) error { payload, err := validateFormData(c, dataStr, "auth0 forms create", true, nil) if err != nil { @@ -440,15 +453,14 @@ func updateFormCmd(cli *cli) *cobra.Command { switch { case provided: - // --data / stdin: whole-payload overwrite. Server-managed fields are - // stripped so an exported form body round-trips, then the payload is - // checked for valid JSON and sent verbatim. + // --data / stdin: whole-payload overwrite. Validated for JSON and sent + // verbatim; server-managed fields are stripped inside formRawUpdate. rawBody, err = validateFormData( cli, dataStr, "auth0 forms update", false, - stripFormServerManagedFields, + nil, ) if err != nil { return err @@ -484,18 +496,35 @@ func updateFormCmd(cli *cli) *cobra.Command { return fmt.Errorf("failed to build form update: %w", err) } case canPrompt(cmd): - // Editor fallback: pre-load the exact wire body and full-replace. + // Editor fallback: strip server-managed fields, reorder for DX, 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 { + var form map[string]json.RawMessage + if err := json.Unmarshal(current, &form); err != nil { return fmt.Errorf("failed to parse form with ID %q: %w", inputs.ID, err) } + for _, f := range formServerManagedFields { + delete(form, f) + } + + seedBytes, err := json.MarshalIndent(formEditorSeed{ + Name: form["name"], + Messages: form["messages"], + Languages: form["languages"], + Translations: form["translations"], + Nodes: form["nodes"], + Start: form["start"], + Ending: form["ending"], + Style: form["style"], + }, "", " ") + if err != nil { + return fmt.Errorf("failed to build form editor seed for %q: %w", inputs.ID, err) + } - if err := editFormJSON(cli, seed.String(), &rawBody); err != nil { + if err := editFormJSON(cli, string(seedBytes), &rawBody); err != nil { return err } default: @@ -793,11 +822,9 @@ func formNextStepsHint(cli *cli, id string) { cli.renderer.Infof("Edit it in the dashboard with: %s", ansi.Faint("auth0 forms open "+id)) } -// readFormData resolves a JSON body from --data (inline JSON or an @file.json -// reference) or piped stdin, returning nil when no such source is available so the -// caller can decide whether to fall back to an editor or error. Unlike ResolveData -// it does not reject other set flags, so `import` can combine a body with its -// --id and --connection modifiers. +// readFormData resolves a JSON body from --data or piped stdin, returning nil +// when neither is available. Does not reject other set flags, so import can +// combine --data with --id and --connection. func readFormData(cmd *cobra.Command) ([]byte, error) { if HasData(cmd) { value, _ := GetData(cmd) @@ -816,18 +843,12 @@ func readFormData(cmd *cobra.Command) ([]byte, error) { return nil, nil } -// validateFormData reads a --data JSON payload (inline JSON, an @file.json -// reference, or the resolved stdin string), applies preClean when it is non-nil, -// and returns the raw bytes to send verbatim. -// -// Forms are sent as raw JSON rather than through the v3 SDK's lossy union types, -// and the published OpenAPI schema likewise cannot faithfully describe a real form -// graph (for example the reserved "$ending" node pointer trips the schema's -// non-exclusive oneOf). So we validate only what we can trust locally: the payload -// is a JSON object and, when requireName is set (create), it carries a non-empty -// "name". The API validates the node graph server-side. The preClean hook runs -// first so an exported form body whose server-managed fields are stripped is -// checked in the same shape it will be sent. +// validateFormData validates a --data JSON payload and returns the raw bytes to +// send verbatim. Only the envelope is checked locally (valid JSON object, non-empty +// "name" when requireName is set) because the v3 SDK's union types are lossy and +// the OpenAPI schema cannot faithfully represent the form graph (e.g. the "$ending" +// node pointer trips the schema's non-exclusive oneOf). The API validates the graph +// server-side. PreClean runs before validation when non-nil. func validateFormData( cli *cli, dataStr, schemaCmd string, @@ -865,7 +886,7 @@ func validateFormData( } } - return json.RawMessage(raw), nil + return raw, nil } // stripFormServerManagedFields removes the fields the API sets and rejects on @@ -958,10 +979,8 @@ 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. Server-managed fields are stripped -// first so a body read or exported from the API round-trips into create the same -// way it does into update. It returns the created form JSON. +// formRawCreate creates a form from raw JSON, stripping server-managed fields +// and preserving node config the typed CreateFormRequestContent would drop. func (c *cli) formRawCreate(ctx context.Context, body json.RawMessage) (json.RawMessage, error) { cleanBody, err := stripFormServerManagedFields(body) if err != nil { diff --git a/internal/cli/forms_envelope.go b/internal/cli/forms_envelope.go index 3d336ee51..aae8e7680 100644 --- a/internal/cli/forms_envelope.go +++ b/internal/cli/forms_envelope.go @@ -282,7 +282,11 @@ func (c *cli) resolveFormEnvelope( flowReplacements[ph] = flow.GetID() } - return substituteIDs(env.Form, flowReplacements) + formBody, err := substituteIDs(env.Form, connReplacements) + if err != nil { + return nil, err + } + return substituteIDs(formBody, flowReplacements) } // buildFormEnvelope turns a fetched form (raw wire JSON) into a Dashboard-style diff --git a/internal/cli/forms_envelope_test.go b/internal/cli/forms_envelope_test.go index 33f8cfaea..b92b71181 100644 --- a/internal/cli/forms_envelope_test.go +++ b/internal/cli/forms_envelope_test.go @@ -194,6 +194,35 @@ func TestResolveFormEnvelope(t *testing.T) { assert.Equal(t, "fl_created", config["flow_id"]) }) + t.Run("resolves connection placeholders in the form body", func(t *testing.T) { + envelopeWithConnInForm := []byte(`{ + "version": "4.0.0", + "form": { + "name": "Test Form", + "nodes": [{"config": {"connection_id": "#CONN-1#"}}] + }, + "flows": {}, + "connections": { + "#CONN-1#": {"id": "ac_placeholder", "app_id": "AUTH0", "name": "My Connection"} + } + }`) + + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + flowMock := mock.NewMockFlowAPI(ctrl) + cli := &cli{api: &auth0.API{Flow: flowMock}} + + body, err := cli.resolveFormEnvelope(&cobra.Command{}, envelopeWithConnInForm, map[string]string{"#CONN-1#": "ac_resolved"}) + 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, "ac_resolved", config["connection_id"]) + }) + t.Run("errors when a connection cannot be resolved without a terminal", func(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() From 5b343fcbbdeb6011874286eee0e412ca8c88ebca Mon Sep 17 00:00:00 2001 From: ramya18101 Date: Wed, 9 Sep 2026 16:12:17 +0530 Subject: [PATCH 7/8] test: update JSON assertion in forms_test to include additional fields --- internal/cli/forms_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/cli/forms_test.go b/internal/cli/forms_test.go index 8f3c6f1c4..508a5ddda 100644 --- a/internal/cli/forms_test.go +++ b/internal/cli/forms_test.go @@ -76,7 +76,7 @@ func TestCreateFormCmdUsesRawClientForSimpleScaffold(t *testing.T) { 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.JSONEq(t, `{"name":"Simple Form","messages":{},"languages":{},"translations":{},"nodes":[],"start":{},"ending":{},"style":{}}`, string(httpClient.payload.(json.RawMessage))) assert.Contains(t, stdout.String(), "Simple Form") } From dbe6a0bfb3567323fffb85114c1b9a1a28fc0134 Mon Sep 17 00:00:00 2001 From: Kunal Dawar Date: Wed, 9 Sep 2026 16:26:04 +0530 Subject: [PATCH 8/8] docs: clarify resolveFormEnvelope restores connection placeholders too --- internal/cli/forms_envelope.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/internal/cli/forms_envelope.go b/internal/cli/forms_envelope.go index aae8e7680..a3fbee8b9 100644 --- a/internal/cli/forms_envelope.go +++ b/internal/cli/forms_envelope.go @@ -237,7 +237,8 @@ func (c *cli) resolveConnectionPlaceholders( // 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. +// IDs into them), and swaps the form's #FLOW-N# and #CONN-N# references for the +// new flow IDs and resolved connection IDs. func (c *cli) resolveFormEnvelope( cmd *cobra.Command, body []byte,