From a4fddd8c596366bfb8c754e1e44ad0e887834b24 Mon Sep 17 00:00:00 2001 From: Alejo Fernandez Date: Wed, 25 May 2022 03:12:47 -0300 Subject: [PATCH 1/4] feat: add support for branding themes (no-code tool) [ULX-430] --- package-lock.json | 3 +- package.json | 4 +- src/context/directory/handlers/index.ts | 2 + src/context/directory/handlers/themes.ts | 49 ++ src/context/yaml/handlers/index.ts | 2 + src/context/yaml/handlers/themes.ts | 23 + src/tools/auth0/handlers/index.ts | 2 + src/tools/auth0/handlers/themes.ts | 656 ++++++++++++++++++++++ src/tools/constants.ts | 1 + src/types.ts | 10 +- test/context/directory/themes.test.js | 88 +++ test/context/yaml/themes.test.js | 88 +++ test/tools/auth0/handlers/themes.tests.js | 172 ++++++ test/tools/auth0/validator.tests.js | 22 + 14 files changed, 1118 insertions(+), 4 deletions(-) create mode 100644 src/context/directory/handlers/themes.ts create mode 100644 src/context/yaml/handlers/themes.ts create mode 100644 src/tools/auth0/handlers/themes.ts create mode 100644 test/context/directory/themes.test.js create mode 100644 test/context/yaml/themes.test.js create mode 100644 test/tools/auth0/handlers/themes.tests.js diff --git a/package-lock.json b/package-lock.json index cd7604f4a..6f6213de7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,7 +13,7 @@ "@types/nconf": "^0.10.2", "@types/winston": "^2.4.4", "ajv": "^6.12.6", - "auth0": "^2.40.0", + "auth0": "^2.42.0", "dot-prop": "^5.2.0", "fs-extra": "^10.1.0", "global-agent": "^2.1.12", @@ -1370,6 +1370,7 @@ "version": "2.42.0", "resolved": "https://registry.npmjs.org/auth0/-/auth0-2.42.0.tgz", "integrity": "sha512-IiA224CoqjPF9Pnx0R80wQZBGlXMia69s7lQHg328Oe2mngdp6Qi32DaIKkkVtJb8FuMleWMgI+zmbwW0znhYw==", + "license": "MIT", "dependencies": { "axios": "^0.26.1", "form-data": "^3.0.1", diff --git a/package.json b/package.json index 166467a96..d70d5cd2e 100644 --- a/package.json +++ b/package.json @@ -34,7 +34,7 @@ "@types/nconf": "^0.10.2", "@types/winston": "^2.4.4", "ajv": "^6.12.6", - "auth0": "^2.40.0", + "auth0": "^2.42.0", "dot-prop": "^5.2.0", "fs-extra": "^10.1.0", "global-agent": "^2.1.12", @@ -52,8 +52,8 @@ "devDependencies": { "@types/expect": "^24.3.0", "@types/mocha": "^9.1.0", - "chai": "^4.3.6", "@typescript-eslint/parser": "^5.18.0", + "chai": "^4.3.6", "chai-as-promised": "^7.1.1", "cross-env": "^3.1.4", "eslint": "^7.28.0", diff --git a/src/context/directory/handlers/index.ts b/src/context/directory/handlers/index.ts index f0fda03fe..707977930 100644 --- a/src/context/directory/handlers/index.ts +++ b/src/context/directory/handlers/index.ts @@ -26,6 +26,7 @@ import branding from './branding'; import logStreams from './logStreams'; import prompts from './prompts'; import customDomains from './customDomains'; +import themes from './themes'; import DirectoryContext from '..'; import { AssetTypes, Asset } from '../../../types'; @@ -66,6 +67,7 @@ const directoryHandlers: { logStreams, prompts, customDomains, + themes, }; export default directoryHandlers; diff --git a/src/context/directory/handlers/themes.ts b/src/context/directory/handlers/themes.ts new file mode 100644 index 000000000..3abdd97db --- /dev/null +++ b/src/context/directory/handlers/themes.ts @@ -0,0 +1,49 @@ +import path from 'path'; +import { ensureDirSync } from 'fs-extra'; +import { getFiles, dumpJSON, loadJSON, existsMustBeDir } from '../../../utils'; +import { DirectoryHandler } from '.'; +import DirectoryContext from '..'; +import { ParsedAsset } from '../../../types'; +import { ThemeRequest } from '../../../tools/auth0/handlers/themes'; +import { constants } from '../../../tools'; + +type ParsedThemes = ParsedAsset<'themes', ThemeRequest[]>; + +function parse(context: DirectoryContext): ParsedThemes { + const baseFolder = path.join(context.filePath, constants.THEMES_DIRECTORY); + if (!existsMustBeDir(baseFolder)) { + return { themes: null }; + } + + const themeDefinitionsFiles = getFiles(baseFolder, ['.json']); + if (!themeDefinitionsFiles.length) { + return { themes: null }; + } + + const themes = themeDefinitionsFiles.map( + (themeDefinitionsFile) => loadJSON(themeDefinitionsFile, context.mappings) as ThemeRequest + ); + + return { themes }; +} + +async function dump(context: DirectoryContext): Promise { + const { themes } = context.assets; + if (!themes) { + return; + } + + const baseFolder = path.join(context.filePath, constants.THEMES_DIRECTORY); + ensureDirSync(baseFolder); + + themes.forEach((themeDefinition, i) => { + dumpJSON(path.join(baseFolder, `theme${i ? i : ''}.json`), themeDefinition); + }); +} + +const themesHandler: DirectoryHandler = { + parse, + dump, +}; + +export default themesHandler; diff --git a/src/context/yaml/handlers/index.ts b/src/context/yaml/handlers/index.ts index 4970f6d2a..3c470bf9f 100644 --- a/src/context/yaml/handlers/index.ts +++ b/src/context/yaml/handlers/index.ts @@ -26,6 +26,7 @@ import branding from './branding'; import logStreams from './logStreams'; import prompts from './prompts'; import customDomains from './customDomains'; +import themes from './themes'; import YAMLContext from '..'; import { AssetTypes } from '../../../types'; @@ -64,6 +65,7 @@ const yamlHandlers: { [key in AssetTypes]: YAMLHandler<{ [key: string]: unknown logStreams, prompts, customDomains, + themes, }; export default yamlHandlers; diff --git a/src/context/yaml/handlers/themes.ts b/src/context/yaml/handlers/themes.ts new file mode 100644 index 000000000..ec1cf9f12 --- /dev/null +++ b/src/context/yaml/handlers/themes.ts @@ -0,0 +1,23 @@ +import { YAMLHandler } from '.'; +import YAMLContext from '..'; +import { ParsedAsset } from '../../../types'; +import { ThemeRequest } from '../../../tools/auth0/handlers/themes'; + +type ParsedThemes = ParsedAsset<'themes', ThemeRequest[]>; + +async function parseAndDump(context: YAMLContext): Promise { + const { themes } = context.assets; + + if (!themes) return { themes: null }; + + return { + themes, + }; +} + +const themesHandler: YAMLHandler = { + parse: parseAndDump, + dump: parseAndDump, +}; + +export default themesHandler; diff --git a/src/tools/auth0/handlers/index.ts b/src/tools/auth0/handlers/index.ts index 0b2d3ddee..e9e0daa3e 100644 --- a/src/tools/auth0/handlers/index.ts +++ b/src/tools/auth0/handlers/index.ts @@ -27,6 +27,7 @@ import * as organizations from './organizations'; import * as attackProtection from './attackProtection'; import * as logStreams from './logStreams'; import * as customDomains from './customDomains'; +import * as themes from './themes'; import { AssetTypes } from '../../../types'; import APIHandler from './default'; @@ -61,6 +62,7 @@ const auth0ApiHandlers: { [key in AssetTypes]: any } = { attackProtection, logStreams, customDomains, + themes, }; export default auth0ApiHandlers as { diff --git a/src/tools/auth0/handlers/themes.ts b/src/tools/auth0/handlers/themes.ts new file mode 100644 index 000000000..e6cb82c49 --- /dev/null +++ b/src/tools/auth0/handlers/themes.ts @@ -0,0 +1,656 @@ +import { cloneDeep } from 'lodash'; +import { Assets } from '../../../types'; +import log from '../../../logger'; +import DefaultHandler from './default'; + +export default class ThemesHandler extends DefaultHandler { + existing: ThemeRequest[] | null; + + constructor(options: DefaultHandler) { + super({ + ...options, + type: 'themes', + }); + } + + async getType(): Promise { + if (!this.existing) { + this.existing = await this.getThemes(); + } + + return this.existing; + } + + async getThemes(): Promise { + let theme; + + try { + theme = await this.client.branding.getDefaultTheme(); + } catch (err) { + // Errors other than 404 (theme doesn't exist) or 400 (no-code not enabled) shouldn't be expected + if (err.statusCode !== 404 && err.statusCode !== 400) { + throw err; + } + } + + if (theme) { + delete theme.themeId; + return [theme]; + } + + return null; + } + + async processChanges(assets: Assets): Promise { + const { themes } = assets; + + if (!themes || themes.length === 0) { + return; + } + + if (themes.length > 1) { + log.warn('Only one theme is supported per tenant'); + } + + let themeId: string = ''; + try { + const theme = await this.client.branding.getDefaultTheme(); + themeId = theme.themeId; + } catch (err) { + // error 404 is expected, the tenant may not have a default theme + if (err.statusCode !== 404) { + throw err; + } + } + + // if theme exists, overwrite it otherwise create it + if (themeId) { + await this.client.branding.updateTheme({ id: themeId }, themes[0]); + } else { + await this.client.branding.createTheme(themes[0]); + } + + this.updated += 1; + this.didUpdate(themes[0]); + } +} + +/** + * Schema + */ +export const schema = { + type: 'array', + items: { + additionalProperties: false, + properties: { + borders: { + additionalProperties: false, + properties: { + button_border_radius: { + description: 'Button border radius', + maximum: 10, + minimum: 1, + type: 'number', + }, + button_border_weight: { + description: 'Button border weight', + maximum: 10, + minimum: 0, + type: 'number', + }, + buttons_style: { + description: 'Buttons style', + enum: ['pill', 'rounded', 'sharp'], + type: 'string', + }, + input_border_radius: { + description: 'Input border radius', + maximum: 10, + minimum: 0, + type: 'number', + }, + input_border_weight: { + description: 'Input border weight', + maximum: 3, + minimum: 0, + type: 'number', + }, + inputs_style: { + description: 'Inputs style', + enum: ['pill', 'rounded', 'sharp'], + type: 'string', + }, + show_widget_shadow: { + description: 'Show widget shadow', + type: 'boolean', + }, + widget_border_weight: { + description: 'Widget border weight', + maximum: 10, + minimum: 0, + type: 'number', + }, + widget_corner_radius: { + description: 'Widget corner radius', + maximum: 50, + minimum: 0, + type: 'number', + }, + }, + required: [ + 'button_border_radius', + 'button_border_weight', + 'buttons_style', + 'input_border_radius', + 'input_border_weight', + 'inputs_style', + 'show_widget_shadow', + 'widget_border_weight', + 'widget_corner_radius', + ], + type: 'object', + }, + colors: { + additionalProperties: false, + properties: { + base_focus_color: { + description: 'Base Focus Color', + pattern: '^#(([0-9a-fA-F]{3}){1,2}|([0-9a-fA-F]{4}){1,2})$', + type: 'string', + }, + base_hover_color: { + description: 'Base Hover Color', + pattern: '^#(([0-9a-fA-F]{3}){1,2}|([0-9a-fA-F]{4}){1,2})$', + type: 'string', + }, + body_text: { + description: 'Body text', + pattern: '^#(([0-9a-fA-F]{3}){1,2}|([0-9a-fA-F]{4}){1,2})$', + type: 'string', + }, + error: { + description: 'Error', + pattern: '^#(([0-9a-fA-F]{3}){1,2}|([0-9a-fA-F]{4}){1,2})$', + type: 'string', + }, + header: { + description: 'Header', + pattern: '^#(([0-9a-fA-F]{3}){1,2}|([0-9a-fA-F]{4}){1,2})$', + type: 'string', + }, + icons: { + description: 'Icons', + pattern: '^#(([0-9a-fA-F]{3}){1,2}|([0-9a-fA-F]{4}){1,2})$', + type: 'string', + }, + input_background: { + description: 'Input background', + pattern: '^#(([0-9a-fA-F]{3}){1,2}|([0-9a-fA-F]{4}){1,2})$', + type: 'string', + }, + input_border: { + description: 'Input border', + pattern: '^#(([0-9a-fA-F]{3}){1,2}|([0-9a-fA-F]{4}){1,2})$', + type: 'string', + }, + input_filled_text: { + description: 'Input filled text', + pattern: '^#(([0-9a-fA-F]{3}){1,2}|([0-9a-fA-F]{4}){1,2})$', + type: 'string', + }, + input_labels_placeholders: { + description: 'Input labels & placeholders', + pattern: '^#(([0-9a-fA-F]{3}){1,2}|([0-9a-fA-F]{4}){1,2})$', + type: 'string', + }, + links_focused_components: { + description: 'Links & focused components', + pattern: '^#(([0-9a-fA-F]{3}){1,2}|([0-9a-fA-F]{4}){1,2})$', + type: 'string', + }, + primary_button: { + description: 'Primary button', + pattern: '^#(([0-9a-fA-F]{3}){1,2}|([0-9a-fA-F]{4}){1,2})$', + type: 'string', + }, + primary_button_label: { + description: 'Primary button label', + pattern: '^#(([0-9a-fA-F]{3}){1,2}|([0-9a-fA-F]{4}){1,2})$', + type: 'string', + }, + secondary_button_border: { + description: 'Secondary button border', + pattern: '^#(([0-9a-fA-F]{3}){1,2}|([0-9a-fA-F]{4}){1,2})$', + type: 'string', + }, + secondary_button_label: { + description: 'Secondary button label', + pattern: '^#(([0-9a-fA-F]{3}){1,2}|([0-9a-fA-F]{4}){1,2})$', + type: 'string', + }, + success: { + description: 'Success', + pattern: '^#(([0-9a-fA-F]{3}){1,2}|([0-9a-fA-F]{4}){1,2})$', + type: 'string', + }, + widget_background: { + description: 'Widget background', + pattern: '^#(([0-9a-fA-F]{3}){1,2}|([0-9a-fA-F]{4}){1,2})$', + type: 'string', + }, + widget_border: { + description: 'Widget border', + pattern: '^#(([0-9a-fA-F]{3}){1,2}|([0-9a-fA-F]{4}){1,2})$', + type: 'string', + }, + }, + required: [ + 'body_text', + 'error', + 'header', + 'icons', + 'input_background', + 'input_border', + 'input_filled_text', + 'input_labels_placeholders', + 'links_focused_components', + 'primary_button', + 'primary_button_label', + 'secondary_button_border', + 'secondary_button_label', + 'success', + 'widget_background', + 'widget_border', + ], + type: 'object', + }, + displayName: { + description: 'Display Name', + maxLength: 2048, + pattern: '^[^<>]*$', + type: 'string', + }, + fonts: { + additionalProperties: false, + properties: { + body_text: { + additionalProperties: false, + description: 'Body text', + properties: { + bold: { + description: 'Body text bold', + type: 'boolean', + }, + size: { + description: 'Body text size', + maximum: 150, + minimum: 0, + type: 'number', + }, + }, + required: ['bold', 'size'], + type: 'object', + }, + buttons_text: { + additionalProperties: false, + description: 'Buttons text', + properties: { + bold: { + description: 'Buttons text bold', + type: 'boolean', + }, + size: { + description: 'Buttons text size', + maximum: 150, + minimum: 0, + type: 'number', + }, + }, + required: ['bold', 'size'], + type: 'object', + }, + font_url: { + description: 'Font URL', + pattern: + "^$|^(?=.)(?!https?:\\/(?:$|[^/]))(?!https?:\\/\\/\\/)(?!https?:[^/])(?:(?:https):(?:(?:\\/\\/(?:[\\w-\\.~%\\dA-Fa-f!\\$&'\\(\\)\\*\\+,;=:]*@)?(?:\\[(?:(?:(?:[\\dA-Fa-f]{1,4}:){6}(?:[\\dA-Fa-f]{1,4}:[\\dA-Fa-f]{1,4}|(?:(?:0{0,2}\\d|0?[1-9]\\d|1\\d\\d|2[0-4]\\d|25[0-5])\\.){3}(?:0{0,2}\\d|0?[1-9]\\d|1\\d\\d|2[0-4]\\d|25[0-5]))|::(?:[\\dA-Fa-f]{1,4}:){5}(?:[\\dA-Fa-f]{1,4}:[\\dA-Fa-f]{1,4}|(?:(?:0{0,2}\\d|0?[1-9]\\d|1\\d\\d|2[0-4]\\d|25[0-5])\\.){3}(?:0{0,2}\\d|0?[1-9]\\d|1\\d\\d|2[0-4]\\d|25[0-5]))|(?:[\\dA-Fa-f]{1,4})?::(?:[\\dA-Fa-f]{1,4}:){4}(?:[\\dA-Fa-f]{1,4}:[\\dA-Fa-f]{1,4}|(?:(?:0{0,2}\\d|0?[1-9]\\d|1\\d\\d|2[0-4]\\d|25[0-5])\\.){3}(?:0{0,2}\\d|0?[1-9]\\d|1\\d\\d|2[0-4]\\d|25[0-5]))|(?:(?:[\\dA-Fa-f]{1,4}:){0,1}[\\dA-Fa-f]{1,4})?::(?:[\\dA-Fa-f]{1,4}:){3}(?:[\\dA-Fa-f]{1,4}:[\\dA-Fa-f]{1,4}|(?:(?:0{0,2}\\d|0?[1-9]\\d|1\\d\\d|2[0-4]\\d|25[0-5])\\.){3}(?:0{0,2}\\d|0?[1-9]\\d|1\\d\\d|2[0-4]\\d|25[0-5]))|(?:(?:[\\dA-Fa-f]{1,4}:){0,2}[\\dA-Fa-f]{1,4})?::(?:[\\dA-Fa-f]{1,4}:){2}(?:[\\dA-Fa-f]{1,4}:[\\dA-Fa-f]{1,4}|(?:(?:0{0,2}\\d|0?[1-9]\\d|1\\d\\d|2[0-4]\\d|25[0-5])\\.){3}(?:0{0,2}\\d|0?[1-9]\\d|1\\d\\d|2[0-4]\\d|25[0-5]))|(?:(?:[\\dA-Fa-f]{1,4}:){0,3}[\\dA-Fa-f]{1,4})?::[\\dA-Fa-f]{1,4}:(?:[\\dA-Fa-f]{1,4}:[\\dA-Fa-f]{1,4}|(?:(?:0{0,2}\\d|0?[1-9]\\d|1\\d\\d|2[0-4]\\d|25[0-5])\\.){3}(?:0{0,2}\\d|0?[1-9]\\d|1\\d\\d|2[0-4]\\d|25[0-5]))|(?:(?:[\\dA-Fa-f]{1,4}:){0,4}[\\dA-Fa-f]{1,4})?::(?:[\\dA-Fa-f]{1,4}:[\\dA-Fa-f]{1,4}|(?:(?:0{0,2}\\d|0?[1-9]\\d|1\\d\\d|2[0-4]\\d|25[0-5])\\.){3}(?:0{0,2}\\d|0?[1-9]\\d|1\\d\\d|2[0-4]\\d|25[0-5]))|(?:(?:[\\dA-Fa-f]{1,4}:){0,5}[\\dA-Fa-f]{1,4})?::[\\dA-Fa-f]{1,4}|(?:(?:[\\dA-Fa-f]{1,4}:){0,6}[\\dA-Fa-f]{1,4})?::)|v[\\dA-Fa-f]+\\.[\\w-\\.~!\\$&'\\(\\)\\*\\+,;=:]+)\\]|(?:(?:0{0,2}\\d|0?[1-9]\\d|1\\d\\d|2[0-4]\\d|25[0-5])\\.){3}(?:0{0,2}\\d|0?[1-9]\\d|1\\d\\d|2[0-4]\\d|25[0-5])|[\\w-\\.~%\\dA-Fa-f!\\$&'\\(\\)\\*\\+,;=]{1,255})(?::\\d*)?(?:\\/[\\w-\\.~%\\dA-Fa-f!\\$&'\\(\\)\\*\\+,;=:@]*)*)|\\/(?:[\\w-\\.~%\\dA-Fa-f!\\$&'\\(\\)\\*\\+,;=:@]+(?:\\/[\\w-\\.~%\\dA-Fa-f!\\$&'\\(\\)\\*\\+,;=:@]*)*)?|[\\w-\\.~%\\dA-Fa-f!\\$&'\\(\\)\\*\\+,;=:@]+(?:\\/[\\w-\\.~%\\dA-Fa-f!\\$&'\\(\\)\\*\\+,;=:@]*)*|(?:\\/\\/\\/[\\w-\\.~%\\dA-Fa-f!\\$&'\\(\\)\\*\\+,;=:@]*(?:\\/[\\w-\\.~%\\dA-Fa-f!\\$&'\\(\\)\\*\\+,;=:@]*)*)))(?:\\?[\\w-\\.~%\\dA-Fa-f!\\$&'\\(\\)\\*\\+,;=:@\\/\\?]*(?=#|$))?(?:#[\\w-\\.~%\\dA-Fa-f!\\$&'\\(\\)\\*\\+,;=:@\\/\\?]*)?$", + type: 'string', + }, + input_labels: { + additionalProperties: false, + description: 'Input Labels', + properties: { + bold: { + description: 'Input Labels bold', + type: 'boolean', + }, + size: { + description: 'Input Labels size', + maximum: 150, + minimum: 0, + type: 'number', + }, + }, + required: ['bold', 'size'], + type: 'object', + }, + links: { + additionalProperties: false, + description: 'Links', + properties: { + bold: { + description: 'Links bold', + type: 'boolean', + }, + size: { + description: 'Links size', + maximum: 150, + minimum: 0, + type: 'number', + }, + }, + required: ['bold', 'size'], + type: 'object', + }, + links_style: { + description: 'Links style', + enum: ['normal', 'underlined'], + type: 'string', + }, + reference_text_size: { + description: 'Reference text size', + maximum: 24, + minimum: 12, + type: 'number', + }, + subtitle: { + additionalProperties: false, + description: 'Subtitle', + properties: { + bold: { + description: 'Subtitle bold', + type: 'boolean', + }, + size: { + description: 'Subtitle size', + maximum: 150, + minimum: 0, + type: 'number', + }, + }, + required: ['bold', 'size'], + type: 'object', + }, + title: { + additionalProperties: false, + description: 'Title', + properties: { + bold: { + description: 'Title bold', + type: 'boolean', + }, + size: { + description: 'Title size', + maximum: 150, + minimum: 75, + type: 'number', + }, + }, + required: ['bold', 'size'], + type: 'object', + }, + }, + required: [ + 'body_text', + 'buttons_text', + 'font_url', + 'input_labels', + 'links', + 'links_style', + 'reference_text_size', + 'subtitle', + 'title', + ], + type: 'object', + }, + page_background: { + additionalProperties: false, + properties: { + background_color: { + description: 'Background color', + pattern: '^#(([0-9a-fA-F]{3}){1,2}|([0-9a-fA-F]{4}){1,2})$', + type: 'string', + }, + background_image_url: { + description: 'Background image url', + pattern: + "^$|^(?=.)(?!https?:\\/(?:$|[^/]))(?!https?:\\/\\/\\/)(?!https?:[^/])(?:(?:https):(?:(?:\\/\\/(?:[\\w-\\.~%\\dA-Fa-f!\\$&'\\(\\)\\*\\+,;=:]*@)?(?:\\[(?:(?:(?:[\\dA-Fa-f]{1,4}:){6}(?:[\\dA-Fa-f]{1,4}:[\\dA-Fa-f]{1,4}|(?:(?:0{0,2}\\d|0?[1-9]\\d|1\\d\\d|2[0-4]\\d|25[0-5])\\.){3}(?:0{0,2}\\d|0?[1-9]\\d|1\\d\\d|2[0-4]\\d|25[0-5]))|::(?:[\\dA-Fa-f]{1,4}:){5}(?:[\\dA-Fa-f]{1,4}:[\\dA-Fa-f]{1,4}|(?:(?:0{0,2}\\d|0?[1-9]\\d|1\\d\\d|2[0-4]\\d|25[0-5])\\.){3}(?:0{0,2}\\d|0?[1-9]\\d|1\\d\\d|2[0-4]\\d|25[0-5]))|(?:[\\dA-Fa-f]{1,4})?::(?:[\\dA-Fa-f]{1,4}:){4}(?:[\\dA-Fa-f]{1,4}:[\\dA-Fa-f]{1,4}|(?:(?:0{0,2}\\d|0?[1-9]\\d|1\\d\\d|2[0-4]\\d|25[0-5])\\.){3}(?:0{0,2}\\d|0?[1-9]\\d|1\\d\\d|2[0-4]\\d|25[0-5]))|(?:(?:[\\dA-Fa-f]{1,4}:){0,1}[\\dA-Fa-f]{1,4})?::(?:[\\dA-Fa-f]{1,4}:){3}(?:[\\dA-Fa-f]{1,4}:[\\dA-Fa-f]{1,4}|(?:(?:0{0,2}\\d|0?[1-9]\\d|1\\d\\d|2[0-4]\\d|25[0-5])\\.){3}(?:0{0,2}\\d|0?[1-9]\\d|1\\d\\d|2[0-4]\\d|25[0-5]))|(?:(?:[\\dA-Fa-f]{1,4}:){0,2}[\\dA-Fa-f]{1,4})?::(?:[\\dA-Fa-f]{1,4}:){2}(?:[\\dA-Fa-f]{1,4}:[\\dA-Fa-f]{1,4}|(?:(?:0{0,2}\\d|0?[1-9]\\d|1\\d\\d|2[0-4]\\d|25[0-5])\\.){3}(?:0{0,2}\\d|0?[1-9]\\d|1\\d\\d|2[0-4]\\d|25[0-5]))|(?:(?:[\\dA-Fa-f]{1,4}:){0,3}[\\dA-Fa-f]{1,4})?::[\\dA-Fa-f]{1,4}:(?:[\\dA-Fa-f]{1,4}:[\\dA-Fa-f]{1,4}|(?:(?:0{0,2}\\d|0?[1-9]\\d|1\\d\\d|2[0-4]\\d|25[0-5])\\.){3}(?:0{0,2}\\d|0?[1-9]\\d|1\\d\\d|2[0-4]\\d|25[0-5]))|(?:(?:[\\dA-Fa-f]{1,4}:){0,4}[\\dA-Fa-f]{1,4})?::(?:[\\dA-Fa-f]{1,4}:[\\dA-Fa-f]{1,4}|(?:(?:0{0,2}\\d|0?[1-9]\\d|1\\d\\d|2[0-4]\\d|25[0-5])\\.){3}(?:0{0,2}\\d|0?[1-9]\\d|1\\d\\d|2[0-4]\\d|25[0-5]))|(?:(?:[\\dA-Fa-f]{1,4}:){0,5}[\\dA-Fa-f]{1,4})?::[\\dA-Fa-f]{1,4}|(?:(?:[\\dA-Fa-f]{1,4}:){0,6}[\\dA-Fa-f]{1,4})?::)|v[\\dA-Fa-f]+\\.[\\w-\\.~!\\$&'\\(\\)\\*\\+,;=:]+)\\]|(?:(?:0{0,2}\\d|0?[1-9]\\d|1\\d\\d|2[0-4]\\d|25[0-5])\\.){3}(?:0{0,2}\\d|0?[1-9]\\d|1\\d\\d|2[0-4]\\d|25[0-5])|[\\w-\\.~%\\dA-Fa-f!\\$&'\\(\\)\\*\\+,;=]{1,255})(?::\\d*)?(?:\\/[\\w-\\.~%\\dA-Fa-f!\\$&'\\(\\)\\*\\+,;=:@]*)*)|\\/(?:[\\w-\\.~%\\dA-Fa-f!\\$&'\\(\\)\\*\\+,;=:@]+(?:\\/[\\w-\\.~%\\dA-Fa-f!\\$&'\\(\\)\\*\\+,;=:@]*)*)?|[\\w-\\.~%\\dA-Fa-f!\\$&'\\(\\)\\*\\+,;=:@]+(?:\\/[\\w-\\.~%\\dA-Fa-f!\\$&'\\(\\)\\*\\+,;=:@]*)*|(?:\\/\\/\\/[\\w-\\.~%\\dA-Fa-f!\\$&'\\(\\)\\*\\+,;=:@]*(?:\\/[\\w-\\.~%\\dA-Fa-f!\\$&'\\(\\)\\*\\+,;=:@]*)*)))(?:\\?[\\w-\\.~%\\dA-Fa-f!\\$&'\\(\\)\\*\\+,;=:@\\/\\?]*(?=#|$))?(?:#[\\w-\\.~%\\dA-Fa-f!\\$&'\\(\\)\\*\\+,;=:@\\/\\?]*)?$", + type: 'string', + }, + page_layout: { + description: 'Page Layout', + enum: ['center', 'left', 'right'], + type: 'string', + }, + }, + required: ['background_color', 'background_image_url', 'page_layout'], + type: 'object', + }, + widget: { + additionalProperties: false, + properties: { + header_text_alignment: { + description: 'Header text alignment', + enum: ['center', 'left', 'right'], + type: 'string', + }, + logo_height: { + description: 'Logo height', + maximum: 100, + minimum: 1, + type: 'number', + }, + logo_position: { + description: 'Logo position', + enum: ['center', 'left', 'none', 'right'], + type: 'string', + }, + logo_url: { + description: 'Logo url', + pattern: + "^$|^(?=.)(?!https?:\\/(?:$|[^/]))(?!https?:\\/\\/\\/)(?!https?:[^/])(?:(?:https):(?:(?:\\/\\/(?:[\\w-\\.~%\\dA-Fa-f!\\$&'\\(\\)\\*\\+,;=:]*@)?(?:\\[(?:(?:(?:[\\dA-Fa-f]{1,4}:){6}(?:[\\dA-Fa-f]{1,4}:[\\dA-Fa-f]{1,4}|(?:(?:0{0,2}\\d|0?[1-9]\\d|1\\d\\d|2[0-4]\\d|25[0-5])\\.){3}(?:0{0,2}\\d|0?[1-9]\\d|1\\d\\d|2[0-4]\\d|25[0-5]))|::(?:[\\dA-Fa-f]{1,4}:){5}(?:[\\dA-Fa-f]{1,4}:[\\dA-Fa-f]{1,4}|(?:(?:0{0,2}\\d|0?[1-9]\\d|1\\d\\d|2[0-4]\\d|25[0-5])\\.){3}(?:0{0,2}\\d|0?[1-9]\\d|1\\d\\d|2[0-4]\\d|25[0-5]))|(?:[\\dA-Fa-f]{1,4})?::(?:[\\dA-Fa-f]{1,4}:){4}(?:[\\dA-Fa-f]{1,4}:[\\dA-Fa-f]{1,4}|(?:(?:0{0,2}\\d|0?[1-9]\\d|1\\d\\d|2[0-4]\\d|25[0-5])\\.){3}(?:0{0,2}\\d|0?[1-9]\\d|1\\d\\d|2[0-4]\\d|25[0-5]))|(?:(?:[\\dA-Fa-f]{1,4}:){0,1}[\\dA-Fa-f]{1,4})?::(?:[\\dA-Fa-f]{1,4}:){3}(?:[\\dA-Fa-f]{1,4}:[\\dA-Fa-f]{1,4}|(?:(?:0{0,2}\\d|0?[1-9]\\d|1\\d\\d|2[0-4]\\d|25[0-5])\\.){3}(?:0{0,2}\\d|0?[1-9]\\d|1\\d\\d|2[0-4]\\d|25[0-5]))|(?:(?:[\\dA-Fa-f]{1,4}:){0,2}[\\dA-Fa-f]{1,4})?::(?:[\\dA-Fa-f]{1,4}:){2}(?:[\\dA-Fa-f]{1,4}:[\\dA-Fa-f]{1,4}|(?:(?:0{0,2}\\d|0?[1-9]\\d|1\\d\\d|2[0-4]\\d|25[0-5])\\.){3}(?:0{0,2}\\d|0?[1-9]\\d|1\\d\\d|2[0-4]\\d|25[0-5]))|(?:(?:[\\dA-Fa-f]{1,4}:){0,3}[\\dA-Fa-f]{1,4})?::[\\dA-Fa-f]{1,4}:(?:[\\dA-Fa-f]{1,4}:[\\dA-Fa-f]{1,4}|(?:(?:0{0,2}\\d|0?[1-9]\\d|1\\d\\d|2[0-4]\\d|25[0-5])\\.){3}(?:0{0,2}\\d|0?[1-9]\\d|1\\d\\d|2[0-4]\\d|25[0-5]))|(?:(?:[\\dA-Fa-f]{1,4}:){0,4}[\\dA-Fa-f]{1,4})?::(?:[\\dA-Fa-f]{1,4}:[\\dA-Fa-f]{1,4}|(?:(?:0{0,2}\\d|0?[1-9]\\d|1\\d\\d|2[0-4]\\d|25[0-5])\\.){3}(?:0{0,2}\\d|0?[1-9]\\d|1\\d\\d|2[0-4]\\d|25[0-5]))|(?:(?:[\\dA-Fa-f]{1,4}:){0,5}[\\dA-Fa-f]{1,4})?::[\\dA-Fa-f]{1,4}|(?:(?:[\\dA-Fa-f]{1,4}:){0,6}[\\dA-Fa-f]{1,4})?::)|v[\\dA-Fa-f]+\\.[\\w-\\.~!\\$&'\\(\\)\\*\\+,;=:]+)\\]|(?:(?:0{0,2}\\d|0?[1-9]\\d|1\\d\\d|2[0-4]\\d|25[0-5])\\.){3}(?:0{0,2}\\d|0?[1-9]\\d|1\\d\\d|2[0-4]\\d|25[0-5])|[\\w-\\.~%\\dA-Fa-f!\\$&'\\(\\)\\*\\+,;=]{1,255})(?::\\d*)?(?:\\/[\\w-\\.~%\\dA-Fa-f!\\$&'\\(\\)\\*\\+,;=:@]*)*)|\\/(?:[\\w-\\.~%\\dA-Fa-f!\\$&'\\(\\)\\*\\+,;=:@]+(?:\\/[\\w-\\.~%\\dA-Fa-f!\\$&'\\(\\)\\*\\+,;=:@]*)*)?|[\\w-\\.~%\\dA-Fa-f!\\$&'\\(\\)\\*\\+,;=:@]+(?:\\/[\\w-\\.~%\\dA-Fa-f!\\$&'\\(\\)\\*\\+,;=:@]*)*|(?:\\/\\/\\/[\\w-\\.~%\\dA-Fa-f!\\$&'\\(\\)\\*\\+,;=:@]*(?:\\/[\\w-\\.~%\\dA-Fa-f!\\$&'\\(\\)\\*\\+,;=:@]*)*)))(?:\\?[\\w-\\.~%\\dA-Fa-f!\\$&'\\(\\)\\*\\+,;=:@\\/\\?]*(?=#|$))?(?:#[\\w-\\.~%\\dA-Fa-f!\\$&'\\(\\)\\*\\+,;=:@\\/\\?]*)?$", + type: 'string', + }, + social_buttons_layout: { + description: 'Social buttons layout', + enum: ['bottom', 'top'], + type: 'string', + }, + }, + required: [ + 'header_text_alignment', + 'logo_height', + 'logo_position', + 'logo_url', + 'social_buttons_layout', + ], + type: 'object', + }, + }, + required: ['borders', 'colors', 'fonts', 'page_background', 'widget'], + type: 'object', + }, +}; + +/** + * Utility types + */ + +export interface Colors { + primary_button: string; + primary_button_label: string; + secondary_button_border: string; + secondary_button_label: string; + links_focused_components: string; + header: string; + body_text: string; + widget_background: string; + widget_border: string; + input_labels_placeholders: string; + input_filled_text: string; + input_border: string; + input_background: string; + icons: string; + error: string; + success: string; + base_focus_color?: string; + base_hover_color?: string; +} + +export interface Fonts { + font_url: string; + reference_text_size: number; + title: { + size: number; + bold: boolean; + }; + subtitle: { + size: number; + bold: boolean; + }; + body_text: { + size: number; + bold: boolean; + }; + buttons_text: { + size: number; + bold: boolean; + }; + input_labels: { + size: number; + bold: boolean; + }; + links: { + size: number; + bold: boolean; + }; + links_style: 'normal' | 'underlined'; +} + +export interface Borders { + button_border_weight: number; + buttons_style: 'sharp' | 'pill' | 'rounded'; + button_border_radius: number; + input_border_weight: number; + inputs_style: 'sharp' | 'pill' | 'rounded'; + input_border_radius: number; + widget_corner_radius: number; + widget_border_weight: number; + show_widget_shadow: boolean; +} + +export interface Widget { + logo_position: 'left' | 'center' | 'right' | 'none'; + logo_url: string; + logo_height: number; + header_text_alignment: 'left' | 'center' | 'right'; + social_buttons_layout: 'top' | 'bottom'; +} + +export interface PageBackground { + page_layout: 'left' | 'center' | 'right'; + background_color: string; + background_image_url: string; +} + +export interface Theme { + colors: Colors; + fonts: Fonts; + borders: Borders; + widget: Widget; + page_background: PageBackground; +} + +export interface ThemeRequest extends Theme { + displayName?: string; +} + +export interface ThemeResponse extends Theme { + themeId: string; + displayName: string; +} + +export const validTheme = (): ThemeRequest => { + return cloneDeep({ + borders: { + button_border_radius: 1, + button_border_weight: 1, + buttons_style: 'pill', + input_border_radius: 3, + input_border_weight: 1, + inputs_style: 'pill', + show_widget_shadow: false, + widget_border_weight: 1, + widget_corner_radius: 3, + }, + colors: { + body_text: '#FF00CC', + error: '#FF00CC', + header: '#FF00CC', + icons: '#FF00CC', + input_background: '#FF00CC', + input_border: '#FF00CC', + input_filled_text: '#FF00CC', + input_labels_placeholders: '#FF00CC', + links_focused_components: '#FF00CC', + primary_button: '#FF00CC', + primary_button_label: '#FF00CC', + secondary_button_border: '#FF00CC', + secondary_button_label: '#FF00CC', + success: '#FF00CC', + widget_background: '#FF00CC', + widget_border: '#FF00CC', + }, + fonts: { + body_text: { + bold: false, + size: 100, + }, + buttons_text: { + bold: false, + size: 100, + }, + font_url: 'https://google.com/font.woff', + input_labels: { + bold: false, + size: 100, + }, + links: { + bold: false, + size: 100, + }, + links_style: 'normal', + reference_text_size: 12, + subtitle: { + bold: false, + size: 100, + }, + title: { + bold: false, + size: 100, + }, + }, + page_background: { + background_color: '#000000', + background_image_url: 'https://google.com/background.png', + page_layout: 'center', + }, + widget: { + header_text_alignment: 'center', + logo_height: 55, + logo_position: 'center', + logo_url: 'https://google.com/logo.png', + social_buttons_layout: 'top', + }, + displayName: 'Default theme', + }); +}; diff --git a/src/tools/constants.ts b/src/tools/constants.ts index 4b42de6d2..c0d2e0a3b 100644 --- a/src/tools/constants.ts +++ b/src/tools/constants.ts @@ -169,6 +169,7 @@ const constants = { LOG_STREAMS_DIRECTORY: 'log-streams', PROMPTS_DIRECTORY: 'prompts', CUSTOM_DOMAINS_DIRECTORY: 'custom-domains', + THEMES_DIRECTORY: 'themes', }; export default constants; diff --git a/src/types.ts b/src/types.ts index 3d8bdf250..642e9f72c 100644 --- a/src/types.ts +++ b/src/types.ts @@ -6,6 +6,8 @@ import { PromptSettings, } from './tools/auth0/handlers/prompts'; +import { ThemeResponse, ThemeRequest } from './tools/auth0/handlers/themes'; + type SharedPaginationParams = { checkpoint?: boolean; paginate?: boolean; @@ -62,6 +64,10 @@ export type BaseAuth0APIClient = { getUniversalLoginTemplate: () => Promise; updateSettings: ({}, Asset) => Promise; setUniversalLoginTemplate: ({}, Asset) => Promise; + getDefaultTheme: () => Promise; + updateTheme: (arg0: { id: string }, ThemeRequest) => Promise; + deleteTheme: (arg0: { id: string }) => Promise; + createTheme: (ThemeRequest) => Promise; }; clients: APIClientBaseFunctions; clientGrants: APIClientBaseFunctions; @@ -231,6 +237,7 @@ export type Assets = Partial<{ [key: string]: string[]; }; clientsOrig: Asset[] | null; + themes: ThemeRequest[] | null; }>; export type CalculatedChanges = { @@ -268,7 +275,8 @@ export type AssetTypes = | 'branding' | 'logStreams' | 'prompts' - | 'customDomains'; + | 'customDomains' + | 'themes'; export type KeywordMappings = { [key: string]: (string | number)[] | string | number }; diff --git a/test/context/directory/themes.test.js b/test/context/directory/themes.test.js new file mode 100644 index 000000000..536bb0102 --- /dev/null +++ b/test/context/directory/themes.test.js @@ -0,0 +1,88 @@ +import path from 'path'; +import { expect } from 'chai'; + +import Context from '../../../src/context/directory'; +import { testDataDir, createDir, mockMgmtClient, cleanThenMkdir } from '../../utils'; +import { constants } from '../../../src/tools'; +import { loadJSON } from '../../../src/utils'; +import { validTheme } from '../../../src/tools/auth0/handlers/themes'; +import handler from '../../../src/context/directory/handlers/themes'; + +describe('#directory context themes', () => { + it('should process themes', async () => { + const theme = validTheme(); + + const dir = path.join(testDataDir, 'directory', 'themesProcess'); + createDir(dir, { + [constants.THEMES_DIRECTORY]: { 'theme.json': JSON.stringify(theme) }, + }); + + const config = { AUTH0_INPUT_FILE: dir, AUTH0_KEYWORD_REPLACE_MAPPINGS: { foo: 'bar' } }; + const context = new Context(config, mockMgmtClient()); + await context.load(); + + expect(context.assets.themes).to.be.an('Array'); + expect(context.assets.themes).to.deep.equal([theme]); + }); + + it('should process multiple themes', async () => { + const theme1 = validTheme(); + theme1.displayName = 'Theme 1'; + const theme2 = validTheme(); + theme2.displayName = 'Theme 2'; + + const dir = path.join(testDataDir, 'directory', 'themesProcess'); + createDir(dir, { + [constants.THEMES_DIRECTORY]: { + 'theme1.json': JSON.stringify(theme1), + 'theme2.json': JSON.stringify(theme2), + }, + }); + + const config = { AUTH0_INPUT_FILE: dir, AUTH0_KEYWORD_REPLACE_MAPPINGS: { foo: 'bar' } }; + const context = new Context(config, mockMgmtClient()); + await context.load(); + + expect(context.assets.themes).to.be.an('Array'); + expect(context.assets.themes).to.deep.equal([theme1, theme2]); + }); + + it('should dump themes', async () => { + const theme = validTheme(); + + const dir = path.join(testDataDir, 'directory', 'themesDump'); + cleanThenMkdir(dir); + + const config = { AUTH0_INPUT_FILE: dir, AUTH0_KEYWORD_REPLACE_MAPPINGS: { foo: 'bar' } }; + const context = new Context(config, mockMgmtClient()); + + context.assets.themes = [theme]; + + await handler.dump(context); + const dumped = loadJSON(path.join(dir, constants.THEMES_DIRECTORY, 'theme.json')); + + expect(dumped).to.deep.equal(theme); + }); + + it('should dump multiple themes', async () => { + const themeJson = validTheme(); + themeJson.displayName = 'Theme'; + const theme1Json = validTheme(); + theme1Json.displayName = 'Theme 1'; + + const dir = path.join(testDataDir, 'directory', 'themesDump'); + cleanThenMkdir(dir); + + const config = { AUTH0_INPUT_FILE: dir, AUTH0_KEYWORD_REPLACE_MAPPINGS: { foo: 'bar' } }; + const context = new Context(config, mockMgmtClient()); + + context.assets.themes = [themeJson, theme1Json]; + + await handler.dump(context); + const dumpedThemeJson = loadJSON(path.join(dir, constants.THEMES_DIRECTORY, 'theme.json')); + const dumpedTheme1Json = loadJSON(path.join(dir, constants.THEMES_DIRECTORY, 'theme1.json')); + + expect(dumpedThemeJson).to.deep.equal(themeJson); + expect(dumpedTheme1Json).to.deep.equal(theme1Json); + }); +}); diff --git a/test/context/yaml/themes.test.js b/test/context/yaml/themes.test.js new file mode 100644 index 000000000..cbf70c6cf --- /dev/null +++ b/test/context/yaml/themes.test.js @@ -0,0 +1,88 @@ +import { dump } from 'js-yaml'; +import fs from 'fs-extra'; +import path from 'path'; +import { expect } from 'chai'; +import { cleanThenMkdir, testDataDir, mockMgmtClient } from '../../utils'; + +import Context from '../../../src/context/yaml'; +import handler from '../../../src/context/yaml/handlers/themes'; +import { validTheme } from '../../../src/tools/auth0/handlers/themes'; + +describe('#YAML context themes', () => { + it('should process themes', async () => { + const theme = validTheme(); + const dir = path.join(testDataDir, 'yaml', 'themes'); + cleanThenMkdir(dir); + + const yaml = dump({ + themes: [theme], + }); + + const yamlFile = path.join(dir, 'config.yaml'); + fs.writeFileSync(yamlFile, yaml); + + const config = { AUTH0_INPUT_FILE: yamlFile, AUTH0_KEYWORD_REPLACE_MAPPINGS: { foo: 'bar' } }; + const context = new Context(config, mockMgmtClient()); + await context.load(); + + expect(context.assets.themes).to.deep.equal([theme]); + }); + + it('should process multiple themes', async () => { + const theme1 = validTheme(); + theme1.displayName = 'Theme 1'; + const theme2 = validTheme(); + theme2.displayName = 'Theme 2'; + + const dir = path.join(testDataDir, 'yaml', 'themes'); + cleanThenMkdir(dir); + + const yaml = dump({ + themes: [theme1, theme2], + }); + + const yamlFile = path.join(dir, 'config.yaml'); + fs.writeFileSync(yamlFile, yaml); + + const config = { AUTH0_INPUT_FILE: yamlFile, AUTH0_KEYWORD_REPLACE_MAPPINGS: { foo: 'bar' } }; + const context = new Context(config, mockMgmtClient()); + await context.load(); + + expect(context.assets.themes).to.deep.equal([theme1, theme2]); + }); + + it('should dump themes', async () => { + const theme = validTheme(); + + const dir = path.join(testDataDir, 'directory', 'themesDump'); + cleanThenMkdir(dir); + + const config = { AUTH0_INPUT_FILE: dir, AUTH0_KEYWORD_REPLACE_MAPPINGS: { foo: 'bar' } }; + const context = new Context(config, mockMgmtClient()); + + context.assets.themes = [theme]; + + const dumped = await handler.dump(context); + + expect(dumped).to.deep.equal({ themes: [theme] }); + }); + + it('should dump multiple themes', async () => { + const theme1 = validTheme(); + theme1.displayName = 'Theme 1'; + const theme2 = validTheme(); + theme2.displayName = 'Theme 2'; + + const dir = path.join(testDataDir, 'directory', 'themesDump'); + cleanThenMkdir(dir); + + const config = { AUTH0_INPUT_FILE: dir, AUTH0_KEYWORD_REPLACE_MAPPINGS: { foo: 'bar' } }; + const context = new Context(config, mockMgmtClient()); + + context.assets.themes = [theme1, theme2]; + + const dumped = await handler.dump(context); + + expect(dumped).to.deep.equal({ themes: [theme1, theme2] }); + }); +}); diff --git a/test/tools/auth0/handlers/themes.tests.js b/test/tools/auth0/handlers/themes.tests.js new file mode 100644 index 000000000..0cdcefd01 --- /dev/null +++ b/test/tools/auth0/handlers/themes.tests.js @@ -0,0 +1,172 @@ +const { expect, assert } = require('chai'); +const { omit } = require('lodash'); +const { + default: ThemesHandler, + validTheme, +} = require('../../../../src/tools/auth0/handlers/themes'); + +function stub() { + const s = function (...args) { + s.callCount += 1; + s.calls.push(args); + s.called = true; + return s.returnValue; + }; + + s.called = false; + s.callCount = 0; + s.calls = []; + s.returnValue = undefined; + s.returns = (r) => { + s.returnValue = r; + return s; + }; + s.calledWith = (...args) => + s.calls.some((p) => { + try { + assert.deepEqual(p, args); + return true; + } catch { + return false; + } + }); + + return s; +} + +function errorWithStatusCode(statusCode, message) { + const err = new Error(message || `Error ${statusCode}`); + err.statusCode = statusCode; + return err; +} + +describe('#themes handler', () => { + describe('#themes getType', () => { + it('should get themes', async () => { + const theme = validTheme(); + theme.themeId = 'myThemeId'; + + const auth0 = { + branding: { + getDefaultTheme: stub().returns(Promise.resolve(theme)), + }, + }; + + const handler = new ThemesHandler({ client: auth0 }); + const data = await handler.getType(); + + expect(data).to.deep.equal([omit(theme, 'themeId')]); + expect(auth0.branding.getDefaultTheme.called).to.equal(true); + expect(auth0.branding.getDefaultTheme.callCount).to.equal(1); + }); + + it('should not fail when there is no theme', async () => { + const auth0 = { + branding: { + getDefaultTheme: stub().returns(Promise.reject(errorWithStatusCode(404))), + }, + }; + + const handler = new ThemesHandler({ client: auth0 }); + const data = await handler.getType(); + + expect(data).to.equal(null); + expect(auth0.branding.getDefaultTheme.called).to.equal(true); + expect(auth0.branding.getDefaultTheme.callCount).to.equal(1); + }); + + it('should not fail when no-code is not enabled for the tenant', async () => { + const auth0 = { + branding: { + getDefaultTheme: stub().returns( + Promise.reject( + errorWithStatusCode( + 400, + 'Your account does not have universal login customizations enabled' + ) + ) + ), + }, + }; + + const handler = new ThemesHandler({ client: auth0 }); + const data = await handler.getType(); + + expect(data).to.equal(null); + expect(auth0.branding.getDefaultTheme.called).to.equal(true); + expect(auth0.branding.getDefaultTheme.callCount).to.equal(1); + }); + + it('should fail for unexpected errors', async () => { + const auth0 = { + branding: { + getDefaultTheme: stub().returns( + Promise.reject(errorWithStatusCode(500, 'Unexpected error')) + ), + }, + }; + + const handler = new ThemesHandler({ client: auth0 }); + await expect(handler.getType()).to.be.rejectedWith('Unexpected error'); + expect(auth0.branding.getDefaultTheme.called).to.equal(true); + expect(auth0.branding.getDefaultTheme.callCount).to.equal(1); + }); + }); + + describe('#themes processChange', () => { + it('should create the theme when default theme does not exist', async () => { + const theme = validTheme(); + + const auth0 = { + branding: { + getDefaultTheme: stub().returns(Promise.reject(errorWithStatusCode(404))), + createTheme: stub().returns(Promise.resolve(theme)), + updateTheme: stub().returns( + Promise.reject(new Error('updateTheme should not have been called')) + ), + }, + }; + + const handler = new ThemesHandler({ client: auth0 }); + const assets = { themes: [theme] }; + + await handler.processChanges(assets); + + expect(auth0.branding.getDefaultTheme.called).to.equal(true); + expect(auth0.branding.getDefaultTheme.callCount).to.equal(1); + expect(auth0.branding.createTheme.called).to.equal(true); + expect(auth0.branding.createTheme.callCount).to.equal(1); + expect(auth0.branding.createTheme.calledWith(theme)).to.equal(true); + expect(auth0.branding.updateTheme.called).to.equal(false); + }); + + it('should create the theme when default exists', async () => { + const theme = validTheme(); + theme.themeId = 'myThemeId'; + + const auth0 = { + branding: { + getDefaultTheme: stub().returns(theme), + createTheme: stub().returns( + Promise.reject(new Error('updateTheme should not have been called')) + ), + updateTheme: stub().returns(Promise.resolve(theme)), + }, + }; + + const handler = new ThemesHandler({ client: auth0 }); + const assets = { themes: [omit(theme, 'themeId')] }; + + await handler.processChanges(assets); + + expect(auth0.branding.getDefaultTheme.called).to.equal(true); + expect(auth0.branding.getDefaultTheme.callCount).to.equal(1); + expect(auth0.branding.updateTheme.called).to.equal(true); + expect(auth0.branding.updateTheme.callCount).to.equal(1); + expect( + auth0.branding.updateTheme.calledWith({ id: theme.themeId }, omit(theme, 'themeId')) + ).to.deep.equal(true); + expect(auth0.branding.createTheme.called).to.equal(false); + }); + }); +}); diff --git a/test/tools/auth0/validator.tests.js b/test/tools/auth0/validator.tests.js index 2cb4eb44a..426a716f1 100644 --- a/test/tools/auth0/validator.tests.js +++ b/test/tools/auth0/validator.tests.js @@ -1,6 +1,7 @@ import { expect } from 'chai'; import Auth0 from '../../../src/tools/auth0'; import constants from '../../../src/tools/constants'; +import { validTheme } from '../../../src/tools/auth0/handlers/themes'; const mockConfigFn = () => {}; @@ -791,4 +792,25 @@ describe('#schema validation tests', () => { checkPassed({ migrations: data }, done); }); }); + + describe('#themes validate', () => { + it('should fail validation if themes is invalid', (done) => { + const data = [ + { + colors: true, + }, + ]; + const auth0 = new Auth0(client, { themes: data }, mockConfigFn); + + auth0 + .validate() + .then(failedCb(done), passedCb(done, "should have required property 'borders'")); + }); + + it('should pass validation', (done) => { + const data = [validTheme()]; + + checkPassed({ themes: data }, done); + }); + }); }); From 6eba0ee59de7ee885fa03f6fc2a280c4c9c27a09 Mon Sep 17 00:00:00 2001 From: Alejo Fernandez Date: Thu, 26 May 2022 17:10:56 -0300 Subject: [PATCH 2/4] chore: apply PR feedback --- src/context/directory/handlers/themes.ts | 8 +- src/context/yaml/handlers/themes.ts | 4 +- src/tools/auth0/handlers/themes.ts | 158 +++++------------- src/types.ts | 8 +- test/context/directory/themes.test.js | 20 +-- test/context/yaml/themes.test.js | 20 +-- test/tools/auth0/handlers/themes.tests.js | 188 ++++++++++++++++++++-- test/tools/auth0/validator.tests.js | 4 +- 8 files changed, 247 insertions(+), 163 deletions(-) diff --git a/src/context/directory/handlers/themes.ts b/src/context/directory/handlers/themes.ts index 3abdd97db..04cf03ace 100644 --- a/src/context/directory/handlers/themes.ts +++ b/src/context/directory/handlers/themes.ts @@ -4,10 +4,10 @@ import { getFiles, dumpJSON, loadJSON, existsMustBeDir } from '../../../utils'; import { DirectoryHandler } from '.'; import DirectoryContext from '..'; import { ParsedAsset } from '../../../types'; -import { ThemeRequest } from '../../../tools/auth0/handlers/themes'; +import { Theme } from '../../../tools/auth0/handlers/themes'; import { constants } from '../../../tools'; -type ParsedThemes = ParsedAsset<'themes', ThemeRequest[]>; +type ParsedThemes = ParsedAsset<'themes', Theme[]>; function parse(context: DirectoryContext): ParsedThemes { const baseFolder = path.join(context.filePath, constants.THEMES_DIRECTORY); @@ -17,11 +17,11 @@ function parse(context: DirectoryContext): ParsedThemes { const themeDefinitionsFiles = getFiles(baseFolder, ['.json']); if (!themeDefinitionsFiles.length) { - return { themes: null }; + return { themes: [] }; } const themes = themeDefinitionsFiles.map( - (themeDefinitionsFile) => loadJSON(themeDefinitionsFile, context.mappings) as ThemeRequest + (themeDefinitionsFile) => loadJSON(themeDefinitionsFile, context.mappings) as Theme ); return { themes }; diff --git a/src/context/yaml/handlers/themes.ts b/src/context/yaml/handlers/themes.ts index ec1cf9f12..51ac477d3 100644 --- a/src/context/yaml/handlers/themes.ts +++ b/src/context/yaml/handlers/themes.ts @@ -1,9 +1,9 @@ import { YAMLHandler } from '.'; import YAMLContext from '..'; import { ParsedAsset } from '../../../types'; -import { ThemeRequest } from '../../../tools/auth0/handlers/themes'; +import { Theme } from '../../../tools/auth0/handlers/themes'; -type ParsedThemes = ParsedAsset<'themes', ThemeRequest[]>; +type ParsedThemes = ParsedAsset<'themes', Theme[]>; async function parseAndDump(context: YAMLContext): Promise { const { themes } = context.assets; diff --git a/src/tools/auth0/handlers/themes.ts b/src/tools/auth0/handlers/themes.ts index e6cb82c49..a1e2c4822 100644 --- a/src/tools/auth0/handlers/themes.ts +++ b/src/tools/auth0/handlers/themes.ts @@ -4,16 +4,17 @@ import log from '../../../logger'; import DefaultHandler from './default'; export default class ThemesHandler extends DefaultHandler { - existing: ThemeRequest[] | null; + existing: Theme[]; constructor(options: DefaultHandler) { super({ ...options, type: 'themes', + identifiers: ['themeId'], }); } - async getType(): Promise { + async getType(): Promise { if (!this.existing) { this.existing = await this.getThemes(); } @@ -21,51 +22,49 @@ export default class ThemesHandler extends DefaultHandler { return this.existing; } - async getThemes(): Promise { - let theme; + async processChanges(assets: Assets): Promise { + const { themes } = assets; - try { - theme = await this.client.branding.getDefaultTheme(); - } catch (err) { - // Errors other than 404 (theme doesn't exist) or 400 (no-code not enabled) shouldn't be expected - if (err.statusCode !== 404 && err.statusCode !== 400) { - throw err; - } + // Non existing section means themes doesn't need to be processed + if (!themes) { + return; } - if (theme) { - delete theme.themeId; - return [theme]; + // Empty array means themes should be deleted + if (themes.length === 0) { + return this.deleteThemes(); } - return null; + return this.updateThemes(themes); } - async processChanges(assets: Assets): Promise { - const { themes } = assets; + async deleteThemes(): Promise { + if (!this.config('AUTH0_ALLOW_DELETE')) { + return; + } - if (!themes || themes.length === 0) { + // if theme exists we need to delete it + const currentTheme = (await this.getThemes())[0]; + if (!currentTheme?.themeId) { return; } + await this.client.branding.deleteTheme({ id: currentTheme.themeId }); + + this.deleted += 1; + this.didDelete(currentTheme); + } + + async updateThemes(themes: Theme[]): Promise { if (themes.length > 1) { log.warn('Only one theme is supported per tenant'); } - let themeId: string = ''; - try { - const theme = await this.client.branding.getDefaultTheme(); - themeId = theme.themeId; - } catch (err) { - // error 404 is expected, the tenant may not have a default theme - if (err.statusCode !== 404) { - throw err; - } - } + const currentTheme = (await this.getThemes())[0]; // if theme exists, overwrite it otherwise create it - if (themeId) { - await this.client.branding.updateTheme({ id: themeId }, themes[0]); + if (currentTheme?.themeId) { + await this.client.branding.updateTheme({ id: currentTheme.themeId }, themes[0]); } else { await this.client.branding.createTheme(themes[0]); } @@ -73,6 +72,20 @@ export default class ThemesHandler extends DefaultHandler { this.updated += 1; this.didUpdate(themes[0]); } + + async getThemes(): Promise { + try { + const theme = (await this.client.branding.getDefaultTheme()) as Theme; + return [theme]; + } catch (err) { + // Errors other than 404 (theme doesn't exist) or 400 (no-code not enabled) shouldn't be expected + if (err.statusCode !== 404 && err.statusCode !== 400) { + throw err; + } + } + + return []; + } } /** @@ -568,89 +581,6 @@ export interface Theme { borders: Borders; widget: Widget; page_background: PageBackground; -} - -export interface ThemeRequest extends Theme { + themeId?: string; displayName?: string; } - -export interface ThemeResponse extends Theme { - themeId: string; - displayName: string; -} - -export const validTheme = (): ThemeRequest => { - return cloneDeep({ - borders: { - button_border_radius: 1, - button_border_weight: 1, - buttons_style: 'pill', - input_border_radius: 3, - input_border_weight: 1, - inputs_style: 'pill', - show_widget_shadow: false, - widget_border_weight: 1, - widget_corner_radius: 3, - }, - colors: { - body_text: '#FF00CC', - error: '#FF00CC', - header: '#FF00CC', - icons: '#FF00CC', - input_background: '#FF00CC', - input_border: '#FF00CC', - input_filled_text: '#FF00CC', - input_labels_placeholders: '#FF00CC', - links_focused_components: '#FF00CC', - primary_button: '#FF00CC', - primary_button_label: '#FF00CC', - secondary_button_border: '#FF00CC', - secondary_button_label: '#FF00CC', - success: '#FF00CC', - widget_background: '#FF00CC', - widget_border: '#FF00CC', - }, - fonts: { - body_text: { - bold: false, - size: 100, - }, - buttons_text: { - bold: false, - size: 100, - }, - font_url: 'https://google.com/font.woff', - input_labels: { - bold: false, - size: 100, - }, - links: { - bold: false, - size: 100, - }, - links_style: 'normal', - reference_text_size: 12, - subtitle: { - bold: false, - size: 100, - }, - title: { - bold: false, - size: 100, - }, - }, - page_background: { - background_color: '#000000', - background_image_url: 'https://google.com/background.png', - page_layout: 'center', - }, - widget: { - header_text_alignment: 'center', - logo_height: 55, - logo_position: 'center', - logo_url: 'https://google.com/logo.png', - social_buttons_layout: 'top', - }, - displayName: 'Default theme', - }); -}; diff --git a/src/types.ts b/src/types.ts index 642e9f72c..d51cf2d78 100644 --- a/src/types.ts +++ b/src/types.ts @@ -6,7 +6,7 @@ import { PromptSettings, } from './tools/auth0/handlers/prompts'; -import { ThemeResponse, ThemeRequest } from './tools/auth0/handlers/themes'; +import { Theme } from './tools/auth0/handlers/themes'; type SharedPaginationParams = { checkpoint?: boolean; @@ -65,9 +65,9 @@ export type BaseAuth0APIClient = { updateSettings: ({}, Asset) => Promise; setUniversalLoginTemplate: ({}, Asset) => Promise; getDefaultTheme: () => Promise; - updateTheme: (arg0: { id: string }, ThemeRequest) => Promise; + updateTheme: (arg0: { id: string }, Theme) => Promise; deleteTheme: (arg0: { id: string }) => Promise; - createTheme: (ThemeRequest) => Promise; + createTheme: (arg0: Theme) => Promise; }; clients: APIClientBaseFunctions; clientGrants: APIClientBaseFunctions; @@ -237,7 +237,7 @@ export type Assets = Partial<{ [key: string]: string[]; }; clientsOrig: Asset[] | null; - themes: ThemeRequest[] | null; + themes: Theme[] | null; }>; export type CalculatedChanges = { diff --git a/test/context/directory/themes.test.js b/test/context/directory/themes.test.js index 536bb0102..30898dfd3 100644 --- a/test/context/directory/themes.test.js +++ b/test/context/directory/themes.test.js @@ -5,12 +5,12 @@ import Context from '../../../src/context/directory'; import { testDataDir, createDir, mockMgmtClient, cleanThenMkdir } from '../../utils'; import { constants } from '../../../src/tools'; import { loadJSON } from '../../../src/utils'; -import { validTheme } from '../../../src/tools/auth0/handlers/themes'; +import { mockTheme } from '../../tools/auth0/handlers/themes.tests'; import handler from '../../../src/context/directory/handlers/themes'; describe('#directory context themes', () => { - it('should process themes', async () => { - const theme = validTheme(); + it('should load single theme', async () => { + const theme = mockTheme(); const dir = path.join(testDataDir, 'directory', 'themesProcess'); createDir(dir, { @@ -25,10 +25,10 @@ describe('#directory context themes', () => { expect(context.assets.themes).to.deep.equal([theme]); }); - it('should process multiple themes', async () => { - const theme1 = validTheme(); + it('should load themes', async () => { + const theme1 = mockTheme(); theme1.displayName = 'Theme 1'; - const theme2 = validTheme(); + const theme2 = mockTheme(); theme2.displayName = 'Theme 2'; const dir = path.join(testDataDir, 'directory', 'themesProcess'); @@ -47,8 +47,8 @@ describe('#directory context themes', () => { expect(context.assets.themes).to.deep.equal([theme1, theme2]); }); - it('should dump themes', async () => { - const theme = validTheme(); + it('should dump single theme', async () => { + const theme = mockTheme(); const dir = path.join(testDataDir, 'directory', 'themesDump'); cleanThenMkdir(dir); @@ -65,9 +65,9 @@ describe('#directory context themes', () => { }); it('should dump multiple themes', async () => { - const themeJson = validTheme(); + const themeJson = mockTheme(); themeJson.displayName = 'Theme'; - const theme1Json = validTheme(); + const theme1Json = mockTheme(); theme1Json.displayName = 'Theme 1'; const dir = path.join(testDataDir, 'directory', 'themesDump'); diff --git a/test/context/yaml/themes.test.js b/test/context/yaml/themes.test.js index cbf70c6cf..87f54c7b7 100644 --- a/test/context/yaml/themes.test.js +++ b/test/context/yaml/themes.test.js @@ -1,4 +1,4 @@ -import { dump } from 'js-yaml'; +import { dump as toYaml } from 'js-yaml'; import fs from 'fs-extra'; import path from 'path'; import { expect } from 'chai'; @@ -6,15 +6,15 @@ import { cleanThenMkdir, testDataDir, mockMgmtClient } from '../../utils'; import Context from '../../../src/context/yaml'; import handler from '../../../src/context/yaml/handlers/themes'; -import { validTheme } from '../../../src/tools/auth0/handlers/themes'; +import { mockTheme } from '../../tools/auth0/handlers/themes.tests'; describe('#YAML context themes', () => { it('should process themes', async () => { - const theme = validTheme(); + const theme = mockTheme(); const dir = path.join(testDataDir, 'yaml', 'themes'); cleanThenMkdir(dir); - const yaml = dump({ + const yaml = toYaml({ themes: [theme], }); @@ -29,15 +29,15 @@ describe('#YAML context themes', () => { }); it('should process multiple themes', async () => { - const theme1 = validTheme(); + const theme1 = mockTheme(); theme1.displayName = 'Theme 1'; - const theme2 = validTheme(); + const theme2 = mockTheme(); theme2.displayName = 'Theme 2'; const dir = path.join(testDataDir, 'yaml', 'themes'); cleanThenMkdir(dir); - const yaml = dump({ + const yaml = toYaml({ themes: [theme1, theme2], }); @@ -52,7 +52,7 @@ describe('#YAML context themes', () => { }); it('should dump themes', async () => { - const theme = validTheme(); + const theme = mockTheme(); const dir = path.join(testDataDir, 'directory', 'themesDump'); cleanThenMkdir(dir); @@ -68,9 +68,9 @@ describe('#YAML context themes', () => { }); it('should dump multiple themes', async () => { - const theme1 = validTheme(); + const theme1 = mockTheme(); theme1.displayName = 'Theme 1'; - const theme2 = validTheme(); + const theme2 = mockTheme(); theme2.displayName = 'Theme 2'; const dir = path.join(testDataDir, 'directory', 'themesDump'); diff --git a/test/tools/auth0/handlers/themes.tests.js b/test/tools/auth0/handlers/themes.tests.js index 0cdcefd01..ce92488a5 100644 --- a/test/tools/auth0/handlers/themes.tests.js +++ b/test/tools/auth0/handlers/themes.tests.js @@ -1,9 +1,6 @@ const { expect, assert } = require('chai'); -const { omit } = require('lodash'); -const { - default: ThemesHandler, - validTheme, -} = require('../../../../src/tools/auth0/handlers/themes'); +const { omit, cloneDeep } = require('lodash'); +const { default: ThemesHandler } = require('../../../../src/tools/auth0/handlers/themes'); function stub() { const s = function (...args) { @@ -40,11 +37,92 @@ function errorWithStatusCode(statusCode, message) { return err; } +const mockTheme = ({ withThemeId } = {}) => { + const theme = cloneDeep({ + borders: { + button_border_radius: 1, + button_border_weight: 1, + buttons_style: 'pill', + input_border_radius: 3, + input_border_weight: 1, + inputs_style: 'pill', + show_widget_shadow: false, + widget_border_weight: 1, + widget_corner_radius: 3, + }, + colors: { + body_text: '#FF00CC', + error: '#FF00CC', + header: '#FF00CC', + icons: '#FF00CC', + input_background: '#FF00CC', + input_border: '#FF00CC', + input_filled_text: '#FF00CC', + input_labels_placeholders: '#FF00CC', + links_focused_components: '#FF00CC', + primary_button: '#FF00CC', + primary_button_label: '#FF00CC', + secondary_button_border: '#FF00CC', + secondary_button_label: '#FF00CC', + success: '#FF00CC', + widget_background: '#FF00CC', + widget_border: '#FF00CC', + }, + fonts: { + body_text: { + bold: false, + size: 100, + }, + buttons_text: { + bold: false, + size: 100, + }, + font_url: 'https://google.com/font.woff', + input_labels: { + bold: false, + size: 100, + }, + links: { + bold: false, + size: 100, + }, + links_style: 'normal', + reference_text_size: 12, + subtitle: { + bold: false, + size: 100, + }, + title: { + bold: false, + size: 100, + }, + }, + page_background: { + background_color: '#000000', + background_image_url: 'https://google.com/background.png', + page_layout: 'center', + }, + widget: { + header_text_alignment: 'center', + logo_height: 55, + logo_position: 'center', + logo_url: 'https://google.com/logo.png', + social_buttons_layout: 'top', + }, + displayName: 'Default theme', + }); + + if (withThemeId) { + theme.themeId = withThemeId; + } + + return theme; +}; + describe('#themes handler', () => { describe('#themes getType', () => { it('should get themes', async () => { - const theme = validTheme(); - theme.themeId = 'myThemeId'; + const theme = mockTheme({ withThemeId: 'myThemeId' }); const auth0 = { branding: { @@ -55,12 +133,12 @@ describe('#themes handler', () => { const handler = new ThemesHandler({ client: auth0 }); const data = await handler.getType(); - expect(data).to.deep.equal([omit(theme, 'themeId')]); + expect(data).to.deep.equal([theme]); expect(auth0.branding.getDefaultTheme.called).to.equal(true); expect(auth0.branding.getDefaultTheme.callCount).to.equal(1); }); - it('should not fail when there is no theme', async () => { + it('should return empty array if there is no theme', async () => { const auth0 = { branding: { getDefaultTheme: stub().returns(Promise.reject(errorWithStatusCode(404))), @@ -70,12 +148,12 @@ describe('#themes handler', () => { const handler = new ThemesHandler({ client: auth0 }); const data = await handler.getType(); - expect(data).to.equal(null); + expect(data).to.deep.equal([]); expect(auth0.branding.getDefaultTheme.called).to.equal(true); expect(auth0.branding.getDefaultTheme.callCount).to.equal(1); }); - it('should not fail when no-code is not enabled for the tenant', async () => { + it('should return empty array when no-code is not enabled for the tenant', async () => { const auth0 = { branding: { getDefaultTheme: stub().returns( @@ -92,12 +170,12 @@ describe('#themes handler', () => { const handler = new ThemesHandler({ client: auth0 }); const data = await handler.getType(); - expect(data).to.equal(null); + expect(data).to.deep.equal([]); expect(auth0.branding.getDefaultTheme.called).to.equal(true); expect(auth0.branding.getDefaultTheme.callCount).to.equal(1); }); - it('should fail for unexpected errors', async () => { + it('should fail for unexpected api errors', async () => { const auth0 = { branding: { getDefaultTheme: stub().returns( @@ -115,7 +193,7 @@ describe('#themes handler', () => { describe('#themes processChange', () => { it('should create the theme when default theme does not exist', async () => { - const theme = validTheme(); + const theme = mockTheme(); const auth0 = { branding: { @@ -124,6 +202,9 @@ describe('#themes handler', () => { updateTheme: stub().returns( Promise.reject(new Error('updateTheme should not have been called')) ), + deleteTheme: stub().returns( + Promise.reject(new Error('deleteTheme should not have been called')) + ), }, }; @@ -138,11 +219,11 @@ describe('#themes handler', () => { expect(auth0.branding.createTheme.callCount).to.equal(1); expect(auth0.branding.createTheme.calledWith(theme)).to.equal(true); expect(auth0.branding.updateTheme.called).to.equal(false); + expect(auth0.branding.deleteTheme.called).to.equal(false); }); it('should create the theme when default exists', async () => { - const theme = validTheme(); - theme.themeId = 'myThemeId'; + const theme = mockTheme({ withThemeId: 'myThemeId' }); const auth0 = { branding: { @@ -151,6 +232,9 @@ describe('#themes handler', () => { Promise.reject(new Error('updateTheme should not have been called')) ), updateTheme: stub().returns(Promise.resolve(theme)), + deleteTheme: stub().returns( + Promise.reject(new Error('deleteTheme should not have been called')) + ), }, }; @@ -164,9 +248,79 @@ describe('#themes handler', () => { expect(auth0.branding.updateTheme.called).to.equal(true); expect(auth0.branding.updateTheme.callCount).to.equal(1); expect( - auth0.branding.updateTheme.calledWith({ id: theme.themeId }, omit(theme, 'themeId')) + auth0.branding.updateTheme.calledWith({ id: 'myThemeId' }, omit(theme, 'themeId')) ).to.deep.equal(true); expect(auth0.branding.createTheme.called).to.equal(false); + expect(auth0.branding.deleteTheme.called).to.equal(false); }); }); + + it('should delete the theme when default theme exists and AUTH0_ALLOW_DELETE: true', async () => { + const theme = mockTheme({ withThemeId: 'delete-me' }); + const config = { + AUTH0_ALLOW_DELETE: true, + }; + + const auth0 = { + branding: { + getDefaultTheme: stub().returns(Promise.resolve(theme)), + createTheme: stub().returns( + Promise.reject(new Error('createTheme should not have been called')) + ), + updateTheme: stub().returns( + Promise.reject(new Error('updateTheme should not have been called')) + ), + deleteTheme: stub().returns(Promise.resolve()), + }, + }; + + const handler = new ThemesHandler({ client: auth0, config: (key) => config[key] }); + const assets = { themes: [] }; + + await handler.processChanges(assets); + + expect(auth0.branding.getDefaultTheme.called).to.equal(true); + expect(auth0.branding.getDefaultTheme.callCount).to.equal(1); + expect(auth0.branding.deleteTheme.callCount).to.equal(1); + expect(auth0.branding.deleteTheme.calledWith({ id: 'delete-me' })).to.equal(true); + expect(auth0.branding.updateTheme.called).to.equal(false); + expect(auth0.branding.createTheme.called).to.equal(false); + }); + + it('should not delete the theme when AUTH0_ALLOW_DELETE: false', async () => { + const config = { + AUTH0_ALLOW_DELETE: false, + }; + + const auth0 = { + branding: { + getDefaultTheme: stub().returns( + Promise.reject(new Error('getDefaultTheme should not have been called')) + ), + createTheme: stub().returns( + Promise.reject(new Error('createTheme should not have been called')) + ), + updateTheme: stub().returns( + Promise.reject(new Error('updateTheme should not have been called')) + ), + deleteTheme: stub().returns( + Promise.reject(new Error('deleteTheme should not have been called')) + ), + }, + }; + + const handler = new ThemesHandler({ client: auth0, config: (key) => config[key] }); + const assets = { themes: [] }; + + await handler.processChanges(assets); + + expect(auth0.branding.getDefaultTheme.called).to.equal(false); + expect(auth0.branding.deleteTheme.called).to.equal(false); + expect(auth0.branding.updateTheme.called).to.equal(false); + expect(auth0.branding.createTheme.called).to.equal(false); + }); }); + +module.exports = { + mockTheme, +}; diff --git a/test/tools/auth0/validator.tests.js b/test/tools/auth0/validator.tests.js index 426a716f1..f88a2be73 100644 --- a/test/tools/auth0/validator.tests.js +++ b/test/tools/auth0/validator.tests.js @@ -1,7 +1,7 @@ import { expect } from 'chai'; import Auth0 from '../../../src/tools/auth0'; import constants from '../../../src/tools/constants'; -import { validTheme } from '../../../src/tools/auth0/handlers/themes'; +import { mockTheme } from './handlers/themes.tests'; const mockConfigFn = () => {}; @@ -808,7 +808,7 @@ describe('#schema validation tests', () => { }); it('should pass validation', (done) => { - const data = [validTheme()]; + const data = [mockTheme()]; checkPassed({ themes: data }, done); }); From 12a66fe29f78e47790e0139962c36d04fca16d71 Mon Sep 17 00:00:00 2001 From: Alejo Fernandez Date: Thu, 26 May 2022 19:52:23 -0300 Subject: [PATCH 3/4] fix: add getDefaultTheme implementation to mockMgmtClient --- test/context/yaml/context.test.js | 3 +++ test/utils.js | 9 ++++++++- 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/test/context/yaml/context.test.js b/test/context/yaml/context.test.js index 515e128f5..73d38795b 100644 --- a/test/context/yaml/context.test.js +++ b/test/context/yaml/context.test.js @@ -251,6 +251,7 @@ describe('#YAML context validation', () => { customText: {}, }, customDomains: [], + themes: [], }); }); @@ -362,6 +363,7 @@ describe('#YAML context validation', () => { customText: {}, }, customDomains: [], + themes: [], }); }); @@ -474,6 +476,7 @@ describe('#YAML context validation', () => { customText: {}, }, customDomains: [], + themes: [], }); }); diff --git a/test/utils.js b/test/utils.js index b53d8df4d..444552194 100644 --- a/test/utils.js +++ b/test/utils.js @@ -110,7 +110,14 @@ export function mockMgmtClient() { getBruteForceConfig: () => ({}), getSuspiciousIpThrottlingConfig: () => ({}), }, - branding: { getSettings: () => ({}) }, + branding: { + getSettings: () => ({}), + getDefaultTheme: () => { + const err = new Error('Not found'); + err.statusCode = 404; + return Promise.reject(err); + }, + }, logStreams: { getAll: () => [] }, prompts: { getCustomTextByLanguage: () => From ac3aee405f5b44afbe71147775284fb36daa9fc3 Mon Sep 17 00:00:00 2001 From: Will Vedder Date: Tue, 31 May 2022 16:08:33 -0400 Subject: [PATCH 4/4] Returning null instead of empty array when tenant doesn't support themes functionality --- package-lock.json | 2 +- src/tools/auth0/handlers/themes.ts | 42 +++++++++++++---------- src/types.ts | 6 ++-- test/tools/auth0/handlers/themes.tests.js | 2 +- 4 files changed, 28 insertions(+), 24 deletions(-) diff --git a/package-lock.json b/package-lock.json index 6f6213de7..2eb85bd82 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10572,7 +10572,7 @@ "istanbul-lib-processinfo": "^2.0.2", "istanbul-lib-report": "^3.0.0", "istanbul-lib-source-maps": "^4.0.0", - "istanbul-reports": "^3.0.2", + "istanbul-reports": "3.1.4", "make-dir": "^3.0.0", "node-preload": "^0.2.1", "p-map": "^3.0.0", diff --git a/src/tools/auth0/handlers/themes.ts b/src/tools/auth0/handlers/themes.ts index a1e2c4822..2f08080e5 100644 --- a/src/tools/auth0/handlers/themes.ts +++ b/src/tools/auth0/handlers/themes.ts @@ -1,19 +1,22 @@ -import { cloneDeep } from 'lodash'; -import { Assets } from '../../../types'; +import { Asset, Assets } from '../../../types'; import log from '../../../logger'; import DefaultHandler from './default'; export default class ThemesHandler extends DefaultHandler { - existing: Theme[]; + existing: Theme[] | null; constructor(options: DefaultHandler) { super({ ...options, type: 'themes', - identifiers: ['themeId'], + id: 'themeId', }); } + objString(theme: Theme): string { + return theme.displayName || JSON.stringify(theme); + } + async getType(): Promise { if (!this.existing) { this.existing = await this.getThemes(); @@ -44,11 +47,12 @@ export default class ThemesHandler extends DefaultHandler { } // if theme exists we need to delete it - const currentTheme = (await this.getThemes())[0]; - if (!currentTheme?.themeId) { + const currentThemes = await this.getThemes(); + if (currentThemes === null || currentThemes.length === 0) { return; } + const currentTheme = currentThemes[0]; await this.client.branding.deleteTheme({ id: currentTheme.themeId }); this.deleted += 1; @@ -60,31 +64,31 @@ export default class ThemesHandler extends DefaultHandler { log.warn('Only one theme is supported per tenant'); } - const currentTheme = (await this.getThemes())[0]; + const currentThemes = await this.getThemes(); - // if theme exists, overwrite it otherwise create it - if (currentTheme?.themeId) { - await this.client.branding.updateTheme({ id: currentTheme.themeId }, themes[0]); - } else { + if (currentThemes === null || currentThemes.length === 0) { await this.client.branding.createTheme(themes[0]); + } else { + const currentTheme = currentThemes[0]; + // if theme exists, overwrite it otherwise create it + await this.client.branding.updateTheme({ id: currentTheme.themeId }, themes[0]); } this.updated += 1; this.didUpdate(themes[0]); } - async getThemes(): Promise { + async getThemes(): Promise { try { - const theme = (await this.client.branding.getDefaultTheme()) as Theme; + const theme = await this.client.branding.getDefaultTheme(); return [theme]; } catch (err) { + if (err.statusCode === 404) return []; + if (err.statusCode === 400) return null; + // Errors other than 404 (theme doesn't exist) or 400 (no-code not enabled) shouldn't be expected - if (err.statusCode !== 404 && err.statusCode !== 400) { - throw err; - } + throw err; } - - return []; } } @@ -581,6 +585,6 @@ export interface Theme { borders: Borders; widget: Widget; page_background: PageBackground; - themeId?: string; + themeId: string; displayName?: string; } diff --git a/src/types.ts b/src/types.ts index d51cf2d78..ae987470c 100644 --- a/src/types.ts +++ b/src/types.ts @@ -64,10 +64,10 @@ export type BaseAuth0APIClient = { getUniversalLoginTemplate: () => Promise; updateSettings: ({}, Asset) => Promise; setUniversalLoginTemplate: ({}, Asset) => Promise; - getDefaultTheme: () => Promise; - updateTheme: (arg0: { id: string }, Theme) => Promise; + getDefaultTheme: () => Promise; + updateTheme: (arg0: { id: string }, Theme) => Promise>; + createTheme: (arg0: Theme) => Promise>; deleteTheme: (arg0: { id: string }) => Promise; - createTheme: (arg0: Theme) => Promise; }; clients: APIClientBaseFunctions; clientGrants: APIClientBaseFunctions; diff --git a/test/tools/auth0/handlers/themes.tests.js b/test/tools/auth0/handlers/themes.tests.js index ce92488a5..46bb7179d 100644 --- a/test/tools/auth0/handlers/themes.tests.js +++ b/test/tools/auth0/handlers/themes.tests.js @@ -170,7 +170,7 @@ describe('#themes handler', () => { const handler = new ThemesHandler({ client: auth0 }); const data = await handler.getType(); - expect(data).to.deep.equal([]); + expect(data).to.deep.equal(null); expect(auth0.branding.getDefaultTheme.called).to.equal(true); expect(auth0.branding.getDefaultTheme.callCount).to.equal(1); });