diff --git a/docs/auth0_acul.md b/docs/auth0_acul.md new file mode 100644 index 000000000..f8fc9e619 --- /dev/null +++ b/docs/auth0_acul.md @@ -0,0 +1,14 @@ +--- +layout: default +has_toc: false +has_children: true +--- +# auth0 acul + +Customize the Universal Login experience. This requires a custom domain to be configured for the tenant. + +## Commands + +- [auth0 acul config](auth0_acul_config.md) - Configure Advanced Customizations for Universal Login screens. +- [auth0 acul init](auth0_acul_init.md) - Generate a new ACUL project from a template + diff --git a/docs/auth0_acul_config.md b/docs/auth0_acul_config.md new file mode 100644 index 000000000..a8e07fad5 --- /dev/null +++ b/docs/auth0_acul_config.md @@ -0,0 +1,17 @@ +--- +layout: default +has_toc: false +has_children: true +--- +# auth0 acul config + +Manage screen-level configuration for Auth0 Universal Login using ACUL (Advanced Customizations). + +## Commands + +- [auth0 acul config docs](auth0_acul_config_docs.md) - Open the ACUL configuration documentation +- [auth0 acul config generate](auth0_acul_config_generate.md) - Generate a stub config file for a Universal Login screen. +- [auth0 acul config get](auth0_acul_config_get.md) - Get the current rendering settings for a specific screen +- [auth0 acul config list](auth0_acul_config_list.md) - List Universal Login rendering configurations +- [auth0 acul config set](auth0_acul_config_set.md) - Set the rendering settings for a specific screen + diff --git a/docs/auth0_acul_config_docs.md b/docs/auth0_acul_config_docs.md new file mode 100644 index 000000000..1a7052803 --- /dev/null +++ b/docs/auth0_acul_config_docs.md @@ -0,0 +1,42 @@ +--- +layout: default +parent: auth0 acul config +has_toc: false +--- +# auth0 acul config docs + +Open the documentation for configuring Advanced Customizations for Universal Login screens. + +## Usage +``` +auth0 acul config docs [flags] +``` + +## Examples + +``` + auth0 acul config docs +``` + + + + +## Inherited Flags + +``` + --debug Enable debug mode. + --no-color Disable colors. + --no-input Disable interactivity. + --tenant string Specific tenant to use. +``` + + +## Related Commands + +- [auth0 acul config docs](auth0_acul_config_docs.md) - Open the ACUL configuration documentation +- [auth0 acul config generate](auth0_acul_config_generate.md) - Generate a stub config file for a Universal Login screen. +- [auth0 acul config get](auth0_acul_config_get.md) - Get the current rendering settings for a specific screen +- [auth0 acul config list](auth0_acul_config_list.md) - List Universal Login rendering configurations +- [auth0 acul config set](auth0_acul_config_set.md) - Set the rendering settings for a specific screen + + diff --git a/docs/auth0_acul_config_generate.md b/docs/auth0_acul_config_generate.md new file mode 100644 index 000000000..560730ae1 --- /dev/null +++ b/docs/auth0_acul_config_generate.md @@ -0,0 +1,49 @@ +--- +layout: default +parent: auth0 acul config +has_toc: false +--- +# auth0 acul config generate + +Generate a stub config file for a Universal Login screen and save it to a file. +If fileName is not provided, it will default to .json in the current directory. + +## Usage +``` +auth0 acul config generate [flags] +``` + +## Examples + +``` + auth0 acul config generate signup-id + auth0 acul config generate login-id --file login-settings.json +``` + + +## Flags + +``` + -f, --file string File to save the rendering configs to. +``` + + +## Inherited Flags + +``` + --debug Enable debug mode. + --no-color Disable colors. + --no-input Disable interactivity. + --tenant string Specific tenant to use. +``` + + +## Related Commands + +- [auth0 acul config docs](auth0_acul_config_docs.md) - Open the ACUL configuration documentation +- [auth0 acul config generate](auth0_acul_config_generate.md) - Generate a stub config file for a Universal Login screen. +- [auth0 acul config get](auth0_acul_config_get.md) - Get the current rendering settings for a specific screen +- [auth0 acul config list](auth0_acul_config_list.md) - List Universal Login rendering configurations +- [auth0 acul config set](auth0_acul_config_set.md) - Set the rendering settings for a specific screen + + diff --git a/docs/auth0_acul_config_get.md b/docs/auth0_acul_config_get.md new file mode 100644 index 000000000..b96d50885 --- /dev/null +++ b/docs/auth0_acul_config_get.md @@ -0,0 +1,48 @@ +--- +layout: default +parent: auth0 acul config +has_toc: false +--- +# auth0 acul config get + +Get the current rendering settings for a specific screen. + +## Usage +``` +auth0 acul config get [flags] +``` + +## Examples + +``` + auth0 acul config get signup-id + auth0 acul config get login-id -f ./login.json" +``` + + +## Flags + +``` + -f, --file string File to save the rendering configs to. +``` + + +## Inherited Flags + +``` + --debug Enable debug mode. + --no-color Disable colors. + --no-input Disable interactivity. + --tenant string Specific tenant to use. +``` + + +## Related Commands + +- [auth0 acul config docs](auth0_acul_config_docs.md) - Open the ACUL configuration documentation +- [auth0 acul config generate](auth0_acul_config_generate.md) - Generate a stub config file for a Universal Login screen. +- [auth0 acul config get](auth0_acul_config_get.md) - Get the current rendering settings for a specific screen +- [auth0 acul config list](auth0_acul_config_list.md) - List Universal Login rendering configurations +- [auth0 acul config set](auth0_acul_config_set.md) - Set the rendering settings for a specific screen + + diff --git a/docs/auth0_acul_config_list.md b/docs/auth0_acul_config_list.md new file mode 100644 index 000000000..414fbad63 --- /dev/null +++ b/docs/auth0_acul_config_list.md @@ -0,0 +1,57 @@ +--- +layout: default +parent: auth0 acul config +has_toc: false +--- +# auth0 acul config list + +List Universal Login rendering configurations with optional filters and pagination. + +## Usage +``` +auth0 acul config list [flags] +``` + +## Examples + +``` + auth0 acul config list --prompt login-id --screen login --rendering-mode advanced --include-fields true --fields head_tags,context_configuration +``` + + +## Flags + +``` + --fields string Comma-separated list of fields to include or exclude in the result (based on value provided for include_fields) + --include-fields Whether specified fields are to be included (default: true) or excluded (false). (default true) + --include-totals Return results inside an object that contains the total result count (true) or as a direct array of results (false). + --json Output in json format. + --json-compact Output in compact json format. + --page int Page index of the results to return. First page is 0. + --per-page int Number of results per page. Default value is 50, maximum value is 100. (default 50) + --prompt string Filter by the Universal Login prompt. + -q, --query string Advanced query. + --rendering-mode string Filter by the rendering mode (advanced or standard). + --screen string Filter by the Universal Login screen. +``` + + +## Inherited Flags + +``` + --debug Enable debug mode. + --no-color Disable colors. + --no-input Disable interactivity. + --tenant string Specific tenant to use. +``` + + +## Related Commands + +- [auth0 acul config docs](auth0_acul_config_docs.md) - Open the ACUL configuration documentation +- [auth0 acul config generate](auth0_acul_config_generate.md) - Generate a stub config file for a Universal Login screen. +- [auth0 acul config get](auth0_acul_config_get.md) - Get the current rendering settings for a specific screen +- [auth0 acul config list](auth0_acul_config_list.md) - List Universal Login rendering configurations +- [auth0 acul config set](auth0_acul_config_set.md) - Set the rendering settings for a specific screen + + diff --git a/docs/auth0_acul_config_set.md b/docs/auth0_acul_config_set.md new file mode 100644 index 000000000..cb3ac82b6 --- /dev/null +++ b/docs/auth0_acul_config_set.md @@ -0,0 +1,48 @@ +--- +layout: default +parent: auth0 acul config +has_toc: false +--- +# auth0 acul config set + +Set the rendering settings for a specific screen. + +## Usage +``` +auth0 acul config set [flags] +``` + +## Examples + +``` + auth0 acul config set signup-id --file settings.json + auth0 acul config set login-id +``` + + +## Flags + +``` + -f, --file string File to save the rendering configs to. +``` + + +## Inherited Flags + +``` + --debug Enable debug mode. + --no-color Disable colors. + --no-input Disable interactivity. + --tenant string Specific tenant to use. +``` + + +## Related Commands + +- [auth0 acul config docs](auth0_acul_config_docs.md) - Open the ACUL configuration documentation +- [auth0 acul config generate](auth0_acul_config_generate.md) - Generate a stub config file for a Universal Login screen. +- [auth0 acul config get](auth0_acul_config_get.md) - Get the current rendering settings for a specific screen +- [auth0 acul config list](auth0_acul_config_list.md) - List Universal Login rendering configurations +- [auth0 acul config set](auth0_acul_config_set.md) - Set the rendering settings for a specific screen + + diff --git a/docs/auth0_acul_init.md b/docs/auth0_acul_init.md new file mode 100644 index 000000000..f6f2769bc --- /dev/null +++ b/docs/auth0_acul_init.md @@ -0,0 +1,50 @@ +--- +layout: default +parent: auth0 acul +has_toc: false +--- +# auth0 acul init + +Generate a new Advanced Customizations for Universal Login (ACUL) project from a template. +This command creates a new project with your choice of framework and authentication screens (login, signup, mfa, etc.). +The generated project includes all necessary configuration and boilerplate code to get started with ACUL customizations. + +## Usage +``` +auth0 acul init [flags] +``` + +## Examples + +``` + auth0 acul init + auth0 acul init acul-sample-app + auth0 acul init acul-sample-app --template react --screens login,signup + auth0 acul init acul-sample-app -t react -s login,mfa,signup +``` + + +## Flags + +``` + -s, --screens strings Comma-separated list of screens to include in your ACUL project. + -t, --template string Template framework to use for your ACUL project. +``` + + +## Inherited Flags + +``` + --debug Enable debug mode. + --no-color Disable colors. + --no-input Disable interactivity. + --tenant string Specific tenant to use. +``` + + +## Related Commands + +- [auth0 acul config](auth0_acul_config.md) - Configure Advanced Customizations for Universal Login screens. +- [auth0 acul init](auth0_acul_init.md) - Generate a new ACUL project from a template + + diff --git a/docs/auth0_universal-login.md b/docs/auth0_universal-login.md index 9440e021e..55d5d66e5 100644 --- a/docs/auth0_universal-login.md +++ b/docs/auth0_universal-login.md @@ -5,14 +5,23 @@ has_children: true --- # auth0 universal-login -Manage a consistent, branded Universal Login experience that can handle all of your authentication flows. +Manage Universal Login branding and customization settings. + +� DEPRECATION WARNING: Advanced Customizations (ACUL) have moved! + +The 'auth0 ul customize --rendering-mode advanced' functionality will be +DEPRECATED on April 18, 2026. Please migrate to the new ACUL commands: + + ✅ auth0 acul config generate|get|set|list|docs + +Standard Universal Login customizations continue to work as before. ## Commands -- [auth0 universal-login customize](auth0_universal-login_customize.md) - Customize the Universal Login experience for the standard or advanced mode +- [auth0 universal-login customize](auth0_universal-login_customize.md) - ⚠️ Customize Universal Login (Advanced mode DEPRECATED) - [auth0 universal-login prompts](auth0_universal-login_prompts.md) - Manage custom text for prompts - [auth0 universal-login show](auth0_universal-login_show.md) - Display the custom branding settings for Universal Login -- [auth0 universal-login switch](auth0_universal-login_switch.md) - Switch the rendering mode for Universal Login +- [auth0 universal-login switch](auth0_universal-login_switch.md) - ⚠️ Switch rendering mode (DEPRECATED) - [auth0 universal-login templates](auth0_universal-login_templates.md) - Manage custom Universal Login templates - [auth0 universal-login update](auth0_universal-login_update.md) - Update the custom branding settings for Universal Login diff --git a/docs/auth0_universal-login_customize.md b/docs/auth0_universal-login_customize.md index 61c8f4995..a9175cc8d 100644 --- a/docs/auth0_universal-login_customize.md +++ b/docs/auth0_universal-login_customize.md @@ -6,18 +6,19 @@ has_toc: false # auth0 universal-login customize -Customize your Universal Login Experience. Note that this requires a custom domain to be configured for the tenant. +Customize your Universal Login Experience. Note that this requires a custom domain to be configured for the tenant. * Standard mode is recommended for creating a consistent, branded experience for users. Choosing Standard mode will open a webpage -within your browser where you can edit and preview your branding changes.For a comprehensive list of editable parameters and their values, -please visit the [Management API Documentation](https://auth0.com/docs/api/management/v2) + in your browser where you can edit and preview your branding changes. For a comprehensive list of editable parameters and their values, + please visit the Management API documentation: https://auth0.com/docs/api/management/v2 -* Advanced mode is recommended for full customization/granular control of the login experience and to integrate your own component design system. -Choosing Advanced mode will open the default terminal editor, with the rendering configs: +⚠️ DEPRECATION NOTICE: Advanced mode will be deprecated on 2026-04-30 + For future Advanced Customizations, use: auth0 acul config -![storybook](settings.json) +* Advanced mode is recommended for full customization and granular control of the login experience, allowing integration of your own component design system. + Choosing Advanced mode will open the default terminal editor with rendering configurations in a settings.json file. -Closing the terminal editor will save the settings to your tenant. + Closing the terminal editor will save the settings to your tenant. ## Usage ``` @@ -29,23 +30,18 @@ auth0 universal-login customize [flags] ``` auth0 universal-login customize auth0 ul customize - auth0 ul customize --rendering-mode standard - auth0 ul customize -r standard - auth0 ul customize --rendering-mode advanced --prompt login-id --screen login-id - auth0 ul customize --rendering-mode advanced --prompt login-id --screen login-id --settings-file settings.json - auth0 ul customize -r advanced -p login-id -s login-id -f settings.json ``` ## Flags ``` + -f, --file string File to save the rendering configs to. -p, --prompt string Name of the prompt to to switch or customize. -r, --rendering-mode string standardMode is recommended for customizating consistent, branded experience for users. Alternatively, advancedMode is recommended for full customization/granular control of the login experience and to integrate own component design system - -s, --screen string Name of the screen to to switch or customize. - -f, --settings-file string File to save the rendering configs to. + -s, --screen string Name of the screen to customize. ``` @@ -61,10 +57,10 @@ auth0 universal-login customize [flags] ## Related Commands -- [auth0 universal-login customize](auth0_universal-login_customize.md) - Customize the Universal Login experience for the standard or advanced mode +- [auth0 universal-login customize](auth0_universal-login_customize.md) - ⚠️ Customize Universal Login (Advanced mode DEPRECATED) - [auth0 universal-login prompts](auth0_universal-login_prompts.md) - Manage custom text for prompts - [auth0 universal-login show](auth0_universal-login_show.md) - Display the custom branding settings for Universal Login -- [auth0 universal-login switch](auth0_universal-login_switch.md) - Switch the rendering mode for Universal Login +- [auth0 universal-login switch](auth0_universal-login_switch.md) - ⚠️ Switch rendering mode (DEPRECATED) - [auth0 universal-login templates](auth0_universal-login_templates.md) - Manage custom Universal Login templates - [auth0 universal-login update](auth0_universal-login_update.md) - Update the custom branding settings for Universal Login diff --git a/docs/auth0_universal-login_show.md b/docs/auth0_universal-login_show.md index 98bcdbf86..e3361cb81 100644 --- a/docs/auth0_universal-login_show.md +++ b/docs/auth0_universal-login_show.md @@ -42,10 +42,10 @@ auth0 universal-login show [flags] ## Related Commands -- [auth0 universal-login customize](auth0_universal-login_customize.md) - Customize the Universal Login experience for the standard or advanced mode +- [auth0 universal-login customize](auth0_universal-login_customize.md) - ⚠️ Customize Universal Login (Advanced mode DEPRECATED) - [auth0 universal-login prompts](auth0_universal-login_prompts.md) - Manage custom text for prompts - [auth0 universal-login show](auth0_universal-login_show.md) - Display the custom branding settings for Universal Login -- [auth0 universal-login switch](auth0_universal-login_switch.md) - Switch the rendering mode for Universal Login +- [auth0 universal-login switch](auth0_universal-login_switch.md) - ⚠️ Switch rendering mode (DEPRECATED) - [auth0 universal-login templates](auth0_universal-login_templates.md) - Manage custom Universal Login templates - [auth0 universal-login update](auth0_universal-login_update.md) - Update the custom branding settings for Universal Login diff --git a/docs/auth0_universal-login_switch.md b/docs/auth0_universal-login_switch.md index ed44db77e..d3c93d888 100644 --- a/docs/auth0_universal-login_switch.md +++ b/docs/auth0_universal-login_switch.md @@ -7,6 +7,14 @@ has_toc: false Switch the rendering mode for Universal Login. Note that this requires a custom domain to be configured for the tenant. +🚨 DEPRECATION WARNING: The 'auth0 ul switch' command will be DEPRECATED on April 30, 2026 + +✅ For Advanced Customizations, migrate to the new ACUL config commands: + • auth0 acul config generate + • auth0 acul config get + • auth0 acul config set + • auth0 acul config list + ## Usage ``` auth0 universal-login switch [flags] @@ -29,7 +37,7 @@ auth0 universal-login switch [flags] -r, --rendering-mode string standardMode is recommended for customizating consistent, branded experience for users. Alternatively, advancedMode is recommended for full customization/granular control of the login experience and to integrate own component design system - -s, --screen string Name of the screen to to switch or customize. + -s, --screen string Name of the screen to customize. ``` @@ -45,10 +53,10 @@ auth0 universal-login switch [flags] ## Related Commands -- [auth0 universal-login customize](auth0_universal-login_customize.md) - Customize the Universal Login experience for the standard or advanced mode +- [auth0 universal-login customize](auth0_universal-login_customize.md) - ⚠️ Customize Universal Login (Advanced mode DEPRECATED) - [auth0 universal-login prompts](auth0_universal-login_prompts.md) - Manage custom text for prompts - [auth0 universal-login show](auth0_universal-login_show.md) - Display the custom branding settings for Universal Login -- [auth0 universal-login switch](auth0_universal-login_switch.md) - Switch the rendering mode for Universal Login +- [auth0 universal-login switch](auth0_universal-login_switch.md) - ⚠️ Switch rendering mode (DEPRECATED) - [auth0 universal-login templates](auth0_universal-login_templates.md) - Manage custom Universal Login templates - [auth0 universal-login update](auth0_universal-login_update.md) - Update the custom branding settings for Universal Login diff --git a/docs/auth0_universal-login_update.md b/docs/auth0_universal-login_update.md index d7023cab4..3f0a25315 100644 --- a/docs/auth0_universal-login_update.md +++ b/docs/auth0_universal-login_update.md @@ -52,10 +52,10 @@ auth0 universal-login update [flags] ## Related Commands -- [auth0 universal-login customize](auth0_universal-login_customize.md) - Customize the Universal Login experience for the standard or advanced mode +- [auth0 universal-login customize](auth0_universal-login_customize.md) - ⚠️ Customize Universal Login (Advanced mode DEPRECATED) - [auth0 universal-login prompts](auth0_universal-login_prompts.md) - Manage custom text for prompts - [auth0 universal-login show](auth0_universal-login_show.md) - Display the custom branding settings for Universal Login -- [auth0 universal-login switch](auth0_universal-login_switch.md) - Switch the rendering mode for Universal Login +- [auth0 universal-login switch](auth0_universal-login_switch.md) - ⚠️ Switch rendering mode (DEPRECATED) - [auth0 universal-login templates](auth0_universal-login_templates.md) - Manage custom Universal Login templates - [auth0 universal-login update](auth0_universal-login_update.md) - Update the custom branding settings for Universal Login diff --git a/docs/index.md b/docs/index.md index 7614f7701..bb3bae925 100644 --- a/docs/index.md +++ b/docs/index.md @@ -80,6 +80,7 @@ Authenticating as a user is not supported for **private cloud** tenants. Instead ## Available Commands - [auth0 actions](auth0_actions.md) - Manage resources for actions +- [auth0 acul](auth0_acul.md) - Advance Customize the Universal Login experience - [auth0 api](auth0_api.md) - Makes an authenticated HTTP request to the Auth0 Management API - [auth0 apis](auth0_apis.md) - Manage resources for APIs - [auth0 apps](auth0_apps.md) - Manage resources for applications diff --git a/go.mod b/go.mod index 97156cae1..d96c357e4 100644 --- a/go.mod +++ b/go.mod @@ -6,7 +6,7 @@ require ( github.com/AlecAivazis/survey/v2 v2.3.7 github.com/PuerkitoBio/rehttp v1.4.0 github.com/atotto/clipboard v0.1.4 - github.com/auth0/go-auth0 v1.30.0 + github.com/auth0/go-auth0 v1.30.1-0.20251015051405-47f1f1d61b35 github.com/briandowns/spinner v1.23.2 github.com/charmbracelet/glamour v0.10.0 github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e diff --git a/go.sum b/go.sum index 0baf1d0e0..4c5b090c0 100644 --- a/go.sum +++ b/go.sum @@ -22,8 +22,8 @@ github.com/apparentlymart/go-textseg/v15 v15.0.0 h1:uYvfpb3DyLSCGWnctWKGj857c6ew github.com/apparentlymart/go-textseg/v15 v15.0.0/go.mod h1:K8XmNZdhEBkdlyDdvbmmsvpAG721bKi0joRfFdHIWJ4= github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= -github.com/auth0/go-auth0 v1.30.0 h1:LZUWkvhyvaqzRYjP+mywc3141cP7bI6EsjaUX0t+mcw= -github.com/auth0/go-auth0 v1.30.0/go.mod h1:32sQB1uAn+99fJo6N819EniKq8h785p0ag0lMWhiTaE= +github.com/auth0/go-auth0 v1.30.1-0.20251015051405-47f1f1d61b35 h1:2XuJRBR1l6ntTquXgUd/yahSqSBoTETXB5enD1TcaZI= +github.com/auth0/go-auth0 v1.30.1-0.20251015051405-47f1f1d61b35/go.mod h1:32sQB1uAn+99fJo6N819EniKq8h785p0ag0lMWhiTaE= github.com/aybabtme/iocontrol v0.0.0-20150809002002-ad15bcfc95a0 h1:0NmehRCgyk5rljDQLKUO+cRJCnduDyn11+zGZIc9Z48= github.com/aybabtme/iocontrol v0.0.0-20150809002002-ad15bcfc95a0/go.mod h1:6L7zgvqo0idzI7IO8de6ZC051AfXb5ipkIJ7bIA2tGA= github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= diff --git a/internal/cli/acul.go b/internal/cli/acul.go new file mode 100644 index 000000000..87a3471a6 --- /dev/null +++ b/internal/cli/acul.go @@ -0,0 +1,51 @@ +package cli + +import ( + "context" + + "github.com/spf13/cobra" + + "github.com/auth0/auth0-cli/internal/auth0" +) + +func aculCmd(cli *cli) *cobra.Command { + cmd := &cobra.Command{ + Use: "acul", + Short: "Advance Customize the Universal Login experience", + Long: `Customize the Universal Login experience. This requires a custom domain to be configured for the tenant.`, + } + + cmd.AddCommand(aculConfigureCmd(cli)) + cmd.AddCommand(aculInitCmd(cli)) + + return cmd +} + +func aculConfigureCmd(cli *cli) *cobra.Command { + cmd := &cobra.Command{ + Use: "config", + Short: "Configure Advanced Customizations for Universal Login screens.", + Long: "Manage screen-level configuration for Auth0 Universal Login using ACUL (Advanced Customizations).", + } + + cmd.AddCommand(aculConfigGenerateCmd(cli)) + cmd.AddCommand(aculConfigGetCmd(cli)) + cmd.AddCommand(aculConfigSetCmd(cli)) + cmd.AddCommand(aculConfigListCmd(cli)) + cmd.AddCommand(aculConfigDocsCmd(cli)) + + return cmd +} + +// ensureACULPrerequisites checks that custom domain and new UL are enabled. +func ensureACULPrerequisites(ctx context.Context, api *auth0.API) error { + if err := ensureCustomDomainIsEnabled(ctx, api); err != nil { + return err + } + + if err := ensureNewUniversalLoginExperienceIsActive(ctx, api); err != nil { + return err + } + + return nil +} diff --git a/internal/cli/acul_app_scaffolding.go b/internal/cli/acul_app_scaffolding.go new file mode 100644 index 000000000..a436834e3 --- /dev/null +++ b/internal/cli/acul_app_scaffolding.go @@ -0,0 +1,727 @@ +package cli + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "os/exec" + "path/filepath" + "regexp" + "strconv" + "strings" + "time" + + "github.com/spf13/cobra" + + "github.com/auth0/auth0-cli/internal/ansi" + "github.com/auth0/auth0-cli/internal/prompt" + "github.com/auth0/auth0-cli/internal/utils" +) + +type Manifest struct { + Templates map[string]Template `json:"templates"` + Metadata Metadata `json:"metadata"` +} + +type Template struct { + Name string `json:"name"` + Description string `json:"description"` + Framework string `json:"framework"` + SDK string `json:"sdk"` + BaseFiles []string `json:"base_files"` + BaseDirectories []string `json:"base_directories"` + Screens []Screens `json:"screens"` +} + +type Screens struct { + ID string `json:"id"` + Name string `json:"name"` + Description string `json:"description"` + Path string `json:"path"` +} + +type Metadata struct { + Version string `json:"version"` + Repository string `json:"repository"` + LastUpdated string `json:"last_updated"` + Description string `json:"description"` +} + +// loadManifest downloads and parses the manifest.json for the latest release. +func loadManifest(tag string) (*Manifest, error) { + client := &http.Client{Timeout: 15 * time.Second} + url := fmt.Sprintf("https://raw.githubusercontent.com/auth0-samples/auth0-acul-samples/%s/manifest.json", tag) + + resp, err := client.Get(url) + if err != nil { + return nil, fmt.Errorf("cannot fetch manifest: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("failed to fetch manifest: received status code %d", resp.StatusCode) + } + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("cannot read manifest body: %w", err) + } + + var manifest Manifest + if err := json.Unmarshal(body, &manifest); err != nil { + return nil, fmt.Errorf("invalid manifest format: %w", err) + } + + return &manifest, nil +} + +// getLatestReleaseTag fetches the latest tag from GitHub API. +func getLatestReleaseTag() (string, error) { + client := &http.Client{Timeout: 15 * time.Second} + url := "https://api.github.com/repos/auth0-samples/auth0-acul-samples/tags" + + resp, err := client.Get(url) + if err != nil { + return "", fmt.Errorf("failed to fetch tags: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return "", fmt.Errorf("failed to fetch tags: received status code %d", resp.StatusCode) + } + + body, err := io.ReadAll(resp.Body) + if err != nil { + return "", fmt.Errorf("failed to read response body: %w", err) + } + + var tags []struct { + Name string `json:"name"` + } + + if err := json.Unmarshal(body, &tags); err != nil { + return "", fmt.Errorf("failed to parse tags response: %w", err) + } + + if len(tags) == 0 { + return "", fmt.Errorf("no tags found in repository") + } + + // TODO: return tags[0].Name, nil. + return "monorepo-sample", nil +} + +var ( + templateFlag = Flag{ + Name: "Template", + LongForm: "template", + ShortForm: "t", + Help: "Template framework to use for your ACUL project.", + IsRequired: false, + } + + screensFlag = Flag{ + Name: "Screens", + LongForm: "screens", + ShortForm: "s", + Help: "Comma-separated list of screens to include in your ACUL project.", + IsRequired: false, + } +) + +// / aculInitCmd returns the cobra.Command for project initialization. +func aculInitCmd(cli *cli) *cobra.Command { + var inputs struct { + Template string + Screens []string + } + + cmd := &cobra.Command{ + Use: "init", + Args: cobra.MaximumNArgs(1), + Short: "Generate a new ACUL project from a template", + Long: `Generate a new Advanced Customizations for Universal Login (ACUL) project from a template. +This command creates a new project with your choice of framework and authentication screens (login, signup, mfa, etc.). +The generated project includes all necessary configuration and boilerplate code to get started with ACUL customizations.`, + Example: ` auth0 acul init + auth0 acul init acul-sample-app + auth0 acul init acul-sample-app --template react --screens login,signup + auth0 acul init acul-sample-app -t react -s login,mfa,signup`, + RunE: func(cmd *cobra.Command, args []string) error { + ctx := cmd.Context() + + if err := ensureACULPrerequisites(ctx, cli.api); err != nil { + return err + } + + return runScaffold(cli, cmd, args, &inputs) + }, + } + + templateFlag.RegisterString(cmd, &inputs.Template, "") + screensFlag.RegisterStringSlice(cmd, &inputs.Screens, []string{}) + + return cmd +} + +func runScaffold(cli *cli, cmd *cobra.Command, args []string, inputs *struct { + Template string + Screens []string +}) error { + if err := checkNodeInstallation(); err != nil { + return err + } + + latestTag, err := getLatestReleaseTag() + if err != nil { + return fmt.Errorf("failed to get latest release tag: %w", err) + } + + manifest, err := loadManifest(latestTag) + if err != nil { + return err + } + + chosenTemplate, err := selectTemplate(cmd, manifest, inputs.Template) + if err != nil { + return err + } + + selectedScreens, err := validateAndSelectScreens(cli, manifest.Templates[chosenTemplate].Screens, inputs.Screens) + if err != nil { + return err + } + + destDir := getDestDir(args) + + if err := os.MkdirAll(destDir, 0755); err != nil { + return fmt.Errorf("failed to create project dir: %w", err) + } + + tempUnzipDir, err := downloadAndUnzipSampleRepo() + if err != nil { + return err + } + + defer os.RemoveAll(tempUnzipDir) + + selectedTemplate := manifest.Templates[chosenTemplate] + + err = copyTemplateBaseDirs(cli, selectedTemplate.BaseDirectories, chosenTemplate, tempUnzipDir, destDir) + if err != nil { + return err + } + + err = copyProjectTemplateFiles(cli, selectedTemplate.BaseFiles, chosenTemplate, tempUnzipDir, destDir) + if err != nil { + return err + } + + err = copyProjectScreens(cli, selectedTemplate.Screens, selectedScreens, chosenTemplate, tempUnzipDir, destDir) + if err != nil { + return err + } + + err = writeAculConfig(destDir, chosenTemplate, selectedScreens, manifest.Metadata.Version, latestTag) + if err != nil { + fmt.Printf("Failed to write config: %v\n", err) + } + + runNpmGenerateScreenLoader(cli, destDir) + + if prompt.Confirm("Do you want to run npm install?") { + runNpmInstall(cli, destDir) + } + + showPostScaffoldingOutput(cli, destDir, "Project successfully created") + + return nil +} + +func selectTemplate(cmd *cobra.Command, manifest *Manifest, providedTemplate string) (string, error) { + var templateNames []string + nameToKey := make(map[string]string) + + for key, template := range manifest.Templates { + templateNames = append(templateNames, template.Name) + nameToKey[template.Name] = key + } + + // If template provided via flag, validate it. + if providedTemplate != "" { + for key, template := range manifest.Templates { + if template.Name == providedTemplate || key == providedTemplate { + return key, nil + } + } + return "", fmt.Errorf("invalid template '%s'. Available templates: %s", + providedTemplate, strings.Join(templateNames, ", ")) + } + + var chosenTemplateName string + err := templateFlag.Select(cmd, &chosenTemplateName, templateNames, nil) + if err != nil { + return "", handleInputError(err) + } + + return nameToKey[chosenTemplateName], nil +} + +func validateAndSelectScreens(cli *cli, screens []Screens, providedScreens []string) ([]string, error) { + var availableScreenIDs []string + for _, s := range screens { + availableScreenIDs = append(availableScreenIDs, s.ID) + } + + if len(providedScreens) > 0 { + var validScreens []string + var invalidScreens []string + + for _, providedScreen := range providedScreens { + if strings.TrimSpace(providedScreen) == "" { + continue + } + + found := false + for _, availableScreen := range availableScreenIDs { + if providedScreen == availableScreen { + validScreens = append(validScreens, providedScreen) + found = true + break + } + } + if !found { + invalidScreens = append(invalidScreens, providedScreen) + } + } + + if len(invalidScreens) > 0 { + cli.renderer.Warnf("⚠️ The following screens are not supported for the chosen template: %s", + ansi.Bold(ansi.Red(strings.Join(invalidScreens, ", ")))) + cli.renderer.Infof("Available screens: %s", + ansi.Bold(ansi.Cyan(strings.Join(availableScreenIDs, ", ")))) + cli.renderer.Infof("%s We're planning to support all screens in the future.", + ansi.Blue("Note:")) + } + + if len(validScreens) == 0 { + cli.renderer.Warnf("%s %s", + ansi.Bold(ansi.Yellow("⚠️")), + ansi.Bold("None of the provided screens are valid for this template.")) + } else { + return validScreens, nil + } + } + + // If no screens provided via flag or no valid screens, prompt for multi-select. + var selectedScreens []string + err := prompt.AskMultiSelect("Select screens to include:", &selectedScreens, availableScreenIDs...) + + if len(selectedScreens) == 0 { + return nil, fmt.Errorf("at least one screen must be selected") + } + + return selectedScreens, err +} + +func getDestDir(args []string) string { + if len(args) < 1 { + return "acul-sample-app" + } + return args[0] +} + +func downloadAndUnzipSampleRepo() (string, error) { + _, err := getLatestReleaseTag() + if err != nil { + return "", fmt.Errorf("failed to get latest release tag: %w", err) + } + + // TODO: repoURL := fmt.Sprintf("https://github.com/auth0-samples/auth0-acul-samples/archive/refs/tags/%s.zip", latestTag). + repoURL := "https://github.com/auth0-samples/auth0-acul-samples/archive/refs/heads/monorepo-sample.zip" + tempZipFile, err := downloadFile(repoURL) + if err != nil { + return "", fmt.Errorf("error downloading sample repo: %w", err) + } + + defer os.Remove(tempZipFile) // Clean up the temp zip file. + + tempUnzipDir, err := os.MkdirTemp("", "unzipped-repo-*") + if err != nil { + return "", fmt.Errorf("error creating temporary unzip dir: %w", err) + } + + if err = utils.Unzip(tempZipFile, tempUnzipDir); err != nil { + return "", err + } + + return tempUnzipDir, nil +} + +// This supports any version tag (v1.0.0, v2.0.0, etc.) without hardcoding. +func findExtractedRepoDir(tempUnzipDir string) (string, error) { + entries, err := os.ReadDir(tempUnzipDir) + if err != nil { + return "", fmt.Errorf("failed to read temp directory: %w", err) + } + + for _, entry := range entries { + if entry.IsDir() && strings.HasPrefix(entry.Name(), "auth0-acul-samples-") { + return entry.Name(), nil + } + } + + return "", fmt.Errorf("could not find extracted auth0-acul-samples directory") +} + +func copyTemplateBaseDirs(cli *cli, baseDirs []string, chosenTemplate, tempUnzipDir, destDir string) error { + extractedDir, err := findExtractedRepoDir(tempUnzipDir) + if err != nil { + return fmt.Errorf("failed to find extracted directory: %w", err) + } + + sourcePathPrefix := filepath.Join(extractedDir, chosenTemplate) + for _, dirPath := range baseDirs { + srcPath := filepath.Join(tempUnzipDir, sourcePathPrefix, dirPath) + destPath := filepath.Join(destDir, dirPath) + + if _, err := os.Stat(srcPath); os.IsNotExist(err) { + cli.renderer.Warnf("%s Source directory does not exist: %s", + ansi.Bold(ansi.Yellow("⚠️")), ansi.Faint(srcPath)) + continue + } + + if err := copyDir(srcPath, destPath); err != nil { + return fmt.Errorf("error copying directory %s: %w", dirPath, err) + } + } + + return nil +} + +func copyProjectTemplateFiles(cli *cli, baseFiles []string, chosenTemplate, tempUnzipDir, destDir string) error { + extractedDir, err := findExtractedRepoDir(tempUnzipDir) + if err != nil { + return fmt.Errorf("failed to find extracted directory: %w", err) + } + + sourcePathPrefix := filepath.Join(extractedDir, chosenTemplate) + + for _, filePath := range baseFiles { + srcPath := filepath.Join(tempUnzipDir, sourcePathPrefix, filePath) + destPath := filepath.Join(destDir, filePath) + + if _, err := os.Stat(srcPath); os.IsNotExist(err) { + cli.renderer.Warnf("%s Source file does not exist: %s", + ansi.Bold(ansi.Yellow("⚠️")), ansi.Faint(srcPath)) + continue + } + + parentDir := filepath.Dir(destPath) + if err := os.MkdirAll(parentDir, 0755); err != nil { + cli.renderer.Warnf("%s Error creating parent directory for %s: %v", + ansi.Bold(ansi.Red("❌")), ansi.Bold(filePath), err) + continue + } + + if err := copyFile(srcPath, destPath); err != nil { + return fmt.Errorf("error copying file %s: %w", filePath, err) + } + } + + return nil +} + +func copyProjectScreens(cli *cli, screens []Screens, selectedScreens []string, chosenTemplate, tempUnzipDir, destDir string) error { + extractedDir, err := findExtractedRepoDir(tempUnzipDir) + if err != nil { + return fmt.Errorf("failed to find extracted directory: %w", err) + } + + sourcePathPrefix := filepath.Join(extractedDir, chosenTemplate) + screenInfo := createScreenMap(screens) + for _, s := range selectedScreens { + screen := screenInfo[s] + + srcPath := filepath.Join(tempUnzipDir, sourcePathPrefix, screen.Path) + destPath := filepath.Join(destDir, screen.Path) + + if _, err := os.Stat(srcPath); os.IsNotExist(err) { + cli.renderer.Warnf("%s Source directory does not exist: %s", + ansi.Bold(ansi.Yellow("⚠️")), ansi.Faint(srcPath)) + continue + } + + parentDir := filepath.Dir(destPath) + if err := os.MkdirAll(parentDir, 0755); err != nil { + cli.renderer.Warnf("%s Error creating parent directory for %s: %v", + ansi.Bold(ansi.Red("❌")), ansi.Bold(screen.Path), err) + continue + } + + if err := copyDir(srcPath, destPath); err != nil { + return fmt.Errorf("error copying screen directory %s: %w", screen.Path, err) + } + } + + return nil +} + +func writeAculConfig(destDir, chosenTemplate string, selectedScreens []string, manifestVersion, appVersion string) error { + config := AculConfig{ + ChosenTemplate: chosenTemplate, + Screens: selectedScreens, + CreatedAt: time.Now().UTC().Format(time.RFC3339), + ModifiedAt: time.Now().UTC().Format(time.RFC3339), + AculManifestVersion: manifestVersion, + AppVersion: appVersion, + } + + data, err := json.MarshalIndent(config, "", " ") + if err != nil { + return fmt.Errorf("failed to marshal config: %w", err) + } + + configPath := filepath.Join(destDir, "acul_config.json") + if err = os.WriteFile(configPath, data, 0644); err != nil { + return fmt.Errorf("failed to write config: %v", err) + } + + return nil +} + +// downloadFile downloads a file from a URL to a temporary file and returns its name. +func downloadFile(url string) (string, error) { + client := &http.Client{Timeout: 15 * time.Second} + + resp, err := client.Get(url) + if err != nil { + return "", fmt.Errorf("failed to download %s: %w", url, err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return "", fmt.Errorf("unexpected status code %d when downloading %s", resp.StatusCode, url) + } + + tempFile, err := os.CreateTemp("", "github-zip-*.zip") + if err != nil { + return "", fmt.Errorf("failed to create temp file: %w", err) + } + defer tempFile.Close() + + if _, err := io.Copy(tempFile, resp.Body); err != nil { + return "", fmt.Errorf("failed to save zip file: %w", err) + } + + return tempFile.Name(), nil +} + +// Function to copy a file from a source path to a destination path. +func copyFile(src, dst string) error { + in, err := os.Open(src) + if err != nil { + return fmt.Errorf("failed to open source file: %w", err) + } + defer in.Close() + + out, err := os.Create(dst) + if err != nil { + return fmt.Errorf("failed to create destination file: %w", err) + } + defer out.Close() + + if _, err = io.Copy(out, in); err != nil { + return fmt.Errorf("failed to copy file contents: %w", err) + } + return out.Close() +} + +// Function to recursively copy a directory. +func copyDir(src, dst string) error { + sourceInfo, err := os.Stat(src) + if err != nil { + return err + } + + err = os.MkdirAll(dst, sourceInfo.Mode()) + if err != nil { + return err + } + + return filepath.Walk(src, func(path string, info os.FileInfo, err error) error { + if err != nil { + return err + } + if path == src { + return nil + } + + relPath, err := filepath.Rel(src, path) + if err != nil { + return err + } + destPath := filepath.Join(dst, relPath) + + if info.IsDir() { + return os.MkdirAll(destPath, info.Mode()) + } + return copyFile(path, destPath) + }) +} + +func createScreenMap(screens []Screens) map[string]Screens { + screenMap := make(map[string]Screens) + for _, screen := range screens { + screenMap[screen.ID] = screen + } + return screenMap +} + +// showPostScaffoldingOutput displays comprehensive post-scaffolding information including +// success message, documentation, Node version check, next steps, and available commands. +func showPostScaffoldingOutput(cli *cli, destDir, successMessage string) { + cli.renderer.Output("") + cli.renderer.Infof("%s %s in %s!", + ansi.Bold(ansi.Green("🎉")), successMessage, ansi.Bold(ansi.Cyan(fmt.Sprintf("'%s'", destDir)))) + cli.renderer.Output("") + + cli.renderer.Infof("📖 Explore the sample app: %s", + ansi.Blue("https://github.com/auth0-samples/auth0-acul-samples")) + cli.renderer.Output("") + + checkNodeVersion(cli) + + // Show next steps and related commands. + cli.renderer.Infof("%s Next Steps: Navigate to %s and run:", ansi.Bold("🚀"), ansi.Bold(ansi.Cyan(destDir))) + cli.renderer.Infof(" %s if not yet installed", ansi.Bold(ansi.Cyan("npm install"))) + cli.renderer.Infof(" %s", ansi.Bold(ansi.Cyan("auth0 acul dev"))) + cli.renderer.Output("") + + fmt.Printf("%s Available Commands:\n", ansi.Bold("📋")) + fmt.Printf(" %s - Add authentication screens\n", + ansi.Bold(ansi.Green("auth0 acul screen add "))) + fmt.Printf(" %s - Local development with hot-reload\n", + ansi.Bold(ansi.Green("auth0 acul dev"))) + fmt.Printf(" %s - Live sync changes to Auth0 tenant\n", + ansi.Bold(ansi.Green("auth0 acul dev --connected"))) + fmt.Printf(" %s - Create starter config template\n", + ansi.Bold(ansi.Green("auth0 acul config generate "))) + fmt.Printf(" %s - Pull current Auth0 settings\n", + ansi.Bold(ansi.Green("auth0 acul config get "))) + fmt.Printf(" %s - Push local config to Auth0\n", + ansi.Bold(ansi.Green("auth0 acul config set "))) + fmt.Printf(" %s - List all configurable screens\n", + ansi.Bold(ansi.Green("auth0 acul config list"))) + fmt.Println() + + fmt.Printf("%s %s: Use %s to see all available commands\n", + ansi.Bold("💡"), ansi.Bold("Tip"), ansi.Bold(ansi.Cyan("'auth0 acul --help'"))) +} + +type AculConfig struct { + ChosenTemplate string `json:"chosen_template"` + Screens []string `json:"screens"` + CreatedAt string `json:"created_at"` + ModifiedAt string `json:"modified_at"` + AppVersion string `json:"app_version"` + AculManifestVersion string `json:"acul_manifest_version"` +} + +// checkNodeInstallation ensures that Node is installed and accessible in the system PATH. +func checkNodeInstallation() error { + cmd := exec.Command("node", "--version") + if err := cmd.Run(); err != nil { + return fmt.Errorf("node is required but not found. Please install Node v22 or higher and try again") + } + return nil +} + +// checkNodeVersion checks the major version number of the installed Node. +func checkNodeVersion(cli *cli) { + cmd := exec.Command("node", "--version") + output, err := cmd.Output() + if err != nil { + cli.renderer.Warnf(ansi.Yellow("Unable to detect Node version. Please ensure Node v22+ is installed.")) + return + } + + version := strings.TrimSpace(string(output)) + re := regexp.MustCompile(`v?(\d+)\.`) + matches := re.FindStringSubmatch(version) + if len(matches) < 2 { + cli.renderer.Warnf("Unable to parse Node version: %s. Please ensure Node v22+ is installed.", version) + return + } + + if major, _ := strconv.Atoi(matches[1]); major < 22 { + fmt.Println( + ansi.Yellow(fmt.Sprintf( + "⚠️ Node %s detected. This project requires Node v22 or higher.\n"+ + " Please upgrade to Node v22+ to run the sample app and build assets successfully.\n", + version, + )), + ) + + cli.renderer.Output("") + } +} + +// runNpmGenerateScreenLoader runs `npm run generate:screenLoader` in the given directory. +// Prints errors or warnings directly; silent if successful with no issues. +func runNpmGenerateScreenLoader(cli *cli, destDir string) { + cmd := exec.Command("npm", "run", "generate:screenLoader") + cmd.Dir = destDir + + output, err := cmd.CombinedOutput() + lines := strings.Split(strings.TrimSpace(string(output)), "\n") + + summary := strings.Join(lines, "\n") + if len(lines) > 5 { + summary = strings.Join(lines[:5], "\n") + "\n..." + } + + if err != nil { + cli.renderer.Warnf( + "⚠️ Screen loader generation failed: %v\n"+ + "👉 Run manually: %s\n"+ + "📄 Required for: %s\n"+ + "💡 Tip: If it continues to fail, verify your Node setup and screen structure.", + err, + ansi.Bold(ansi.Cyan(fmt.Sprintf("cd %s && npm run generate:screenLoader", destDir))), + ansi.Faint(fmt.Sprintf("%s/src/utils/screen/screenLoader.ts", destDir)), + ) + + if len(summary) > 0 { + fmt.Println(summary) + } + + return + } +} + +// runNpmInstall runs `npm install` in the given directory. +// Prints concise logs; warns on failure, silent if successful. +func runNpmInstall(cli *cli, destDir string) { + cmd := exec.Command("npm", "install") + cmd.Dir = destDir + + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + + if err := cmd.Run(); err != nil { + cli.renderer.Warnf( + "⚠️ npm install failed: %v\n"+ + "👉 Run manually: %s\n"+ + "📦 Directory: %s\n"+ + "💡 Tip: Check your Node.js and npm setup, or clear node_modules and retry.", + err, + ansi.Bold(ansi.Cyan(fmt.Sprintf("cd %s && npm install", destDir))), + ansi.Faint(destDir), + ) + } + + fmt.Println("✅ " + ansi.Green("All dependencies installed successfully")) +} diff --git a/internal/cli/acul_config.go b/internal/cli/acul_config.go new file mode 100644 index 000000000..a19a70eec --- /dev/null +++ b/internal/cli/acul_config.go @@ -0,0 +1,598 @@ +package cli + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "os" + "strconv" + + "github.com/auth0/go-auth0/management" + + "github.com/pkg/browser" + "github.com/spf13/cobra" + + "github.com/auth0/auth0-cli/internal/ansi" + "github.com/auth0/auth0-cli/internal/prompt" + "github.com/auth0/auth0-cli/internal/utils" +) + +var ( + screenName = Flag{ + Name: "Screen Name", + LongForm: "screen", + ShortForm: "s", + Help: "Name of the screen to customize.", + IsRequired: true, + } + file = Flag{ + Name: "File", + LongForm: "file", + ShortForm: "f", + Help: "File to save the rendering configs to.", + IsRequired: false, + } + rendererScript = Flag{ + Name: "Script", + LongForm: "script", + Help: "Script contents for the rendering configs.", + IsRequired: true, + } + fieldsFlag = Flag{ + Name: "Fields", + LongForm: "fields", + Help: "Comma-separated list of fields to include or exclude in the result (based on value provided for include_fields) ", + IsRequired: false, + } + includeFieldsFlag = Flag{ + Name: "Include Fields", + LongForm: "include-fields", + Help: "Whether specified fields are to be included (default: true) or excluded (false).", + IsRequired: false, + } + includeTotalsFlag = Flag{ + Name: "Include Totals", + LongForm: "include-totals", + Help: "Return results inside an object that contains the total result count (true) or as a direct array of results (false).", + IsRequired: false, + } + pageFlag = Flag{ + Name: "Page", + LongForm: "page", + Help: "Page index of the results to return. First page is 0.", + IsRequired: false, + } + perPageFlag = Flag{ + Name: "Per Page", + LongForm: "per-page", + Help: "Number of results per page. Default value is 50, maximum value is 100.", + IsRequired: false, + } + promptFlag = Flag{ + Name: "Prompt", + LongForm: "prompt", + Help: "Filter by the Universal Login prompt.", + IsRequired: false, + } + screenFlag = Flag{ + Name: "Screen", + LongForm: "screen", + Help: "Filter by the Universal Login screen.", + IsRequired: false, + } + renderingModeFlag = Flag{ + Name: "Rendering Mode", + LongForm: "rendering-mode", + Help: "Filter by the rendering mode (advanced or standard).", + IsRequired: false, + } + queryFlag = Flag{ + Name: "Query", + LongForm: "query", + ShortForm: "q", + Help: "Advanced query.", + IsRequired: false, + } + ScreenPromptMap = map[string]string{ + "signup-id": "signup-id", + "signup-password": "signup-password", + "login-id": "login-id", + "login-password": "login-password", + "login-passwordless-email-code": "login-passwordless", + "login-passwordless-sms-otp": "login-passwordless", + "phone-identifier-enrollment": "phone-identifier-enrollment", + "phone-identifier-challenge": "phone-identifier-challenge", + "email-identifier-challenge": "email-identifier-challenge", + "passkey-enrollment": "passkeys", + "passkey-enrollment-local": "passkeys", + "interstitial-captcha": "captcha", + "login": "login", + "signup": "signup", + "reset-password-request": "reset-password", + "reset-password-email": "reset-password", + "reset-password": "reset-password", + "reset-password-success": "reset-password", + "reset-password-error": "reset-password", + "reset-password-mfa-email-challenge": "reset-password", + "reset-password-mfa-otp-challenge": "reset-password", + "reset-password-mfa-push-challenge-push": "reset-password", + "reset-password-mfa-sms-challenge": "reset-password", + "reset-password-mfa-phone-challenge": "reset-password", + "reset-password-mfa-voice-challenge": "reset-password", + "reset-password-mfa-recovery-code-challenge": "reset-password", + "reset-password-mfa-webauthn-platform-challenge": "reset-password", + "reset-password-mfa-webauthn-roaming-challenge": "reset-password", + "mfa-detect-browser-capabilities": "mfa", + "mfa-enroll-result": "mfa", + "mfa-begin-enroll-options": "mfa", + "mfa-login-options": "mfa", + "mfa-email-challenge": "mfa-email", + "mfa-email-list": "mfa-email", + "mfa-country-codes": "mfa-sms", + "mfa-sms-challenge": "mfa-sms", + "mfa-sms-enrollment": "mfa-sms", + "mfa-sms-list": "mfa-sms", + "mfa-push-challenge-push": "mfa-push", + "mfa-push-enrollment-qr": "mfa-push", + "mfa-push-list": "mfa-push", + "mfa-push-welcome": "mfa-push", + "accept-invitation": "invitation", + "organization-selection": "organizations", + "organization-picker": "organizations", + "mfa-otp-challenge": "mfa-otp", + "mfa-otp-enrollment-code": "mfa-otp", + "mfa-otp-enrollment-qr": "mfa-otp", + "device-code-activation": "device-flow", + "device-code-activation-allowed": "device-flow", + "device-code-activation-denied": "device-flow", + "device-code-confirmation": "device-flow", + "mfa-phone-challenge": "mfa-phone", + "mfa-phone-enrollment": "mfa-phone", + "mfa-voice-challenge": "mfa-voice", + "mfa-voice-enrollment": "mfa-voice", + "mfa-recovery-code-challenge": "mfa-recovery-code", + "mfa-recovery-code-enrollment": "mfa-recovery-code", + "mfa-recovery-code-challenge-new-code": "mfa-recovery-code", + "redeem-ticket": "common", + "email-verification-result": "email-verification", + "login-email-verification": "login-email-verification", + "logout": "logout", + "logout-aborted": "logout", + "logout-complete": "logout", + "mfa-webauthn-change-key-nickname": "mfa-webauthn", + "mfa-webauthn-enrollment-success": "mfa-webauthn", + "mfa-webauthn-error": "mfa-webauthn", + "mfa-webauthn-platform-challenge": "mfa-webauthn", + "mfa-webauthn-platform-enrollment": "mfa-webauthn", + "mfa-webauthn-roaming-challenge": "mfa-webauthn", + "mfa-webauthn-roaming-enrollment": "mfa-webauthn", + } +) + +type aculConfigInput struct { + screenName string + filePath string +} + +// ensureConfigFilePath sets a default config file path if none is provided and creates the config directory. +func ensureConfigFilePath(input *aculConfigInput, cli *cli) error { + if input.filePath == "" { + input.filePath = fmt.Sprintf("config/%s.json", input.screenName) + cli.renderer.Warnf("No configuration file path specified. Defaulting to '%s'.", ansi.Green(input.filePath)) + } + if err := os.MkdirAll("config", 0755); err != nil { + return fmt.Errorf("could not create config directory: %w", err) + } + return nil +} + +// Generate default ACUL config stub. +func defaultACULConfig() map[string]interface{} { + return map[string]interface{}{ + "rendering_mode": "standard", + "context_configuration": []interface{}{}, + "use_page_template": false, + "default_head_tags_disabled": false, + "head_tags": []interface{}{}, + "filters": map[string]interface{}{}, + } +} + +func aculConfigGenerateCmd(cli *cli) *cobra.Command { + var input aculConfigInput + + cmd := &cobra.Command{ + Use: "generate", + Args: cobra.MaximumNArgs(1), + Short: "Generate a stub config file for a Universal Login screen.", + Long: "Generate a stub config file for a Universal Login screen and save it to a file.\n" + + "If fileName is not provided, it will default to .json in the current directory.", + Example: ` auth0 acul config generate signup-id + auth0 acul config generate login-id --file login-settings.json`, + RunE: func(cmd *cobra.Command, args []string) error { + ctx := cmd.Context() + + if err := ensureACULPrerequisites(ctx, cli.api); err != nil { + return err + } + + if len(args) == 0 { + cli.renderer.Output(ansi.Yellow("🔍 Type any part of the screen name (e.g., 'login', 'mfa') to filter results.")) + if err := screenName.Select(cmd, &input.screenName, utils.FetchKeys(ScreenPromptMap), nil); err != nil { + return handleInputError(err) + } + } else { + input.screenName = args[0] + } + + if err := ensureConfigFilePath(&input, cli); err != nil { + return err + } + + config := defaultACULConfig() + + // Error handling omitted for brevity. + data, _ := json.MarshalIndent(config, "", " ") + + message := fmt.Sprintf("Overwrite file '%s' with default config? : ", ansi.Green(input.filePath)) + if shouldCancelOverwrite(cli, cmd, input.filePath, message) { + return nil + } + + if err := os.WriteFile(input.filePath, data, 0644); err != nil { + return fmt.Errorf("could not write config: %w", err) + } + + cli.renderer.Infof("Configuration generated at '%s'.\n"+ + " Review the documentation for configuring screens to use ACUL\n"+ + " https://auth0.com/docs/customize/login-pages/advanced-customizations/getting-started/configure-acul-screens\n", + ansi.Green(input.filePath)) + cli.renderer.Output(ansi.Yellow("💡 Tip: Use `auth0 acul config get` to fetch remote rendering settings or `auth0 acul config set` to sync local configs.")) + cli.renderer.Output(ansi.Cyan("📖 Customization Guide: https://github.com/auth0/auth0-cli/blob/main/CUSTOMIZATION_GUIDE.md")) + return nil + }, + } + + file.RegisterString(cmd, &input.filePath, "") + return cmd +} + +func aculConfigGetCmd(cli *cli) *cobra.Command { + var input aculConfigInput + + cmd := &cobra.Command{ + Use: "get", + Args: cobra.MaximumNArgs(1), + Short: "Get the current rendering settings for a specific screen", + Long: "Get the current rendering settings for a specific screen.", + Example: ` auth0 acul config get signup-id + auth0 acul config get login-id -f ./login.json"`, + RunE: func(cmd *cobra.Command, args []string) error { + ctx := cmd.Context() + if err := ensureACULPrerequisites(ctx, cli.api); err != nil { + return err + } + + if len(args) == 0 { + cli.renderer.Output(ansi.Yellow("🔍 Type any part of the screen name (e.g., 'login', 'mfa') to filter options.")) + if err := screenName.Select(cmd, &input.screenName, utils.FetchKeys(ScreenPromptMap), nil); err != nil { + return handleInputError(err) + } + } else { + input.screenName = args[0] + } + + existingRenderSettings, err := cli.api.Prompt.ReadRendering(ctx, management.PromptType(ScreenPromptMap[input.screenName]), management.ScreenName(input.screenName)) + if err != nil { + return fmt.Errorf("failed to fetch the existing render settings: %w", err) + } + + if existingRenderSettings == nil { + cli.renderer.Warnf("No rendering settings found for screen '%s' in tenant '%s'.", ansi.Green(input.screenName), ansi.Blue(cli.tenant)) + return nil + } + + if err := ensureConfigFilePath(&input, cli); err != nil { + return err + } + + message := fmt.Sprintf("Overwrite file '%s' with new data from tenant '%s'? : ", ansi.Green(input.filePath), ansi.Blue(cli.tenant)) + if shouldCancelOverwrite(cli, cmd, input.filePath, message) { + return nil + } + + data, err := json.MarshalIndent(existingRenderSettings, "", " ") + if err != nil { + return fmt.Errorf("failed to marshal render settings: %w", err) + } + if err := os.WriteFile(input.filePath, data, 0644); err != nil { + return fmt.Errorf("failed to write render settings to file %q: %w", input.filePath, err) + } + + cli.renderer.Infof("Configuration downloaded and saved at '%s'.", ansi.Green(input.filePath)) + cli.renderer.Output(ansi.Yellow("💡 Tip: Use `auth0 acul config set` to sync local config to remote, or `auth0 acul config list` to view all ACUL screens.")) + return nil + }, + } + + file.RegisterString(cmd, &input.filePath, "") + + return cmd +} + +func shouldCancelOverwrite(cli *cli, cmd *cobra.Command, filePath, message string) bool { + _, err := os.Stat(filePath) + if os.IsNotExist(err) { + return false + } + if !cli.force && canPrompt(cmd) { + if confirmed := prompt.Confirm(message); !confirmed { + return true + } + } + + return false +} + +func aculConfigSetCmd(cli *cli) *cobra.Command { + var input aculConfigInput + + cmd := &cobra.Command{ + Use: "set", + Args: cobra.MaximumNArgs(1), + Short: "Set the rendering settings for a specific screen", + Long: "Set the rendering settings for a specific screen.", + Example: ` auth0 acul config set signup-id --file settings.json + auth0 acul config set login-id`, + RunE: func(cmd *cobra.Command, args []string) error { + ctx := cmd.Context() + if err := ensureACULPrerequisites(ctx, cli.api); err != nil { + return err + } + + if len(args) == 0 { + cli.renderer.Output(ansi.Yellow("🔍 Type any part of the screen name to filter options.")) + if err := screenName.Select(cmd, &input.screenName, utils.FetchKeys(ScreenPromptMap), nil); err != nil { + return handleInputError(err) + } + } else { + input.screenName = args[0] + } + + cli.renderer.Output(ansi.Yellow("📖 Customization Guide: https://github.com/auth0/auth0-cli/blob/main/CUSTOMIZATION_GUIDE.md")) + + return advanceCustomize(cmd, cli, input) + }, + } + + file.RegisterString(cmd, &input.filePath, "") + return cmd +} + +func advanceCustomize(cmd *cobra.Command, cli *cli, input aculConfigInput) error { + currMode := standardMode + renderSettings, err := fetchRenderSettings(cmd, cli, input) + if renderSettings != nil && renderSettings.RenderingMode != nil { + currMode = string(*renderSettings.RenderingMode) + } + + if errors.Is(err, ErrNoChangesDetected) { + cli.renderer.Infof("Current rendering mode for Prompt '%s', Screen '%s': %s", ansi.Green(ScreenPromptMap[input.screenName]), ansi.Green(input.screenName), ansi.Green(currMode)) + return nil + } + + if err != nil { + return err + } + + if err = ansi.Waiting(func() error { + return cli.api.Prompt.UpdateRendering(cmd.Context(), management.PromptType(ScreenPromptMap[input.screenName]), management.ScreenName(input.screenName), renderSettings) + }); err != nil { + return fmt.Errorf("failed to set the render settings: %w", err) + } + + cli.renderer.Infof("Rendering settings updated. Current rendering mode for '%s', Screen '%s': %s", ansi.Green(ScreenPromptMap[input.screenName]), ansi.Green(input.screenName), ansi.Green(currMode)) + cli.renderer.Output(ansi.Yellow("💡 Tip: Use `auth0 acul config get` to fetch remote rendering settings or `auth0 acul config list` to view all ACUL screens.")) + return nil +} + +func fetchRenderSettings(cmd *cobra.Command, cli *cli, input aculConfigInput) (*management.PromptRendering, error) { + var ( + userRenderSettings string + renderSettings = &management.PromptRendering{} + existingSettings = map[string]interface{}{} + currentSettings = map[string]interface{}{} + ) + + if input.filePath != "" { + // Case 1: File path is provided, use that file's content. + data, err := os.ReadFile(input.filePath) + if err != nil { + return nil, fmt.Errorf("unable to read file %q: %v", input.filePath, err) + } + if err := json.Unmarshal(data, &renderSettings); err != nil { + return nil, fmt.Errorf("file %q contains invalid JSON: %v", input.filePath, err) + } + return renderSettings, nil + } + + // Case 2: No file path provided, default to config/.json. + defaultFilePath := fmt.Sprintf("config/%s.json", input.screenName) + data, err := os.ReadFile(defaultFilePath) + if err == nil { + cli.renderer.Warnf("No file path specified. Defaulting to '%s'.", ansi.Green(defaultFilePath)) + if !cli.force && canPrompt(cmd) { + message := fmt.Sprintf("Use file '%s' for updating remote ACUL configs for '%s'? : ", ansi.Green(defaultFilePath), ansi.Blue(input.screenName)) + if confirmed := prompt.Confirm(message); confirmed { + if err := json.Unmarshal(data, &renderSettings); err != nil { + return nil, fmt.Errorf("file %s contains invalid JSON: %v", defaultFilePath, err) + } + return renderSettings, nil + } + } + } + + // Case 3: No file path provided and default file doesn't exist or user declined to use it, open editor. + cli.renderer.Infof("Opening editor to update remote ACUL configs for '%s'.", ansi.Green(input.screenName)) + existingRenderSettings, err := cli.api.Prompt.ReadRendering(cmd.Context(), management.PromptType(ScreenPromptMap[input.screenName]), management.ScreenName(input.screenName)) + if err != nil { + return nil, fmt.Errorf("failed to fetch the existing render settings: %w", err) + } + + if existingRenderSettings != nil { + readRenderingJSON, _ := json.MarshalIndent(existingRenderSettings, "", " ") + if err := json.Unmarshal(readRenderingJSON, &existingSettings); err != nil { + cli.renderer.Warnf("Error parsing fetched rendering JSON: %v", err) + } + } + + existingSettings["___customization guide___"] = "https://github.com/auth0/auth0-cli/blob/main/CUSTOMIZATION_GUIDE.md" + // Error handling omitted for brevity. + finalJSON, _ := json.MarshalIndent(existingSettings, "", " ") + + err = rendererScript.OpenEditor(cmd, &userRenderSettings, string(finalJSON), input.screenName+".json", cli.customizeEditorHint) + if err != nil { + return nil, fmt.Errorf("failed to capture input from the editor: %w", err) + } + + err = json.Unmarshal([]byte(userRenderSettings), ¤tSettings) + if err != nil { + return nil, fmt.Errorf("failed to unmarshal JSON input into a map: %w", err) + } + + // Compare the existing settings with the updated settings to detect changes. + if jsonEqual(existingSettings, currentSettings) { + cli.renderer.Warnf("No changes detected in the customization settings. This could be due to uncommitted configuration changes or no modifications being made to the configurations.") + + return existingRenderSettings, ErrNoChangesDetected + } + + if err := json.Unmarshal([]byte(userRenderSettings), &renderSettings); err != nil { + return nil, fmt.Errorf("failed to unmarshal JSON input: %w", err) + } + + return renderSettings, nil +} + +// jsonEqual ignores the special "___customization guide___" key used for user reference. +func jsonEqual(a, b map[string]interface{}) bool { + copyA := make(map[string]interface{}, len(a)) + copyB := make(map[string]interface{}, len(b)) + for k, v := range a { + if k != "___customization guide___" { + copyA[k] = v + } + } + for k, v := range b { + if k != "___customization guide___" { + copyB[k] = v + } + } + + aj, err1 := json.Marshal(copyA) + bj, err2 := json.Marshal(copyB) + if err1 != nil || err2 != nil { + return false + } + return bytes.Equal(aj, bj) +} + +func aculConfigListCmd(cli *cli) *cobra.Command { + var ( + fields string + includeFields bool + includeTotals bool + page int + perPage int + promptName string + screen string + renderingMode string + query string + ) + + cmd := &cobra.Command{ + Use: "list", + Aliases: []string{"ls"}, + Short: "List Universal Login rendering configurations", + Long: "List Universal Login rendering configurations with optional filters and pagination.", + Example: ` auth0 acul config list --prompt login-id --screen login --rendering-mode advanced --include-fields true --fields head_tags,context_configuration`, + RunE: func(cmd *cobra.Command, args []string) error { + ctx := cmd.Context() + if err := ensureACULPrerequisites(ctx, cli.api); err != nil { + return err + } + + params := []management.RequestOption{ + management.Parameter("page", strconv.Itoa(page)), + management.Parameter("per_page", strconv.Itoa(perPage)), + } + + if query != "" { + params = append(params, management.Parameter("q", query)) + } + if includeFields && fields != "" { + params = append(params, management.IncludeFields(fields)) + } + if !includeFields && fields != "" { + params = append(params, management.ExcludeFields(fields)) + } + if screen != "" { + params = append(params, management.Parameter("screen", screen)) + } + if promptName != "" { + params = append(params, management.Parameter("prompt", promptName)) + } + if renderingMode != "" { + params = append(params, management.Parameter("rendering_mode", renderingMode)) + } + + var results *management.PromptRenderingList + if err := ansi.Waiting(func() (err error) { + results, err = cli.api.Prompt.ListRendering(cmd.Context(), params...) + return err + }); err != nil { + cli.renderer.Errorf("Failed to list rendering configurations: %v", err) + return err + } + + cli.renderer.ACULConfigList(results) + + return nil + }, + } + + 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.MarkFlagsMutuallyExclusive("json", "json-compact") + + fieldsFlag.RegisterString(cmd, &fields, "") + includeFieldsFlag.RegisterBool(cmd, &includeFields, true) + includeTotalsFlag.RegisterBool(cmd, &includeTotals, false) + pageFlag.RegisterInt(cmd, &page, 0) + perPageFlag.RegisterInt(cmd, &perPage, 50) + promptFlag.RegisterString(cmd, &promptName, "") + screenFlag.RegisterString(cmd, &screen, "") + renderingModeFlag.RegisterString(cmd, &renderingMode, "") + queryFlag.RegisterString(cmd, &query, "") + + return cmd +} + +func aculConfigDocsCmd(cli *cli) *cobra.Command { + return &cobra.Command{ + Use: "docs", + Short: "Open the ACUL configuration documentation", + Long: "Open the documentation for configuring Advanced Customizations for Universal Login screens.", + Example: ` auth0 acul config docs`, + RunE: func(cmd *cobra.Command, args []string) error { + url := "https://auth0.com/docs/customize/login-pages/advanced-customizations/getting-started/configure-acul-screens" + cli.renderer.Infof("Opening documentation: %s", url) + return browser.OpenURL(url) + }, + } +} + +func (c *cli) customizeEditorHint() { + c.renderer.Infof("%s Once you close the editor, the shown settings will be saved. To cancel, press CTRL+C.", ansi.Faint("Hint:")) +} diff --git a/internal/cli/input.go b/internal/cli/input.go index 8b577f14f..bd7811486 100644 --- a/internal/cli/input.go +++ b/internal/cli/input.go @@ -5,6 +5,7 @@ import ( "os" "reflect" + "github.com/AlecAivazis/survey/v2" "github.com/AlecAivazis/survey/v2/terminal" "github.com/auth0/go-auth0" @@ -79,7 +80,14 @@ func _select(i commandInput, value interface{}, options []string, defaultValue * defaultValue = &(options[0]) } - input := prompt.SelectInput("", i.GetLabel(), i.GetHelp(), options, auth0.StringValue(defaultValue), isRequired) + var input *survey.Question + + // Use paginated select for large option sets (>15 options). + if len(options) > 15 { + input = prompt.PaginatedSelectInput("", i.GetLabel(), i.GetHelp(), options, auth0.StringValue(defaultValue), isRequired) + } else { + input = prompt.SelectInput("", i.GetLabel(), i.GetHelp(), options, auth0.StringValue(defaultValue), isRequired) + } if err := prompt.AskOne(input, value); err != nil { return handleInputError(err) diff --git a/internal/cli/root.go b/internal/cli/root.go index dafb9e176..918362f7c 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -154,6 +154,7 @@ func addSubCommands(rootCmd *cobra.Command, cli *cli) { rootCmd.AddCommand(logoutCmd(cli)) rootCmd.AddCommand(tenantsCmd(cli)) rootCmd.AddCommand(appsCmd(cli)) + rootCmd.AddCommand(aculCmd(cli)) rootCmd.AddCommand(usersCmd(cli)) rootCmd.AddCommand(rulesCmd(cli)) rootCmd.AddCommand(actionsCmd(cli)) diff --git a/internal/cli/universal_login.go b/internal/cli/universal_login.go index 6e37e0f38..8cd3fc7bc 100644 --- a/internal/cli/universal_login.go +++ b/internal/cli/universal_login.go @@ -56,8 +56,16 @@ func universalLoginCmd(cli *cli) *cobra.Command { cmd := &cobra.Command{ Use: "universal-login", Short: "Manage the Universal Login experience", - Long: "Manage a consistent, branded Universal Login experience that can " + - "handle all of your authentication flows.", + Long: `Manage Universal Login branding and customization settings. + +� DEPRECATION WARNING: Advanced Customizations (ACUL) have moved! + +The 'auth0 ul customize --rendering-mode advanced' functionality will be +DEPRECATED on April 18, 2026. Please migrate to the new ACUL commands: + + ✅ auth0 acul config generate|get|set|list|docs + +Standard Universal Login customizations continue to work as before.`, Aliases: []string{"ul"}, } diff --git a/internal/cli/universal_login_customize.go b/internal/cli/universal_login_customize.go index ce7606297..1c4597bc4 100644 --- a/internal/cli/universal_login_customize.go +++ b/internal/cli/universal_login_customize.go @@ -4,14 +4,11 @@ import ( "context" "embed" "encoding/json" - "errors" "fmt" "io/fs" "net" "net/http" "net/url" - "os" - "reflect" "strings" "time" @@ -39,8 +36,11 @@ const ( fetchPartialFeatureFlag = "FETCH_PARTIALS_FEATURE_FLAG" errorMessageType = "ERROR" successMessageType = "SUCCESS" - advancedMode = "advanced" standardMode = "standard" + advancedMode = "advanced" + + sunsetDate = "2026-04-30" // 6 months from GA. + warningPeriodDays = 30 // Show urgent warnings 30 days before sunset. ) var ( @@ -70,30 +70,6 @@ var ( Help: "Name of the prompt to to switch or customize.", IsRequired: true, } - - screenName = Flag{ - Name: "Screen Name", - LongForm: "screen", - ShortForm: "s", - Help: "Name of the screen to to switch or customize.", - IsRequired: true, - } - - file = Flag{ - Name: "File", - LongForm: "settings-file", - ShortForm: "f", - Help: "File to save the rendering configs to.", - IsRequired: false, - } - - rendererScript = Flag{ - Name: "Script", - LongForm: "script", - ShortForm: "s", - Help: "Script contents for the rendering configs.", - IsRequired: true, - } ) var allowedPromptsWithPartials = []management.PromptType{ @@ -106,7 +82,7 @@ var allowedPromptsWithPartials = []management.PromptType{ management.PromptLoginPasswordLess, } -var ScreenPromptMap = map[string][]string{ +var PromptScreenMap = map[string][]string{ "signup-id": {"signup-id"}, "signup-password": {"signup-password"}, "login-id": {"login-id"}, @@ -271,39 +247,33 @@ func (m *webSocketMessage) UnmarshalJSON(b []byte) error { } type promptScreen struct { + filePath string promptName string screenName string } -type customizationInputs struct { - promptScreen - filePath string -} - func customizeUniversalLoginCmd(cli *cli) *cobra.Command { var ( selectedRenderingMode string - input customizationInputs + input promptScreen ) cmd := &cobra.Command{ Use: "customize", Args: cobra.NoArgs, - Short: "Customize the Universal Login experience for the standard or advanced mode", - Long: "\nCustomize your Universal Login Experience. Note that this requires a custom domain to be configured for the tenant. \n\n" + + Short: "⚠️ Customize Universal Login (Advanced mode DEPRECATED)", + Long: "\nCustomize your Universal Login Experience. Note that this requires a custom domain to be configured for the tenant.\n\n" + "* Standard mode is recommended for creating a consistent, branded experience for users. Choosing Standard mode will open a webpage\n" + - "within your browser where you can edit and preview your branding changes.For a comprehensive list of editable parameters and their values,\n" + - "please visit the [Management API Documentation](https://auth0.com/docs/api/management/v2)\n\n" + - "* Advanced mode is recommended for full customization/granular control of the login experience and to integrate your own component design system. \n" + - "Choosing Advanced mode will open the default terminal editor, with the rendering configs:\n\n" + - "![storybook](settings.json)\n\nClosing the terminal editor will save the settings to your tenant.", + " in your browser where you can edit and preview your branding changes. For a comprehensive list of editable parameters and their values,\n" + + " please visit the Management API documentation: https://auth0.com/docs/api/management/v2\n\n" + + "⚠️ DEPRECATION NOTICE: Advanced mode will be deprecated on " + sunsetDate + "\n" + + " For future Advanced Customizations, use: auth0 acul config \n\n" + + "* Advanced mode is recommended for full customization and granular control of the login experience, allowing integration of your own component design system.\n" + + " Choosing Advanced mode will open the default terminal editor with rendering configurations in a settings.json file.\n\n" + + " Closing the terminal editor will save the settings to your tenant.", + Example: ` auth0 universal-login customize - auth0 ul customize - auth0 ul customize --rendering-mode standard - auth0 ul customize -r standard - auth0 ul customize --rendering-mode advanced --prompt login-id --screen login-id - auth0 ul customize --rendering-mode advanced --prompt login-id --screen login-id --settings-file settings.json - auth0 ul customize -r advanced -p login-id -s login-id -f settings.json`, + auth0 ul customize`, RunE: func(cmd *cobra.Command, args []string) error { ctx := cmd.Context() @@ -315,17 +285,35 @@ func customizeUniversalLoginCmd(cli *cli) *cobra.Command { return err } - cli.renderer.Infof("Tip : Use `auth0 ul switch` to switch the rendering-modes between standard and advanced mode") - if selectedRenderingMode == "" { cli.renderer.Infof("Please select a rendering mode to customize:") - if err := renderingMode.Select(cmd, &selectedRenderingMode, []string{advancedMode, standardMode}, nil); err != nil { + if err := renderingMode.Select(cmd, &selectedRenderingMode, []string{standardMode, advancedMode}, auth0.String(standardMode)); err != nil { return err } } if selectedRenderingMode == advancedMode { - return advanceCustomize(cmd, cli, input) + if err := displayDeprecationStatus(cli, true); err != nil { + return err + } + + // Add visual separation and brief pause to ensure users see the warning. + cli.renderer.Output(ansi.Bold("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━")) + cli.renderer.Output("") + + if !cli.noInput { + time.Sleep(1 * time.Second) // Brief pause to let users read. + } + + err := fetchPromptScreenInfo(cmd, cli, &input, "customize") + if err != nil { + return err + } + + return advanceCustomize(cmd, cli, aculConfigInput{ + screenName: input.screenName, + filePath: input.filePath, + }) } // RenderingMode as standard. @@ -341,140 +329,28 @@ func customizeUniversalLoginCmd(cli *cli) *cobra.Command { return cmd } -func advanceCustomize(cmd *cobra.Command, cli *cli, input customizationInputs) error { - var currMode = standardMode - - err := fetchPromptScreenInfo(cmd, cli, &input.promptScreen, "customize") - if err != nil { - return err - } - - renderSettings, err := fetchRenderSettings(cmd, cli, input) - if renderSettings != nil && renderSettings.RenderingMode != nil { - currMode = string(*renderSettings.RenderingMode) - } - - if errors.Is(err, ErrNoChangesDetected) { - cli.renderer.Infof("Current rendering mode for Prompt '%s' and Screen '%s': %s", - ansi.Green(input.promptName), ansi.Green(input.screenName), ansi.Green(currMode)) - return nil - } - - if err != nil { - return err - } - - if err = ansi.Waiting(func() error { - return cli.api.Prompt.UpdateRendering(cmd.Context(), management.PromptType(input.promptName), management.ScreenName(input.screenName), renderSettings) - }); err != nil { - return fmt.Errorf("failed to set the render settings: %w", err) - } - - cli.renderer.Infof( - "Successfully updated the rendering settings.\n Current rendering mode for Prompt '%s' and Screen '%s': %s", - ansi.Green(input.promptName), - ansi.Green(input.screenName), - ansi.Green(currMode), - ) - - return nil -} - func fetchPromptScreenInfo(cmd *cobra.Command, cli *cli, input *promptScreen, action string) error { if input.promptName == "" { cli.renderer.Infof("Please select a prompt to %s its rendering mode:", action) - if err := promptName.Select(cmd, &input.promptName, utils.FetchKeys(ScreenPromptMap), nil); err != nil { + if err := promptName.Select(cmd, &input.promptName, utils.FetchKeys(PromptScreenMap), nil); err != nil { return handleInputError(err) } } if input.screenName == "" { - if len(ScreenPromptMap[input.promptName]) > 1 { + if len(PromptScreenMap[input.promptName]) > 1 { cli.renderer.Infof("Please select a screen to %s its rendering mode:", action) - if err := screenName.Select(cmd, &input.screenName, ScreenPromptMap[input.promptName], nil); err != nil { + if err := screenName.Select(cmd, &input.screenName, PromptScreenMap[input.promptName], nil); err != nil { return handleInputError(err) } } else { - input.screenName = ScreenPromptMap[input.promptName][0] + input.screenName = PromptScreenMap[input.promptName][0] } } return nil } -func fetchRenderSettings(cmd *cobra.Command, cli *cli, input customizationInputs) (*management.PromptRendering, error) { - var ( - userRenderSettings string - renderSettings = &management.PromptRendering{} - existingSettings = map[string]interface{}{} - currentSettings = map[string]interface{}{} - ) - - if input.filePath != "" { - data, err := os.ReadFile(input.filePath) - if err != nil { - return nil, fmt.Errorf("unable to read file %q: %v", input.filePath, err) - } - - // Validate JSON content. - if err := json.Unmarshal(data, &renderSettings); err != nil { - return nil, fmt.Errorf("file %q contains invalid JSON: %v", input.filePath, err) - } - - return renderSettings, nil - } - - // Fetch existing render settings from the API. - existingRenderSettings, err := cli.api.Prompt.ReadRendering(cmd.Context(), management.PromptType(input.promptName), management.ScreenName(input.screenName)) - if err != nil { - return nil, fmt.Errorf("failed to fetch the existing render settings: %w", err) - } - - // Marshal existing render settings into JSON and parse into a map if it's not nil. - if existingRenderSettings != nil { - readRenderingJSON, _ := json.MarshalIndent(existingRenderSettings, "", " ") - if err := json.Unmarshal(readRenderingJSON, &existingSettings); err != nil { - fmt.Println("Error parsing readRendering JSON:", err) - } - } - - existingSettings["___customization guide___"] = "https://github.com/auth0/auth0-cli/blob/main/CUSTOMIZATION_GUIDE.md" - - // Marshal final JSON. - finalJSON, err := json.MarshalIndent(existingSettings, "", " ") - if err != nil { - fmt.Println("Error generating final JSON:", err) - } - - err = rendererScript.OpenEditor(cmd, &userRenderSettings, string(finalJSON), input.promptName+"_"+input.screenName+".json", cli.customizeEditorHint) - if err != nil { - return nil, fmt.Errorf("failed to capture input from the editor: %w", err) - } - - // Unmarshal user-provided JSON into a map for comparison. - err = json.Unmarshal([]byte(userRenderSettings), ¤tSettings) - if err != nil { - return nil, fmt.Errorf("failed to unmarshal JSON input into a map: %w", err) - } - - // Compare the existing settings with the updated settings to detect changes. - if reflect.DeepEqual(existingSettings, currentSettings) { - cli.renderer.Warnf("No changes detected in the customization settings. This could be due to uncommitted configuration changes or no modifications being made to the configurations.") - - return existingRenderSettings, ErrNoChangesDetected - } - - if err := json.Unmarshal([]byte(userRenderSettings), &renderSettings); err != nil { - return nil, fmt.Errorf("failed to unmarshal JSON input: %w", err) - } - - return renderSettings, nil -} - -func (c *cli) customizeEditorHint() { - c.renderer.Infof("%s Once you close the editor, the shown settings will be saved. To cancel, press CTRL+C.", ansi.Faint("Hint:")) -} - func ensureNewUniversalLoginExperienceIsActive(ctx context.Context, api *auth0.API) error { authenticationProfile, err := api.Prompt.Read(ctx) if err != nil { @@ -491,6 +367,70 @@ func ensureNewUniversalLoginExperienceIsActive(ctx context.Context, api *auth0.A ) } +// displayDeprecationStatus displays timeline information. +// The `showCommands` flag controls whether to display helpful usage examples. +func displayDeprecationStatus(cli *cli, showCommands bool) error { + parsedSunset, _ := time.Parse("2006-01-02", sunsetDate) + now := time.Now() + daysUntil := int(parsedSunset.Sub(now).Hours() / 24) + formattedDate := ansi.Bold(ansi.Cyan(parsedSunset.Format("Jan 2, 2006"))) + + switch { + case daysUntil <= 0: + cli.renderer.Errorf("Advanced rendering mode was retired on %s", formattedDate) + cli.renderer.Errorf(" Use instead: %s", ansi.Green("auth0 acul config")) + if showCommands { + showNewConfigExamples(cli) + } + return fmt.Errorf("advanced rendering mode is no longer supported") + + case daysUntil <= warningPeriodDays: + cli.renderer.Output("") + cli.renderer.Output(ansi.Bold(ansi.Red("🚨 URGENT: DEPRECATION NOTICE 🚨"))) + cli.renderer.Warnf("%s will be retired soon (%d days left)", + ansi.Bold("Advanced rendering mode"), daysUntil) + cli.renderer.Warnf(" Switch to: %s", ansi.Bold(ansi.Cyan("auth0 acul config"))) + + if showCommands { + cli.renderer.Output("") + showNewConfigExamples(cli) + } + + cli.renderer.Warnf("⏳ Proceeding with advanced rendering mode (deprecated)") + + default: + cli.renderer.Output("") + cli.renderer.Output(ansi.Bold(ansi.Red("⚠️ DEPRECATION NOTICE ⚠️"))) + cli.renderer.Warnf("%s will be retired on %s (%d days remaining)", + ansi.Bold("Advanced rendering mode"), formattedDate, daysUntil) + cli.renderer.Warnf(" Try new commands: %s", ansi.Bold(ansi.Cyan("auth0 acul config"))) + if showCommands { + cli.renderer.Output("") + showNewConfigExamples(cli) + } + } + + cli.renderer.Output("") + cli.renderer.Warnf(" For help: %s", ansi.Bold(ansi.Cyan("auth0 acul config --help"))) + cli.renderer.Output("") + + return nil +} + +// showNewConfigExamples displays example commands for managing ACUL configurations. +func showNewConfigExamples(cli *cli) { + cli.renderer.Warnf(" %s - Create config files", ansi.Yellow("auth0 acul config generate ")) + cli.renderer.Warnf(" %s - Download current settings", ansi.Yellow("auth0 acul config get ")) + cli.renderer.Warnf(" %s - Upload customizations", ansi.Yellow("auth0 acul config set ")) + cli.renderer.Warnf(" %s - View available screens", ansi.Yellow("auth0 acul config list")) + cli.renderer.Output("") + cli.renderer.Warnf(" %s", ansi.Bold("Quick Start:")) + cli.renderer.Warnf(" 1. %s", ansi.Cyan("auth0 acul config generate login-id")) + cli.renderer.Warnf(" 2. Edit the generated JSON file as needed") + cli.renderer.Warnf(" 3. %s", ansi.Cyan("auth0 acul config set login-id --file login-id.json")) + cli.renderer.Output("") +} + func startWebSocketServer( ctx context.Context, api *auth0.API, @@ -1096,20 +1036,33 @@ func switchUniversalLoginRendererModeCmd(cli *cli) *cobra.Command { cmd := &cobra.Command{ Use: "switch", Args: cobra.NoArgs, - Short: "Switch the rendering mode for Universal Login", - Long: "Switch the rendering mode for Universal Login. Note that this requires a custom domain to be configured for the tenant.", + Short: "⚠️ Switch rendering mode (DEPRECATED)", + Long: `Switch the rendering mode for Universal Login. Note that this requires a custom domain to be configured for the tenant. + +🚨 DEPRECATION WARNING: The 'auth0 ul switch' command will be DEPRECATED on April 30, 2026 + +✅ For Advanced Customizations, migrate to the new ACUL config commands: + • auth0 acul config generate + • auth0 acul config get + • auth0 acul config set + • auth0 acul config list`, Example: ` auth0 universal-login switch auth0 universal-login switch --prompt login-id --screen login-id --rendering-mode standard auth0 ul switch --prompt login-id --screen login-id --rendering-mode advanced auth0 ul switch -p login-id -s login-id -r standard`, RunE: func(cmd *cobra.Command, args []string) error { - err := fetchPromptScreenInfo(cmd, cli, &input, "switch") + err := displayDeprecationStatus(cli, false) + if err != nil { + return err + } + + err = fetchPromptScreenInfo(cmd, cli, &input, "switch") if err != nil { return err } if selectedRenderingMode == "" { - cli.renderer.Infof("Please select a select a rendering mode to switch:\n") + cli.renderer.Infof("Please select a rendering mode to switch:") if err = renderingMode.Select(cmd, &selectedRenderingMode, []string{advancedMode, standardMode}, nil); err != nil { return err } diff --git a/internal/display/acul.go b/internal/display/acul.go new file mode 100644 index 000000000..104b153b7 --- /dev/null +++ b/internal/display/acul.go @@ -0,0 +1,61 @@ +package display + +import ( + "github.com/auth0/go-auth0/management" +) + +type aculConfigView struct { + ScreenName string + RenderingMode string + raw interface{} +} + +func (v *aculConfigView) Object() interface{} { + return v.raw +} + +func (v *aculConfigView) AsTableHeader() []string { + return []string{ + "Screen Name", + "Rendering Mode", + } +} + +func (v *aculConfigView) AsTableRow() []string { + return []string{v.ScreenName, v.RenderingMode} +} + +func (r *Renderer) ACULConfigList(aculConfigs *management.PromptRenderingList) { + resource := "prompt rendering configurations" + r.Heading(resource) + + if len(aculConfigs.PromptRenderings) == 0 { + r.EmptyState(resource, " Use 'auth0 acul config get' to fetch remote rendering settings or 'auth0 acul config set' to sync local configs.") + } + + if r.Format == OutputFormatJSONCompact { + r.JSONCompactResult(aculConfigs) + return + } + + if r.Format == OutputFormatJSON { + r.JSONResult(aculConfigs) + return + } + + r.Results(makeACULConfigView(aculConfigs)) +} + +func makeACULConfigView(aculConfig *management.PromptRenderingList) []View { + views := make([]View, 0, len(aculConfig.PromptRenderings)) + + for _, v := range aculConfig.PromptRenderings { + views = append(views, &aculConfigView{ + ScreenName: string(*v.Screen), + RenderingMode: string(*v.RenderingMode), + raw: v, + }) + } + + return views +} diff --git a/internal/prompt/paginated_select.go b/internal/prompt/paginated_select.go new file mode 100644 index 000000000..46c8f7c0f --- /dev/null +++ b/internal/prompt/paginated_select.go @@ -0,0 +1,35 @@ +package prompt + +import ( + "strings" + + "github.com/AlecAivazis/survey/v2" +) + +var ( + defaultPageSize = 13 +) + +func PaginatedSelectInput(name string, message string, help string, options []string, defaultValue string, required bool) *survey.Question { + input := &survey.Question{ + Name: name, + Prompt: &survey.Select{ + Message: message, + Help: help, + Options: options, + PageSize: defaultPageSize, + Default: defaultValue, + + Filter: func(filterVal string, optionVal string, optionIndex int) bool { + // Case-insensitive search - matches if the search term appears anywhere in the option. + return strings.Contains(strings.ToLower(optionVal), strings.ToLower(filterVal)) + }, + }, + } + + if required { + input.Validate = survey.Required + } + + return input +} diff --git a/internal/utils/utils.go b/internal/utils/utils.go index bebcd89ad..4ffd4ef68 100644 --- a/internal/utils/utils.go +++ b/internal/utils/utils.go @@ -1,10 +1,13 @@ package utils +import "sort" + // FetchKeys function to get all keys from a map. -func FetchKeys[K comparable, V any](m map[K]V) []K { - keys := make([]K, 0, len(m)) +func FetchKeys[V any](m map[string]V) []string { + keys := make([]string, 0, len(m)) for k := range m { keys = append(keys, k) } + sort.Strings(keys) return keys }