diff --git a/internal/auth0/auth0.go b/internal/auth0/auth0.go index 6ad73bdcf..737e6a90d 100644 --- a/internal/auth0/auth0.go +++ b/internal/auth0/auth0.go @@ -12,6 +12,7 @@ type API struct { Anomaly AnomalyAPI AttackProtection AttackProtectionAPI Branding BrandingAPI + BrandingTheme BrandingThemeAPI Client ClientAPI ClientGrant ClientGrantAPI Connection ConnectionAPI @@ -38,6 +39,7 @@ func NewAPI(m *management.Management) *API { Anomaly: m.Anomaly, AttackProtection: m.AttackProtection, Branding: m.Branding, + BrandingTheme: m.BrandingTheme, Client: m.Client, ClientGrant: m.ClientGrant, Connection: m.Connection, diff --git a/internal/auth0/branding_theme.go b/internal/auth0/branding_theme.go new file mode 100644 index 000000000..d234e1e3a --- /dev/null +++ b/internal/auth0/branding_theme.go @@ -0,0 +1,17 @@ +//go:generate mockgen -source=branding_theme.go -destination=mock/branding_theme_mock.go -package=mock + +package auth0 + +import ( + "context" + + "github.com/auth0/go-auth0/management" +) + +type BrandingThemeAPI interface { + Default(ctx context.Context, opts ...management.RequestOption) (theme *management.BrandingTheme, err error) + Create(ctx context.Context, theme *management.BrandingTheme, opts ...management.RequestOption) (err error) + Read(ctx context.Context, id string, opts ...management.RequestOption) (theme *management.BrandingTheme, err error) + Update(ctx context.Context, id string, theme *management.BrandingTheme, opts ...management.RequestOption) (err error) + Delete(ctx context.Context, id string, opts ...management.RequestOption) (err error) +} diff --git a/internal/auth0/mock/branding_theme_mock.go b/internal/auth0/mock/branding_theme_mock.go new file mode 100644 index 000000000..041f28ac9 --- /dev/null +++ b/internal/auth0/mock/branding_theme_mock.go @@ -0,0 +1,133 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: branding_theme.go + +// Package mock is a generated GoMock package. +package mock + +import ( + context "context" + reflect "reflect" + + management "github.com/auth0/go-auth0/management" + gomock "github.com/golang/mock/gomock" +) + +// MockBrandingThemeAPI is a mock of BrandingThemeAPI interface. +type MockBrandingThemeAPI struct { + ctrl *gomock.Controller + recorder *MockBrandingThemeAPIMockRecorder +} + +// MockBrandingThemeAPIMockRecorder is the mock recorder for MockBrandingThemeAPI. +type MockBrandingThemeAPIMockRecorder struct { + mock *MockBrandingThemeAPI +} + +// NewMockBrandingThemeAPI creates a new mock instance. +func NewMockBrandingThemeAPI(ctrl *gomock.Controller) *MockBrandingThemeAPI { + mock := &MockBrandingThemeAPI{ctrl: ctrl} + mock.recorder = &MockBrandingThemeAPIMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockBrandingThemeAPI) EXPECT() *MockBrandingThemeAPIMockRecorder { + return m.recorder +} + +// Create mocks base method. +func (m *MockBrandingThemeAPI) Create(ctx context.Context, theme *management.BrandingTheme, opts ...management.RequestOption) error { + m.ctrl.T.Helper() + varargs := []interface{}{ctx, theme} + 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 *MockBrandingThemeAPIMockRecorder) Create(ctx, theme interface{}, opts ...interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + varargs := append([]interface{}{ctx, theme}, opts...) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Create", reflect.TypeOf((*MockBrandingThemeAPI)(nil).Create), varargs...) +} + +// Default mocks base method. +func (m *MockBrandingThemeAPI) Default(ctx context.Context, opts ...management.RequestOption) (*management.BrandingTheme, error) { + m.ctrl.T.Helper() + varargs := []interface{}{ctx} + for _, a := range opts { + varargs = append(varargs, a) + } + ret := m.ctrl.Call(m, "Default", varargs...) + ret0, _ := ret[0].(*management.BrandingTheme) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// Default indicates an expected call of Default. +func (mr *MockBrandingThemeAPIMockRecorder) Default(ctx interface{}, opts ...interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + varargs := append([]interface{}{ctx}, opts...) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Default", reflect.TypeOf((*MockBrandingThemeAPI)(nil).Default), varargs...) +} + +// Delete mocks base method. +func (m *MockBrandingThemeAPI) Delete(ctx context.Context, id string, opts ...management.RequestOption) error { + m.ctrl.T.Helper() + varargs := []interface{}{ctx, id} + for _, a := range opts { + varargs = append(varargs, a) + } + ret := m.ctrl.Call(m, "Delete", varargs...) + ret0, _ := ret[0].(error) + return ret0 +} + +// Delete indicates an expected call of Delete. +func (mr *MockBrandingThemeAPIMockRecorder) 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((*MockBrandingThemeAPI)(nil).Delete), varargs...) +} + +// Read mocks base method. +func (m *MockBrandingThemeAPI) Read(ctx context.Context, id string, opts ...management.RequestOption) (*management.BrandingTheme, error) { + m.ctrl.T.Helper() + varargs := []interface{}{ctx, id} + for _, a := range opts { + varargs = append(varargs, a) + } + ret := m.ctrl.Call(m, "Read", varargs...) + ret0, _ := ret[0].(*management.BrandingTheme) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// Read indicates an expected call of Read. +func (mr *MockBrandingThemeAPIMockRecorder) 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((*MockBrandingThemeAPI)(nil).Read), varargs...) +} + +// Update mocks base method. +func (m *MockBrandingThemeAPI) Update(ctx context.Context, id string, theme *management.BrandingTheme, opts ...management.RequestOption) error { + m.ctrl.T.Helper() + varargs := []interface{}{ctx, id, theme} + 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 *MockBrandingThemeAPIMockRecorder) Update(ctx, id, theme interface{}, opts ...interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + varargs := append([]interface{}{ctx, id, theme}, opts...) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Update", reflect.TypeOf((*MockBrandingThemeAPI)(nil).Update), varargs...) +} diff --git a/internal/cli/universal_login_customize.go b/internal/cli/universal_login_customize.go index bf0681d0b..5a038aa2d 100644 --- a/internal/cli/universal_login_customize.go +++ b/internal/cli/universal_login_customize.go @@ -1,12 +1,40 @@ package cli import ( - "fmt" + "context" + "github.com/auth0/go-auth0/management" "github.com/spf13/cobra" + "golang.org/x/sync/errgroup" + + "github.com/auth0/auth0-cli/internal/ansi" + "github.com/auth0/auth0-cli/internal/auth0" ) -func customizeUniversalLoginCmd(_ *cli) *cobra.Command { +type ( + universalLoginBrandingData struct { + AuthenticationProfile *management.Prompt `json:"auth_profile"` + Settings *management.Branding `json:"settings"` + Template *management.BrandingUniversalLogin `json:"template"` + Theme *management.BrandingTheme `json:"theme"` + Tenant *tenantData `json:"tenant"` + Prompt *promptData `json:"prompt"` + } + + tenantData struct { + FriendlyName string `json:"friendly_name"` + EnabledLocales []string `json:"enabled_locales"` + Domain string `json:"domain"` + } + + promptData struct { + Language string `json:"language"` + Prompt string `json:"prompt"` + CustomText interface{} `json:"custom_text"` + } +) + +func customizeUniversalLoginCmd(cli *cli) *cobra.Command { cmd := &cobra.Command{ Use: "customize", Args: cobra.NoArgs, @@ -18,9 +46,195 @@ func customizeUniversalLoginCmd(_ *cli) *cobra.Command { Example: ` auth0 universal-login customize auth0 ul customize`, RunE: func(cmd *cobra.Command, args []string) error { - return fmt.Errorf("not yet implemented") + var universalLoginBrandingData *universalLoginBrandingData + + if err := ansi.Spinner("Fetching Universal Login branding data", func() (err error) { + universalLoginBrandingData, err = fetchUniversalLoginBrandingData(cmd.Context(), cli.api, cli.tenant) + return err + }); err != nil { + return err + } + + cli.renderer.JSONResult(universalLoginBrandingData) + + return nil }, } return cmd } + +func fetchUniversalLoginBrandingData( + ctx context.Context, + api *auth0.API, + tenantDomain string, +) (*universalLoginBrandingData, error) { + group, ctx := errgroup.WithContext(ctx) + + group.Go(func() (err error) { + return ensureCustomDomainIsEnabled(ctx, api) + }) + + var authenticationProfile *management.Prompt + group.Go(func() (err error) { + authenticationProfile, err = api.Prompt.Read(ctx) + return err + }) + + var brandingSettings *management.Branding + group.Go(func() (err error) { + brandingSettings = fetchBrandingSettingsOrUseDefaults(ctx, api) + return nil + }) + + var currentTemplate *management.BrandingUniversalLogin + group.Go(func() (err error) { + currentTemplate = fetchBrandingTemplateOrUseEmpty(ctx, api) + return nil + }) + + var currentTheme *management.BrandingTheme + group.Go(func() (err error) { + currentTheme = fetchBrandingThemeOrUseDefault(ctx, api) + return nil + }) + + var tenant *management.Tenant + var prompt *promptData + group.Go(func() (err error) { + tenant, err = api.Tenant.Read(ctx) + if err != nil { + return err + } + + defaultPrompt := "login" + defaultLanguage := "en" + if len(tenant.GetEnabledLocales()) > 0 { + defaultLanguage = tenant.GetEnabledLocales()[0] + } + + prompt, err = fetchPromptCustomTextWithDefaults(ctx, api, defaultPrompt, defaultLanguage) + return err + }) + + if err := group.Wait(); err != nil { + return nil, err + } + + return &universalLoginBrandingData{ + AuthenticationProfile: authenticationProfile, + Settings: brandingSettings, + Template: currentTemplate, + Theme: currentTheme, + Tenant: &tenantData{ + FriendlyName: tenant.GetFriendlyName(), + EnabledLocales: tenant.GetEnabledLocales(), + Domain: tenantDomain, + }, + Prompt: prompt, + }, nil +} + +func fetchBrandingThemeOrUseDefault(ctx context.Context, api *auth0.API) *management.BrandingTheme { + currentTheme, err := api.BrandingTheme.Default(ctx) + if err == nil { + return currentTheme + } + + return &management.BrandingTheme{ + Borders: management.BrandingThemeBorders{ + ButtonBorderRadius: 3, + ButtonBorderWeight: 1, + ButtonsStyle: "rounded", + InputBorderRadius: 3, + InputBorderWeight: 1, + InputsStyle: "rounded", + ShowWidgetShadow: true, + WidgetBorderWeight: 0, + WidgetCornerRadius: 5, + }, + Colors: management.BrandingThemeColors{ + BaseFocusColor: auth0.String("#635dff"), + BaseHoverColor: auth0.String("#000000"), + BodyText: "#1e212a", + Error: "#d03c38", + Header: "#1e212a", + Icons: "#65676e", + InputBackground: "#ffffff", + InputBorder: "#c9cace", + InputFilledText: "#000000", + InputLabelsPlaceholders: "#65676e", + LinksFocusedComponents: "#635dff", + PrimaryButton: "#635dff", + PrimaryButtonLabel: "#ffffff", + SecondaryButtonBorder: "#c9cace", + SecondaryButtonLabel: "#1e212a", + Success: "#13a688", + WidgetBackground: "#ffffff", + WidgetBorder: "#c9cace", + }, + Fonts: management.BrandingThemeFonts{ + BodyText: management.BrandingThemeText{ + Bold: false, + Size: 87.5, + }, + ButtonsText: management.BrandingThemeText{ + Bold: false, + Size: 100.0, + }, + FontURL: "", + InputLabels: management.BrandingThemeText{ + Bold: false, + Size: 100.0, + }, + Links: management.BrandingThemeText{ + Bold: true, + Size: 87.5, + }, + LinksStyle: "normal", + ReferenceTextSize: 16.0, + Subtitle: management.BrandingThemeText{ + Bold: false, + Size: 87.5, + }, + Title: management.BrandingThemeText{ + Bold: false, + Size: 150.0, + }, + }, + PageBackground: management.BrandingThemePageBackground{ + BackgroundColor: "#000000", + BackgroundImageURL: "", + PageLayout: "center", + }, + Widget: management.BrandingThemeWidget{ + HeaderTextAlignment: "center", + LogoHeight: 52.0, + LogoPosition: "center", + LogoURL: "", + SocialButtonsLayout: "bottom", + }, + } +} + +func fetchPromptCustomTextWithDefaults( + ctx context.Context, + api *auth0.API, + promptName string, + language string, +) (*promptData, error) { + customTranslations, err := api.Prompt.CustomText(ctx, promptName, language) + if err != nil { + return nil, err + } + + defaultTranslations := downloadDefaultBrandingTextTranslations(promptName, language) + + brandingTextTranslations := mergeBrandingTextTranslations(defaultTranslations, customTranslations) + + return &promptData{ + Language: language, + Prompt: promptName, + CustomText: brandingTextTranslations, + }, nil +} diff --git a/internal/cli/universal_login_customize_test.go b/internal/cli/universal_login_customize_test.go new file mode 100644 index 000000000..1d5c6d4b8 --- /dev/null +++ b/internal/cli/universal_login_customize_test.go @@ -0,0 +1,959 @@ +package cli + +import ( + "context" + "fmt" + "testing" + + "github.com/auth0/go-auth0/management" + "github.com/golang/mock/gomock" + "github.com/stretchr/testify/assert" + + "github.com/auth0/auth0-cli/internal/auth0" + "github.com/auth0/auth0-cli/internal/auth0/mock" +) + +func TestFetchUniversalLoginBrandingData(t *testing.T) { + const tenantDomain = "tenant-example.auth0.com" + + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + var testCases = []struct { + name string + mockedAPI func() *auth0.API + expectedData *universalLoginBrandingData + expectedError string + }{ + { + name: "it can correctly fetch universal login branding data", + mockedAPI: func() *auth0.API { + mockCustomDomainAPI := mock.NewMockCustomDomainAPI(ctrl) + mockCustomDomainAPI. + EXPECT(). + List(gomock.Any()). + Return( + []*management.CustomDomain{ + { + Status: auth0.String("ready"), + }, + }, + nil, + ) + + mockPromptAPI := mock.NewMockPromptAPI(ctrl) + mockPromptAPI. + EXPECT(). + Read(gomock.Any()). + Return( + &management.Prompt{ + UniversalLoginExperience: "new", + IdentifierFirst: auth0.Bool(true), + WebAuthnPlatformFirstFactor: auth0.Bool(true), + }, + nil, + ) + + mockBrandingAPI := mock.NewMockBrandingAPI(ctrl) + mockBrandingAPI. + EXPECT(). + Read(gomock.Any()). + Return( + &management.Branding{ + Colors: &management.BrandingColors{ + Primary: auth0.String("#334455"), + PageBackground: auth0.String("#00AABB"), + }, + LogoURL: auth0.String("https://some-log.example.com"), + }, + nil, + ) + + mockBrandingAPI. + EXPECT(). + UniversalLogin(gomock.Any()). + Return( + &management.BrandingUniversalLogin{ + Body: auth0.String(""), + }, + nil, + ) + + mockBrandingThemeAPI := mock.NewMockBrandingThemeAPI(ctrl) + mockBrandingThemeAPI. + EXPECT(). + Default(gomock.Any()). + Return(&management.BrandingTheme{}, nil) + + mockTenantAPI := mock.NewMockTenantAPI(ctrl) + mockTenantAPI. + EXPECT(). + Read(gomock.Any()). + Return( + &management.Tenant{ + FriendlyName: auth0.String("My Test Tenant"), + EnabledLocales: &[]string{"en", "es"}, + }, + nil, + ) + + mockPromptAPI. + EXPECT(). + CustomText(gomock.Any(), "login", "en"). + Return( + map[string]interface{}{ + "login": map[string]interface{}{ + "title": "Welcome friend, glad to have you!", + }, + }, + nil, + ) + + mockAPI := &auth0.API{ + Branding: mockBrandingAPI, + BrandingTheme: mockBrandingThemeAPI, + CustomDomain: mockCustomDomainAPI, + Prompt: mockPromptAPI, + Tenant: mockTenantAPI, + } + + return mockAPI + }, + expectedData: &universalLoginBrandingData{ + AuthenticationProfile: &management.Prompt{ + UniversalLoginExperience: "new", + IdentifierFirst: auth0.Bool(true), + WebAuthnPlatformFirstFactor: auth0.Bool(true), + }, + Settings: &management.Branding{ + Colors: &management.BrandingColors{ + Primary: auth0.String("#334455"), + PageBackground: auth0.String("#00AABB"), + }, + LogoURL: auth0.String("https://some-log.example.com"), + }, + Template: &management.BrandingUniversalLogin{ + Body: auth0.String(""), + }, + Theme: &management.BrandingTheme{}, + Tenant: &tenantData{ + FriendlyName: "My Test Tenant", + EnabledLocales: []string{"en", "es"}, + Domain: "tenant-example.auth0.com", + }, + Prompt: &promptData{ + Language: "en", + Prompt: "login", + CustomText: map[string]map[string]interface{}{ + "login": { + "alertListTitle": "Alerts", + "auth0-users-validation": "Something went wrong, please try again later", + "authentication-failure": "We are sorry, something went wrong when attempting to login", + "buttonText": "Continue", + "custom-script-error-code": "Something went wrong, please try again later.", + "description": "Log in to ${companyName} to continue to ${clientName}.", + "editEmailText": "Edit", + "emailPlaceholder": "Email address", + "federatedConnectionButtonText": "Continue with ${connectionName}", + "footerLinkText": "Sign up", + "footerText": "Don't have an account?", + "forgotPasswordText": "Forgot password?", + "hidePasswordText": "Hide password", + "invalid-connection": "Invalid connection", + "invalid-email-format": "Email is not valid.", + "invitationDescription": "Log in to accept ${inviterName}'s invitation to join ${companyName} on ${clientName}.", + "invitationTitle": "You've Been Invited!", + "ip-blocked": "We have detected suspicious login behavior and further attempts will be blocked. Please contact the administrator.", + "logoAltText": "${companyName}", + "no-db-connection": "Invalid connection", + "no-email": "Please enter an email address", + "no-password": "Password is required", + "no-username": "Username is required", + "pageTitle": "Log in | ${clientName}", + "password-breached": "We have detected a potential security issue with this account. To protect your account, we have prevented this login. Please reset your password to proceed.", + "passwordPlaceholder": "Password", + "same-user-login": "Too many login attempts for this user. Please wait, and try again later.", + "separatorText": "Or", + "showPasswordText": "Show password", + "signupActionLinkText": "${footerLinkText}", + "signupActionText": "${footerText}", + "title": "Welcome friend, glad to have you!", + "user-blocked": "Your account has been blocked after multiple consecutive login attempts.", + "usernamePlaceholder": "Username or email address", + "wrong-credentials": "Wrong username or password", + "wrong-email-credentials": "Wrong email or password", + }, + }, + }, + }, + }, + { + name: "it uses default branding settings if it fails to fetch them", + mockedAPI: func() *auth0.API { + mockCustomDomainAPI := mock.NewMockCustomDomainAPI(ctrl) + mockCustomDomainAPI. + EXPECT(). + List(gomock.Any()). + Return( + []*management.CustomDomain{ + { + Status: auth0.String("ready"), + }, + }, + nil, + ) + + mockPromptAPI := mock.NewMockPromptAPI(ctrl) + mockPromptAPI. + EXPECT(). + Read(gomock.Any()). + Return( + &management.Prompt{ + UniversalLoginExperience: "new", + IdentifierFirst: auth0.Bool(true), + WebAuthnPlatformFirstFactor: auth0.Bool(true), + }, + nil, + ) + + mockBrandingAPI := mock.NewMockBrandingAPI(ctrl) + mockBrandingAPI. + EXPECT(). + Read(gomock.Any()). + Return(nil, fmt.Errorf("failed to fetch branding settings")) + + mockBrandingAPI. + EXPECT(). + UniversalLogin(gomock.Any()). + Return( + &management.BrandingUniversalLogin{ + Body: auth0.String(""), + }, + nil, + ) + + mockBrandingThemeAPI := mock.NewMockBrandingThemeAPI(ctrl) + mockBrandingThemeAPI. + EXPECT(). + Default(gomock.Any()). + Return(&management.BrandingTheme{}, nil) + + mockTenantAPI := mock.NewMockTenantAPI(ctrl) + mockTenantAPI. + EXPECT(). + Read(gomock.Any()). + Return( + &management.Tenant{ + FriendlyName: auth0.String("My Test Tenant"), + EnabledLocales: &[]string{"en", "es"}, + }, + nil, + ) + + mockPromptAPI. + EXPECT(). + CustomText(gomock.Any(), "login", "en"). + Return( + map[string]interface{}{ + "login": map[string]interface{}{ + "title": "Welcome friend, glad to have you!", + }, + }, + nil, + ) + + mockAPI := &auth0.API{ + Branding: mockBrandingAPI, + BrandingTheme: mockBrandingThemeAPI, + CustomDomain: mockCustomDomainAPI, + Prompt: mockPromptAPI, + Tenant: mockTenantAPI, + } + + return mockAPI + }, + expectedData: &universalLoginBrandingData{ + AuthenticationProfile: &management.Prompt{ + UniversalLoginExperience: "new", + IdentifierFirst: auth0.Bool(true), + WebAuthnPlatformFirstFactor: auth0.Bool(true), + }, + Settings: &management.Branding{ + Colors: &management.BrandingColors{ + Primary: auth0.String(defaultPrimaryColor), + PageBackground: auth0.String(defaultBackgroundColor), + }, + LogoURL: auth0.String(defaultLogoURL), + }, + Template: &management.BrandingUniversalLogin{ + Body: auth0.String(""), + }, + Theme: &management.BrandingTheme{}, + Tenant: &tenantData{ + FriendlyName: "My Test Tenant", + EnabledLocales: []string{"en", "es"}, + Domain: "tenant-example.auth0.com", + }, + Prompt: &promptData{ + Language: "en", + Prompt: "login", + CustomText: map[string]map[string]interface{}{ + "login": { + "alertListTitle": "Alerts", + "auth0-users-validation": "Something went wrong, please try again later", + "authentication-failure": "We are sorry, something went wrong when attempting to login", + "buttonText": "Continue", + "custom-script-error-code": "Something went wrong, please try again later.", + "description": "Log in to ${companyName} to continue to ${clientName}.", + "editEmailText": "Edit", + "emailPlaceholder": "Email address", + "federatedConnectionButtonText": "Continue with ${connectionName}", + "footerLinkText": "Sign up", + "footerText": "Don't have an account?", + "forgotPasswordText": "Forgot password?", + "hidePasswordText": "Hide password", + "invalid-connection": "Invalid connection", + "invalid-email-format": "Email is not valid.", + "invitationDescription": "Log in to accept ${inviterName}'s invitation to join ${companyName} on ${clientName}.", + "invitationTitle": "You've Been Invited!", + "ip-blocked": "We have detected suspicious login behavior and further attempts will be blocked. Please contact the administrator.", + "logoAltText": "${companyName}", + "no-db-connection": "Invalid connection", + "no-email": "Please enter an email address", + "no-password": "Password is required", + "no-username": "Username is required", + "pageTitle": "Log in | ${clientName}", + "password-breached": "We have detected a potential security issue with this account. To protect your account, we have prevented this login. Please reset your password to proceed.", + "passwordPlaceholder": "Password", + "same-user-login": "Too many login attempts for this user. Please wait, and try again later.", + "separatorText": "Or", + "showPasswordText": "Show password", + "signupActionLinkText": "${footerLinkText}", + "signupActionText": "${footerText}", + "title": "Welcome friend, glad to have you!", + "user-blocked": "Your account has been blocked after multiple consecutive login attempts.", + "usernamePlaceholder": "Username or email address", + "wrong-credentials": "Wrong username or password", + "wrong-email-credentials": "Wrong email or password", + }, + }, + }, + }, + }, + { + name: "it uses an empty branding template if it fails to fetch it", + mockedAPI: func() *auth0.API { + mockCustomDomainAPI := mock.NewMockCustomDomainAPI(ctrl) + mockCustomDomainAPI. + EXPECT(). + List(gomock.Any()). + Return( + []*management.CustomDomain{ + { + Status: auth0.String("ready"), + }, + }, + nil, + ) + + mockPromptAPI := mock.NewMockPromptAPI(ctrl) + mockPromptAPI. + EXPECT(). + Read(gomock.Any()). + Return( + &management.Prompt{ + UniversalLoginExperience: "new", + IdentifierFirst: auth0.Bool(true), + WebAuthnPlatformFirstFactor: auth0.Bool(true), + }, + nil, + ) + + mockBrandingAPI := mock.NewMockBrandingAPI(ctrl) + mockBrandingAPI. + EXPECT(). + Read(gomock.Any()). + Return( + &management.Branding{ + Colors: &management.BrandingColors{ + Primary: auth0.String("#334455"), + PageBackground: auth0.String("#00AABB"), + }, + LogoURL: auth0.String("https://some-log.example.com"), + }, + nil, + ) + + mockBrandingAPI. + EXPECT(). + UniversalLogin(gomock.Any()). + Return(nil, fmt.Errorf("failed to fetch universal login template")) + + mockBrandingThemeAPI := mock.NewMockBrandingThemeAPI(ctrl) + mockBrandingThemeAPI. + EXPECT(). + Default(gomock.Any()). + Return(&management.BrandingTheme{}, nil) + + mockTenantAPI := mock.NewMockTenantAPI(ctrl) + mockTenantAPI. + EXPECT(). + Read(gomock.Any()). + Return( + &management.Tenant{ + FriendlyName: auth0.String("My Test Tenant"), + EnabledLocales: &[]string{"en", "es"}, + }, + nil, + ) + + mockPromptAPI. + EXPECT(). + CustomText(gomock.Any(), "login", "en"). + Return( + map[string]interface{}{ + "login": map[string]interface{}{ + "title": "Welcome friend, glad to have you!", + }, + }, + nil, + ) + + mockAPI := &auth0.API{ + Branding: mockBrandingAPI, + BrandingTheme: mockBrandingThemeAPI, + CustomDomain: mockCustomDomainAPI, + Prompt: mockPromptAPI, + Tenant: mockTenantAPI, + } + + return mockAPI + }, + expectedData: &universalLoginBrandingData{ + AuthenticationProfile: &management.Prompt{ + UniversalLoginExperience: "new", + IdentifierFirst: auth0.Bool(true), + WebAuthnPlatformFirstFactor: auth0.Bool(true), + }, + Settings: &management.Branding{ + Colors: &management.BrandingColors{ + Primary: auth0.String("#334455"), + PageBackground: auth0.String("#00AABB"), + }, + LogoURL: auth0.String("https://some-log.example.com"), + }, + Template: &management.BrandingUniversalLogin{}, + Theme: &management.BrandingTheme{}, + Tenant: &tenantData{ + FriendlyName: "My Test Tenant", + EnabledLocales: []string{"en", "es"}, + Domain: "tenant-example.auth0.com", + }, + Prompt: &promptData{ + Language: "en", + Prompt: "login", + CustomText: map[string]map[string]interface{}{ + "login": { + "alertListTitle": "Alerts", + "auth0-users-validation": "Something went wrong, please try again later", + "authentication-failure": "We are sorry, something went wrong when attempting to login", + "buttonText": "Continue", + "custom-script-error-code": "Something went wrong, please try again later.", + "description": "Log in to ${companyName} to continue to ${clientName}.", + "editEmailText": "Edit", + "emailPlaceholder": "Email address", + "federatedConnectionButtonText": "Continue with ${connectionName}", + "footerLinkText": "Sign up", + "footerText": "Don't have an account?", + "forgotPasswordText": "Forgot password?", + "hidePasswordText": "Hide password", + "invalid-connection": "Invalid connection", + "invalid-email-format": "Email is not valid.", + "invitationDescription": "Log in to accept ${inviterName}'s invitation to join ${companyName} on ${clientName}.", + "invitationTitle": "You've Been Invited!", + "ip-blocked": "We have detected suspicious login behavior and further attempts will be blocked. Please contact the administrator.", + "logoAltText": "${companyName}", + "no-db-connection": "Invalid connection", + "no-email": "Please enter an email address", + "no-password": "Password is required", + "no-username": "Username is required", + "pageTitle": "Log in | ${clientName}", + "password-breached": "We have detected a potential security issue with this account. To protect your account, we have prevented this login. Please reset your password to proceed.", + "passwordPlaceholder": "Password", + "same-user-login": "Too many login attempts for this user. Please wait, and try again later.", + "separatorText": "Or", + "showPasswordText": "Show password", + "signupActionLinkText": "${footerLinkText}", + "signupActionText": "${footerText}", + "title": "Welcome friend, glad to have you!", + "user-blocked": "Your account has been blocked after multiple consecutive login attempts.", + "usernamePlaceholder": "Username or email address", + "wrong-credentials": "Wrong username or password", + "wrong-email-credentials": "Wrong email or password", + }, + }, + }, + }, + }, + { + name: "it uses a default branding theme if it fails to fetch it", + mockedAPI: func() *auth0.API { + mockCustomDomainAPI := mock.NewMockCustomDomainAPI(ctrl) + mockCustomDomainAPI. + EXPECT(). + List(gomock.Any()). + Return( + []*management.CustomDomain{ + { + Status: auth0.String("ready"), + }, + }, + nil, + ) + + mockPromptAPI := mock.NewMockPromptAPI(ctrl) + mockPromptAPI. + EXPECT(). + Read(gomock.Any()). + Return( + &management.Prompt{ + UniversalLoginExperience: "new", + IdentifierFirst: auth0.Bool(true), + WebAuthnPlatformFirstFactor: auth0.Bool(true), + }, + nil, + ) + + mockBrandingAPI := mock.NewMockBrandingAPI(ctrl) + mockBrandingAPI. + EXPECT(). + Read(gomock.Any()). + Return( + &management.Branding{ + Colors: &management.BrandingColors{ + Primary: auth0.String("#334455"), + PageBackground: auth0.String("#00AABB"), + }, + LogoURL: auth0.String("https://some-log.example.com"), + }, + nil, + ) + + mockBrandingAPI. + EXPECT(). + UniversalLogin(gomock.Any()). + Return( + &management.BrandingUniversalLogin{ + Body: auth0.String(""), + }, + nil, + ) + + mockBrandingThemeAPI := mock.NewMockBrandingThemeAPI(ctrl) + mockBrandingThemeAPI. + EXPECT(). + Default(gomock.Any()). + Return(nil, fmt.Errorf("failed to fetch branding theme")) + + mockTenantAPI := mock.NewMockTenantAPI(ctrl) + mockTenantAPI. + EXPECT(). + Read(gomock.Any()). + Return( + &management.Tenant{ + FriendlyName: auth0.String("My Test Tenant"), + EnabledLocales: &[]string{"en", "es"}, + }, + nil, + ) + + mockPromptAPI. + EXPECT(). + CustomText(gomock.Any(), "login", "en"). + Return( + map[string]interface{}{ + "login": map[string]interface{}{ + "title": "Welcome friend, glad to have you!", + }, + }, + nil, + ) + + mockAPI := &auth0.API{ + Branding: mockBrandingAPI, + BrandingTheme: mockBrandingThemeAPI, + CustomDomain: mockCustomDomainAPI, + Prompt: mockPromptAPI, + Tenant: mockTenantAPI, + } + + return mockAPI + }, + expectedData: &universalLoginBrandingData{ + AuthenticationProfile: &management.Prompt{ + UniversalLoginExperience: "new", + IdentifierFirst: auth0.Bool(true), + WebAuthnPlatformFirstFactor: auth0.Bool(true), + }, + Settings: &management.Branding{ + Colors: &management.BrandingColors{ + Primary: auth0.String("#334455"), + PageBackground: auth0.String("#00AABB"), + }, + LogoURL: auth0.String("https://some-log.example.com"), + }, + Template: &management.BrandingUniversalLogin{ + Body: auth0.String(""), + }, + Theme: &management.BrandingTheme{ + Borders: management.BrandingThemeBorders{ + ButtonBorderRadius: 3, + ButtonBorderWeight: 1, + ButtonsStyle: "rounded", + InputBorderRadius: 3, + InputBorderWeight: 1, + InputsStyle: "rounded", + ShowWidgetShadow: true, + WidgetBorderWeight: 0, + WidgetCornerRadius: 5, + }, + Colors: management.BrandingThemeColors{ + BaseFocusColor: auth0.String("#635dff"), + BaseHoverColor: auth0.String("#000000"), + BodyText: "#1e212a", + Error: "#d03c38", + Header: "#1e212a", + Icons: "#65676e", + InputBackground: "#ffffff", + InputBorder: "#c9cace", + InputFilledText: "#000000", + InputLabelsPlaceholders: "#65676e", + LinksFocusedComponents: "#635dff", + PrimaryButton: "#635dff", + PrimaryButtonLabel: "#ffffff", + SecondaryButtonBorder: "#c9cace", + SecondaryButtonLabel: "#1e212a", + Success: "#13a688", + WidgetBackground: "#ffffff", + WidgetBorder: "#c9cace", + }, + Fonts: management.BrandingThemeFonts{ + BodyText: management.BrandingThemeText{ + Bold: false, + Size: 87.5, + }, + ButtonsText: management.BrandingThemeText{ + Bold: false, + Size: 100.0, + }, + FontURL: "", + InputLabels: management.BrandingThemeText{ + Bold: false, + Size: 100.0, + }, + Links: management.BrandingThemeText{ + Bold: true, + Size: 87.5, + }, + LinksStyle: "normal", + ReferenceTextSize: 16.0, + Subtitle: management.BrandingThemeText{ + Bold: false, + Size: 87.5, + }, + Title: management.BrandingThemeText{ + Bold: false, + Size: 150.0, + }, + }, + PageBackground: management.BrandingThemePageBackground{ + BackgroundColor: "#000000", + BackgroundImageURL: "", + PageLayout: "center", + }, + Widget: management.BrandingThemeWidget{ + HeaderTextAlignment: "center", + LogoHeight: 52.0, + LogoPosition: "center", + LogoURL: "", + SocialButtonsLayout: "bottom", + }, + }, + Tenant: &tenantData{ + FriendlyName: "My Test Tenant", + EnabledLocales: []string{"en", "es"}, + Domain: "tenant-example.auth0.com", + }, + Prompt: &promptData{ + Language: "en", + Prompt: "login", + CustomText: map[string]map[string]interface{}{ + "login": { + "alertListTitle": "Alerts", + "auth0-users-validation": "Something went wrong, please try again later", + "authentication-failure": "We are sorry, something went wrong when attempting to login", + "buttonText": "Continue", + "custom-script-error-code": "Something went wrong, please try again later.", + "description": "Log in to ${companyName} to continue to ${clientName}.", + "editEmailText": "Edit", + "emailPlaceholder": "Email address", + "federatedConnectionButtonText": "Continue with ${connectionName}", + "footerLinkText": "Sign up", + "footerText": "Don't have an account?", + "forgotPasswordText": "Forgot password?", + "hidePasswordText": "Hide password", + "invalid-connection": "Invalid connection", + "invalid-email-format": "Email is not valid.", + "invitationDescription": "Log in to accept ${inviterName}'s invitation to join ${companyName} on ${clientName}.", + "invitationTitle": "You've Been Invited!", + "ip-blocked": "We have detected suspicious login behavior and further attempts will be blocked. Please contact the administrator.", + "logoAltText": "${companyName}", + "no-db-connection": "Invalid connection", + "no-email": "Please enter an email address", + "no-password": "Password is required", + "no-username": "Username is required", + "pageTitle": "Log in | ${clientName}", + "password-breached": "We have detected a potential security issue with this account. To protect your account, we have prevented this login. Please reset your password to proceed.", + "passwordPlaceholder": "Password", + "same-user-login": "Too many login attempts for this user. Please wait, and try again later.", + "separatorText": "Or", + "showPasswordText": "Show password", + "signupActionLinkText": "${footerLinkText}", + "signupActionText": "${footerText}", + "title": "Welcome friend, glad to have you!", + "user-blocked": "Your account has been blocked after multiple consecutive login attempts.", + "usernamePlaceholder": "Username or email address", + "wrong-credentials": "Wrong username or password", + "wrong-email-credentials": "Wrong email or password", + }, + }, + }, + }, + }, + { + name: "it fails to fetch branding data if there's an error retrieving tenant data", + mockedAPI: func() *auth0.API { + mockCustomDomainAPI := mock.NewMockCustomDomainAPI(ctrl) + mockCustomDomainAPI. + EXPECT(). + List(gomock.Any()). + Return( + []*management.CustomDomain{ + { + Status: auth0.String("ready"), + }, + }, + nil, + ) + + mockPromptAPI := mock.NewMockPromptAPI(ctrl) + mockPromptAPI. + EXPECT(). + Read(gomock.Any()). + Return( + &management.Prompt{ + UniversalLoginExperience: "new", + IdentifierFirst: auth0.Bool(true), + WebAuthnPlatformFirstFactor: auth0.Bool(true), + }, + nil, + ) + + mockBrandingAPI := mock.NewMockBrandingAPI(ctrl) + mockBrandingAPI. + EXPECT(). + Read(gomock.Any()). + Return( + &management.Branding{ + Colors: &management.BrandingColors{ + Primary: auth0.String("#334455"), + PageBackground: auth0.String("#00AABB"), + }, + LogoURL: auth0.String("https://some-log.example.com"), + }, + nil, + ) + + mockBrandingAPI. + EXPECT(). + UniversalLogin(gomock.Any()). + Return( + &management.BrandingUniversalLogin{ + Body: auth0.String(""), + }, + nil, + ) + + mockBrandingThemeAPI := mock.NewMockBrandingThemeAPI(ctrl) + mockBrandingThemeAPI. + EXPECT(). + Default(gomock.Any()). + Return(&management.BrandingTheme{}, nil) + + mockTenantAPI := mock.NewMockTenantAPI(ctrl) + mockTenantAPI. + EXPECT(). + Read(gomock.Any()). + Return(nil, fmt.Errorf("failed to fetch tenant data")) + + mockAPI := &auth0.API{ + Branding: mockBrandingAPI, + BrandingTheme: mockBrandingThemeAPI, + CustomDomain: mockCustomDomainAPI, + Prompt: mockPromptAPI, + Tenant: mockTenantAPI, + } + + return mockAPI + }, + expectedError: "failed to fetch tenant data", + }, + { + name: "it fails to fetch branding data if there's an error retrieving prompt text data", + mockedAPI: func() *auth0.API { + mockCustomDomainAPI := mock.NewMockCustomDomainAPI(ctrl) + mockCustomDomainAPI. + EXPECT(). + List(gomock.Any()). + Return( + []*management.CustomDomain{ + { + Status: auth0.String("ready"), + }, + }, + nil, + ) + + mockPromptAPI := mock.NewMockPromptAPI(ctrl) + mockPromptAPI. + EXPECT(). + Read(gomock.Any()). + Return( + &management.Prompt{ + UniversalLoginExperience: "new", + IdentifierFirst: auth0.Bool(true), + WebAuthnPlatformFirstFactor: auth0.Bool(true), + }, + nil, + ) + + mockBrandingAPI := mock.NewMockBrandingAPI(ctrl) + mockBrandingAPI. + EXPECT(). + Read(gomock.Any()). + Return( + &management.Branding{ + Colors: &management.BrandingColors{ + Primary: auth0.String("#334455"), + PageBackground: auth0.String("#00AABB"), + }, + LogoURL: auth0.String("https://some-log.example.com"), + }, + nil, + ) + + mockBrandingAPI. + EXPECT(). + UniversalLogin(gomock.Any()). + Return( + &management.BrandingUniversalLogin{ + Body: auth0.String(""), + }, + nil, + ) + + mockBrandingThemeAPI := mock.NewMockBrandingThemeAPI(ctrl) + mockBrandingThemeAPI. + EXPECT(). + Default(gomock.Any()). + Return(&management.BrandingTheme{}, nil) + + mockTenantAPI := mock.NewMockTenantAPI(ctrl) + mockTenantAPI. + EXPECT(). + Read(gomock.Any()). + Return( + &management.Tenant{ + FriendlyName: auth0.String("My Test Tenant"), + EnabledLocales: &[]string{"en", "es"}, + }, + nil, + ) + + mockPromptAPI. + EXPECT(). + CustomText(gomock.Any(), "login", "en"). + Return(nil, fmt.Errorf("failed to fetch custom text")) + + mockAPI := &auth0.API{ + Branding: mockBrandingAPI, + BrandingTheme: mockBrandingThemeAPI, + CustomDomain: mockCustomDomainAPI, + Prompt: mockPromptAPI, + Tenant: mockTenantAPI, + } + + return mockAPI + }, + expectedError: "failed to fetch custom text", + }, + { + name: "it fails to fetch any data if a custom domain is not enabled", + mockedAPI: func() *auth0.API { + mockCustomDomainAPI := mock.NewMockCustomDomainAPI(ctrl) + mockCustomDomainAPI. + EXPECT(). + List(gomock.Any()). + Return( + []*management.CustomDomain{ + { + Status: auth0.String("pending"), + }, + }, + nil, + ) + + mockPromptAPI := mock.NewMockPromptAPI(ctrl) + mockPromptAPI.EXPECT().Read(gomock.Any()).Return(nil, nil) + + mockBrandingAPI := mock.NewMockBrandingAPI(ctrl) + mockBrandingAPI.EXPECT().Read(gomock.Any()).Return(nil, nil) + + mockBrandingAPI.EXPECT().UniversalLogin(gomock.Any()).Return(nil, nil) + + mockBrandingThemeAPI := mock.NewMockBrandingThemeAPI(ctrl) + mockBrandingThemeAPI.EXPECT().Default(gomock.Any()).Return(nil, nil) + + mockTenantAPI := mock.NewMockTenantAPI(ctrl) + mockTenantAPI.EXPECT().Read(gomock.Any()).Return(nil, nil) + + mockPromptAPI.EXPECT().CustomText(gomock.Any(), "login", "en").Return(map[string]interface{}{}, nil) + + mockAPI := &auth0.API{ + Branding: mockBrandingAPI, + BrandingTheme: mockBrandingThemeAPI, + CustomDomain: mockCustomDomainAPI, + Prompt: mockPromptAPI, + Tenant: mockTenantAPI, + } + + return mockAPI + }, + expectedError: "this feature requires at least one custom domain to be set and verified for the tenant, use 'auth0 domains create' to create one and 'auth0 domains verify' to have it verified", + }, + } + + for _, test := range testCases { + t.Run(test.name, func(t *testing.T) { + actualData, err := fetchUniversalLoginBrandingData(context.Background(), test.mockedAPI(), tenantDomain) + + if test.expectedError != "" { + assert.EqualError(t, err, test.expectedError) + return + } + + assert.NoError(t, err) + assert.Equal(t, test.expectedData, actualData) + }) + } +}