From a5584eaabce74ed224416fa4cd1faef2b1dd5033 Mon Sep 17 00:00:00 2001 From: Joel Hamilton Date: Wed, 31 Jan 2024 17:03:39 -0500 Subject: [PATCH] feat: add custom prompts partials --- src/context/directory/handlers/prompts.ts | 34 +++++- src/context/directory/index.ts | 2 +- src/context/index.ts | 8 +- src/tools/auth0/handlers/prompts.ts | 114 ++++++++++++++++++++- src/types.ts | 11 ++ test/context/directory/prompts.test.ts | 105 +++++++++++++++++-- test/context/yaml/context.test.js | 3 + test/context/yaml/prompts.test.ts | 35 +++++++ test/tools/auth0/handlers/prompts.tests.ts | 63 ++++++++++-- test/utils.js | 1 + 10 files changed, 356 insertions(+), 20 deletions(-) diff --git a/src/context/directory/handlers/prompts.ts b/src/context/directory/handlers/prompts.ts index 12fee4eae..fd8aa76a0 100644 --- a/src/context/directory/handlers/prompts.ts +++ b/src/context/directory/handlers/prompts.ts @@ -9,6 +9,7 @@ import { Prompts, PromptSettings, AllPromptsByLanguage, + CustomPromptsConfig, } from '../../../tools/auth0/handlers/prompts'; type ParsedPrompts = ParsedAsset<'prompts', Prompts>; @@ -25,6 +26,10 @@ const getCustomTextFile = (promptsDirectory: string) => { return path.join(promptsDirectory, 'custom-text.json'); }; +const getPartialsFile = (promptsDirectory: string) => { + return path.join(promptsDirectory, 'partials.json'); +}; + function parse(context: DirectoryContext): ParsedPrompts { const promptsDirectory = getPromptsDirectory(context.filePath); if (!existsMustBeDir(promptsDirectory)) return { prompts: null }; // Skip @@ -47,10 +52,33 @@ function parse(context: DirectoryContext): ParsedPrompts { }) as AllPromptsByLanguage; })(); + const partials = (() => { + const partialsFile = getPartialsFile(promptsDirectory); + if (!isFile(partialsFile)) return {}; + const partialsFileContent = loadJSON(partialsFile, { + mappings: context.mappings, + disableKeywordReplacement: context.disableKeywordReplacement, + }) as CustomPromptsConfig; + + Object.entries(partialsFileContent).forEach(([promptName, partialsArray]) => { + partialsArray.forEach((partialConfig, i) => { + if (partialConfig.template) { + partialsFileContent[promptName][i].template = context.loadFile( + path.join(promptsDirectory, partialConfig.template), + promptsDirectory + ); + } + }); + }); + + return partialsFileContent; + })(); + return { prompts: { ...promptsSettings, customText, + partials, }, }; } @@ -60,7 +88,7 @@ async function dump(context: DirectoryContext): Promise { if (!prompts) return; - const { customText, ...promptsSettings } = prompts; + const { customText, partials, ...promptsSettings } = prompts; const promptsDirectory = getPromptsDirectory(context.filePath); ensureDirSync(promptsDirectory); @@ -72,6 +100,10 @@ async function dump(context: DirectoryContext): Promise { if (!customText) return; const customTextFile = getCustomTextFile(promptsDirectory); dumpJSON(customTextFile, customText); + + if (!partials) return; + const partialsFile = getPartialsFile(promptsDirectory); + dumpJSON(partialsFile, partials); } const promptsHandler: DirectoryHandler = { diff --git a/src/context/directory/index.ts b/src/context/directory/index.ts index c154f10c7..e09f06972 100644 --- a/src/context/directory/index.ts +++ b/src/context/directory/index.ts @@ -21,7 +21,7 @@ export default class DirectoryContext { assets: Assets; disableKeywordReplacement: boolean; - constructor(config: Config, mgmtClient: Auth0APIClient) { + constructor(config: Config, mgmtClient) { this.filePath = config.AUTH0_INPUT_FILE; this.config = config; this.mappings = config.AUTH0_KEYWORD_REPLACE_MAPPINGS || {}; diff --git a/src/context/index.ts b/src/context/index.ts index 89532db20..ea26ae63d 100644 --- a/src/context/index.ts +++ b/src/context/index.ts @@ -161,19 +161,19 @@ export const setupContext = async ( return new AuthenticationClient({ domain: AUTH0_DOMAIN, clientId: AUTH0_CLIENT_ID, - clientAssertionSigningKey: readFileSync(AUTH0_CLIENT_SIGNING_KEY_PATH), + clientAssertionSigningKey: readFileSync(AUTH0_CLIENT_SIGNING_KEY_PATH, 'utf-8'), clientAssertionSigningAlg: !!AUTH0_CLIENT_SIGNING_ALGORITHM ? AUTH0_CLIENT_SIGNING_ALGORITHM : undefined, }); })(); - - const clientCredentials = await authClient.clientCredentialsGrant({ + + const clientCredentials = await authClient.oauth.clientCredentialsGrant({ audience: config.AUTH0_AUDIENCE ? config.AUTH0_AUDIENCE : `https://${config.AUTH0_DOMAIN}/api/v2/`, }); - return clientCredentials.access_token; + return clientCredentials.data.access_token; })(); const mgmtClient = new ManagementClient({ diff --git a/src/tools/auth0/handlers/prompts.ts b/src/tools/auth0/handlers/prompts.ts index b98ce7335..a02bf11df 100644 --- a/src/tools/auth0/handlers/prompts.ts +++ b/src/tools/auth0/handlers/prompts.ts @@ -153,6 +153,7 @@ export type PromptsCustomText = { export type Prompts = Partial< PromptSettings & { customText: AllPromptsByLanguage; + partials: CustomPromptPartials; } >; @@ -160,6 +161,56 @@ export type AllPromptsByLanguage = Partial<{ [key in Language]: Partial; }>; +const customPromptsPromptTypes = [ + 'login', + 'login-id', + 'login-password', + 'signup', + 'signup-id', + 'signup-password', +] as const; + +export type CustomPromptsPromptTypes = typeof customPromptsPromptTypes[number]; + +const customPromptsScreenTypes = [ + 'login', + 'login-id', + 'login-password', + 'signup', + 'signup-id', + 'signup-password', +] as const; + +export type CustomPromptsScreenTypes = typeof customPromptsPromptTypes[number]; + +export type CustomPromptsInsertionPoints = + | 'form-content-start' + | 'form-content-end' + | 'form-footer-start' + | 'form-footer-end' + | 'secondary-actions-start' + | 'secondary-actions-end'; + + +export type CustomPromptPartialsScreens = Partial<{ + [screen in CustomPromptsScreenTypes]: Partial<{ + [insertionPoint in CustomPromptsInsertionPoints]: string; + }>; +}>; + +export type CustomPromptPartials = Partial<{ + [prompt in CustomPromptsPromptTypes]: CustomPromptPartialsScreens; +}>; + +export type CustomPromptsConfig = { + [prompt in CustomPromptsPromptTypes]: [ + { + name: string; + template: string; + } + ]; +}; + export default class PromptsHandler extends DefaultHandler { existing: Prompts; @@ -179,9 +230,12 @@ export default class PromptsHandler extends DefaultHandler { const customText = await this.getCustomTextSettings(); + const partials = await this.getCustomPromptsPartials(); + return { ...promptsSettings, customText, + partials, }; } @@ -236,12 +290,48 @@ export default class PromptsHandler extends DefaultHandler { }); } + async getCustomPromptsPartials(): Promise { + return this.client.pool + .addEachTask({ + data: customPromptsPromptTypes.map((promptType) => ({ promptType })), + generator: ({ promptType }) => + this.client.prompts + .getPartials({ + prompt: promptType, + }) + .then((partialsData: CustomPromptPartials) => { + if (isEmpty(partialsData)) return null; + return { + [promptType]: { + ...partialsData, + }, + }; + }), + }) + .promise() + .then((partialsDataWithNulls) => + partialsDataWithNulls + .filter(Boolean) + .reduce( + ( + acc: CustomPromptPartials, + partialsData: { [prompt: string]: CustomPromptPartials } + ) => { + const [promptName] = Object.keys(partialsData); + acc[promptName] = partialsData[promptName]; + return acc; + }, + {} + ) + ); + } + async processChanges(assets: Assets): Promise { const { prompts } = assets; if (!prompts) return; - const { customText, ...promptSettings } = prompts; + const { partials, customText, ...promptSettings } = prompts; if (!isEmpty(promptSettings)) { await this.client.prompts.updateSettings({}, promptSettings); @@ -249,6 +339,8 @@ export default class PromptsHandler extends DefaultHandler { await this.updateCustomTextSettings(customText); + await this.updateCustomPromptsPartials(partials); + this.updated += 1; this.didUpdate(prompts); } @@ -284,4 +376,24 @@ export default class PromptsHandler extends DefaultHandler { }) .promise(); } + + async updateCustomPromptsPartials(partials: Prompts['partials']): Promise { + /* + Note: deletes are not currently supported + */ + if (!partials) return; + + await this.client.pool + .addEachTask({ + data: Object.keys(partials).map((prompt: CustomPromptsPromptTypes) => { + const body = partials[prompt] || {}; + return { + body, + prompt, + }; + }), + generator: ({ prompt, body }) => this.client.prompts.updatePartials({ prompt }, body), + }) + .promise(); + } } diff --git a/src/types.ts b/src/types.ts index 8fe333ee1..080630de1 100644 --- a/src/types.ts +++ b/src/types.ts @@ -5,6 +5,9 @@ import { Prompts, PromptsCustomText, PromptSettings, + CustomPromptPartials, + CustomPromptsPromptTypes, + CustomPromptPartialsScreens, } from './tools/auth0/handlers/prompts'; import { Tenant } from './tools/auth0/handlers/tenant'; import { Theme } from './tools/auth0/handlers/themes'; @@ -159,6 +162,14 @@ export type BaseAuth0APIClient = { prompt: PromptTypes; language: Language; }) => Promise>; + updatePartials: (arg0: { + prompt: CustomPromptsPromptTypes; + }, + body: CustomPromptPartialsScreens + ) => Promise; + getPartials: (arg0: { + prompt: CustomPromptsPromptTypes; + }) => Promise; getSettings: () => Promise; updateSettings: (arg0: {}, arg1: Partial) => Promise; }; diff --git a/test/context/directory/prompts.test.ts b/test/context/directory/prompts.test.ts index 295b8de38..0f213ed9a 100644 --- a/test/context/directory/prompts.test.ts +++ b/test/context/directory/prompts.test.ts @@ -12,6 +12,7 @@ const promptsDirectory = path.join(dir, constants.PROMPTS_DIRECTORY); const promptsSettingsFile = 'prompts.json'; const customTextFile = 'custom-text.json'; +const partialsFile = 'partials.json'; describe('#directory context prompts', () => { it('should parse prompts', async () => { @@ -45,12 +46,39 @@ describe('#directory context prompts', () => { }, }, }), + [partialsFile]: JSON.stringify({ + login: [ + { + name: 'form-content-start', + template: './partials/login/form-content-start.liquid', + }, + ], + signup: [ + { + name: 'form-content-end', + template: './partials/signup/form-content-end.liquid', + }, + ], + }), }, }; - const repoDir = path.join(testDataDir, 'directory', 'prompts'); + const repoDir = path.join(testDataDir, 'directory'); + createDir(repoDir, files); + const partialsDir = path.join( + repoDir, + constants.PROMPTS_DIRECTORY, + 'partials' + ); + const partialsFiles = { + login: { 'form-content-start.liquid': '
TEST
' }, + signup: { 'form-content-end.liquid': '
TEST AGAIN
' }, + }; + + createDir(partialsDir, partialsFiles); + const config = { AUTH0_INPUT_FILE: repoDir, AUTH0_KEYWORD_REPLACE_MAPPINGS: { @@ -64,6 +92,20 @@ describe('#directory context prompts', () => { expect(context.assets.prompts).to.deep.equal({ universal_login_experience: 'classic', identifier_first: true, + partials: { + login: [ + { + name: 'form-content-start', + template: '
TEST
', + }, + ], + signup: [ + { + name: 'form-content-end', + template: '
TEST AGAIN
', + }, + ], + }, customText: { en: { login: { @@ -91,7 +133,7 @@ describe('#directory context prompts', () => { }); }); - describe('should parse prompts even if one or both files are absent', async () => { + describe('should parse prompts even if one or more files are absent', async () => { it('should parse even if custom text file is absent', async () => { cleanThenMkdir(promptsDirectory); const mockPromptsSettings = { @@ -101,6 +143,7 @@ describe('#directory context prompts', () => { const promptsDirectoryNoCustomTextFile = { [constants.PROMPTS_DIRECTORY]: { [promptsSettingsFile]: JSON.stringify(mockPromptsSettings), + [partialsFile]: JSON.stringify({}), }, }; @@ -112,10 +155,42 @@ describe('#directory context prompts', () => { const context = new Context(config, mockMgmtClient()); await context.loadAssetsFromLocal(); - expect(context.assets.prompts).to.deep.equal({ ...mockPromptsSettings, customText: {} }); + expect(context.assets.prompts).to.deep.equal({ + ...mockPromptsSettings, + customText: {}, + partials: {}, + }); + }); + + it('should parse even if custom prompts file is absent', async () => { + cleanThenMkdir(promptsDirectory); + const mockPromptsSettings = { + universal_login_experience: 'classic', + identifier_first: true, + }; + const promptsDirectoryNoPartialsFile = { + [constants.PROMPTS_DIRECTORY]: { + [promptsSettingsFile]: JSON.stringify(mockPromptsSettings), + [customTextFile]: JSON.stringify({}), + }, + }; + + createDir(promptsDirectory, promptsDirectoryNoPartialsFile); + + const config = { + AUTH0_INPUT_FILE: promptsDirectory, + }; + const context = new Context(config, mockMgmtClient()); + await context.loadAssetsFromLocal(); + + expect(context.assets.prompts).to.deep.equal({ + ...mockPromptsSettings, + customText: {}, + partials: {}, + }); }); - it('should parse even if both files are absent', async () => { + it('should parse even if all files are absent', async () => { cleanThenMkdir(promptsDirectory); const emptyPromptsDirectory = { [constants.PROMPTS_DIRECTORY]: {}, @@ -129,7 +204,7 @@ describe('#directory context prompts', () => { const context = new Context(config, mockMgmtClient()); await context.loadAssetsFromLocal(); - expect(context.assets.prompts).to.deep.equal({ customText: {} }); + expect(context.assets.prompts).to.deep.equal({ customText: {}, partials: {} }); }); }); @@ -182,6 +257,20 @@ describe('#directory context prompts', () => { }, }, }, + partials: { + login: [ + { + name: 'form-content-start', + template: './partials/login/form-content-start.liquid', + }, + ], + signup: [ + { + name: 'form-content-end', + template: './partials/signup/form-content-end.liquid', + }, + ], + }, }; await promptsHandler.dump(context); @@ -190,11 +279,15 @@ describe('#directory context prompts', () => { expect(dumpedFiles).to.deep.equal([ path.join(promptsDirectory, customTextFile), + path.join(promptsDirectory, partialsFile), path.join(promptsDirectory, promptsSettingsFile), ]); expect(loadJSON(path.join(promptsDirectory, customTextFile), {})).to.deep.equal( - context.assets.prompts.customText + context.assets.prompts?.customText + ); + expect(loadJSON(path.join(promptsDirectory, partialsFile), {})).to.deep.equal( + context.assets.prompts?.partials ); expect(loadJSON(path.join(promptsDirectory, promptsSettingsFile), {})).to.deep.equal({ universal_login_experience: context.assets.prompts.universal_login_experience, diff --git a/test/context/yaml/context.test.js b/test/context/yaml/context.test.js index 0d46ce645..c6578cea7 100644 --- a/test/context/yaml/context.test.js +++ b/test/context/yaml/context.test.js @@ -282,6 +282,7 @@ describe('#YAML context validation', () => { logStreams: [], prompts: { customText: {}, + partials: {}, }, customDomains: [], themes: [], @@ -394,6 +395,7 @@ describe('#YAML context validation', () => { logStreams: [], prompts: { customText: {}, + partials: {}, }, customDomains: [], themes: [], @@ -507,6 +509,7 @@ describe('#YAML context validation', () => { logStreams: [], prompts: { customText: {}, + partials: {}, }, customDomains: [], themes: [], diff --git a/test/context/yaml/prompts.test.ts b/test/context/yaml/prompts.test.ts index ff815ccd7..ced558b40 100644 --- a/test/context/yaml/prompts.test.ts +++ b/test/context/yaml/prompts.test.ts @@ -62,6 +62,13 @@ describe('#YAML context prompts', () => { passwordSecurityText: 'Your password must contain:' title: Create Your Account! usernamePlaceholder: Username + customPrompts: + login: + - name: form-content-start + template: ./templatePartials/login/form-content-start.liquid + signup: + - name: form-content-end + template: ./templatePartials/signup/form-content-end.liquid `; const yamlFile = path.join(dir, 'config.yaml'); @@ -131,6 +138,20 @@ describe('#YAML context prompts', () => { }, identifier_first: true, universal_login_experience: 'classic', + customPrompts: { + login: [ + { + name: 'form-content-start', + template: './templatePartials/login/form-content-start.liquid', + }, + ], + signup: [ + { + name: 'form-content-end', + template: './templatePartials/signup/form-content-end.liquid', + }, + ], + }, }); }); @@ -171,6 +192,20 @@ describe('#YAML context prompts', () => { }, }, }, + customPrompts: { + login: [ + { + name: 'form-content-start', + template: './templatePartials/login/form-content-start.liquid', + }, + ], + signup: [ + { + name: 'form-content-end', + template: './templatePartials/signup/form-content-end.liquid', + }, + ], + }, }; const dumped = await promptsHandler.dump(context); diff --git a/test/tools/auth0/handlers/prompts.tests.ts b/test/tools/auth0/handlers/prompts.tests.ts index 8e8ae6bf2..989fc5491 100644 --- a/test/tools/auth0/handlers/prompts.tests.ts +++ b/test/tools/auth0/handlers/prompts.tests.ts @@ -1,5 +1,5 @@ import { expect } from 'chai'; -import promptsHandler from '../../../../src/tools/auth0/handlers/prompts'; +import promptsHandler, { Prompts } from '../../../../src/tools/auth0/handlers/prompts'; import { Language } from '../../../../src/types'; import _ from 'lodash'; import { PromisePoolExecutor } from 'promise-pool-executor'; @@ -11,7 +11,7 @@ const mockPromptsSettings = { describe('#prompts handler', () => { describe('#prompts process', () => { - it('should get prompts settings and prompts custom text', async () => { + it('should get prompts settings, custom texts and template partials', async () => { const supportedLanguages: Language[] = ['es', 'fr', 'en']; const englishCustomText = { @@ -47,6 +47,18 @@ describe('#prompts handler', () => { 'signup-password': {}, 'mfa-webauthn': {}, }; // Has no prompts configured. + const templatePartials: Prompts['partials'] = { + login: { + login: { + 'form-content-start': '
TEST
', + }, + }, + 'signup-id': { + 'signup-id': { + 'form-content-start': '
TEST
', + }, + }, + }; const auth0 = { tenant: { @@ -70,6 +82,8 @@ describe('#prompts handler', () => { return Promise.resolve(customTextValue); }, + getPartials: ({ prompt }) => Promise.resolve(_.cloneDeep(templatePartials[prompt])), + updatePartials: ({ prompt }, body) => Promise.resolve(body), }, pool: new PromisePoolExecutor({ concurrencyLimit: 3, @@ -80,6 +94,7 @@ describe('#prompts handler', () => { const handler = new promptsHandler({ client: auth0 }); const data = await handler.getType(); + expect(data).to.deep.equal({ ...mockPromptsSettings, customText: { @@ -92,12 +107,14 @@ describe('#prompts handler', () => { }, //does not have spanish custom text because all responses returned empty objects }, + partials: templatePartials, }); }); - it('should update prompts settings but not custom text settings if not set', async () => { + it('should update prompts settings but not custom text/partials settings if not set', async () => { let didCallUpdatePromptsSettings = false; let didCallUpdateCustomText = false; + let didCallUpdatePartials = false; const auth0 = { tenant: { @@ -106,6 +123,9 @@ describe('#prompts handler', () => { }), }, prompts: { + updatePartials: () => { + didCallUpdatePartials = true; + }, updateCustomTextByLanguage: () => { didCallUpdateCustomText = true; }, @@ -124,12 +144,15 @@ describe('#prompts handler', () => { await stageFn.apply(handler, [{ prompts: { ...mockPromptsSettings, customText } }]); expect(didCallUpdatePromptsSettings).to.equal(true); expect(didCallUpdateCustomText).to.equal(false); + expect(didCallUpdatePartials).to.equal(false); }); - it('should update prompts settings and custom text settings when both are set', async () => { + it('should update prompts settings and custom text/partials settings when set', async () => { let didCallUpdatePromptsSettings = false; let didCallUpdateCustomText = false; + let didCallUpdatePartials = false; let numberOfUpdateCustomTextCalls = 0; + let numberOfUpdatePartialsCalls = 0; const customTextToSet = { en: { @@ -149,8 +172,31 @@ describe('#prompts handler', () => { }, }; + const partialsToSet: Prompts['partials'] = { + login: { + login: { + 'form-content-start': '
TEST
', + }, + }, + 'signup-id': { + 'signup-id': { + 'form-content-start': '
TEST
', + }, + }, + 'signup-password': { + 'signup-password': { + 'form-content-start': '
TEST
', + }, + }, + }; + const auth0 = { prompts: { + updatePartials: () => { + didCallUpdatePartials = true; + numberOfUpdatePartialsCalls++; + return Promise.resolve({}); + }, updateCustomTextByLanguage: () => { didCallUpdateCustomText = true; numberOfUpdateCustomTextCalls++; @@ -172,16 +218,17 @@ describe('#prompts handler', () => { const handler = new promptsHandler({ client: auth0 }); const stageFn = Object.getPrototypeOf(handler).processChanges; - await stageFn.apply(handler, [ - { prompts: { ...mockPromptsSettings, customText: customTextToSet } }, + { prompts: { ...mockPromptsSettings, customText: customTextToSet, partials: partialsToSet } }, ]); expect(didCallUpdatePromptsSettings).to.equal(true); expect(didCallUpdateCustomText).to.equal(true); + expect(didCallUpdatePartials).to.equal(true); expect(numberOfUpdateCustomTextCalls).to.equal(3); + expect(numberOfUpdatePartialsCalls).to.equal(3); }); - it('should not fail if tenant languages undefined', async () => { + it('should not fail if tenant languages or partials are undefined', async () => { const auth0 = { tenant: { getSettings: () => @@ -191,6 +238,7 @@ describe('#prompts handler', () => { }, prompts: { getSettings: () => mockPromptsSettings, + getPartials: async () => {}, }, pool: new PromisePoolExecutor({ concurrencyLimit: 3, @@ -204,6 +252,7 @@ describe('#prompts handler', () => { expect(data).to.deep.equal({ ...mockPromptsSettings, customText: {}, // Custom text empty + partials: {}, // Partials empty }); }); }); diff --git a/test/utils.js b/test/utils.js index 4a61197a3..daf501879 100644 --- a/test/utils.js +++ b/test/utils.js @@ -124,6 +124,7 @@ export function mockMgmtClient() { new Promise((res) => { res({}); }), + getPartials: async () => ({}), getSettings: () => {}, }, customDomains: { getAll: () => [] },