diff --git a/README.md b/README.md index 823d5b8c3..4e82fc315 100644 --- a/README.md +++ b/README.md @@ -274,6 +274,7 @@ Select **y** to proceed with your default tenant, or **N** to choose a different - [auth0 domains](https://auth0.github.io/auth0-cli/auth0_domains.html) - Manage custom domains - [auth0 email](https://auth0.github.io/auth0-cli/auth0_email.html) - Manage email settings - [auth0 flows](https://auth0.github.io/auth0-cli/auth0_flows.html) - Manage Flows +- [auth0 forms](https://auth0.github.io/auth0-cli/auth0_forms.html) - Manage Forms - [auth0 login](https://auth0.github.io/auth0-cli/auth0_login.html) - Authenticate the Auth0 CLI - [auth0 logout](https://auth0.github.io/auth0-cli/auth0_logout.html) - Log out of a tenant's session - [auth0 logs](https://auth0.github.io/auth0-cli/auth0_logs.html) - View tenant logs diff --git a/docs/auth0_forms.md b/docs/auth0_forms.md 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..62b8d27d1 --- /dev/null +++ b/docs/auth0_forms_create.md @@ -0,0 +1,70 @@ +--- +layout: default +parent: auth0 forms +has_toc: false +--- +# auth0 forms create + +Create a new form. + +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. + +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. + +## Usage +``` +auth0 forms create [flags] +``` + +## Examples + +``` + auth0 forms create + auth0 forms create --name "My Form" + auth0 forms create --example > form.json + 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. + --example Print an example form JSON body and exit. + --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. +``` + + +## 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..d7ccfc1b4 --- /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 +``` + + +## 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..9ca815c8d --- /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 `--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`. + +## Usage +``` +auth0 forms import [flags] +``` + +## Examples + +``` + 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 +``` + + +## Flags + +``` + --connection stringToString Map an exported connection placeholder to an existing vault connection ID, e.g. --connection '#CONN-1#=ac_123'. Repeatable. (default []) + --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. +``` + + +## 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..359a14abb --- /dev/null +++ b/docs/auth0_forms_update.md @@ -0,0 +1,65 @@ +--- +layout: default +parent: auth0 forms +has_toc: false +--- +# auth0 forms update + +Update a form. + +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. + +## Usage +``` +auth0 forms update [flags] +``` + +## Examples + +``` + auth0 forms update --name "New Name" + auth0 forms update --schema + auth0 forms update --data '{"name":"New Name"}' + auth0 forms update --data @form.json + cat form.json | auth0 forms update +``` + + +## Flags + +``` + --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. +``` + + +## 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 c08e6ed14..018e54344 100644 --- a/docs/index.md +++ b/docs/index.md @@ -98,6 +98,7 @@ The help for any command can also be emitted as JSON by combining `--help` with - [auth0 email](auth0_email.md) - Manage email settings and configure email providers - [auth0 event-streams](auth0_event-streams.md) - Manage Event Stream - [auth0 flows](auth0_flows.md) - Manage Flows +- [auth0 forms](auth0_forms.md) - Manage Forms - [auth0 login](auth0_login.md) - Authenticate the Auth0 CLI - [auth0 logout](auth0_logout.md) - Log out of a tenant's session - [auth0 logs](auth0_logs.md) - View tenant logs diff --git a/internal/auth/auth.go b/internal/auth/auth.go index 87c24ce7d..3aea29b83 100644 --- a/internal/auth/auth.go +++ b/internal/auth/auth.go @@ -134,7 +134,8 @@ var RequiredScopes = []string{ "create:email_templates", "read:email_templates", "update:email_templates", "create:email_provider", "read:email_provider", "update:email_provider", "delete:email_provider", "read:flows", "create:flows", "update:flows", "delete:flows", - "read:flows_executions", "read:forms", + "read:flows_executions", + "read:forms", "create:forms", "update:forms", "delete:forms", "read:flows_vault_connections", "create:flows_vault_connections", "update:flows_vault_connections", "delete:flows_vault_connections", "read:connections", "update:connections", "read:connections_options", "update:connections_options", "read:client_keys", "read:logs", "read:tenant_settings", "update:tenant_settings", diff --git a/internal/auth0/auth0.go b/internal/auth0/auth0.go index de30fc442..1ec8df22f 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 Flow FlowAPIV3 FlowExecution FlowExecutionAPIV3 FlowVaultConnection FlowVaultConnectionAPIV3 @@ -98,6 +97,7 @@ func NewAPIV3(m *managementv3.Management) *APIV3 { ClientGrant: m.ClientGrants, ClientGrantOrganization: m.ClientGrants.Organizations, Events: m.Events, + Form: m.Forms, Flow: m.Flows, FlowExecution: m.Flows.Executions, FlowVaultConnection: m.Flows.Vault.Connections, diff --git a/internal/auth0/form.go b/internal/auth0/form.go index 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/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 new file mode 100644 index 000000000..e9616f6bb --- /dev/null +++ b/internal/cli/forms.go @@ -0,0 +1,1094 @@ +package cli + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "os" + + managementv3 "github.com/auth0/go-auth0/v3/management" + "github.com/auth0/go-auth0/v3/management/core" + "github.com/spf13/cobra" + + "github.com/auth0/auth0-cli/internal/ansi" + "github.com/auth0/auth0-cli/internal/iostream" + "github.com/auth0/auth0-cli/internal/prompt" +) + +// formCreateSkeleton seeds the editor for interactive form creation. The name is +// prompted separately, so the seed carries empty containers for all writable fields. +const formCreateSkeleton = `{ + "messages": {}, + "languages": {}, + "translations": {}, + "nodes": [], + "start": {}, + "ending": {}, + "style": {} +} +` + +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", +} + +// 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", + Help: "Id of the Form.", + } + + formName = Flag{ + Name: "Name", + LongForm: "name", + Help: "Name of the Form.", + } + + 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.", + } + + 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 + Data string + LanguagePrimary string + LanguageDefault string + Example bool + Schema 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 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" + + "`--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.", + Example: ` auth0 forms create + auth0 forms create --name "My Form" + auth0 forms create --example > form.json + 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 + } + + // 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) + } + + // 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) + // 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 + } + } + } + + rawBody, err = applyRawFormOverrides( + rawBody, + inputs.Name, + inputs.LanguagePrimary, + inputs.LanguageDefault, + ) + if err != nil { + return fmt.Errorf("failed to build form body: %w", err) + } + + 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, "") + dataFlag.RegisterString(cmd, &inputs.Data, "") + formLanguagePrimary.RegisterString(cmd, &inputs.LanguagePrimary, "") + formLanguageDefault.RegisterString(cmd, &inputs.LanguageDefault, "") + 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, 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 { + 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 + Data string + LanguagePrimary string + LanguageDefault string + Schema bool + } + + cmd := &cobra.Command{ + Use: "update", + Args: cobra.MaximumNArgs(1), + 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. 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" + + "`--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 --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 { + if err := formID.Pick(cmd, &inputs.ID, cli.formPickerOptions); err != nil { + return err + } + } + + // JSON input mode (for agents and automation): explicit --data or piped stdin. + dataStr, provided, err := ResolveData(cmd) + if err != nil { + return err + } + + var rawBody json.RawMessage + + switch { + case provided: + // --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, + nil, + ) + if err != nil { + return 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: 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 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, string(seedBytes), &rawBody); err != nil { + return err + } + default: + 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) + 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, "") + 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 +} + +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`, + 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 + Data string + Connections map[string]string + } + + cmd := &cobra.Command{ + Use: "import", + Args: cobra.NoArgs, + Short: "Import a form", + 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 --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 := readFormData(cmd) + if err != nil { + return err + } + if body == nil { + return errors.New("no form body provided; supply --data 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) + }, + } + + 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, "+ + "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] + } + + openBuilderURL(cli, fmt.Sprintf("forms/%s/edit", inputs.ID)) + + return nil + }, + } + + return cmd +} + +// 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)) +} + +// 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) + 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 []byte(value), nil + } + if piped := iostream.PipedInput(); len(piped) > 0 { + return piped, nil + } + return nil, nil +} + +// 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, + requireName bool, + preClean func(json.RawMessage) (json.RawMessage, error), +) (json.RawMessage, error) { + handler := &DataJSONHandler{cli: cli} + + 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) + } + } + + 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 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 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) { + 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, 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 { + 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 +// typed UpdateFormRequestContent would drop. It returns the updated form JSON. +func (c *cli) formRawUpdate(ctx context.Context, id string, body json.RawMessage) (json.RawMessage, error) { + cleanBody, err := stripFormServerManagedFields(body) + 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..a3fbee8b9 --- /dev/null +++ b/internal/cli/forms_envelope.go @@ -0,0 +1,403 @@ +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# and #CONN-N# references for the +// new flow IDs and resolved connection 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() + } + + 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 +// 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..b92b71181 --- /dev/null +++ b/internal/cli/forms_envelope_test.go @@ -0,0 +1,235 @@ +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("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() + + 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..508a5ddda --- /dev/null +++ b/internal/cli/forms_test.go @@ -0,0 +1,482 @@ +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/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/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","messages":{},"languages":{},"translations":{},"nodes":[],"start":{},"ending":{},"style":{}}`, string(httpClient.payload.(json.RawMessage))) + assert.Contains(t, stdout.String(), "Simple Form") +} + +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"}]}}] + }`) + + 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"}]}}] + }`), + } + c := &cli{api: &auth0.API{HTTPClient: httpClient}} + + created, err := c.formRawCreate(context.Background(), body) + require.NoError(t, err) + + 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, string(created), `"components"`) +} + +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 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"}}]}}], + "ending":null, + "created_at":"2026-08-24T00:00:00Z", + "updated_at":"2026-08-24T00:00:00Z", + "flow_count":0, + "links":{} + }`) + + 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 + }`), + } + c := &cli{api: &auth0.API{HTTPClient: httpClient}} + + _, err := c.formRawUpdate(context.Background(), "ap_rich", body) + require.NoError(t, err) + + 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") + assert.NotContains(t, form, "created_at") + assert.NotContains(t, form, "updated_at") + assert.NotContains(t, form, "flow_count") + assert.NotContains(t, form, "links") +} + +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)) + + 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) { + 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 --data is not set", 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 := 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) { + tests := []struct { + name string + forms []*managementv3.FormSummary + apiError error + assertOutput func(t testing.TB, options pickerOptions) + assertError func(t testing.TB, err error) + }{ + { + name: "happy path", + forms: []*managementv3.FormSummary{ + {ID: "some-id-1", Name: "some-name-1"}, + {ID: "some-id-2", Name: "some-name-2"}, + }, + assertOutput: func(t testing.TB, options pickerOptions) { + assert.Len(t, options, 2) + assert.Equal(t, "some-name-1 (some-id-1)", options[0].label) + assert.Equal(t, "some-id-1", options[0].value) + assert.Equal(t, "some-name-2 (some-id-2)", options[1].label) + assert.Equal(t, "some-id-2", options[1].value) + }, + assertError: func(t testing.TB, err error) { + t.Fail() + }, + }, + { + name: "no forms", + forms: []*managementv3.FormSummary{}, + assertOutput: func(t testing.TB, options pickerOptions) { + t.Fail() + }, + assertError: func(t testing.TB, err error) { + assert.ErrorContains(t, err, "there are currently no forms to choose from. Create one by running: `auth0 forms create`") + }, + }, + { + name: "API error", + apiError: errors.New("error"), + assertOutput: func(t testing.TB, options pickerOptions) { + t.Fail() + }, + assertError: func(t testing.TB, err error) { + assert.Error(t, err) + }, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + formAPI := mock.NewMockFormAPIV3(ctrl) + if test.apiError != nil { + formAPI.EXPECT().List(gomock.Any(), gomock.Any()).Return(nil, test.apiError) + } else { + formAPI.EXPECT().List(gomock.Any(), gomock.Any()).Return( + &auth0.FormSummaryPage{ + Results: test.forms, + NextPageFunc: func(_ context.Context) (*auth0.FormSummaryPage, error) { + return nil, core.ErrNoPages + }, + }, nil) + } + + cli := &cli{ + apiv3: &auth0.APIV3{Form: formAPI}, + } + + options, err := cli.formPickerOptions(context.Background()) + + if err != nil { + test.assertError(t, err) + } else { + test.assertOutput(t, options) + } + }) + } +} + +func TestCollectForms(t *testing.T) { + t.Run("pages across responses until exhausted", func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + secondPage := &auth0.FormSummaryPage{ + Results: []*managementv3.FormSummary{{ID: "id-3", Name: "Form 3"}}, + NextPageFunc: func(_ context.Context) (*auth0.FormSummaryPage, error) { + return nil, core.ErrNoPages + }, + } + firstPage := &auth0.FormSummaryPage{ + Results: []*managementv3.FormSummary{ + {ID: "id-1", Name: "Form 1"}, + {ID: "id-2", Name: "Form 2"}, + }, + NextPageFunc: func(_ context.Context) (*auth0.FormSummaryPage, error) { + return secondPage, nil + }, + } + + formAPI := mock.NewMockFormAPIV3(ctrl) + formAPI.EXPECT().List(gomock.Any(), gomock.Any()).Return(firstPage, nil) + + cli := &cli{apiv3: &auth0.APIV3{Form: formAPI}} + + forms, err := collectForms(context.Background(), cli, &managementv3.ListFormsRequestParameters{}, 0) + assert.NoError(t, err) + assert.Len(t, forms, 3) + assert.Equal(t, "id-3", forms[2].GetID()) + }) + + t.Run("stops at the requested limit without paging further", func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + firstPage := &auth0.FormSummaryPage{ + Results: []*managementv3.FormSummary{ + {ID: "id-1", Name: "Form 1"}, + {ID: "id-2", Name: "Form 2"}, + }, + NextPageFunc: func(_ context.Context) (*auth0.FormSummaryPage, error) { + t.Fatal("should not page past the limit") + return nil, nil + }, + } + + formAPI := mock.NewMockFormAPIV3(ctrl) + formAPI.EXPECT().List(gomock.Any(), gomock.Any()).Return(firstPage, nil) + + cli := &cli{apiv3: &auth0.APIV3{Form: formAPI}} + + forms, err := collectForms(context.Background(), cli, &managementv3.ListFormsRequestParameters{}, 1) + assert.NoError(t, err) + assert.Len(t, forms, 1) + assert.Equal(t, "id-1", forms[0].GetID()) + }) + + t.Run("returns the list error", func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + formAPI := mock.NewMockFormAPIV3(ctrl) + formAPI.EXPECT().List(gomock.Any(), gomock.Any()).Return(nil, errors.New("boom")) + + cli := &cli{apiv3: &auth0.APIV3{Form: formAPI}} + + _, err := collectForms(context.Background(), cli, &managementv3.ListFormsRequestParameters{}, 0) + assert.EqualError(t, err, "boom") + }) +} + +type formHTTPClientStub struct { + method string + payload interface{} + response json.RawMessage +} + +func (s *formHTTPClientStub) NewRequest( + ctx context.Context, + method string, + uri string, + payload interface{}, + _ ...management.RequestOption, +) (*http.Request, error) { + s.method = method + s.payload = payload + return http.NewRequestWithContext(ctx, method, uri, nil) +} + +func (s *formHTTPClientStub) Do(_ *http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: http.StatusOK, + Header: make(http.Header), + Body: io.NopCloser(strings.NewReader(string(s.response))), + }, nil +} + +func (s *formHTTPClientStub) Request( + context.Context, + string, + string, + interface{}, + ...management.RequestOption, +) error { + return nil +} + +func (s *formHTTPClientStub) URI(path ...string) string { + return "https://example.test/api/v2/" + strings.Join(path, "/") +} diff --git a/internal/cli/root.go b/internal/cli/root.go index 9e472aac6..1910da6b2 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -300,6 +300,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(flowsCmd(cli)) rootCmd.AddCommand(networkACLCmd(cli)) rootCmd.AddCommand(tenantSettingsCmd(cli)) diff --git a/internal/cli/terraform.go b/internal/cli/terraform.go index a73e1ebcb..ee87a2244 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 5eaf52815..1fdd18851 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{} @@ -436,16 +438,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 a981935e9..5615568e8 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..bfc972ff3 --- /dev/null +++ b/internal/display/forms.go @@ -0,0 +1,198 @@ +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" +} + +// 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) + } +} 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..50322678c --- /dev/null +++ b/test/integration/forms-test-cases.yaml @@ -0,0 +1,130 @@ +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 | jq '.name="integration-test-form-example"' | auth0 forms create --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 --no-input + exit-code: 1 + stderr: + contains: + - must include + - name + + 006 - it fails to create a form from invalid json: + command: echo 'not-json' | auth0 forms create --no-input + exit-code: 1 + stderr: + contains: + - Invalid JSON + - JSON object + + 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) --data @./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) --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/test-cleanup.sh b/test/integration/scripts/test-cleanup.sh index 54400e09c..9c9d1afb9 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 "flows vault connections" "integration-test-" "id" delete_resources "flows" "integration-test-" "id" delete_resources "logs streams" "integration-test-" "id"