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..9b1f7b627 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 map[string]map[string]interface{} `json:"custom_text"` + } +) + +func customizeUniversalLoginCmd(cli *cli) *cobra.Command { cmd := &cobra.Command{ Use: "customize", Args: cobra.NoArgs, @@ -18,9 +46,194 @@ 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") + ctx := cmd.Context() + + if err := ensureCustomDomainIsEnabled(ctx, cli.api); err != nil { + return err + } + + var universalLoginBrandingData *universalLoginBrandingData + + if err := ansi.Spinner("Fetching Universal Login branding data", func() (err error) { + universalLoginBrandingData, err = fetchUniversalLoginBrandingData(ctx, 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) + + 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 := 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..25837a5b0 --- /dev/null +++ b/internal/cli/universal_login_customize_test.go @@ -0,0 +1,831 @@ +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 { + 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, + 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 { + 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, + 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 { + 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, + 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 { + 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, + 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 { + 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, + 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 { + 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, + Prompt: mockPromptAPI, + Tenant: mockTenantAPI, + } + + return mockAPI + }, + expectedError: "failed to fetch custom text", + }, + } + + 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) + }) + } +}