diff --git a/auth/arm-auth.js b/auth/arm-auth.js index 9fea5ad9a..dd4001e3d 100644 --- a/auth/arm-auth.js +++ b/auth/arm-auth.js @@ -1,36 +1,41 @@ -const { InteractiveBrowserCredential } = require('@azure/identity'); - -/** - * Gets authentication ARM token using interactive browser authentication - * @param {Object} options - Configuration options - * @param {string} [options.tenantId] - Optional tenant ID to override default tenant - * @param {string} [options.clientId] - Optional client ID for the application - * @returns {Promise} - Bearer token for portal.azure.com - */ -async function getArmToken(options = {}) { - try { - const credential = new InteractiveBrowserCredential({ - tenantId: options.tenantId, - clientId: options.clientId - }); - console.log("Please sign in via the browser window that will open..."); - - // Get the token - this will open a browser window for authentication - const scope = "https://management.azure.com/user_impersonation"; - const response = await credential.getToken(scope); - - if (response && response.token) { - console.log("Successfully acquired token with expiration at:", (new Date(response.expiresOnTimestamp)).toLocaleString()); - return `${response.tokenType} ${response.token}`; - } else { - throw new Error("Failed to acquire token: Empty response"); - } - } catch (error) { - console.error("Error acquiring portal token:", error.message); - throw error; - } -} - -module.exports = { - getArmToken -}; +const { InteractiveBrowserCredential } = require('@azure/identity'); + +/** + * Gets authentication ARM token using interactive browser authentication + * @param {Object} options - Configuration options + * @param {string} [options.tenantId] - Optional tenant ID to override default tenant + * @param {string} [options.clientId] - Optional client ID for the application + * @returns {Promise} - Bearer token for portal.azure.com + */ +async function getArmToken(options = {}) { + try { + const credential = new InteractiveBrowserCredential({ + tenantId: options.tenantId, + clientId: options.clientId, + loginStyle: "popup" + }); + + console.log("Please sign in via the browser window that will open..."); + + // Get the token - this will open a browser window for authentication + const scope = "https://management.azure.com/user_impersonation"; + const response = await credential.getToken(scope); + + if (response && response.token) { + console.log("Successfully acquired token with expiration at:", (new Date(response.expiresOnTimestamp)).toLocaleString()); + + return `${response.tokenType} ${response.token}`; + } + else { + throw new Error("Failed to acquire token: Empty response"); + } + } + catch (error) { + console.error("Error acquiring portal token:", error.message); + throw error; + } +} + +module.exports = { + getArmToken +}; diff --git a/package-lock.json b/package-lock.json index 23cfe4450..756d4e4f6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -21,12 +21,12 @@ "@microsoft/applicationinsights-web": "^3.0.2", "@monaco-editor/loader": "^1.3.3", "@monaco-editor/react": "^4.6.0", - "@paperbits/azure": "0.1.658", - "@paperbits/common": "0.1.658", - "@paperbits/core": "0.1.658", - "@paperbits/forms": "0.1.658", + "@paperbits/azure": "0.1.661", + "@paperbits/common": "0.1.661", + "@paperbits/core": "0.1.661", + "@paperbits/forms": "0.1.661", "@paperbits/react": "1.0.10", - "@paperbits/styles": "0.1.658", + "@paperbits/styles": "0.1.661", "@webcomponents/custom-elements": "1.6.0", "@webcomponents/shadydom": "^1.11.0", "client-oauth2": "4.3.3", @@ -144,18 +144,52 @@ } }, "node_modules/@azure-rest/core-client": { - "version": "1.0.0", + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@azure-rest/core-client/-/core-client-1.4.0.tgz", + "integrity": "sha512-ozTDPBVUDR5eOnMIwhggbnVmOrka4fXCs8n8mvUo4WLLc38kki6bAOByDoVZZPz/pZy2jMt2kwfpvy/UjALj6w==", "license": "MIT", "dependencies": { + "@azure/abort-controller": "^2.0.0", "@azure/core-auth": "^1.3.0", "@azure/core-rest-pipeline": "^1.5.0", + "@azure/core-tracing": "^1.0.1", "@azure/core-util": "^1.0.0", - "tslib": "^2.2.0" + "tslib": "^2.6.2" }, "engines": { - "node": ">=12.0.0" + "node": ">=18.0.0" + } + }, + "node_modules/@azure-rest/core-client/node_modules/@azure/abort-controller": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/@azure/abort-controller/-/abort-controller-2.1.2.tgz", + "integrity": "sha512-nBrLsEWm4J2u5LpAPjxADTlq3trDgVZZXHNKabeXZtpq3d3AbN/KGO82R87rdDz5/lYB024rtEf10/q0urNgsA==", + "license": "MIT", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@azure-rest/core-client/node_modules/@azure/core-tracing": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@azure/core-tracing/-/core-tracing-1.3.1.tgz", + "integrity": "sha512-9MWKevR7Hz8kNzzPLfX4EAtGM2b8mr50HPDBvio96bURP/9C+HjdH3sBlLSNNrvRAr5/k/svoH457gB5IKpmwQ==", + "license": "MIT", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" } }, + "node_modules/@azure-rest/core-client/node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, "node_modules/@azure/abort-controller": { "version": "1.0.4", "license": "MIT", @@ -352,25 +386,25 @@ } }, "node_modules/@azure/api-management-custom-widgets-tools": { - "version": "1.0.0-beta.2", + "version": "1.0.0-beta.3", + "resolved": "https://registry.npmjs.org/@azure/api-management-custom-widgets-tools/-/api-management-custom-widgets-tools-1.0.0-beta.3.tgz", + "integrity": "sha512-wu2dSLRhumJl8/MXZleLLEHJA+crtzZN104MKzQyNcHZ77cl+ur+5u1iY0ZVvYykNHkfnfUTKbCvL+DcdWNoiw==", "license": "MIT", "dependencies": { - "@azure-rest/core-client": "^1.0.0-beta.10", - "@azure/identity": "^3.3.0", - "@azure/storage-blob": "^12.9.0", - "@rollup/plugin-node-resolve": "^13.1.3", - "mime": "^3.0.0", - "mustache": "^4.2.0", - "prettier": "^2.5.1", - "rollup": "^2.66.1", - "tslib": "^2.2.0" + "@azure-rest/core-client": "^1.3.1", + "@azure/identity": "^4.0.1", + "@azure/storage-blob": "^12.17.0", + "mime": "^4.0.1", + "tslib": "^2.6.2" }, "engines": { - "node": ">=14.0.0" + "node": ">=20.0.0" } }, - "node_modules/@azure/api-management-custom-widgets-tools/node_modules/@azure/core-tracing": { - "version": "1.2.0", + "node_modules/@azure/api-management-custom-widgets-tools/node_modules/@azure/abort-controller": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/@azure/abort-controller/-/abort-controller-2.1.2.tgz", + "integrity": "sha512-nBrLsEWm4J2u5LpAPjxADTlq3trDgVZZXHNKabeXZtpq3d3AbN/KGO82R87rdDz5/lYB024rtEf10/q0urNgsA==", "license": "MIT", "dependencies": { "tslib": "^2.6.2" @@ -379,53 +413,53 @@ "node": ">=18.0.0" } }, - "node_modules/@azure/api-management-custom-widgets-tools/node_modules/@azure/identity": { - "version": "3.4.2", + "node_modules/@azure/api-management-custom-widgets-tools/node_modules/@azure/core-tracing": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@azure/core-tracing/-/core-tracing-1.3.1.tgz", + "integrity": "sha512-9MWKevR7Hz8kNzzPLfX4EAtGM2b8mr50HPDBvio96bURP/9C+HjdH3sBlLSNNrvRAr5/k/svoH457gB5IKpmwQ==", "license": "MIT", "dependencies": { - "@azure/abort-controller": "^1.0.0", - "@azure/core-auth": "^1.5.0", - "@azure/core-client": "^1.4.0", - "@azure/core-rest-pipeline": "^1.1.0", - "@azure/core-tracing": "^1.0.0", - "@azure/core-util": "^1.6.1", - "@azure/logger": "^1.0.0", - "@azure/msal-browser": "^3.5.0", - "@azure/msal-node": "^2.5.1", - "events": "^3.0.0", - "jws": "^4.0.0", - "open": "^8.0.0", - "stoppable": "^1.1.0", - "tslib": "^2.2.0" + "tslib": "^2.6.2" }, "engines": { - "node": ">=14.0.0" + "node": ">=20.0.0" } }, - "node_modules/@azure/api-management-custom-widgets-tools/node_modules/@azure/msal-browser": { - "version": "3.28.1", + "node_modules/@azure/api-management-custom-widgets-tools/node_modules/@azure/storage-blob": { + "version": "12.28.0", + "resolved": "https://registry.npmjs.org/@azure/storage-blob/-/storage-blob-12.28.0.tgz", + "integrity": "sha512-VhQHITXXO03SURhDiGuHhvc/k/sD2WvJUS7hqhiVNbErVCuQoLtWql7r97fleBlIRKHJaa9R7DpBjfE0pfLYcA==", "license": "MIT", "dependencies": { - "@azure/msal-common": "14.16.0" + "@azure/abort-controller": "^2.1.2", + "@azure/core-auth": "^1.9.0", + "@azure/core-client": "^1.9.3", + "@azure/core-http-compat": "^2.2.0", + "@azure/core-lro": "^2.2.0", + "@azure/core-paging": "^1.6.2", + "@azure/core-rest-pipeline": "^1.19.1", + "@azure/core-tracing": "^1.2.0", + "@azure/core-util": "^1.11.0", + "@azure/core-xml": "^1.4.5", + "@azure/logger": "^1.1.4", + "@azure/storage-common": "^12.0.0-beta.2", + "events": "^3.0.0", + "tslib": "^2.8.1" }, "engines": { - "node": ">=0.8.0" + "node": ">=20.0.0" } }, - "node_modules/@azure/api-management-custom-widgets-tools/node_modules/@azure/msal-common": { - "version": "14.16.0", - "license": "MIT", - "engines": { - "node": ">=0.8.0" - } - }, - "node_modules/@azure/api-management-custom-widgets-tools/node_modules/@azure/msal-node": { - "version": "2.16.2", + "node_modules/@azure/api-management-custom-widgets-tools/node_modules/mime": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-4.1.0.tgz", + "integrity": "sha512-X5ju04+cAzsojXKes0B/S4tcYtFAJ6tTMuSPBEn9CPGlrWr8Fiw7qYeLT0XyH80HSoAoqWCaz+MWKh22P7G1cw==", + "funding": [ + "https://github.com/sponsors/broofa" + ], "license": "MIT", - "dependencies": { - "@azure/msal-common": "14.16.0", - "jsonwebtoken": "^9.0.0", - "uuid": "^8.3.0" + "bin": { + "mime": "bin/cli.js" }, "engines": { "node": ">=16" @@ -433,12 +467,10 @@ }, "node_modules/@azure/api-management-custom-widgets-tools/node_modules/tslib": { "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "license": "0BSD" }, - "node_modules/@azure/core-asynciterator-polyfill": { - "version": "1.0.0", - "license": "MIT" - }, "node_modules/@azure/core-auth": { "version": "1.9.0", "license": "MIT", @@ -507,6 +539,7 @@ }, "node_modules/@azure/core-http": { "version": "3.0.1", + "dev": true, "license": "MIT", "dependencies": { "@azure/abort-controller": "^1.0.0", @@ -562,6 +595,7 @@ }, "node_modules/@azure/core-http/node_modules/xml2js": { "version": "0.5.0", + "dev": true, "license": "MIT", "dependencies": { "sax": ">=0.6.0", @@ -585,16 +619,23 @@ } }, "node_modules/@azure/core-paging": { - "version": "1.2.0", + "version": "1.6.2", + "resolved": "https://registry.npmjs.org/@azure/core-paging/-/core-paging-1.6.2.tgz", + "integrity": "sha512-YKWi9YuCU04B55h25cnOYZHxXYtEvQEbKST5vqRga7hWY9ydd3FZHdeQF8pyh+acWZvppw13M/LMGx0LABUVMA==", "license": "MIT", "dependencies": { - "@azure/core-asynciterator-polyfill": "^1.0.0", - "tslib": "^2.2.0" + "tslib": "^2.6.2" }, "engines": { - "node": ">=12.0.0" + "node": ">=18.0.0" } }, + "node_modules/@azure/core-paging/node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, "node_modules/@azure/core-rest-pipeline": { "version": "1.21.0", "resolved": "https://registry.npmjs.org/@azure/core-rest-pipeline/-/core-rest-pipeline-1.21.0.tgz", @@ -694,7 +735,6 @@ }, "node_modules/@azure/identity": { "version": "4.8.0", - "dev": true, "license": "MIT", "dependencies": { "@azure/abort-controller": "^2.0.0", @@ -718,7 +758,6 @@ }, "node_modules/@azure/identity/node_modules/@azure/abort-controller": { "version": "2.1.2", - "dev": true, "license": "MIT", "dependencies": { "tslib": "^2.6.2" @@ -729,7 +768,6 @@ }, "node_modules/@azure/identity/node_modules/@azure/core-tracing": { "version": "1.0.1", - "dev": true, "license": "MIT", "dependencies": { "tslib": "^2.2.0" @@ -740,7 +778,6 @@ }, "node_modules/@azure/identity/node_modules/@azure/msal-browser": { "version": "4.9.1", - "dev": true, "license": "MIT", "dependencies": { "@azure/msal-common": "15.4.0" @@ -751,7 +788,6 @@ }, "node_modules/@azure/identity/node_modules/define-lazy-prop": { "version": "3.0.0", - "dev": true, "license": "MIT", "engines": { "node": ">=12" @@ -762,7 +798,6 @@ }, "node_modules/@azure/identity/node_modules/is-wsl": { "version": "3.1.0", - "dev": true, "license": "MIT", "dependencies": { "is-inside-container": "^1.0.0" @@ -776,7 +811,6 @@ }, "node_modules/@azure/identity/node_modules/open": { "version": "10.1.0", - "dev": true, "license": "MIT", "dependencies": { "default-browser": "^5.2.1", @@ -793,19 +827,76 @@ }, "node_modules/@azure/identity/node_modules/tslib": { "version": "2.8.1", - "dev": true, "license": "0BSD" }, "node_modules/@azure/logger": { - "version": "1.0.3", + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@azure/logger/-/logger-1.3.0.tgz", + "integrity": "sha512-fCqPIfOcLE+CGqGPd66c8bZpwAji98tZ4JI9i/mlTNTlsIWslCfpg48s/ypyLxZTump5sypjrKn2/kY7q8oAbA==", "license": "MIT", "dependencies": { - "tslib": "^2.2.0" + "@typespec/ts-http-runtime": "^0.3.0", + "tslib": "^2.6.2" }, "engines": { - "node": ">=12.0.0" + "node": ">=20.0.0" + } + }, + "node_modules/@azure/logger/node_modules/@typespec/ts-http-runtime": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/@typespec/ts-http-runtime/-/ts-http-runtime-0.3.1.tgz", + "integrity": "sha512-SnbaqayTVFEA6/tYumdF0UmybY0KHyKwGPBXnyckFlrrKdhWFrL3a2HIPXHjht5ZOElKGcXfD2D63P36btb+ww==", + "license": "MIT", + "dependencies": { + "http-proxy-agent": "^7.0.0", + "https-proxy-agent": "^7.0.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@azure/logger/node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/@azure/logger/node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/@azure/logger/node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" } }, + "node_modules/@azure/logger/node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, "node_modules/@azure/msal-browser": { "version": "2.38.0", "license": "MIT", @@ -825,7 +916,6 @@ }, "node_modules/@azure/msal-common": { "version": "15.4.0", - "dev": true, "license": "MIT", "engines": { "node": ">=0.8.0" @@ -833,7 +923,6 @@ }, "node_modules/@azure/msal-node": { "version": "3.4.1", - "dev": true, "license": "MIT", "dependencies": { "@azure/msal-common": "15.4.0", @@ -846,6 +935,7 @@ }, "node_modules/@azure/storage-blob": { "version": "12.16.0", + "dev": true, "license": "MIT", "dependencies": { "@azure/abort-controller": "^1.0.0", @@ -861,6 +951,56 @@ "node": ">=14.0.0" } }, + "node_modules/@azure/storage-common": { + "version": "12.0.0", + "resolved": "https://registry.npmjs.org/@azure/storage-common/-/storage-common-12.0.0.tgz", + "integrity": "sha512-QyEWXgi4kdRo0wc1rHum9/KnaWZKCdQGZK1BjU4fFL6Jtedp7KLbQihgTTVxldFy1z1ZPtuDPx8mQ5l3huPPbA==", + "license": "MIT", + "dependencies": { + "@azure/abort-controller": "^2.1.2", + "@azure/core-auth": "^1.9.0", + "@azure/core-http-compat": "^2.2.0", + "@azure/core-rest-pipeline": "^1.19.1", + "@azure/core-tracing": "^1.2.0", + "@azure/core-util": "^1.11.0", + "@azure/logger": "^1.1.4", + "events": "^3.3.0", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@azure/storage-common/node_modules/@azure/abort-controller": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/@azure/abort-controller/-/abort-controller-2.1.2.tgz", + "integrity": "sha512-nBrLsEWm4J2u5LpAPjxADTlq3trDgVZZXHNKabeXZtpq3d3AbN/KGO82R87rdDz5/lYB024rtEf10/q0urNgsA==", + "license": "MIT", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@azure/storage-common/node_modules/@azure/core-tracing": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@azure/core-tracing/-/core-tracing-1.3.1.tgz", + "integrity": "sha512-9MWKevR7Hz8kNzzPLfX4EAtGM2b8mr50HPDBvio96bURP/9C+HjdH3sBlLSNNrvRAr5/k/svoH457gB5IKpmwQ==", + "license": "MIT", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@azure/storage-common/node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, "node_modules/@babel/code-frame": { "version": "7.16.0", "license": "MIT", @@ -2933,6 +3073,43 @@ "optional": true, "peer": true }, + "node_modules/@inquirer/external-editor": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@inquirer/external-editor/-/external-editor-1.0.2.tgz", + "integrity": "sha512-yy9cOoBnx58TlsPrIxauKIFQTiyH+0MK4e97y4sV9ERbI+zDxw7i2hxHLCIEGIE/8PPvDxGhgzIOTSOWcs6/MQ==", + "license": "MIT", + "dependencies": { + "chardet": "^2.1.0", + "iconv-lite": "^0.7.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/external-editor/node_modules/iconv-lite": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.0.tgz", + "integrity": "sha512-cf6L2Ds3h57VVmkZe+Pn+5APsT7FpqJtEhhieDCvrE2MK5Qk9MyffgQyuxQTm6BChfeZNtcOLHp9IcWRVcIcBQ==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.2", "dev": true, @@ -3209,13 +3386,13 @@ } }, "node_modules/@paperbits/azure": { - "version": "0.1.658", - "resolved": "https://registry.npmjs.org/@paperbits/azure/-/azure-0.1.658.tgz", - "integrity": "sha512-EztMxY/3uRnW3y7unTNY2vVthn/a2VYTZ9r7+rdzboGaz5czg6dnNXX9NlzlTXKW8p+P7apSMnvuzrpplUR4oQ==", + "version": "0.1.661", + "resolved": "https://registry.npmjs.org/@paperbits/azure/-/azure-0.1.661.tgz", + "integrity": "sha512-pJfx0uQlOKuOatcMUFWIYISBoF/dZtG8hoITxHirW8b1It3E2trjKfka5DpVSyf/a9qOx1b87L1TrabTAWF4aA==", "license": "MIT", "dependencies": { "@azure/storage-blob": "12.27.0", - "@paperbits/common": "0.1.658", + "@paperbits/common": "0.1.661", "applicationinsights-js": "1.0.21", "mime": "^3.0.0" } @@ -3275,9 +3452,9 @@ "license": "0BSD" }, "node_modules/@paperbits/common": { - "version": "0.1.658", - "resolved": "https://registry.npmjs.org/@paperbits/common/-/common-0.1.658.tgz", - "integrity": "sha512-EZxaoXgxTzLxHhN4xVINdGNI4jHME8Gm99+QsT5ga3gCMQ3mGme6M4FkboIE8+F7rZ6Fsyn556kdzC66yqujzw==", + "version": "0.1.661", + "resolved": "https://registry.npmjs.org/@paperbits/common/-/common-0.1.661.tgz", + "integrity": "sha512-lLLcIq9h/lN+R8zYEt7q7LDFE5SvSzKpg9ZgHSQPI0oT3M6yCYD9XQqYCWyPf2eDM6lmTRChjcdQzbfVbibalg==", "license": "MIT", "dependencies": { "@googlemaps/js-api-loader": "^1.13.2", @@ -3412,15 +3589,15 @@ } }, "node_modules/@paperbits/core": { - "version": "0.1.658", - "resolved": "https://registry.npmjs.org/@paperbits/core/-/core-0.1.658.tgz", - "integrity": "sha512-O0ULN5tw4fjR6kRmUbCnDaM2NZomrxP8K0Tku/7czkhR/X7bP/1XNq27iM1IlvWEwQbEHMTtdvzpLz1rYzQRhw==", + "version": "0.1.661", + "resolved": "https://registry.npmjs.org/@paperbits/core/-/core-0.1.661.tgz", + "integrity": "sha512-YAQ4k8UD/Xmc1MilJHyV4s16qmBHavPj70BD0Ls43SpEmoT6dXdDuHCDG1xFPBrmGcL3IUSyHY+u/PzF65i/AQ==", "license": "MIT", "dependencies": { "@googlemaps/js-api-loader": "^1.12.9", - "@paperbits/common": "0.1.658", - "@paperbits/prosemirror": "0.1.658", - "@paperbits/styles": "0.1.658", + "@paperbits/common": "0.1.661", + "@paperbits/prosemirror": "0.1.661", + "@paperbits/styles": "0.1.661", "await-parallel-limit": "^2.1.0", "basiclightbox": "^5.0.4", "cropperjs": "^1.5.11", @@ -3443,25 +3620,25 @@ } }, "node_modules/@paperbits/forms": { - "version": "0.1.658", - "resolved": "https://registry.npmjs.org/@paperbits/forms/-/forms-0.1.658.tgz", - "integrity": "sha512-WFuzYieiLLUKO2kri9RQBF07x1EsiSD6NLOnWL92L9HWD0nPi6CUXpSCwKFY1Ca0gsTQt4l4QxUcyF1rXU0AJw==", + "version": "0.1.661", + "resolved": "https://registry.npmjs.org/@paperbits/forms/-/forms-0.1.661.tgz", + "integrity": "sha512-7+7J33IWa3NaDGmOjlOf2neKib4yUTTVgf+n7iEF2kRh3awZ5vkdBk3J0xFXlRBLsSYkBkp7OeZ9xl7ca01YBg==", "license": "Commercial", "dependencies": { - "@paperbits/common": "0.1.658", - "@paperbits/core": "0.1.658", - "@paperbits/styles": "0.1.658", + "@paperbits/common": "0.1.661", + "@paperbits/core": "0.1.661", + "@paperbits/styles": "0.1.661", "knockout": "^3.5.1", "knockout.validation": "^2.0.4" } }, "node_modules/@paperbits/prosemirror": { - "version": "0.1.658", - "resolved": "https://registry.npmjs.org/@paperbits/prosemirror/-/prosemirror-0.1.658.tgz", - "integrity": "sha512-yzR107HCCMpshXXMuoExCNkXazMtY72Sz/SRhiXobK+Tdb/0FKHvWAz6MCWTDy0SuVG16qyd5D1eeihnVpKRsQ==", + "version": "0.1.661", + "resolved": "https://registry.npmjs.org/@paperbits/prosemirror/-/prosemirror-0.1.661.tgz", + "integrity": "sha512-/c9qsigEGUAx+7RUuRdCP8Kezp2UD3gf2C4zQFfnmmVeMoAuLRwTtjct0xDLGPzj/r3MmwrheDyi2g00HomaKA==", "license": "MIT", "dependencies": { - "@paperbits/common": "0.1.658", + "@paperbits/common": "0.1.661", "prosemirror-commands": "^1.5.2", "prosemirror-history": "^1.3.2", "prosemirror-inputrules": "^1.2.1", @@ -3641,12 +3818,12 @@ } }, "node_modules/@paperbits/styles": { - "version": "0.1.658", - "resolved": "https://registry.npmjs.org/@paperbits/styles/-/styles-0.1.658.tgz", - "integrity": "sha512-0sEQY64AbxIbtYj+6Jrx6LvJYmUYE/c7WmQbUsxrJ4drIxqUs3gu0QO8CAz9izGNL2qVU986D2H/xprU8S++wg==", + "version": "0.1.661", + "resolved": "https://registry.npmjs.org/@paperbits/styles/-/styles-0.1.661.tgz", + "integrity": "sha512-hnOIv7IZvvzMnsQEY8yNSoZXgg/FBVarcFsS0GOWMKS49C1Ff05/9zJvw4sZUYduQvyLlbiqV8W9P/EFISsHVw==", "license": "MIT", "dependencies": { - "@paperbits/common": "0.1.658", + "@paperbits/common": "0.1.661", "@simonwep/pickr": "^1.7.4", "jss": "^10.4.0", "jss-preset-default": "^10.4.0", @@ -3703,43 +3880,6 @@ "node": ">=16" } }, - "node_modules/@rollup/plugin-node-resolve": { - "version": "13.3.0", - "license": "MIT", - "dependencies": { - "@rollup/pluginutils": "^3.1.0", - "@types/resolve": "1.17.1", - "deepmerge": "^4.2.2", - "is-builtin-module": "^3.1.0", - "is-module": "^1.0.0", - "resolve": "^1.19.0" - }, - "engines": { - "node": ">= 10.0.0" - }, - "peerDependencies": { - "rollup": "^2.42.0" - } - }, - "node_modules/@rollup/pluginutils": { - "version": "3.1.0", - "license": "MIT", - "dependencies": { - "@types/estree": "0.0.39", - "estree-walker": "^1.0.1", - "picomatch": "^2.2.2" - }, - "engines": { - "node": ">= 8.0.0" - }, - "peerDependencies": { - "rollup": "^1.20.0||^2.0.0" - } - }, - "node_modules/@rollup/pluginutils/node_modules/@types/estree": { - "version": "0.0.39", - "license": "MIT" - }, "node_modules/@rollup/rollup-android-arm-eabi": { "version": "4.45.1", "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.45.1.tgz", @@ -4363,6 +4503,7 @@ }, "node_modules/@types/node-fetch": { "version": "2.5.12", + "dev": true, "license": "MIT", "dependencies": { "@types/node": "*", @@ -4370,12 +4511,17 @@ } }, "node_modules/@types/node-fetch/node_modules/form-data": { - "version": "3.0.1", + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-3.0.4.tgz", + "integrity": "sha512-f0cRzm6dkyVYV3nPoooP8XlccPQukegwhAnpoLcXy+X+A8KfpGOoXwDr9FLZd3wzgLaBGQBE3lY93Zm/i1JvIQ==", + "dev": true, "license": "MIT", "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", - "mime-types": "^2.1.12" + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.35" }, "engines": { "node": ">= 6" @@ -4416,13 +4562,6 @@ "@types/react": "*" } }, - "node_modules/@types/resolve": { - "version": "1.17.1", - "license": "MIT", - "dependencies": { - "@types/node": "*" - } - }, "node_modules/@types/retry": { "version": "0.12.1", "dev": true, @@ -4486,6 +4625,7 @@ }, "node_modules/@types/tunnel": { "version": "0.0.3", + "dev": true, "license": "MIT", "dependencies": { "@types/node": "*" @@ -5535,7 +5675,6 @@ }, "node_modules/bundle-name": { "version": "4.1.0", - "dev": true, "license": "MIT", "dependencies": { "run-applescript": "^7.0.0" @@ -5574,7 +5713,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", - "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0", @@ -5729,7 +5867,9 @@ } }, "node_modules/chardet": { - "version": "0.7.0", + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/chardet/-/chardet-2.1.0.tgz", + "integrity": "sha512-bNFETTG/pM5ryzQ9Ad0lJOTa6HWD/YsScAR3EnCPZRPlQh77JocYktSHOUHelyhm8IARL+o4c4F1bP5KVOjiRA==", "license": "MIT" }, "node_modules/check-error": { @@ -7035,7 +7175,6 @@ }, "node_modules/default-browser": { "version": "5.2.1", - "dev": true, "license": "MIT", "dependencies": { "bundle-name": "^4.1.0", @@ -7050,7 +7189,6 @@ }, "node_modules/default-browser-id": { "version": "5.0.0", - "dev": true, "license": "MIT", "engines": { "node": ">=18" @@ -7079,6 +7217,7 @@ }, "node_modules/define-lazy-prop": { "version": "2.0.0", + "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -7291,7 +7430,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", - "dev": true, "license": "MIT", "dependencies": { "call-bind-apply-helpers": "^1.0.1", @@ -7434,7 +7572,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -7444,7 +7581,6 @@ "version": "1.3.0", "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -7459,7 +7595,6 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", - "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0" @@ -7468,6 +7603,21 @@ "node": ">= 0.4" } }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/escalade": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", @@ -7757,10 +7907,6 @@ "node": ">=4.0" } }, - "node_modules/estree-walker": { - "version": "1.0.1", - "license": "MIT" - }, "node_modules/esutils": { "version": "2.0.3", "license": "BSD-2-Clause", @@ -7919,18 +8065,6 @@ "version": "3.0.2", "license": "MIT" }, - "node_modules/external-editor": { - "version": "3.1.0", - "license": "MIT", - "dependencies": { - "chardet": "^0.7.0", - "iconv-lite": "^0.4.24", - "tmp": "^0.0.33" - }, - "engines": { - "node": ">=4" - } - }, "node_modules/extract-files": { "version": "11.0.0", "license": "MIT", @@ -8217,11 +8351,15 @@ } }, "node_modules/form-data": { - "version": "4.0.0", + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.4.tgz", + "integrity": "sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==", "license": "MIT", "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", "mime-types": "^2.1.12" }, "engines": { @@ -8338,7 +8476,6 @@ "version": "1.3.0", "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", - "dev": true, "license": "MIT", "dependencies": { "call-bind-apply-helpers": "^1.0.2", @@ -8363,7 +8500,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", - "dev": true, "license": "MIT", "dependencies": { "dunder-proto": "^1.0.1", @@ -8450,7 +8586,6 @@ "version": "1.2.0", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -8631,7 +8766,6 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -8640,6 +8774,21 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/hasown": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", @@ -9104,6 +9253,7 @@ }, "node_modules/iconv-lite": { "version": "0.4.24", + "dev": true, "license": "MIT", "dependencies": { "safer-buffer": ">= 2.1.2 < 3" @@ -9257,14 +9407,16 @@ "license": "MIT" }, "node_modules/inquirer": { - "version": "8.2.4", + "version": "8.2.7", + "resolved": "https://registry.npmjs.org/inquirer/-/inquirer-8.2.7.tgz", + "integrity": "sha512-UjOaSel/iddGZJ5xP/Eixh6dY1XghiBw4XK13rCCIJcJfyhhoul/7KhLLUGtebEj6GDYM6Vnx/mVsjx2L/mFIA==", "license": "MIT", "dependencies": { + "@inquirer/external-editor": "^1.0.0", "ansi-escapes": "^4.2.1", "chalk": "^4.1.1", "cli-cursor": "^3.1.0", "cli-width": "^3.0.0", - "external-editor": "^3.0.3", "figures": "^3.0.0", "lodash": "^4.17.21", "mute-stream": "0.0.8", @@ -9274,12 +9426,26 @@ "string-width": "^4.1.0", "strip-ansi": "^6.0.0", "through": "^2.3.6", - "wrap-ansi": "^7.0.0" + "wrap-ansi": "^6.0.1" }, "engines": { "node": ">=12.0.0" } }, + "node_modules/inquirer/node_modules/wrap-ansi": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", + "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/internmap": { "version": "2.0.3", "license": "ISC", @@ -9404,6 +9570,7 @@ }, "node_modules/is-docker": { "version": "2.2.1", + "dev": true, "license": "MIT", "bin": { "is-docker": "cli.js" @@ -9455,7 +9622,6 @@ }, "node_modules/is-inside-container": { "version": "1.0.0", - "dev": true, "license": "MIT", "dependencies": { "is-docker": "^3.0.0" @@ -9472,7 +9638,6 @@ }, "node_modules/is-inside-container/node_modules/is-docker": { "version": "3.0.0", - "dev": true, "license": "MIT", "bin": { "is-docker": "cli.js" @@ -9582,6 +9747,7 @@ }, "node_modules/is-wsl": { "version": "2.2.0", + "dev": true, "license": "MIT", "dependencies": { "is-docker": "^2.0.0" @@ -10294,7 +10460,6 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -11632,6 +11797,7 @@ }, "node_modules/open": { "version": "8.4.0", + "dev": true, "license": "MIT", "dependencies": { "define-lazy-prop": "^2.0.0", @@ -11703,13 +11869,6 @@ "integrity": "sha512-TvAWxi0nDe1j/rtMcWcIj94+Ffe6n7zhow33h40SKxmsmozs6dz/e+EajymfoFcHd7sxNn8yHM8839uixMOV6g==", "license": "MIT" }, - "node_modules/os-tmpdir": { - "version": "1.0.2", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/p-limit": { "version": "3.1.0", "license": "MIT", @@ -12296,6 +12455,7 @@ }, "node_modules/process": { "version": "0.11.10", + "dev": true, "license": "MIT", "engines": { "node": ">= 0.6.0" @@ -12371,9 +12531,9 @@ } }, "node_modules/prosemirror-model": { - "version": "1.25.2", - "resolved": "https://registry.npmjs.org/prosemirror-model/-/prosemirror-model-1.25.2.tgz", - "integrity": "sha512-BVypCAJ4SL6jOiTsDffP3Wp6wD69lRhI4zg/iT8JXjp3ccZFiq5WyguxvMKmdKFC3prhaig7wSr8dneDToHE1Q==", + "version": "1.25.3", + "resolved": "https://registry.npmjs.org/prosemirror-model/-/prosemirror-model-1.25.3.tgz", + "integrity": "sha512-dY2HdaNXlARknJbrManZ1WyUtos+AP97AmvqdOQtWtrrC5g4mohVX5DTi9rXNFSk09eczLq9GuNTtq3EfMeMGA==", "license": "MIT", "dependencies": { "orderedmap": "^2.0.0" @@ -12411,9 +12571,9 @@ } }, "node_modules/prosemirror-view": { - "version": "1.40.1", - "resolved": "https://registry.npmjs.org/prosemirror-view/-/prosemirror-view-1.40.1.tgz", - "integrity": "sha512-pbwUjt3G7TlsQQHDiYSupWBhJswpLVB09xXm1YiJPdkjkh9Pe7Y51XdLh5VWIZmROLY8UpUpG03lkdhm9lzIBA==", + "version": "1.41.2", + "resolved": "https://registry.npmjs.org/prosemirror-view/-/prosemirror-view-1.41.2.tgz", + "integrity": "sha512-PGS/jETmh+Qjmre/6vcG7SNHAKiGc4vKOJmHMPRmvcUl7ISuVtrtHmH06UDUwaim4NDJfZfVMl7U7JkMMETa6g==", "license": "MIT", "dependencies": { "prosemirror-model": "^1.20.0", @@ -12627,16 +12787,16 @@ "license": "MIT" }, "node_modules/react-syntax-highlighter": { - "version": "15.6.1", - "resolved": "https://registry.npmjs.org/react-syntax-highlighter/-/react-syntax-highlighter-15.6.1.tgz", - "integrity": "sha512-OqJ2/vL7lEeV5zTJyG7kmARppUjiB9h9udl4qHQjjgEos66z00Ia0OckwYfRxCSFrW8RJIBnsBwQsHZbVPspqg==", + "version": "15.6.6", + "resolved": "https://registry.npmjs.org/react-syntax-highlighter/-/react-syntax-highlighter-15.6.6.tgz", + "integrity": "sha512-DgXrc+AZF47+HvAPEmn7Ua/1p10jNoVZVI/LoPiYdtY+OM+/nG5yefLHKJwdKqY1adMuHFbeyBaG9j64ML7vTw==", "license": "MIT", "dependencies": { "@babel/runtime": "^7.3.1", "highlight.js": "^10.4.1", "highlightjs-vue": "^1.0.0", "lowlight": "^1.17.0", - "prismjs": "^1.27.0", + "prismjs": "^1.30.0", "refractor": "^3.6.0" }, "peerDependencies": { @@ -13072,21 +13232,6 @@ "version": "3.0.1", "license": "Unlicense" }, - "node_modules/rollup": { - "version": "2.79.2", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-2.79.2.tgz", - "integrity": "sha512-fS6iqSPZDs3dr/y7Od6y5nha8dW1YnbgtsyotCVvoFGKbERG++CVRFv1meyGDE1SNItQA8BrnCw7ScdAhRJ3XQ==", - "license": "MIT", - "bin": { - "rollup": "dist/bin/rollup" - }, - "engines": { - "node": ">=10.0.0" - }, - "optionalDependencies": { - "fsevents": "~2.3.2" - } - }, "node_modules/rope-sequence": { "version": "1.3.4", "resolved": "https://registry.npmjs.org/rope-sequence/-/rope-sequence-1.3.4.tgz", @@ -13102,7 +13247,6 @@ }, "node_modules/run-applescript": { "version": "7.0.0", - "dev": true, "license": "MIT", "engines": { "node": ">=18" @@ -13236,6 +13380,7 @@ }, "node_modules/sax": { "version": "1.2.4", + "dev": true, "license": "ISC" }, "node_modules/saxen": { @@ -14051,16 +14196,6 @@ "integrity": "sha512-lBN9zLN/oAf68o3zNXYrdCt1kP8WsiGW8Oo2ka41b2IM5JL/S1CTyX1rW0mb/zSuJun0ZUrDxx4sqvYS2FWzPA==", "license": "MIT" }, - "node_modules/tmp": { - "version": "0.0.33", - "license": "MIT", - "dependencies": { - "os-tmpdir": "~1.0.2" - }, - "engines": { - "node": ">=0.6.0" - } - }, "node_modules/to-regex-range": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", @@ -14236,6 +14371,7 @@ }, "node_modules/tunnel": { "version": "0.0.6", + "dev": true, "license": "MIT", "engines": { "node": ">=0.6.11 <=0.7.0 || >=0.7.3" @@ -15148,6 +15284,7 @@ }, "node_modules/xmlbuilder": { "version": "11.0.1", + "dev": true, "license": "MIT", "engines": { "node": ">=4.0" diff --git a/package.json b/package.json index 2588131f5..c30ffc524 100644 --- a/package.json +++ b/package.json @@ -25,6 +25,7 @@ "build-mock-static-data": "webpack --config webpack.mockStaticData.js && node dist/publisher/index.js", "build-static-data": "webpack --config webpack.staticData.js && node dist/publisher/index.js", "serve-static-website": "npm run build-static-data && npm run serve-website", + "serve-designer": "webpack serve --open --static ./dist/designer --no-stats", "lint": "eslint src/**/*.ts", "lint:fix": "eslint src/**/*.ts --fix" }, @@ -92,12 +93,12 @@ "@microsoft/applicationinsights-web": "^3.0.2", "@monaco-editor/loader": "^1.3.3", "@monaco-editor/react": "^4.6.0", - "@paperbits/azure": "0.1.658", - "@paperbits/common": "0.1.658", - "@paperbits/core": "0.1.658", - "@paperbits/forms": "0.1.658", + "@paperbits/azure": "0.1.661", + "@paperbits/common": "0.1.661", + "@paperbits/core": "0.1.661", + "@paperbits/forms": "0.1.661", "@paperbits/react": "1.0.10", - "@paperbits/styles": "0.1.658", + "@paperbits/styles": "0.1.661", "@webcomponents/custom-elements": "1.6.0", "@webcomponents/shadydom": "^1.11.0", "client-oauth2": "4.3.3", diff --git a/scripts.v2/capture.bat b/scripts.v2/capture.bat deleted file mode 100644 index 39f369219..000000000 --- a/scripts.v2/capture.bat +++ /dev/null @@ -1,7 +0,0 @@ -@REM Capture the content of an API Management portal into dest_folder - incl. pages, layouts, configuration, etc. but excluding media files - -set management_endpoint="< service name >.management.azure-api.net" -set access_token="SharedAccessSignature ..." -set dest_folder="../dist/snapshot" - -node ./capture %management_endpoint% %access_token% %dest_folder% \ No newline at end of file diff --git a/scripts.v2/capture.js b/scripts.v2/capture.js deleted file mode 100644 index 9f3d4adb1..000000000 --- a/scripts.v2/capture.js +++ /dev/null @@ -1,89 +0,0 @@ -const fs = require("fs"); -const path = require("path"); -const { request, downloadBlobs, getStorageSasTokenOrThrow, apiVersion } = require("./utils"); -const managementApiEndpoint = process.argv[2]; -const managementApiAccessToken = process.argv[3]; -const destinationFolder = process.argv[4]; - - -async function getContentTypes() { - try { - const data = await request("GET", `https://${managementApiEndpoint}/subscriptions/00000/resourceGroups/00000/providers/Microsoft.ApiManagement/service/00000/contentTypes?api-version=${apiVersion}`, managementApiAccessToken); - const contentTypes = data.value.map(x => x.id.replace("\/contentTypes\/", "")); - - return contentTypes; - } - catch (error) { - throw new Error(`Unable to fetch content types. ${error.message}`); - } -} - -async function getContentItems(contentType) { - try { - const contentItems = []; - let nextPageUrl = `https://${managementApiEndpoint}/subscriptions/00000/resourceGroups/00000/providers/Microsoft.ApiManagement/service/00000/contentTypes/${contentType}/contentItems?api-version=${apiVersion}`; - - do { - const data = await request("GET", nextPageUrl, managementApiAccessToken); - contentItems.push(...data.value); - - if (data.value.length > 0 && data.nextLink) { - nextPageUrl = data.nextLink; - } - else { - nextPageUrl = null; - } - } - while (nextPageUrl) - - return contentItems; - } - catch (error) { - throw new Error(`Unable to fetch content items. ${error.message}`); - } -} - -async function captureJson() { - try { - const result = {}; - const contentTypes = await getContentTypes(); - - for (const contentType of contentTypes) { - const contentItems = await getContentItems(contentType); - - contentItems.forEach(contentItem => { - result[contentItem.id] = contentItem; - delete contentItem.id; - }); - } - - await fs.promises.mkdir(path.resolve(destinationFolder), { recursive: true }); - - fs.writeFileSync(`${destinationFolder}/data.json`, JSON.stringify(result)); - } - catch (error) { - throw new Error(`Unable to capture content. ${error.message}`); - } -} - -async function capture() { - try { - const blobStorageUrl = await getStorageSasTokenOrThrow(managementApiEndpoint, managementApiAccessToken); - const localMediaFolder = `./${destinationFolder}/media`; - - await captureJson(); - await downloadBlobs(blobStorageUrl, localMediaFolder); - } - catch (error) { - throw new Error(`Unable to complete export. ${error.message}`); - } -} - -capture() - .then(() => { - console.log("DONE"); - }) - .catch(error => { - console.log(error.message); - process.exitCode = 1; - }); \ No newline at end of file diff --git a/scripts.v2/cleanup.bat b/scripts.v2/cleanup.bat deleted file mode 100644 index 6a061d8d7..000000000 --- a/scripts.v2/cleanup.bat +++ /dev/null @@ -1,6 +0,0 @@ -@REM Delete the content of an API Management portal - incl. pages, layouts, configuration, media files, etc. - -set management_endpoint="< service name >.management.azure-api.net" -set access_token="SharedAccessSignature ..." - -node ./cleanup %management_endpoint% %access_token% \ No newline at end of file diff --git a/scripts.v2/cleanup.js b/scripts.v2/cleanup.js deleted file mode 100644 index 9d71abaad..000000000 --- a/scripts.v2/cleanup.js +++ /dev/null @@ -1,66 +0,0 @@ -const { request, deleteBlobs, getStorageSasTokenOrThrow, apiVersion } = require("./utils"); -const managementApiEndpoint = process.argv[2]; -const managementApiAccessToken = process.argv[3]; - - -async function getContentTypes() { - try { - const data = await request("GET", `https://${managementApiEndpoint}/subscriptions/00000/resourceGroups/00000/providers/Microsoft.ApiManagement/service/00000/contentTypes?api-version=${apiVersion}`, managementApiAccessToken); - const contentTypes = data.value.map(x => x.id.replace("\/contentTypes\/", "")); - - return contentTypes; - } - catch (error) { - throw new Error(`Unable to fetch content types. ${error.message}`); - } -} - -async function getContentItems(contentType) { - try { - const data = await request("GET", `https://${managementApiEndpoint}/subscriptions/00000/resourceGroups/00000/providers/Microsoft.ApiManagement/service/00000/contentTypes/${contentType}/contentItems?api-version=${apiVersion}`, managementApiAccessToken); - const contentItems = data.value; - - return contentItems; - } - catch (error) { - throw new Error(`Unable to fetch content items. ${error.message}`); - } -} - -async function deleteContent() { - try { - const contentTypes = await getContentTypes(); - - for (const contentType of contentTypes) { - const contentItems = await getContentItems(contentType); - - for (const contentItem of contentItems) { - await request("DELETE", `https://${managementApiEndpoint}/subscriptions/00000/resourceGroups/00000/providers/Microsoft.ApiManagement/service/00000/${contentItem.id}?api-version=${apiVersion}`, managementApiAccessToken); - } - } - } - catch (error) { - throw new Error(`Unable to delete content. ${error.message}`); - } -} - -async function cleanup() { - try { - const blobStorageUrl = await getStorageSasTokenOrThrow(managementApiEndpoint, managementApiAccessToken); - - await deleteContent(); - await deleteBlobs(blobStorageUrl); - } - catch (error) { - throw new Error(`Unable to complete cleanup. ${error.message}`); - } -} - -cleanup() - .then(() => { - console.log("DONE"); - }) - .catch(error => { - console.log(error.message); - process.exitCode = 1; - }); \ No newline at end of file diff --git a/scripts.v2/configure.js b/scripts.v2/configure.js deleted file mode 100644 index 75c723896..000000000 --- a/scripts.v2/configure.js +++ /dev/null @@ -1,79 +0,0 @@ -const fs = require('fs'), - crypto = require('crypto'), - path = require('path'), - configDesignFile = path.join(__dirname, '\\..\\src\\config.design.json'), - configPublishFile = path.join(__dirname, '\\..\\src\\config.publish.json'), - configRuntimeFile = path.join(__dirname, '\\..\\src\\config.runtime.json'); - -const apimServiceNameValue = process.argv[2]; -const apimAccountKey = process.argv[3]; - -const managementEndpoint = `${apimServiceNameValue}.management.azure-api.net`; -const apimSasAccessTokenValue = createSharedAccessToken("integration", apimAccountKey, 14); -const backendUrlValue = `https://${apimServiceNameValue}.developer.azure-api.net`; - -const apimServiceUrlValue = `https://${managementEndpoint}/subscriptions/00000/resourceGroups/00000/providers/Microsoft.ApiManagement/service/${apimServiceNameValue}`; - -const apimServiceParameter = "managementApiUrl"; -const apimSasAccessTokenParameter = "managementApiAccessToken"; -const backendUrlParameter = "backendUrl"; - -fs.readFile(configDesignFile, { encoding: 'utf-8' }, function (err, data) { - if (!err) { - const obj = JSON.parse(data); - obj[apimServiceParameter] = apimServiceUrlValue; - obj[apimSasAccessTokenParameter] = apimSasAccessTokenValue; - obj[backendUrlParameter] = backendUrlValue; - fs.writeFile(configDesignFile, JSON.stringify(obj, null, 4), function (errWrite) { - if (errWrite) { - return console.log(errWrite); - } - }); - } else { - console.log(err); - } -}); - -fs.readFile(configPublishFile, { encoding: 'utf-8' }, function (err, data) { - if (!err) { - const obj = JSON.parse(data); - obj[apimServiceParameter] = apimServiceUrlValue; - obj[apimSasAccessTokenParameter] = apimSasAccessTokenValue; - fs.writeFile(configPublishFile, JSON.stringify(obj, null, 4), function (errWrite) { - if (errWrite) { - return console.log(errWrite); - } - }); - } else { - console.log(err); - } -}); - -fs.readFile(configRuntimeFile, { encoding: 'utf-8' }, function (err, data) { - if (!err) { - const obj = JSON.parse(data); - obj[apimServiceParameter] = apimServiceUrlValue; - obj[backendUrlParameter] = backendUrlValue; - fs.writeFile(configRuntimeFile, JSON.stringify(obj, null, 4), function (errWrite) { - if (errWrite) { - return console.log(errWrite); - } - }); - } else { - console.log(err); - } -}); - -function createSharedAccessToken(apimUid, apimAccessKey, validDays) { - - const expiryDate = new Date() - expiryDate.setDate(expiryDate.getDate() + validDays) - - let expiry = expiryDate.toISOString().replace(/\d+.\d+Z/, "00.0000000Z") - let expiryShort = expiryDate.toISOString().substr(0,16).replace(/[^\d]/g,'',) - - const signature = crypto.createHmac('sha512', apimAccessKey).update(`${apimUid}\n${expiry}`).digest('base64'); - const sasToken = `SharedAccessSignature ${apimUid}&${expiryShort}&${signature}`; - - return sasToken; -} \ No newline at end of file diff --git a/scripts.v2/deploy-default.bat b/scripts.v2/deploy-default.bat deleted file mode 100644 index 336faaacc..000000000 --- a/scripts.v2/deploy-default.bat +++ /dev/null @@ -1,25 +0,0 @@ -@REM Reprovision an existing API Management portal deployment - clean all the content, autogenerate new content, upload it, publish the portal, and host it - -cd .. -call npm install -cd scripts - -set apimServiceName="" -set management_endpoint=".management.azure-api.net/subscriptions/xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx/resourceGroups/xxxxx/providers/Microsoft.ApiManagement/service/" -set access_token="SharedAccessSignature integration&..." -set portalUrl="https://portalstorage.../" -set backendUrl="https://.developer.azure-api.net" - -set source_folder="../dist/snapshot" - -node ./cleanup %management_endpoint% %access_token% -node ./configure %management_endpoint% %access_token% %backendUrl% %apimServiceName% -node ./generate %management_endpoint% %access_token% %source_folder% - -cd .. - -@REM Run the publishing step and upload the generated portal to a Storage Account for hosting - -call npm run publish -call az storage blob upload-batch --source dist/website --destination $web --connection-string %storage_connection_string% -explorer %portalUrl% diff --git a/scripts.v2/generate.bat b/scripts.v2/generate.bat deleted file mode 100644 index dc574bafc..000000000 --- a/scripts.v2/generate.bat +++ /dev/null @@ -1,7 +0,0 @@ -@REM Generate and provision default content of an API Management portal - incl. pages, layouts, configuration, media files, etc. - -set management_endpoint="< service name >.management.azure-api.net" -set access_token="SharedAccessSignature ..." -set source_folder="../dist/snapshot" - -node ./generate %management_endpoint% %access_token% %source_folder% diff --git a/scripts.v2/generate.js b/scripts.v2/generate.js deleted file mode 100644 index 738aac6cc..000000000 --- a/scripts.v2/generate.js +++ /dev/null @@ -1,47 +0,0 @@ -const fs = require("fs"); -const { request, uploadBlobs, getStorageSasTokenOrThrow, apiVersion } = require("./utils"); -const managementApiEndpoint = process.argv[2] -const managementApiAccessToken = process.argv[3] -const sourceFolder = process.argv[4]; - - -async function generateJson() { - try { - const data = fs.readFileSync(`${sourceFolder}/data.json`); - const dataObj = JSON.parse(data); - const keys = Object.keys(dataObj); - - for (const key of keys) { - await request( - "PUT", - `https://${managementApiEndpoint}/subscriptions/00000/resourceGroups/00000/providers/Microsoft.ApiManagement/service/00000/${key}?api-version=${apiVersion}`, - managementApiAccessToken, - dataObj[key]); - } - } - catch (error) { - throw new Error(`Unable to generate the content. ${error.message}`); - } -} - -async function generate() { - try { - const blobStorageUrl = await getStorageSasTokenOrThrow(managementApiEndpoint, managementApiAccessToken); - const localMediaFolder = `./${sourceFolder}/media`; - - await generateJson(); - await uploadBlobs(blobStorageUrl, localMediaFolder); - } - catch (error) { - throw new Error(`Unable to complete import. ${error.message}`); - } -} - -generate() - .then(() => { - console.log("DONE"); - }) - .catch(error => { - console.log(error.message); - process.exitCode = 1; - }); \ No newline at end of file diff --git a/scripts.v2/generate.sh b/scripts.v2/generate.sh deleted file mode 100644 index 19e384090..000000000 --- a/scripts.v2/generate.sh +++ /dev/null @@ -1,8 +0,0 @@ -# Generate and provision default content of an API Management portal - incl. pages, layouts, configuration, media files, etc. - -export management_endpoint="< service name >.management.azure-api.net" -export access_token="SharedAccessSignature ..." -export source_folder="../dist/snapshot" - -# make sure to double quote the $access_token variable so it handles the space correctly -node ./generate $management_endpoint "$access_token" $data_file \ No newline at end of file diff --git a/scripts.v2/media/09879768-b2c8-afbd-a945-934c046b3c2d.jpg b/scripts.v2/media/09879768-b2c8-afbd-a945-934c046b3c2d.jpg deleted file mode 100644 index 070b549d1..000000000 Binary files a/scripts.v2/media/09879768-b2c8-afbd-a945-934c046b3c2d.jpg and /dev/null differ diff --git a/scripts.v2/media/4cac439d-5a3d-4b03-38bb-197d32256ee0.png b/scripts.v2/media/4cac439d-5a3d-4b03-38bb-197d32256ee0.png deleted file mode 100644 index 04404576e..000000000 Binary files a/scripts.v2/media/4cac439d-5a3d-4b03-38bb-197d32256ee0.png and /dev/null differ diff --git a/scripts.v2/media/70add409-0933-e01e-acef-99999a71167e.png b/scripts.v2/media/70add409-0933-e01e-acef-99999a71167e.png deleted file mode 100644 index ea06432ae..000000000 Binary files a/scripts.v2/media/70add409-0933-e01e-acef-99999a71167e.png and /dev/null differ diff --git a/scripts.v2/media/a2514081-47cb-95b1-ef0b-aef128c7a7ed.jpg b/scripts.v2/media/a2514081-47cb-95b1-ef0b-aef128c7a7ed.jpg deleted file mode 100644 index 81a83d9f0..000000000 Binary files a/scripts.v2/media/a2514081-47cb-95b1-ef0b-aef128c7a7ed.jpg and /dev/null differ diff --git a/scripts.v2/media/c5d2da83-b255-245c-144b-cd3c242e9791.jpg b/scripts.v2/media/c5d2da83-b255-245c-144b-cd3c242e9791.jpg deleted file mode 100644 index 0f3c5da15..000000000 Binary files a/scripts.v2/media/c5d2da83-b255-245c-144b-cd3c242e9791.jpg and /dev/null differ diff --git a/scripts.v2/media/ed8e43d0-5f8e-af38-5536-8f0274656ce4.jpg b/scripts.v2/media/ed8e43d0-5f8e-af38-5536-8f0274656ce4.jpg deleted file mode 100644 index f1ef68f11..000000000 Binary files a/scripts.v2/media/ed8e43d0-5f8e-af38-5536-8f0274656ce4.jpg and /dev/null differ diff --git a/scripts.v2/migrate.bat b/scripts.v2/migrate.bat deleted file mode 100644 index 9ee803d56..000000000 --- a/scripts.v2/migrate.bat +++ /dev/null @@ -1,8 +0,0 @@ -@REM This script automates content migration between developer portal instances. - -node ./migrate ^ ---sourceEndpoint "" ^ ---sourceToken "" ^ ---destEndpoint "" ^ ---destToken "" ^ ---publishEndpoint "" diff --git a/scripts.v2/migrate.js b/scripts.v2/migrate.js deleted file mode 100644 index 70d953779..000000000 --- a/scripts.v2/migrate.js +++ /dev/null @@ -1,198 +0,0 @@ -/** - * This script automates deployments between developer portal instances. - * In order to run it, you need to: - * - * 1) Clone the api-management-developer-portal repository - * 2) `npm install` in the root of the project - * 3) Run this script with a valid combination of arguments - * - * Managed portal command example: - * node migrate --sourceEndpoint from.management.azure-api.net --destEndpoint to.management.azure-api.net --publishEndpoint to.developer.azure-api.net --sourceToken "SharedAccessSignature integration&2020..." --destToken "SharedAccessSignature integration&2020..." - * - * Auto-publishing is not supported for self-hosted versions, so make sure you publish the portal (for example, locally) and upload the generated static files to your hosting after the migration is completed. - * - * You can specify the SAS tokens directly (via sourceToken and destToken), or you can supply an identifier and key, - * and the script will generate tokens that expire in 1 hour. (via sourceId, sourceKey, destId, destKey) - */ - -const moment = require('moment'); -const crypto = require('crypto'); -const execSync = require('child_process').execSync; -const { request } = require('./utils.js'); - -const yargs = require('yargs') - .example('$0 \ - --publishEndpoint \ - --sourceEndpoint \ - --sourceToken \ - --destEndpoint \ - --destToken \n', 'Managed') - .example('$0 --selfHosted \ - --sourceEndpoint \ - --sourceToken \ - --destEndpoint \ - --destToken ') - /*.option('interactive', { - alias: 'i', - type: 'boolean', - description: 'Whether to use interactive login', - conflicts: ['sourceToken', 'sourceId', 'sourceKey', 'destToken', 'destId', 'destKey'] - })*/ - .option('selfHosted', { - alias: 'h', - type: 'boolean', - description: 'If the portal is self-hosted' - }) - .option('publishEndpoint', { - alias: 'p', - type: 'string', - description: 'Endpoint of the destination managed developer portal; if empty, destination portal will not be published; unsupported in self-hosted scenario', - example: '' - }) - .option('sourceEndpoint', { - type: 'string', - description: 'The hostname of the management endpoint of the source API Management service', - example: '', - demandOption: true - }) - .option('sourceId', { - type: 'string', - description: 'The management API identifier', - implies: 'sourceKey', - conflicts: 'sourceToken' - }) - .option('sourceKey', { - type: 'string', - description: 'The management API key (primary or secondary)', - implies: 'sourceId', - conflicts: 'sourceToken' - }) - .option('sourceToken', { - type: 'string', - description: 'A SAS token for the source portal', - example: 'SharedAccessSignature…', - conflicts: ['sourceId, sourceToken'] - }) - .option('destEndpoint', { - type: 'string', - description: 'The hostname of the management endpoint of the destination API Management service', - example: '', - demandOption: true - }) - .option('destId', { - type: 'string', - description: 'The management API identifier', - implies: 'destKey', - conflicts: 'destToken' - }) - .option('destKey', { - type: 'string', - description: 'The management API key (primary or secondary)', - implies: 'destId', - conflicts: 'destToken' - }) - .option('destToken', { - type: 'string', - example: 'SharedAccessSignature…', - description: 'A SAS token for the destination portal', - conflicts: ['destId, destKey'] - }) - .argv; - -async function run() { - try { - const sourceManagementApiEndpoint = yargs.sourceEndpoint; - const sourceManagementApiAccessToken = await getTokenOrThrow(yargs.sourceToken, yargs.sourceId, yargs.sourceKey); - - const destManagementApiEndpoint = yargs.destEndpoint; - const destManagementApiAccessToken = await getTokenOrThrow(yargs.destToken, yargs.destId, yargs.destKey); - const publishEndpoint = yargs.publishEndpoint; - - // the rest of this mirrors migrate.bat, but since we're JS, we're platform-agnostic. - const snapshotFolder = '../dist/snapshot'; - - // capture the content of the source portal - execSync(`node ./capture ${sourceManagementApiEndpoint} "${sourceManagementApiAccessToken}" "${snapshotFolder}"`); - - // remove all content of the target portal - execSync(`node ./cleanup ${destManagementApiEndpoint} "${destManagementApiAccessToken}"`); - - // upload the content of the source portal - execSync(`node ./generate ${destManagementApiEndpoint} "${destManagementApiAccessToken}" "${snapshotFolder}"`); - - if (publishEndpoint && !yargs.selfHosted) { - process.env.NODE_TLS_REJECT_UNAUTHORIZED = 0; - await publish(publishEndpoint, destManagementApiAccessToken); - } - else if (publishEndpoint) { - console.warn("Auto-publishing self-hosted portal is not supported."); - } - } - catch (error) { - throw new Error(`Unable to complete migration. ${error.message}`); - } -} - - -/** - * Attempts to get a SAS token in two ways: - * 1) if the token is explicitly set by the user, use that token. - * 2) if the id and key are specified, manually generate a SAS token. - * @param {string} token an optionally specified token - * @param {string} id the Management API identifier - * @param {string} key the Management API key - */ -async function getTokenOrThrow(token, id, key) { - if (token) { - return token; - } - if (id && key) { - return await generateSASToken(id, key); - } - throw Error('You need to specify either: token or id AND key'); -} - -/** - * Generates a SAS token from the specified Management API id and key. Optionally - * specify the expiry time, in seconds. - * - * See https://docs.microsoft.com/en-us/rest/api/apimanagement/apimanagementrest/azure-api-management-rest-api-authentication#ManuallyCreateToken - * @param {string} id The Management API identifier. - * @param {string} key The Management API key (primary or secondary) - * @param {number} expiresIn The number of seconds in which the token should expire. - */ -async function generateSASToken(id, key, expiresIn = 3600) { - const now = moment.utc(moment()); - const expiry = now.clone().add(expiresIn, 'seconds'); - const expiryString = expiry.format(`YYYY-MM-DD[T]HH:mm:ss.SSSSSSS[Z]`); - - const dataToSign = `${id}\n${expiryString}`; - const signedData = crypto.createHmac('sha512', key).update(dataToSign).digest('base64'); - return `SharedAccessSignature uid=${id}&ex=${expiryString}&sn=${signedData}`; -} - -/** - * Publishes the content of the specified APIM instance using a SAS token. - * @param {string} endpoint the publishing endpoint of the destination developer portal instance - * @param {string} token the SAS token - */ -async function publish(endpoint, token) { - try { - const url = `https://${endpoint}/publish`; - - // returns with literal OK (missing quotes), which is invalid json. - await request("POST", url, token); - } - catch (error) { - throw new Error(`Unable to schedule website publishing. ${error.message}`); - } -} - -run() - .then(() => { - console.log("DONE"); - }) - .catch(error => { - console.error(error.message); - process.exitCode = 1; - }); diff --git a/scripts.v2/utils.js b/scripts.v2/utils.js deleted file mode 100644 index 6c54c4529..000000000 --- a/scripts.v2/utils.js +++ /dev/null @@ -1,216 +0,0 @@ -const fs = require("fs"); -const path = require("path"); -const https = require("https"); -const { BlobServiceClient } = require("@azure/storage-blob"); -const blobStorageContainer = "content"; -const mime = require("mime"); -const apiVersion = '2021-08-01'; - -process.env["NODE_TLS_REJECT_UNAUTHORIZED"] = 0; - -function listFilesInDirectory(dir) { - const results = []; - - fs.readdirSync(dir).forEach((file) => { - file = dir + "/" + file; - const stat = fs.statSync(file); - - if (stat && stat.isDirectory()) { - results.push(...listFilesInDirectory(file)); - } else { - results.push(file); - } - }); - - return results; -} - -/** - * Attempts to get a developer portal storage connection string in two ways: - * 1) if the connection string is explicitly set by the user, use it. - * 2) retrieving the connection string from the management API using the instance endpoint and SAS token - * @param {string} managementApiEndpoint the management endpoint of service instance - * @param {string} managementApiAccessToken the SAS token - */ -async function getStorageSasTokenOrThrow(managementApiEndpoint, managementApiAccessToken) { - if (managementApiAccessToken) { - // token should always be available, because we call - // getTokenOrThrow before this - return await getStorageSasToken(managementApiEndpoint, managementApiAccessToken); - } - throw Error('Storage connection could not be retrieved'); -} - -/** - * Gets a storage connection string from the management API for the specified APIM instance and - * SAS token. - * @param {string} managementApiEndpoint the management endpoint of service instance - * @param {string} managementApiAccessToken the SAS token - */ -async function getStorageSasToken(managementApiEndpoint, managementApiAccessToken) { - const response = await request("POST", `https://${managementApiEndpoint}/subscriptions/00000/resourceGroups/00000/providers/Microsoft.ApiManagement/service/00000/portalSettings/mediaContent/listSecrets?api-version=${apiVersion}`, managementApiAccessToken); - return response.containerSasUrl; -} - -/** - * A wrapper for making a request and returning its response body. - * @param {Object} options https options - */ -async function request(method, url, accessToken, body) { - let requestBody; - - const headers = { - "If-Match": "*", - "Content-Type": "application/json", - "Authorization": accessToken - }; - - if (body) { - if (!body.properties) { - body = { - properties: body - } - } - requestBody = JSON.stringify(body); - headers["Content-Length"] = Buffer.byteLength(requestBody); - } - - const options = { - port: 443, - method: method, - headers: headers - }; - - return new Promise((resolve, reject) => { - const req = https.request(url, options, (resp) => { - let chunks = []; - resp.on('data', (chunk) => { - chunks.push(chunk); - }); - - resp.on('end', () => { - let data = Buffer.concat(chunks).toString('utf8'); - switch (resp.statusCode) { - case 200: - case 201: - data.startsWith("{") ? resolve(JSON.parse(data)) : resolve(data); - break; - case 404: - reject({ code: "NotFound", message: `Resource not found: ${url}` }); - break; - case 401: - reject({ code: "Unauthorized", message: `Unauthorized. Make sure you correctly specified management API access token before running the script.` }); - break; - case 403: - reject({ code: "Forbidden", message: `Looks like you are not allowed to perform this operation. Please check with your administrator.` }); - break; - default: - reject({ code: "UnhandledError", message: `Could not complete request to ${url}. Status: ${resp.statusCode} ${resp.statusMessage}` }); - } - }); - }); - - req.on('error', (e) => { - reject(e); - }); - - if (requestBody) { - req.write(requestBody); - } - - req.end(); - }); -} - -async function downloadBlobs(blobStorageUrl, snapshotMediaFolder) { - try { - const blobServiceClient = new BlobServiceClient(blobStorageUrl.replace(`/${blobStorageContainer}`, "")); - const containerClient = blobServiceClient.getContainerClient(blobStorageContainer); - - await fs.promises.mkdir(path.resolve(snapshotMediaFolder), { recursive: true }); - - await downloadBlobsRecursive(containerClient, snapshotMediaFolder) - } - catch (error) { - throw new Error(`Unable to download media files. ${error.message}`); - } -} - -async function downloadBlobsRecursive(containerClient, outputFolder, prefix = undefined) { - let blobs = containerClient.listBlobsByHierarchy("/", prefix ? { prefix: prefix } : undefined); - for await (const blob of blobs) { - if (blob.kind === "prefix") { - await downloadBlobsRecursive(containerClient, outputFolder, blob.name); - continue; - } - - const blockBlobClient = containerClient.getBlockBlobClient(blob.name); - const extension = mime.getExtension(blob.properties.contentType); - let pathToFile; - - if (extension != null) { - pathToFile = `${outputFolder}/${blob.name}.${extension}`; - } - else { - pathToFile = `${outputFolder}/${blob.name}`; - } - - const folderPath = pathToFile.substring(0, pathToFile.lastIndexOf("/")); - await fs.promises.mkdir(path.resolve(folderPath), { recursive: true }); - await blockBlobClient.downloadToFile(pathToFile); - } -} - -async function uploadBlobs(blobStorageUrl, localMediaFolder) { - if (!fs.existsSync(localMediaFolder)) { - console.info("No media files found in the snapshot folder. Skipping media upload..."); - return; - } - - try { - const blobServiceClient = new BlobServiceClient(blobStorageUrl.replace(`/${blobStorageContainer}`, "")); - const containerClient = blobServiceClient.getContainerClient(blobStorageContainer); - const fileNames = listFilesInDirectory(localMediaFolder); - - for (const fileName of fileNames) { - const blobKey = fileName.replace(localMediaFolder + "/", "").split(".")[0]; - const contentType = mime.getType(fileName) || "application/octet-stream"; - const blockBlobClient = containerClient.getBlockBlobClient(blobKey); - - await blockBlobClient.uploadFile(fileName, { - blobHTTPHeaders: { - blobContentType: contentType - } - }); - } - } - catch (error) { - throw new Error(`Unable to upload media files. ${error.message}`); - } -} - -async function deleteBlobs(blobStorageUrl) { - try { - const blobServiceClient = new BlobServiceClient(blobStorageUrl.replace(`/${blobStorageContainer}`, "")); - const containerClient = blobServiceClient.getContainerClient(blobStorageContainer); - - let blobs = containerClient.listBlobsFlat(); - - for await (const blob of blobs) { - const blockBlobClient = containerClient.getBlockBlobClient(blob.name); - await blockBlobClient.delete(); - } - } - catch (error) { - throw new Error(`Unable to delete media files. ${error.message}`); - } -} - -module.exports = { - apiVersion, - request, - downloadBlobs, - uploadBlobs, - deleteBlobs, - getStorageSasTokenOrThrow -}; diff --git a/src/apim.design.module.ts b/src/apim.design.module.ts index 1302b7729..e2cef6127 100644 --- a/src/apim.design.module.ts +++ b/src/apim.design.module.ts @@ -50,7 +50,7 @@ import { CustomHtmlDesignModule } from "./components/custom-html/customHtml.desi import { CustomWidgetDesignModule } from "./components/custom-widget/customWidget.design.module"; import { CodeEditor } from "./components/code-editor/code-editor"; import { DefaultSettingsProvider } from "./configuration"; -import { ArmService } from "./services/armService"; +import { AzureResourceManagementService } from "./services/armService"; import { StaticDelegationService } from "./services/staticDelegationService"; import { NoRetryStrategy } from "./clients/retryStrategy/noRetryStrategy"; @@ -102,8 +102,9 @@ export class ApimDesignModule implements IInjectorModule { injector.bindSingleton("sessionManager", DefaultSessionManager); injector.bindInstance("configFileUri", Constants.ConfigEndpoints.backend); + injector.bindInstance("configCacheDurationMs", Constants.DEFAULT_CONFIG_CACHE_DURATION_MS); injector.bindSingleton("settingsProvider", DefaultSettingsProvider); - injector.bindSingleton("armService", ArmService); + injector.bindSingleton("armService", AzureResourceManagementService); injector.bindSingleton("delegationService", StaticDelegationService); injector.bind("CodeEditor", CodeEditor); injector.bindModule(new ContentModule()); diff --git a/src/apim.publish.module.ts b/src/apim.publish.module.ts index 5894cc4f9..aee366a91 100644 --- a/src/apim.publish.module.ts +++ b/src/apim.publish.module.ts @@ -45,8 +45,9 @@ import { staticDataEnvironment, mockStaticDataEnvironment } from "./../environme import { TenantService } from "./services/tenantService"; import { StaticDelegationService } from "./services/staticDelegationService"; import { PublishingRetryStrategy } from "./clients/retryStrategy/publishingRetryStrategy"; -import MapiClientDirect from "./clients/mapiClientDirect"; import { RedesignConfigPublisher } from "./publishing/redesignConfigPublisher"; +import { MapiClient } from "./clients"; +import { AzureResourceManagementService } from "./services/armService"; export class ApimPublishModule implements IInjectorModule { public register(injector: IInjector): void { @@ -85,11 +86,13 @@ export class ApimPublishModule implements IInjectorModule { injector.bindSingleton("router", StaticRouter); injector.bindSingleton("authenticator", StaticAuthenticator); injector.bindSingleton("retryStrategy", PublishingRetryStrategy); - injector.bindSingleton("apiClient", MapiClientDirect); + injector.bindSingleton("apiClient", MapiClient); injector.bindSingleton("objectStorage", MapiObjectStorage); injector.bindSingleton("blobStorage", MapiBlobStorage); injector.bindSingleton("logger", ConsoleLogger); injector.bindSingleton("oauthService", OAuthService); + injector.bindSingleton("armService", AzureResourceManagementService); + injector.bindSingleton("runtimeConfigBuilder", RuntimeConfigBuilder); injector.bindSingleton("delegationService", StaticDelegationService); injector.bindToCollection("publishers", AadConfigPublisher); diff --git a/src/apim.runtime.module.ts b/src/apim.runtime.module.ts index 2aae19373..c0ac75d02 100644 --- a/src/apim.runtime.module.ts +++ b/src/apim.runtime.module.ts @@ -81,7 +81,7 @@ import { AnalyticsService } from "./services/analyticsService"; import { ApiService } from "./services/apiService"; import { BackendService } from "./services/backendService"; import { DelegationService } from "./services/delegationService"; -import DataApiClient from "./clients/dataApiClient"; +import { DataApiClient } from "./clients/dataApiClient"; import { UsersService } from "./services/usersService"; import { TagService } from "./services/tagService"; import { StaticDataHttpClient } from "./services/staticDataHttpClient"; @@ -113,7 +113,7 @@ import { ConfirmPasswordRuntimeModule } from "./components/users/confirm-passwor import { SubscriptionsRuntimeModule } from "./components/users/subscriptions/subscriptions.runtime.module"; import { ReportsRuntimeModule } from "./components/reports/reports.runtime.module"; import { ValidationSummaryRuntimeModule } from "./components/users/validation-summary/validationSummary.runtime.module"; -import { ClientLogger } from "./logging/clientLogger"; + export class ApimRuntimeModule implements IInjectorModule { public register(injector: IInjector): void { diff --git a/src/authentication/IEditorSettings.spec.ts b/src/authentication/IEditorSettings.spec.ts new file mode 100644 index 000000000..03f391bc8 --- /dev/null +++ b/src/authentication/IEditorSettings.spec.ts @@ -0,0 +1,32 @@ +import { assert } from 'chai'; +import { SettingNames } from '../constants'; +import { IEditorSettings } from './IEditorSettings'; + +describe("IEditorSetting", () => { + it("Property names should be consistent with constants", () => { + const expectedConstantProperties = [ + SettingNames.armEndpoint, + SettingNames.aadClientId, + SettingNames.aadAuthority + ]; + + const settingKeys: IEditorSettings = { + armEndpoint: "armEndpoint", + tenantId: "tenantId", + clientId: "clientId" + }; + + const objectProps = Object.keys(settingKeys); + for (const prop of expectedConstantProperties) { + if (!objectProps.includes(prop)) { + assert.fail(`Required property "${prop}" is missing.`); + } + } + + for (const prop of objectProps) { + if (!expectedConstantProperties.includes(prop as SettingNames)) { + assert.fail(`Property "${prop}" is not expected.`); + } + } + }); +}); \ No newline at end of file diff --git a/src/authentication/IEditorSettings.ts b/src/authentication/IEditorSettings.ts new file mode 100644 index 000000000..bfb786530 --- /dev/null +++ b/src/authentication/IEditorSettings.ts @@ -0,0 +1,25 @@ + +/** + * Service environment settings fro editor. + */ +export interface IEditorSettings { + /** + * ARM endpoint host. example: management.azure.com + */ + armEndpoint: string; + + /** + * AAD ClientId. example: 4c6edb5e-d0fb-4ca1-ac29-8c181c1a9522 + */ + clientId: string; + + /** + * AAD authority. example: https://login.windows-ppe.net/2083f1d9-e72c-4514-b8cc-13d228bcf8a6 + */ + tenantId: string; + + /** + * Optional. AAD scopes. example: ["https://management.azure.com/.default"] + */ + scopes?: string[]; +} \ No newline at end of file diff --git a/src/authentication/accessToken.spec.ts b/src/authentication/accessToken.spec.ts new file mode 100644 index 000000000..301dd90c9 --- /dev/null +++ b/src/authentication/accessToken.spec.ts @@ -0,0 +1,165 @@ +import { AccessToken } from './accessToken'; +import { expect } from 'chai'; +import * as crypto from 'crypto'; + +describe('AccessToken', () => { + describe('parseExtendedSharedAccessSignature', () => { + const generateTestToken = (userId: string, daysValid: number, tokenSuffix: string = ''): string => { + const expiryDate = new Date(); + expiryDate.setDate(expiryDate.getDate() + daysValid); + + // Format date as YYYYMMDDHHMM to match AccessToken parsing logic + const year = expiryDate.getFullYear().toString(); + const month = (expiryDate.getMonth() + 1).toString().padStart(2, '0'); + const day = expiryDate.getDate().toString().padStart(2, '0'); + const hour = expiryDate.getHours().toString().padStart(2, '0'); + const minute = expiryDate.getMinutes().toString().padStart(2, '0'); + const expiryShort = `${year}${month}${day}${hour}${minute}`; + + const signature = crypto.randomBytes(32).toString('base64'); + return `${userId}&${expiryShort}&${signature}${tokenSuffix}`; + }; + + it('should parse token ending with ==', () => { + const token = generateTestToken('user123', 1, '=='); + const extendedToken = `token="${token}",refresh="true"`; + + const result = AccessToken.parse(extendedToken); + + expect(result).to.not.be.null; + expect(result.userId).to.equal('user123'); + expect(result.value).to.equal(token); + }); + + it('should parse token ending with =', () => { + const token = generateTestToken('user456', 1, '='); + const extendedToken = `token="${token}",refresh="true"`; + + const result = AccessToken.parse(extendedToken); + + expect(result).to.not.be.null; + expect(result.userId).to.equal('user456'); + expect(result.value).to.equal(token); + }); + + it('should parse token without any = suffix', () => { + const token = generateTestToken('user789', 1); + const extendedToken = `token="${token}",refresh="true"`; + + const result = AccessToken.parse(extendedToken); + + expect(result).to.not.be.null; + expect(result.userId).to.equal('user789'); + expect(result.value).to.equal(token); + }); + + it('should parse token with arbitrary ending characters', () => { + const token = generateTestToken('userABC', 1, 'xyz'); + const extendedToken = `token="${token}",refresh="true"`; + + const result = AccessToken.parse(extendedToken); + + expect(result).to.not.be.null; + expect(result.userId).to.equal('userABC'); + expect(result.value).to.equal(token); + }); + + it('should parse token with special characters', () => { + const token = generateTestToken('user-test_123', 1, '+/'); + const extendedToken = `token="${token}",refresh="true"`; + + const result = AccessToken.parse(extendedToken); + + expect(result).to.not.be.null; + expect(result.userId).to.equal('user-test_123'); + expect(result.value).to.equal(token); + }); + + it('should parse token with SharedAccessSignature prefix', () => { + const token = generateTestToken('user999', 1, '=='); + const extendedToken = `SharedAccessSignature token="${token}",refresh="true"`; + + const result = AccessToken.parse(extendedToken); + + expect(result).to.not.be.null; + expect(result.userId).to.equal('user999'); + expect(result.value).to.equal(token); + }); + + it('should throw error for malformed token format', () => { + const malformedTokens = [ + 'token=invalid', // missing quotes + 'token="', // unclosed quote + 'token=""', // empty token + 'invalid format', // no token= prefix + 'token="token"refresh="true"', // missing comma + ]; + + malformedTokens.forEach(malformedToken => { + expect(() => AccessToken.parse(malformedToken)).to.throw('SharedAccessSignature token format is not valid'); + }); + }); + + it('should handle edge cases correctly', () => { + // Token with quotes in the value should be properly escaped in real scenarios + // but our regex should handle basic cases + const baseToken = generateTestToken('edge_user', 1); + + // Test with different refresh values + const extendedToken1 = `token="${baseToken}",refresh="false"`; + const extendedToken2 = `token="${baseToken}",refresh="true"`; + + expect(() => AccessToken.parse(extendedToken1)).to.not.throw(); + expect(() => AccessToken.parse(extendedToken2)).to.not.throw(); + }); + + it('should correctly parse expiration date', () => { + const token = generateTestToken('timetest', 5); // 5 days from now + const extendedToken = `token="${token}",refresh="true"`; + + const result = AccessToken.parse(extendedToken); + + expect(result.expires).to.be.instanceof(Date); + expect(result.expires.getTime()).to.be.greaterThan(Date.now()); + + // Should expire approximately 5 days from now (with some tolerance) + const expectedExpiry = new Date(); + expectedExpiry.setDate(expectedExpiry.getDate() + 5); + const timeDifference = Math.abs(result.expires.getTime() - expectedExpiry.getTime()); + expect(timeDifference).to.be.lessThan(24 * 60 * 60 * 1000); // Less than 1 day difference + }); + }); + + describe('toString method for extended tokens', () => { + it('should format SharedAccessSignature correctly', () => { + const token = 'user123&202412311200&signature=='; + const extendedToken = `token="${token}",refresh="true"`; + + const accessToken = AccessToken.parse(extendedToken); + const result = accessToken.toString(); + + expect(result).to.equal(`SharedAccessSignature token="${token}",refresh="true"`); + }); + }); + + describe('Bearer token compatibility', () => { + it('should still parse Bearer tokens correctly', () => { + // Mock JWT token for testing (this is a simple test token, not a real JWT) + const mockJwtPayload = { + exp: Math.floor(Date.now() / 1000) + 3600, // 1 hour from now + userId: 'bearer_user' + }; + const mockJwt = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.' + + Buffer.from(JSON.stringify(mockJwtPayload)).toString('base64') + + '.signature'; + + const bearerToken = `Bearer ${mockJwt}`; + + const result = AccessToken.parse(bearerToken); + + expect(result).to.not.be.null; + expect(result.value).to.equal(mockJwt); + expect(result.toString()).to.equal(bearerToken); + }); + }); +}); diff --git a/src/authentication/accessToken.ts b/src/authentication/accessToken.ts index d438a8251..51b24f0cc 100644 --- a/src/authentication/accessToken.ts +++ b/src/authentication/accessToken.ts @@ -29,7 +29,7 @@ export class AccessToken { } private static parseExtendedSharedAccessSignature(value: string): AccessToken { - const regex = /token=\"(.*==)\"/gm; + const regex = /token=\"([^\"]+)\"/gm; const match = regex.exec(value); if (match && match.length >= 2) { diff --git a/src/authentication/accessTokenRefresher.spec.ts b/src/authentication/accessTokenRefresher.spec.ts new file mode 100644 index 000000000..67075d3e1 --- /dev/null +++ b/src/authentication/accessTokenRefresher.spec.ts @@ -0,0 +1,117 @@ +import { AccessTokenRefresher } from './accessTokenRefresher'; +import { ISettingsProvider } from "@paperbits/common/configuration"; +import { HttpHeader } from "@paperbits/common/http"; +import { ConsoleLogger, Logger } from "@paperbits/common/logging"; +import { AccessToken, IAuthenticator } from "./../authentication"; +import { IApiClient } from "../clients"; +import { expect } from 'chai'; +import { SinonFakeTimers, useFakeTimers, stub } from 'sinon'; +import * as crypto from 'crypto'; +import * as moment from "moment"; + +describe('AccessTokenRefresher', () => { + let settingsProvider: ISettingsProvider; + let authenticator: IAuthenticator; + let apiClient: IApiClient; + let logger: Logger; + let accessTokenRefresher: AccessTokenRefresher; + let clock: SinonFakeTimers; + + beforeEach(() => { + settingsProvider = {} as ISettingsProvider; + authenticator = { + setAccessToken: async () => { }, + }; + apiClient = { + send: async () => null, + getPortalHeader: async (header) => { name: "test", value: header }, + }; + logger = new ConsoleLogger(); + accessTokenRefresher = new AccessTokenRefresher(settingsProvider, authenticator, apiClient, logger); + clock = useFakeTimers({ shouldClearNativeTimers: true }); + }); + + afterEach(() => { + accessTokenRefresher?.dispose(); + clock.restore(); + }); + + it('should refresh access token when it is expired', async () => { + const settings = { backendUrl: 'https://example.com' }; + const storedAccessToken = { expiresInMs: () => 0 }; + + const tokenValue = await generateTestSASToken('testUser', 'testKey'); + const newAccessToken = AccessToken.parse(tokenValue); + const response = { headers: [{ name: 'Ocp-Apim-Sas-Token', value: newAccessToken.toString() }] }; + + const authenticatorStub = stub(authenticator, 'setAccessToken').resolves(); + settingsProvider.getSettings = async () => Promise.resolve(settings); + authenticator.getStoredAccessToken = () => storedAccessToken; + apiClient.send = async () => response; + + await accessTokenRefresher['refreshToken'](); + + expect(authenticatorStub.calledOnce).to.be.true; + expect(authenticatorStub.calledWith(newAccessToken)).to.be.true; + }); + + it('should not refresh access token when it is not expired', async () => { + const settings = { backendUrl: 'https://example.com' }; + const storedAccessToken = { expiresInMs: () => 10 * 60 * 1000 }; + + + const tokenValue = await generateTestSASToken('testUser', 'testKey'); + const newAccessToken = AccessToken.parse(tokenValue); + const response = { headers: [{ name: 'Ocp-Apim-Sas-Token', value: newAccessToken.toString() }] }; + + const authenticatorStub = stub(authenticator, 'setAccessToken').resolves(); + apiClient.send = async () => response; + settingsProvider.getSettings = async () => Promise.resolve(settings); + authenticator.getStoredAccessToken = () => storedAccessToken; + + await accessTokenRefresher['refreshToken'](); + + expect(authenticatorStub.called).to.be.false; + }); + + it('should not refresh access token when it is missing', async () => { + const settings = { backendUrl: 'https://example.com' }; + + const tokenValue = await generateTestSASToken('testUser', 'testKey'); + const newAccessToken = AccessToken.parse(tokenValue); + const response = { headers: [{ name: 'Ocp-Apim-Sas-Token', value: newAccessToken.toString() }] }; + + const authenticatorStub = stub(authenticator, 'setAccessToken').resolves(); + apiClient.send = async () => response; + settingsProvider.getSettings = async () => Promise.resolve(settings); + authenticator.getStoredAccessToken = () => null; + + await accessTokenRefresher['refreshToken'](); + + expect(authenticatorStub.called).to.be.false; + }); + + it('should handle error when refreshing access token', async () => { + const settings = { backendUrl: 'https://example.com' }; + const storedAccessToken = { expiresInMs: () => 0 }; + const error = new Error('Unable to refresh access token.'); + + const authenticatorStub = stub(authenticator, 'setAccessToken').resolves(); + apiClient.send = async () => { throw error; }; + settingsProvider.getSettings = async () => Promise.resolve(settings); + authenticator.getStoredAccessToken = () => storedAccessToken; + + await accessTokenRefresher['refreshToken'](); + expect(authenticatorStub.called).to.be.false; + }); + + async function generateTestSASToken(userId, key) { + const now = moment(new Date(2100, 0, 1)); + const expiry = now.clone().add(3600, 'seconds'); + const expiryString = expiry.format(`YYYY-MM-DD[T]HH:mm:ss.SSSSSSS[Z]`); + const expiryStringShort = moment(expiry).format(`YYYYMMDDHHmmss`); + const dataToSign = `${userId}\n${expiryString}`; + const signedData = crypto.createHmac('sha512', key).update(dataToSign).digest('base64'); + return `SharedAccessSignature ${userId}&${expiryStringShort}&${signedData}`; + } +}); \ No newline at end of file diff --git a/src/authentication/armAuthenticator.ts b/src/authentication/armAuthenticator.ts index 877a738e4..052e0e09c 100644 --- a/src/authentication/armAuthenticator.ts +++ b/src/authentication/armAuthenticator.ts @@ -1,14 +1,145 @@ -import * as Constants from "../constants"; -import { AccessToken, IAuthenticator } from "."; +import * as Msal from "@azure/msal-browser"; +import { IAuthenticator, AccessToken } from "."; +import { AadLoginRequest } from "../constants"; +import { IEditorSettings } from "./IEditorSettings"; +import { Logger } from "@paperbits/common/logging"; + const ARM_TOKEN = "armAccessToken"; +const TOKEN_REFRESH_BEFORE = 15 * 60 * 1000; // 15 min before token expiration + +export class ArmAuthenticator implements IAuthenticator { + private msalInstance: Msal.PublicClientApplication; + private authPromise: Promise; + private editorSettings: IEditorSettings; + + private readonly loginRequest: Msal.SilentRequest; + + constructor(private readonly logger: Logger) { + this.loginRequest = { ...AadLoginRequest, forceRefresh: true }; + this.refreshToken = this.refreshToken.bind(this); + this.getAccount = this.getAccount.bind(this); + this.acquireToken = this.acquireToken.bind(this); + setInterval(() => this.refreshToken(), 5 * 60 * 1000); // check token expiration every 5 min + } + + public setEditorSettings(settings: IEditorSettings): void { + this.editorSettings = settings; + } + + public get armEndpoint() { + return this.editorSettings.armEndpoint; + } + + private async checkCallbacks(): Promise { + try { + return await this.msalInstance.handleRedirectPromise(); + } + catch (error) { + this.logger.trackError(error, { message: "Error on checkCallbacks." }); + return null; + } + } + + private async authenticate(): Promise { + const clientId = this.editorSettings.clientId; + const tenantId = this.editorSettings.tenantId; + + if (!clientId) { + throw new Error(`Settings "clientId" was not provided. It is required for MSAL configuration.`); + } + + if (!tenantId) { + throw new Error(`Settings "tenantId" was not provided. It is required for MSAL configuration.`); + } + + if (this.editorSettings.scopes) { + this.loginRequest.scopes = this.editorSettings.scopes; + } + + const redirectUri = location.origin; + + const msalConfig: Msal.Configuration = { + auth: { + clientId: clientId, + authority: `https://login.microsoftonline.com/${tenantId}`, + redirectUri: redirectUri + }, + cache: { + cacheLocation: "sessionStorage", // This configures where your cache will be stored + storeAuthStateInCookie: false // Set this to "true" if you are having issues on IE11 or Edge + } + }; + + this.msalInstance = new Msal.PublicClientApplication(msalConfig); + const result = await this.acquireToken(); + + return result; + } + + private async refreshToken(): Promise { + const current = await this.getAccessToken(); + + if (current.expiresInMs() < TOKEN_REFRESH_BEFORE) { + await this.acquireToken(); + this.logger.trackEvent("ArmAuthenticator", { message: "Token refreshed." }); + } + } + + private async acquireToken(): Promise { + const account = await this.getAccount(); + + let authenticationResult: Msal.AuthenticationResult; + + if (account) { + authenticationResult = await this.acquireTokenSilent(account); + } + else { + authenticationResult = await this.checkCallbacks(); + } + + if (!authenticationResult) { + await this.msalInstance.acquireTokenRedirect(this.loginRequest); + } + + const accessToken = AccessToken.parse(`${authenticationResult.tokenType} ${authenticationResult.accessToken}`); + + await this.setAccessToken(accessToken); + + return accessToken; + } + + private async acquireTokenSilent(account: Msal.AccountInfo): Promise { + try { + this.msalInstance.setActiveAccount(account); + const result = await this.msalInstance.acquireTokenSilent(this.loginRequest); + + return result; + } + catch (error) { + this.logger.trackError(error, { message: "Error on acquireTokenSilent." }); + return null; + } + } + + private async getAccount(): Promise { + if (!this.msalInstance) { + await this.authenticate(); + } + const accounts = this.msalInstance.getAllAccounts(); + + if (accounts.length === 0) { + return null; + } + + return accounts[0]; + } -export class SelfHostedArmAuthenticator implements IAuthenticator { public async getAccessToken(): Promise { - const storedToken = sessionStorage.getItem(ARM_TOKEN); + const accessTokenString = sessionStorage.getItem(ARM_TOKEN) - if (storedToken) { - const accessToken = AccessToken.parse(storedToken); + if (accessTokenString) { + const accessToken = AccessToken.parse(accessTokenString); if (!accessToken.isExpired()) { return accessToken; @@ -16,19 +147,15 @@ export class SelfHostedArmAuthenticator implements IAuthenticator { else { this.clearAccessToken(); alert("You session expired. Please sign-in again."); - window.location.assign(Constants.pageUrlSignIn); - } - } else { - if (process.env.ARM_TOKEN) { - const token = AccessToken.parse(process.env.ARM_TOKEN); - await this.setAccessToken(token); - return token; - } else { - alert("ARM token was not provided. Please sign-in."); } } - return null; + if (this.authPromise) { + return this.authPromise; + } + + this.authPromise = this.authenticate(); + return this.authPromise; } public getStoredAccessToken(): AccessToken { @@ -54,7 +181,7 @@ export class SelfHostedArmAuthenticator implements IAuthenticator { public async setAccessToken(accessToken: AccessToken): Promise { if (accessToken.isExpired()) { - console.warn(`Cannot set expired access token.`); + this.logger.trackEvent("ArmAuthenticator", { message: "Cannot set expired access token." }); return; } sessionStorage.setItem(ARM_TOKEN, accessToken.toString()); diff --git a/src/authentication/authenticatorResolver.ts b/src/authentication/authenticatorResolver.ts new file mode 100644 index 000000000..d0da4566d --- /dev/null +++ b/src/authentication/authenticatorResolver.ts @@ -0,0 +1,45 @@ +import { HttpClient } from "@paperbits/common/http"; +import { Logger } from "@paperbits/common/logging"; +import { ConfigEndpoints } from "../constants"; +import { AccessToken } from "./accessToken"; +import { ArmAuthenticator } from "./armAuthenticator"; +import { IAuthenticator } from "./IAuthenticator"; +import { IEditorSettings } from "./IEditorSettings"; + +export class AuthenticatorResolver { + private loadPromise: Promise; + + constructor( + private readonly httpClient: HttpClient, + private readonly logger: Logger + ) { } + + public async getAuthenticator(): Promise { + if (!this.loadPromise) { + this.loadPromise = this.resolveAuthenticator(); + } + return this.loadPromise; + } + + public async resolveAuthenticator(): Promise { + this.logger.trackEvent("AuthenticatorResolver", { message: "Using ARM authenticator." }); + + const authenticator = new ArmAuthenticator(this.logger); + + if (typeof ARM_TOKEN !== "undefined") { + await authenticator.setAccessToken(AccessToken.parse(ARM_TOKEN)); + return authenticator; + } + + const response = await this.httpClient.send({ url: ConfigEndpoints.editor, method: "GET" }); + + if (response.statusCode !== 200) { + throw new Error(`Failed to load editor settings from ${ConfigEndpoints.editor}. Please ensure the file exists and is accessible.`); + } + + const editorConfig = response.toObject(); + authenticator.setEditorSettings(editorConfig); + + return authenticator; + } +} \ No newline at end of file diff --git a/src/authentication/defaultAuthenticator.ts b/src/authentication/defaultAuthenticator.ts index fd0b5314d..d745b077e 100644 --- a/src/authentication/defaultAuthenticator.ts +++ b/src/authentication/defaultAuthenticator.ts @@ -32,17 +32,7 @@ export class DefaultAuthenticator implements IAuthenticator { } public getStoredAccessToken(): AccessToken { - let storedToken = sessionStorage.getItem("accessToken"); - if (!storedToken) { - const designSettings = sessionStorage.getItem(Constants.SettingNames.designTimeSettings); - if (designSettings) { - const settings = JSON.parse(designSettings); - storedToken = settings[Constants.SettingNames.managementApiAccessToken]; - if (storedToken) { - sessionStorage.setItem("accessToken", storedToken); - } - } - } + const storedToken = sessionStorage.getItem("accessToken"); if (storedToken) { const accessToken = AccessToken.parse(storedToken); diff --git a/src/authentication/ssoAuthenticator.ts b/src/authentication/ssoAuthenticator.ts new file mode 100644 index 000000000..d4dbfda01 --- /dev/null +++ b/src/authentication/ssoAuthenticator.ts @@ -0,0 +1,133 @@ +import { IAuthenticator, AccessToken } from "."; +import { HttpClient } from "@paperbits/common/http"; +import { Logger } from "@paperbits/common/logging"; +import { sanitizeUrl } from "@braintree/sanitize-url"; +import { SettingNames } from "../constants"; + +const accessTokenSetting = "accessToken"; + +export class SsoAuthenticator implements IAuthenticator { + constructor( + private readonly httpClient: HttpClient, + private readonly logger: Logger + ) { } + + private runSsoFlow(): Promise { + return new Promise(async () => { + const url = new URL(location.href); + let tokenValue = url.searchParams.get("token"); + let returnUrl = url.searchParams.get("returnUrl") || "/"; + if (!tokenValue && url.hash.startsWith("#token=")) { + const hashParams = new URLSearchParams(url.hash.replace(/#/g, "?")); + tokenValue = hashParams.get("token"); + returnUrl = hashParams.get("returnUrl") || returnUrl || "/"; + } + const tokenString = `SharedAccessSignature ${tokenValue}`; + const token = AccessToken.parse(tokenString); + + await this.setAccessToken(token); + + if (!returnUrl.startsWith("/") && !returnUrl.startsWith(location.origin)) { + returnUrl = "/"; + } + + // wait for redirect to happen, deliberatly not resolving the promise + window.location.assign(sanitizeUrl(returnUrl)); + }); + } + + /** + * Check is access token can be restored from HTTP-only cookie is present + */ + private async tryRestoreFromHttpOnlyCookie(): Promise { + const response = await this.httpClient.send({ url: "/token", method: "GET" }); + + if (response.statusCode !== 200) { + return null; + } + + try { + const tokenValue = response.toText(); + const accessToken = AccessToken.parse(tokenValue); + await this.setAccessToken(accessToken); + + return accessToken + } + catch (error) { + return null; + } + } + + public async getAccessToken(): Promise { + try { + if (location.pathname.startsWith("/signin-sso")) { + await this.runSsoFlow(); + } + + const storedToken = this.getStoredAccessToken(); + + if (storedToken) { + return storedToken; + } + + const serverToken = await this.tryRestoreFromHttpOnlyCookie(); + + if (!serverToken) { + return null; + } + + return serverToken; + } + catch (error) { + this.logger.trackError(error); + return null; + } + } + + public getStoredAccessToken(): AccessToken { + const storedToken = sessionStorage.getItem(accessTokenSetting); + + if (storedToken) { + const accessToken = AccessToken.parse(storedToken); + + if (!accessToken.isExpired()) { + return accessToken; + } + else { + console.warn('%cAccess token expired.', 'font-weight: bold;'); + this.clearAccessToken(); + } + } + + return null; + } + + public async getAccessTokenAsString(): Promise { + const accessToken = await this.getAccessToken(); + return accessToken?.toString(); + } + + public async setAccessToken(accessToken: AccessToken): Promise { + if (accessToken.isExpired()) { + console.warn(`Cannot set expired access token.`); + return; + } + + /* Setting up HTTP-only cookie only in published version*/ + if (!sessionStorage.getItem(SettingNames.designTimeSettings)) { + await this.httpClient.send({ url: "/sso-refresh", method: "GET", headers: [{ name: "Authorization", value: accessToken.value }] }); + } + + sessionStorage.setItem(accessTokenSetting, accessToken.toString()); + } + + public clearAccessToken(): void { + sessionStorage.removeItem(accessTokenSetting); + } + + public async isAuthenticated(): Promise { + const accessToken = this.getStoredAccessToken() || await this.tryRestoreFromHttpOnlyCookie(); + + return !!accessToken; + } +} \ No newline at end of file diff --git a/src/authentication/staticAuthenticator.ts b/src/authentication/staticAuthenticator.ts index b38e5d6e0..3d6a0a5c6 100644 --- a/src/authentication/staticAuthenticator.ts +++ b/src/authentication/staticAuthenticator.ts @@ -1,17 +1,25 @@ import { IAuthenticator, AccessToken } from "."; +/** + * Static implementation of the IAuthenticator interface to mimic actual authentication in publish time. + */ export class StaticAuthenticator implements IAuthenticator { private accessToken: AccessToken; - public async getAccessToken(): Promise { - if (!this.accessToken) { - if (process.env.ARM_TOKEN) { - const token = AccessToken.parse(process.env.ARM_TOKEN); - this.accessToken = token; - } else { - console.log("Token was not provided. Please sign-in."); - } + constructor() { + /* + * The ARM token injected acquired in build-time. It's used in on local development only. + * TODO: Static authenticator is used in production publishing, therefore it's safer to introduce dedicated implementation. + */ + if (!ARM_TOKEN) { + return; } + + const token = AccessToken.parse(ARM_TOKEN); + this.accessToken = token; + } + + public async getAccessToken(): Promise { return this.accessToken; } diff --git a/src/bindingHandlers/traceClick.ts b/src/bindingHandlers/traceClick.ts index d7722ba04..4137ffeb9 100644 --- a/src/bindingHandlers/traceClick.ts +++ b/src/bindingHandlers/traceClick.ts @@ -3,8 +3,7 @@ import * as ko from "knockout"; import { eventTypes } from "../logging/clientLogger"; export class TraceClick { - constructor(private readonly logger: Logger) { - } + constructor(private readonly logger: Logger) { } public setupBinding(): void { ko.bindingHandlers["traceClick"] = { diff --git a/src/clients/apiClient.ts b/src/clients/apiClient.ts index 11ea2b9d0..de70933d0 100644 --- a/src/clients/apiClient.ts +++ b/src/clients/apiClient.ts @@ -57,9 +57,11 @@ export default abstract class ApiClient implements IApiClient { let managementApiAccessToken = await this.authenticator.getAccessTokenAsString(); - if(!managementApiAccessToken) { + if (!managementApiAccessToken) { + // fallback to configuration file managementApiAccessToken = settings[Constants.SettingNames.managementApiAccessToken] || settings[Constants.SettingNames.armAccessToken]; - if(managementApiAccessToken) { + + if (managementApiAccessToken) { const accessToken = AccessToken.parse(managementApiAccessToken); await this.authenticator.setAccessToken(accessToken); } @@ -165,10 +167,10 @@ export default abstract class ApiClient implements IApiClient { try { response = await this.httpClient.send(httpRequest); - this.logger.trackEvent(eventTypes.clientRequest, { message: `request response`, method: httpRequest.method, requestUrl: httpRequest.url, responseCode: response.statusCode+""}); + this.logger.trackEvent(eventTypes.clientRequest, { message: `request response`, method: httpRequest.method, requestUrl: httpRequest.url, responseCode: response.statusCode + "" }); } catch (error) { - this.logger.trackEvent(eventTypes.clientRequest, { message: `request error: ${error?.message}`, method: httpRequest.method, requestUrl: httpRequest.url, responseCode: response?.statusCode+"" }); + this.logger.trackEvent(eventTypes.clientRequest, { message: `request error: ${error?.message}`, method: httpRequest.method, requestUrl: httpRequest.url, responseCode: response?.statusCode + "" }); throw new Error(`Unable to complete request. Error: ${error.message}`); } @@ -200,10 +202,10 @@ export default abstract class ApiClient implements IApiClient { private async handleError(errorResponse: HttpResponse, requestedUrl: string): Promise { if (errorResponse.statusCode === 429) { const retryAfterHeader = errorResponse.headers.find(h => h.name.toLowerCase() === "retry-after"); - throw new MapiError(MapiErrorCodes.TooManyRequest, "Too Many Requests made. Please try later.", retryAfterHeader? [{ + throw new MapiError(MapiErrorCodes.TooManyRequest, "Too Many Requests made. Please try later.", retryAfterHeader ? [{ message: retryAfterHeader.name, target: retryAfterHeader.value - }]: []); + }] : []); } if (errorResponse.statusCode === 401) { @@ -292,7 +294,6 @@ export default abstract class ApiClient implements IApiClient { return this.get(url, headers).then(takeResult); } - protected prepareNextLink(nextLink: string): string { if (!nextLink) return ""; diff --git a/src/clients/dataApiClient.spec.ts b/src/clients/dataApiClient.spec.ts index 97df6aeb9..be433a0bc 100644 --- a/src/clients/dataApiClient.spec.ts +++ b/src/clients/dataApiClient.spec.ts @@ -1,5 +1,5 @@ import { describe, it } from "mocha"; -import { DataApiClient } from "../clients"; +import { DataApiClient, MapiClient } from "../clients"; import { MockHttpClient } from "./../../tests/mocks"; import { StaticAuthenticator } from "../authentication/staticAuthenticator"; import { StaticSettingsProvider } from "./../configuration/staticSettingsProvider"; @@ -10,7 +10,7 @@ import * as Constants from "./../constants"; import { AccessToken } from "../authentication"; import { ConsoleLogger } from "@paperbits/common/logging"; import { NoRetryStrategy } from "./retryStrategy/noRetryStrategy"; -import { HttpClient, HttpResponse } from "@paperbits/common/http"; +import { HttpClient, HttpRequest, HttpResponse } from "@paperbits/common/http"; interface Validity { isValid: boolean; @@ -29,8 +29,8 @@ describe("Data API Client", async () => { const httpClient = new MockHttpClient(); const authenticator = new StaticAuthenticator(); const settings = await settingsProvider.getSettings(); - const path = "isValid"; - const mockUrl = `${Utils.getDataApiUrl(settings)}/${path}`; + const path = "isValid" + const mockUrl = `${Utils.getDataApiUrl(settings)}/${path}` httpClient.mock() .get(mockUrl) .reply(200, { @@ -54,11 +54,11 @@ describe("Data API Client", async () => { const settings = await settingsProvider.getSettings(); authenticator.setAccessToken(AccessToken.parse(settings[Constants.SettingNames.managementApiAccessToken])); - const mainPath = "/isValid"; - const path = `/users/${(await authenticator.getAccessToken()).userId}${mainPath}`; + const mainPath = "/isValid" + const path = `/users/${(await authenticator.getAccessToken()).userId}${mainPath}` - const mockUrlWithUser = `${Utils.getDataApiUrl(settings)}${path}`; - const mockUrl = `${Utils.getDataApiUrl(settings)}${mainPath}`; + const mockUrlWithUser = `${Utils.getDataApiUrl(settings)}${path}` + const mockUrl = `${Utils.getDataApiUrl(settings)}${mainPath}` httpClient.mock() .get(mockUrlWithUser) @@ -93,8 +93,8 @@ describe("Data API Client", async () => { }); const settings = await settingsProviderMock.getSettings(); - const mainPath = "/isValid"; - const mockUrl = `${Utils.getDataApiUrl(settings)}${mainPath}`; + const mainPath = "/isValid" + const mockUrl = `${Utils.getDataApiUrl(settings)}${mainPath}` httpClient.mock() .get(mockUrl) @@ -124,8 +124,8 @@ describe("Data API Client", async () => { }); const settings = await settingsProviderMock.getSettings(); - const mainPath = "/isValid"; - const mockUrl = `${Utils.getDataApiUrl(settings)}${mainPath}`; + const mainPath = "/isValid" + const mockUrl = `${Utils.getDataApiUrl(settings)}${mainPath}` httpClient.mock() .get(mockUrl) @@ -231,8 +231,8 @@ describe("Data API Client", async () => { describe("Send method", async () => { const testsData = [ - { httpMethod: "GET", body: undefined }, - { httpMethod: "POST", body: { name: "test" } } + { httpMethod: 'GET', body: undefined }, + { httpMethod: 'POST', body: { name: 'test' } } ]; testsData.forEach(testData => { @@ -242,10 +242,10 @@ describe("Data API Client", async () => { const response = >{ statusCode: 200, headers: [], - body: { message: "Success" } + body: { message: 'Success' } }; const httpClient: HttpClient = { - send: async () => { return response; } + send: async () => { } }; const authenticator = new StaticAuthenticator(); const settingsProviderMock = new StaticSettingsProvider({ @@ -259,7 +259,7 @@ describe("Data API Client", async () => { const mockUrl = `${Utils.getDataApiUrl(settings)}${url}?api-version=${Constants.dataApiVersion}`; const apiClient = new DataApiClient(httpClient, authenticator, settingsProviderMock, new NoRetryStrategy(), new ConsoleLogger()); - const sendStub = stub(httpClient, "send").resolves(response); + const sendStub = stub(httpClient, 'send').resolves(response); const result = await apiClient.send(url, httpMethod, undefined, testData.body); diff --git a/src/clients/dataApiClient.ts b/src/clients/dataApiClient.ts index 19f3a0505..9837e3e88 100644 --- a/src/clients/dataApiClient.ts +++ b/src/clients/dataApiClient.ts @@ -7,13 +7,14 @@ import * as Constants from "./../constants"; import { IRetryStrategy } from "./retryStrategy/retryStrategy"; import { Logger } from "@paperbits/common/logging"; -export default class DataApiClient extends ApiClient { +export class DataApiClient extends ApiClient { constructor( readonly httpClient: HttpClient, readonly authenticator: IAuthenticator, readonly settingsProvider: ISettingsProvider, readonly retryStrategy: IRetryStrategy, - readonly logger: Logger) { + readonly logger: Logger + ) { super(httpClient, authenticator, settingsProvider, retryStrategy, logger) } diff --git a/src/clients/index.ts b/src/clients/index.ts index fad8918e4..65559bc66 100644 --- a/src/clients/index.ts +++ b/src/clients/index.ts @@ -1,5 +1,5 @@ import ApiClient from "./apiClient"; -import DataApiClient from "./dataApiClient"; +import { DataApiClient } from "./dataApiClient"; import IApiClient from "./IApiClient"; import { MapiClient } from "./mapiClient"; diff --git a/src/clients/mapiClient.spec.ts b/src/clients/mapiClient.spec.ts index 243e1e788..cbd98f24f 100644 --- a/src/clients/mapiClient.spec.ts +++ b/src/clients/mapiClient.spec.ts @@ -5,14 +5,13 @@ import { assert } from "chai"; import { stub } from "sinon"; import { Utils } from "../utils"; import * as Constants from "./../constants"; -import { StaticSettingsProvider } from "./../configuration/staticSettingsProvider"; -import { ArmService } from "../services/armService"; -import { SelfHostedArmAuthenticator } from "./../authentication/armAuthenticator"; +import { StaticSettingsProvider } from "../configuration/staticSettingsProvider"; +import { AzureResourceManagementService } from "../services/armService"; +import { ArmAuthenticator } from "../authentication/armAuthenticator"; import { NoRetryStrategy } from "./retryStrategy/noRetryStrategy"; import { ConsoleLogger } from "@paperbits/common/logging"; import { HttpClient, HttpResponse } from "@paperbits/common/http"; import { StaticAuthenticator } from "../authentication/staticAuthenticator"; -import { DefaultSessionManager } from "@paperbits/common/persistence/defaultSessionManager"; interface Validity { isValid: boolean; @@ -22,7 +21,7 @@ describe("Mapi Client", async () => { global.sessionStorage = { _values: new Map(), - get length() {return this._values.size}, + length: global.sessionStorage._values.size, key: (index: number) => { return null; }, getItem: (key: string) => { return global.sessionStorage._values.get(key); }, setItem: (key: string, value: string) => { global.sessionStorage._values.set(key, value); }, @@ -30,58 +29,26 @@ describe("Mapi Client", async () => { clear: () => { global.sessionStorage._values.clear(); } } - const userAdminToken = { - "value": createMockToken() - }; - - const serviceDescriptor = { - "properties": { - "developerPortalUrl": "https://test-service.developer.azure-api.net", - "dataApiUrl": null, - "managementApiUrl": "https://test-service.management.azure-api.net" - }, - "sku": { - "name": "Developer", - "capacity": 1 - }, - "id": "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/test-rg/providers/Microsoft.ApiManagement/service/test-service", - "name": "test-service", - "type": "Microsoft.ApiManagement/service" - }; - - const armAuthSettings = { - "armEndpoint": "management.azure.com", - "subscriptionId": "00000000-0000-0000-0000-000000000000", - "resourceGroupName": "test-rg", - "serviceName": "test-service" - }; - const settingsProvider = new StaticSettingsProvider(armAuthSettings); - - const managementApiUrl = "https://management.azure.com/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/test-rg/providers/Microsoft.ApiManagement/service/test-service"; - const userMapiUrl = `${managementApiUrl}/users/${Constants.adminUserId}/token?api-version=${Constants.managementApiVersion}` - const serviceUrl = `${managementApiUrl}?api-version=${Constants.managementApiVersion}` - - sessionStorage.setItem("armAccessToken", createMockToken()); - const authenticator = new SelfHostedArmAuthenticator(); - - it("setBaseUrl - Initialized with full ARM url", async () => { + const settingsProvider = new StaticSettingsProvider({ + managementApiUrl: "https://contoso.management.azure-api.net", + backendUrl: "https://contoso.developer.azure-api.net", + managementApiAccessToken: createMockToken() + }); - //arrange - const httpClient = new MockHttpClient(); - const sessionManager = new DefaultSessionManager(); - const armService = new ArmService(httpClient, authenticator, sessionManager, new ConsoleLogger()); + const authenticator = new ArmAuthenticator(new ConsoleLogger()); - httpClient.mock() - .post(userMapiUrl) - .reply(200, userAdminToken); - httpClient.mock() - .get(serviceUrl) - .reply(200, serviceDescriptor); - - await armService.loadSessionSettings(settingsProvider); + authenticator.setEditorSettings({ + armEndpoint: "management.azure.com", + clientId: "a962e1ed-5694-4abe-9e9b-d08d35877efc", + tenantId: "https://login.windows.net/72f988bf-86f1-41af-91ab-2d7cd011db47" + }); - const path = "isValid"; + it("setBaseUrl - Appends /mapi", async () => { + //arrange + const httpClient = new MockHttpClient(); const settings = await settingsProvider.getSettings(); + const armService = new AzureResourceManagementService(httpClient, authenticator, new ConsoleLogger()); + const path = "isValid"; const mockUrl = `${settings[Constants.SettingNames.managementApiUrl]}/${path}` httpClient.mock() .get(mockUrl) @@ -98,24 +65,58 @@ describe("Mapi Client", async () => { assert.isTrue(result.isValid); }); - it("Mapi client should never prefix user using header & token", async () => { + it("Localhost calls - replace https to http", async () => { //arrange + const publisherSettingsProvider = new StaticSettingsProvider({ + backendUrl: "http://localhost:10000", + managementApiAccessToken: createMockToken() + }); + const httpClient = new MockHttpClient(); - const sessionManager = new DefaultSessionManager(); - const path = "isValid"; + const armService = new AzureResourceManagementService(httpClient, authenticator, new ConsoleLogger()); + + const baseHost = "http://localhost:10000" + const basePath = "/0/isValid"; + const expectedPath = "/mapi" + basePath; + const expectedNextLinkPath = "/mapi/1/isValid" - const armService = new ArmService(httpClient, authenticator, sessionManager, new ConsoleLogger()); httpClient.mock() - .post(userMapiUrl) - .reply(200, userAdminToken); + .get(baseHost + expectedPath) + .reply(200, { + value: [{ + isValid: true, + }], + nextLink: "https://localhost:10000" + expectedNextLinkPath + }); + httpClient.mock() - .get(serviceUrl) - .reply(200, serviceDescriptor); + .get(baseHost + expectedNextLinkPath) + .reply(200, { + value: [{ + isValid: true, + }], + nextLink: null + }); - await armService.loadSessionSettings(settingsProvider); + const apiClient = new MapiClient(armService, httpClient, authenticator, publisherSettingsProvider, new NoRetryStrategy(), new ConsoleLogger()); + + //act + const result = await apiClient.getAll(basePath, []); + //assert + assert.equal(result.length, 2); + assert.isTrue(result[0].isValid); + assert.isTrue(result[1].isValid); + }); + + it("Mapi client should never prefix user using header & token", async () => { + + //arrange + const httpClient = new MockHttpClient(); const settings = await settingsProvider.getSettings(); + const path = "isValid"; + const armService = new AzureResourceManagementService(httpClient, authenticator, new ConsoleLogger()); const mockUrl = `${settings[Constants.SettingNames.managementApiUrl]}/${path}` httpClient.mock() .get(mockUrl) @@ -133,19 +134,9 @@ describe("Mapi Client", async () => { }); describe("Send method", async () => { - const httpClient = new MockHttpClient(); - const sessionManager = new DefaultSessionManager(); - const armService = new ArmService(httpClient, authenticator, sessionManager, new ConsoleLogger()); - httpClient.mock() - .post(userMapiUrl) - .reply(200, userAdminToken); - httpClient.mock() - .get(serviceUrl) - .reply(200, serviceDescriptor); - const testsData = [ - { httpMethod: "GET", body: undefined }, - { httpMethod: "POST", body: { name: "test" } } + { httpMethod: 'GET', body: undefined }, + { httpMethod: 'POST', body: { name: 'test' } } ]; testsData.forEach(testData => { it(`Should return the response from the ${testData.httpMethod} send method`, async () => { @@ -154,20 +145,24 @@ describe("Mapi Client", async () => { const response = >{ statusCode: 200, headers: [], - body: { message: "Success" } + body: { message: 'Success' } }; const httpClient: HttpClient = { - send: async () => { return response; } + send: async () => { } }; const authenticator = new StaticAuthenticator(); + const settingsProviderMock = new StaticSettingsProvider({ + backendUrl: "https://contoso.developer.azure-api.net", + managementApiAccessToken: "SharedAccessSignature integration&220001010000&000000000000000000000000000==" + }); - const settings = await settingsProvider.getSettings(); + const settings = await settingsProviderMock.getSettings(); const url = "/users"; - const mockUrl = `${settings[Constants.SettingNames.managementApiUrl]}${url}?api-version=${Constants.managementApiVersion}`; - const apiClient = new MapiClient(armService, httpClient, authenticator, settingsProvider, new NoRetryStrategy(), new ConsoleLogger()); + const mockUrl = `${Utils.getBaseUrlWithMapiSuffix(settings[Constants.SettingNames.backendUrl])}${url}?api-version=${Constants.managementApiVersion}`; + const apiClient = new MapiClient(undefined, httpClient, authenticator, settingsProviderMock, new NoRetryStrategy(), new ConsoleLogger()); - const sendStub = stub(httpClient, "send").resolves(response); + const sendStub = stub(httpClient, 'send').resolves(response); const result = await apiClient.send(url, httpMethod, undefined, testData.body); diff --git a/src/clients/mapiClient.ts b/src/clients/mapiClient.ts index 9fd64a4e0..e0925f607 100644 --- a/src/clients/mapiClient.ts +++ b/src/clients/mapiClient.ts @@ -1,32 +1,61 @@ import { ISettingsProvider } from "@paperbits/common/configuration"; import { HttpClient } from "@paperbits/common/http"; +import { Logger } from "@paperbits/common/logging"; import { IAuthenticator } from "../authentication"; +import { AzureResourceManagementService } from "../services/armService"; +import * as Constants from "../constants"; import ApiClient from "./apiClient"; -import * as Constants from "./../constants"; import { IRetryStrategy } from "./retryStrategy/retryStrategy"; -import { Logger } from "@paperbits/common/logging"; -import { ArmService } from "../services/armService"; +import { SettingNames } from "../constants"; export class MapiClient extends ApiClient { constructor( - readonly armService: ArmService, + readonly armService: AzureResourceManagementService, readonly httpClient: HttpClient, readonly authenticator: IAuthenticator, readonly settingsProvider: ISettingsProvider, readonly retryStrategy: IRetryStrategy, - readonly logger: Logger) { - super(httpClient, authenticator, settingsProvider, retryStrategy, logger) + readonly logger: Logger + ) { + super(httpClient, authenticator, settingsProvider, retryStrategy, logger); } protected override async setBaseUrl() { + const settings = await this.settingsProvider.getSettings(); + + const serviceName = settings[Constants.SettingNames.serviceName]; + const subscriptionId = settings[Constants.SettingNames.subscriptionId]; + const resourceGroupName = settings[Constants.SettingNames.resourceGroupName]; + + if (!serviceName) { + throw new Error("Service name setting is missing."); + } + + if (!subscriptionId) { + throw new Error("Subscription ID setting is missing."); + } + + if (!resourceGroupName) { + throw new Error("Resource Group name setting is missing."); + } + + this.baseUrl = `https://management.azure.com/subscriptions/${subscriptionId}/resourceGroups/${resourceGroupName}/providers/Microsoft.ApiManagement/service/${serviceName}`; + + } + + public async getTenantArmUriAsync(): Promise { let settings = await this.settingsProvider.getSettings(); - this.baseUrl = settings[Constants.SettingNames.managementApiUrl] || ""; - if (!this.baseUrl) { - await this.armService.loadSessionSettings(this.settingsProvider); - settings = await this.settingsProvider.getSettings(); - this.baseUrl = settings[Constants.SettingNames.managementApiUrl] || ""; + const armEndpoint = settings[SettingNames.armEndpoint]; + const subscriptionId = settings[SettingNames.subscriptionId]; + const resourceGroupName = settings[SettingNames.resourceGroupName]; + const serviceName = settings[SettingNames.serviceName]; + + if (!subscriptionId || !resourceGroupName || !serviceName) { + throw new Error("Required service parameters (like subscription, resource group, service name) were not provided to start editor"); } + + return `https://${armEndpoint}/subscriptions/${subscriptionId}/resourceGroups/${resourceGroupName}/providers/Microsoft.ApiManagement/service/${serviceName}`; } protected setUserPrefix(query: string): string { @@ -37,4 +66,3 @@ export class MapiClient extends ApiClient { return Constants.managementApiVersion; } } - diff --git a/src/clients/mapiClientDirect.ts b/src/clients/mapiClientDirect.ts deleted file mode 100644 index ed9743218..000000000 --- a/src/clients/mapiClientDirect.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { ISettingsProvider } from "@paperbits/common/configuration"; -import { HttpClient } from "@paperbits/common/http"; -import { IAuthenticator } from "../authentication"; -import ApiClient from "./apiClient"; -import * as Constants from "./../constants"; -import { IRetryStrategy } from "./retryStrategy/retryStrategy"; -import { Logger } from "@paperbits/common/logging"; - -// For dedicated: publisher MAPI client to avoid proxy through the backend. -// For multitenant: publisher MAPI client to direct calls for smapi. -export default class MapiClientDirect extends ApiClient { - constructor( - readonly httpClient: HttpClient, - readonly authenticator: IAuthenticator, - readonly settingsProvider: ISettingsProvider, - readonly retryStrategy: IRetryStrategy, - readonly logger: Logger) { - super(httpClient, authenticator, settingsProvider, retryStrategy, logger); - } - - protected override async setBaseUrl() { - const settings = await this.settingsProvider.getSettings(); - this.baseUrl = settings[Constants.SettingNames.managementApiUrl]; - } - - protected override getApiVersion(): string { - return Constants.managementApiVersion; - } - - protected setUserPrefix(query: string): string { - return query; - } -} diff --git a/src/clients/mapiClientSelfhosted.ts b/src/clients/mapiClientSelfhosted.ts deleted file mode 100644 index c49dbadf6..000000000 --- a/src/clients/mapiClientSelfhosted.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { ISettingsProvider } from "@paperbits/common/configuration"; -import { HttpClient } from "@paperbits/common/http"; -import { IAuthenticator } from "../authentication"; -import ApiClient from "./apiClient"; -import * as Constants from "./../constants"; -import { IRetryStrategy } from "./retryStrategy/retryStrategy"; -import { Logger } from "@paperbits/common/logging"; -import { Utils } from "../utils"; - -export class MapiClientSelfhosted extends ApiClient { - constructor( - readonly httpClient: HttpClient, - readonly authenticator: IAuthenticator, - readonly settingsProvider: ISettingsProvider, - readonly retryStrategy: IRetryStrategy, - readonly logger: Logger) { - super(httpClient, authenticator, settingsProvider, retryStrategy, logger) - } - - protected override async setBaseUrl() { - const settings = await this.settingsProvider.getSettings(); - this.baseUrl = Utils.getBaseUrlWithMapiSuffix(settings[Constants.SettingNames.backendUrl]) || ""; - } - - protected setUserPrefix(query: string): string { - return query; - } - - protected override getApiVersion(): string { - return Constants.managementApiVersion; - } -} - diff --git a/src/components/app/app.ts b/src/components/app/app.ts index c7910f316..ebf353cbf 100644 --- a/src/components/app/app.ts +++ b/src/components/app/app.ts @@ -1,13 +1,15 @@ +import { ISettingsProvider } from "@paperbits/common/configuration"; import { EventManager } from "@paperbits/common/events"; -import template from "./app.html"; -import { ViewManager } from "@paperbits/common/ui"; import { Component, OnMounted } from "@paperbits/common/ko/decorators"; -import { ISettingsProvider } from "@paperbits/common/configuration"; +import { Logger } from "@paperbits/common/logging/logger"; +import { SessionManager } from "@paperbits/common/persistence/sessionManager"; import { ISiteService } from "@paperbits/common/sites"; +import { ViewManager } from "@paperbits/common/ui"; import { IAuthenticator } from "../../authentication"; import { DeveloperPortalType, SettingNames, WarningBackendUrlMissing } from "../../constants"; -import { ArmService } from "../../services/armService"; -import { Logger } from "@paperbits/common/logging/logger"; +import { AzureResourceManagementService } from "../../services/armService"; +import { AccessToken } from "./../../authentication/accessToken"; +import template from "./app.html"; const startupError = `Unable to start the portal`; @@ -22,40 +24,38 @@ export class App { private readonly viewManager: ViewManager, private readonly eventManager: EventManager, private readonly siteService: ISiteService, - private readonly armService: ArmService, private readonly logger: Logger ) { } @OnMounted() public async initialize(): Promise { - await this.armService.loadSessionSettings(this.settingsProvider); + try { + const settings = await this.settingsProvider.getSettings(); + const accessToken = await this.authenticator.getAccessTokenAsString(); - const settings = await this.settingsProvider.getSettings(); - const developerPortalType = settings[SettingNames.developerPortalType] || DeveloperPortalType.selfHosted; + if (!accessToken) { + const managementApiAccessToken = settings[SettingNames.managementApiAccessToken]; - if (!settings[SettingNames.dataApiUrl] && !settings[SettingNames.backendUrl]) { - if (developerPortalType === DeveloperPortalType.selfHosted) { - const toast = this.viewManager.notifyInfo("Settings", WarningBackendUrlMissing, [{ - title: "Got it", - action: async () => this.viewManager.removeToast(toast) - }]); - } - } + if (!managementApiAccessToken) { + this.viewManager.addToast(startupError, `Management API access token is missing. See setting managementApiAccessToken in the configuration file config.design.json`); + return; + } - if (!settings[SettingNames.managementApiUrl]) { - this.viewManager.addToast(startupError, `Please check required service settings (like subscription, resource group, service name) in the configuration file config.design.json`); - return; - } + const accessToken = AccessToken.parse(managementApiAccessToken); + const now = new Date(); - const token = await this.authenticator.getAccessToken(); - if (!token) { - this.viewManager.addToast(startupError, `ARM access token is missing. Please restart editor to reauthenticate.`); - return; - } + if (now >= accessToken.expires) { + this.viewManager.addToast(startupError, `Management API access token has expired. See setting managementApiAccessToken in the configuration file config.design.json`); + this.authenticator.clearAccessToken(); + return; + } - if (token.isExpired()) { - this.viewManager.addToast(startupError, `ARM access token has expired. Please restart editor to reauthenticate.`); - this.authenticator.clearAccessToken(); + await this.authenticator.setAccessToken(accessToken); + } + } + catch (error) { + this.logger.trackError(error, { message: "Error in app initialize for getting a token" }); + this.viewManager.addToast(startupError, error); return; } @@ -78,7 +78,7 @@ export class App { }), 5000); } catch (error) { - this.logger.trackError(error, { message: "Error in app initialize while resolving settings" } ); + this.logger.trackError(error, { message: "Error in app initialize while resolving settings" }); this.viewManager.addToast(startupError, `Check if the settings specified in the configuration file config.design.json are correct or refer to the frequently asked questions.`); } } diff --git a/src/components/staticSettingsProvider.ts b/src/components/staticSettingsProvider.ts deleted file mode 100644 index 90d3e643c..000000000 --- a/src/components/staticSettingsProvider.ts +++ /dev/null @@ -1,19 +0,0 @@ -import * as Objects from "@paperbits/common/objects"; -import { ISettingsProvider } from "@paperbits/common/configuration"; - -export class StaticSettingsProvider implements ISettingsProvider { - constructor(private readonly configuration: Object) { } - - public getSetting(path: string): Promise { - return Objects.getObjectAt(path, this.configuration); - } - - public async setSetting(path: string, value: T): Promise { - Objects.setValue(path, this.configuration, value); - this.configuration[path] = value; - } - - public async getSettings(): Promise { - return this.configuration; - } -} \ No newline at end of file diff --git a/src/components/users/signin-social/react/SignInSocialViewModel.tsx b/src/components/users/signin-social/react/SignInSocialViewModel.tsx index a3099c002..641465766 100644 --- a/src/components/users/signin-social/react/SignInSocialViewModel.tsx +++ b/src/components/users/signin-social/react/SignInSocialViewModel.tsx @@ -1,59 +1,130 @@ import * as React from "react"; import { StyleModel } from "@paperbits/common/styles"; import { SecurityModel } from "@paperbits/common/security"; - +import { Resolve } from "@paperbits/react/decorators"; +import { BuiltInRoles, UserService } from "@paperbits/common/user"; +import { EventManager, Events } from "@paperbits/common/events"; interface ComponentProps { isRedesignEnabled: boolean; - styles: StyleModel; - security: SecurityModel; - aadConfig: string; - aadB2CConfig: string; - mode: string; + styles: StyleModel; + security: SecurityModel; + aadConfig: string; + aadB2CConfig: string; + mode: string; + visible: boolean; + roles?: string; } -interface ComponentState extends ComponentProps { } +interface ComponentState extends ComponentProps {} + +export class SignInSocialViewModel extends React.Component< + ComponentProps, + ComponentState +> { + @Resolve("userService") + public declare userService: UserService; + + @Resolve("eventManager") + public declare eventManager: EventManager; -export class SignInSocialViewModel extends React.Component { constructor(props) { super(props); + this.applyVisibility = this.applyVisibility.bind(this); + this.state = { isRedesignEnabled: props.isRedesignEnabled, styles: props.styles, security: props.security, aadConfig: props.aadConfig, aadB2CConfig: props.aadB2CConfig, - mode: props.mode + mode: props.mode, + visible: true, }; } + public async applyVisibility(): Promise { + const securitySettings: any = this.state.security; + + const widgetRolesArray = securitySettings?.roles || [ + BuiltInRoles.everyone.key, + ]; + + const userRoles = await this.userService.getUserRoles(); + + const visibleToUser = + userRoles.some((x) => widgetRolesArray.includes(x)) || + widgetRolesArray.includes(BuiltInRoles.everyone.key); + + this.setState({ visible: visibleToUser }); + } + + public componentDidMount(): void { + this.eventManager.addEventListener( + Events.UserRoleChanged, + this.applyVisibility + ); + } + public render(): JSX.Element { - if (!this.state.aadB2CConfig && !this.state.aadConfig && this.state.mode !== "publishing") { - return + if ( + !this.state.aadB2CConfig && + !this.state.aadConfig && + this.state.mode !== "publishing" + ) { + return; -
This widget will display a sign-up form when you configure Microsoft Entra ID or Azure Active Directory B2C integration in your API - Management service. This message appears only in the portal's administrative mode and the widget will be rendered as - an empty space in the published portal, so you don't need to remove it. +
+ This widget will display a sign-up form when you configure{" "} + + Microsoft Entra ID + {" "} + or{" "} + + Azure Active Directory B2C + {" "} + integration in your API Management service. This message + appears only in the portal's administrative mode and the + widget will be rendered as an empty space in the published + portal, so you don't need to remove it.
; } const aadConfig = JSON.stringify(this.state.aadConfig); const aadB2CConfig = JSON.stringify(this.state.aadB2CConfig); - const containerStyle = {display: "flex"}; + const classNames = ["flex", this.state.visible ? "" : "hidden"].join( + " " + ); if (this.state.isRedesignEnabled) { - return
- {this.state.aadConfig && } - {this.state.aadB2CConfig && } -
; + return ( +
+ {this.state.aadConfig && ( + + )} + {this.state.aadB2CConfig && ( + + )} +
+ ); } - return
- {this.state.aadConfig && } - {this.state.aadB2CConfig && } -
; + return ( +
+ {this.state.aadConfig && ( + + )} + {this.state.aadB2CConfig && ( + + )} +
+ ); } -} \ No newline at end of file +} diff --git a/src/components/users/signin-social/signinSocial.design.module.ts b/src/components/users/signin-social/signinSocial.design.module.ts index 02269538e..8deb3ded6 100644 --- a/src/components/users/signin-social/signinSocial.design.module.ts +++ b/src/components/users/signin-social/signinSocial.design.module.ts @@ -2,7 +2,6 @@ import { IInjector, IInjectorModule } from "@paperbits/common/injection"; import { IWidgetService } from "@paperbits/common/widgets"; import { KnockoutComponentBinder } from "@paperbits/core/ko"; import { ReactComponentBinder } from "@paperbits/react/bindings"; -import { ComponentFlow } from "@paperbits/common/components"; import { SigninSocialHandlers } from "./signinSocialHandlers"; import { SignInSocialEditor } from "./ko/signinSocialEditor"; import { SigninSocialModel } from "./signinSocialModel"; @@ -25,7 +24,6 @@ export class SigninSocialDesignModule implements IInjectorModule { componentDefinition: SignInSocialViewModel, modelBinder: SigninSocialModelBinder, viewModelBinder: SigninSocialViewModelBinder, - componentFlow: ComponentFlow.Block }); widgetService.registerWidgetEditor("signin-social", { diff --git a/src/components/users/signin-social/signinSocial.publish.module.ts b/src/components/users/signin-social/signinSocial.publish.module.ts index 73b3d4607..b6d0ff916 100644 --- a/src/components/users/signin-social/signinSocial.publish.module.ts +++ b/src/components/users/signin-social/signinSocial.publish.module.ts @@ -19,8 +19,7 @@ export class SigninSocialPublishModule implements IInjectorModule { componentBinder: ReactComponentBinder, componentDefinition: SignInSocialViewModel, modelBinder: SigninSocialModelBinder, - viewModelBinder: SigninSocialViewModelBinder, - componentFlow: ComponentFlow.Block + viewModelBinder: SigninSocialViewModelBinder }); } } \ No newline at end of file diff --git a/src/components/users/signin-social/signinSocialViewModelBinder.ts b/src/components/users/signin-social/signinSocialViewModelBinder.ts index d0ffa20e9..ad0faa877 100644 --- a/src/components/users/signin-social/signinSocialViewModelBinder.ts +++ b/src/components/users/signin-social/signinSocialViewModelBinder.ts @@ -8,6 +8,9 @@ import { IdentityService } from "../../../services/identityService"; import { SigninSocialModel } from "./signinSocialModel"; import { SignInSocialViewModel } from "./react/SignInSocialViewModel"; import { isRedesignEnabledSetting } from "../../../constants"; +import { BuiltInRoles } from "@paperbits/common/user"; +import { StaticUserService } from "../../../services"; + export class SigninSocialViewModelBinder implements ViewModelBinder { constructor( @@ -15,8 +18,10 @@ export class SigninSocialViewModelBinder implements ViewModelBinder { const termsOfService = await this.identityService.getTermsOfService(); @@ -75,13 +80,26 @@ export class SigninSocialViewModelBinder implements ViewModelBinder widgetRoles.includes(x)) || + widgetRoles.includes(BuiltInRoles.everyone.key); + + state.visible = visibleToUser; + state.roles = widgetRoles.join(","); + let isRedesignEnabled = false; - + try { isRedesignEnabled = !!(await this.siteService.getSetting(isRedesignEnabledSetting)); - } catch (error) { + } + catch (error) { this.logger?.trackError(error, { message: `Failed to get setting: ${isRedesignEnabledSetting} - SigninSocialViewModelBinder` }); - } finally { + } + finally { state.isRedesignEnabled = isRedesignEnabled; } } diff --git a/src/config.design.json b/src/config.design.json index 877c8c168..0b614812a 100644 --- a/src/config.design.json +++ b/src/config.design.json @@ -1,15 +1,7 @@ { "environment": "development", - "useHipCaptcha": false, - "integration": { - "googleFonts": { - "apiKey": "" - } - }, - "armEndpoint": "management.azure.com", - "subscriptionId":"", - "resourceGroupName":"", - "serviceName":"", - "clientId": "", - "tenantId": "" + "isArmAuthEnabled": true, + "subscriptionId": "< subscription ID >", + "resourceGroupName": "< resource group name >", + "serviceName": "< service name >" } \ No newline at end of file diff --git a/src/config.publish.json b/src/config.publish.json index 3c756f944..e6f5d20b3 100644 --- a/src/config.publish.json +++ b/src/config.publish.json @@ -1,10 +1,7 @@ { "environment": "publishing", - "armEndpoint": "management.azure.com", - "subscriptionId":"", - "resourceGroupName":"", - "serviceName":"", - "clientId": "", - "tenantId": "", - "useHipCaptcha": false + "isArmAuthEnabled": true, + "subscriptionId": "< subscription ID >", + "resourceGroupName": "< resource group name >", + "serviceName": "< service name >" } \ No newline at end of file diff --git a/src/config.runtime.json b/src/config.runtime.json index 0b1746fd2..66109a6e6 100644 --- a/src/config.runtime.json +++ b/src/config.runtime.json @@ -1,10 +1,4 @@ { "environment": "runtime", - "backendUrl": "https://.developer.azure-api.net", - "featureFlags": { - "clientTelemetry": true - }, - - "directDataApi": false, - "dataApiUrl": "https://.data.azure-api.net" + "backendUrl": "https://< service name >.developer.azure-api.net" } \ No newline at end of file diff --git a/src/config.validate.json b/src/config.validate.json index 3cae59bb4..b9a167789 100644 --- a/src/config.validate.json +++ b/src/config.validate.json @@ -1,13 +1,23 @@ { "environment": "validation", - "isLocalRun": true, - "root": "http://localhost:8080", + "isLocalRun": false, + "root": "https://.developer.azure-api.net", + "managementUrl": "https://.management.azure-api.net", + "accessToken": "SharedAccessSignature ...", + "useArmAuth": false, + "useExternalEditor": false, + "isMultitenant": false, + "externalEditorUrl": "", + "certificate": "", "urls": { "home": "/", "signin": "/signin", "signup": "/signup", "profile": "/profile", "products": "/products", - "apis": "/apis" + "apis": "/apis", + "apiDetails": "/api-details/#api=", + "confirmPassword":"/confirm-password", + "changePassword":"/change-password" } } \ No newline at end of file diff --git a/src/config.validate.publish.json b/src/config.validate.publish.json index 80a0c4a25..41599cb96 100644 --- a/src/config.validate.publish.json +++ b/src/config.validate.publish.json @@ -1,6 +1,6 @@ { "environment": "publishing", - "managementApiUrl": "http://localhost:8181/subscriptions/sid/resourceGroups/rgid/providers/Microsoft.ApiManagement/service/sid", + "managementApiUrl": "http://localhost:8181/", "managementApiAccessToken": "SharedAccessSignature...", "useHipCaptcha": false } \ No newline at end of file diff --git a/src/config.validate.runtime.json b/src/config.validate.runtime.json index 811aae098..4d7229269 100644 --- a/src/config.validate.runtime.json +++ b/src/config.validate.runtime.json @@ -1,5 +1,5 @@ { "environment": "runtime", - "managementApiUrl": "http://127.0.0.1:8181/subscriptions/sid/resourceGroups/rgid/providers/Microsoft.ApiManagement/service/sid", - "backendUrl": "http://127.0.0.1:8181/" + "managementApiUrl": "http://localhost:8181/", + "backendUrl": "http://localhost:8181/" } \ No newline at end of file diff --git a/src/configuration/apimSettingsProvider.ts b/src/configuration/apimSettingsProvider.ts index 221a9e606..44236d9bc 100644 --- a/src/configuration/apimSettingsProvider.ts +++ b/src/configuration/apimSettingsProvider.ts @@ -26,17 +26,18 @@ export class ApimSettingsProvider implements ISettingsProvider { const commonConfigurationResponse = await this.httpClient.send({ url: ConfigEndpoints.backend }); const commonConfiguration = commonConfigurationResponse.toObject(); - const serializedDesignTimeSettings = await this.sessionManager?.getItem("designTimeSettings"); + const serializedDesignTimeSettings = await this.sessionManager?.getItem(SettingNames.designTimeSettings); if (serializedDesignTimeSettings) { const designTimeSettings = serializedDesignTimeSettings; Object.assign(commonConfiguration, designTimeSettings); - // TODO: check it in self-hosted case const accessTokenFromEditor = designTimeSettings[SettingNames.managementApiAccessToken]; - if(accessTokenFromEditor) { + + if (accessTokenFromEditor) { sessionStorage.setItem("accessToken", accessTokenFromEditor); } - } else { + } + else { const apimsConfigurationResponse = await this.httpClient.send({ url: ConfigEndpoints.service }); if (apimsConfigurationResponse.statusCode === 200) { diff --git a/src/configuration/defaultSettingsProvider.spec.ts b/src/configuration/defaultSettingsProvider.spec.ts new file mode 100644 index 000000000..d5a9e258d --- /dev/null +++ b/src/configuration/defaultSettingsProvider.spec.ts @@ -0,0 +1,348 @@ +import { DefaultSettingsProvider } from "./defaultSettingsProvider"; +import { EventManager } from "@paperbits/common/events"; +import { HttpClient } from "@paperbits/common/http"; +import { expect } from "chai"; +import { beforeEach, afterEach, describe, it } from "mocha"; +import { stub, restore, SinonStub, createSandbox } from "sinon"; + +describe("DefaultSettingsProvider", () => { + let settingsProvider: DefaultSettingsProvider; + let mockHttpClient: Partial; + let mockEventManager: Partial; + let httpSendStub: SinonStub; + let eventAddListenerStub: SinonStub; + let eventDispatchStub: SinonStub; + let dateNowStub: SinonStub; + let mockResponse: any; + const configFileUri = "https://example.com/config.json"; + const mockConfigData = { + apiUrl: "https://api.example.com", + theme: "dark", + timeout: 5000, + features: { + enableAnalytics: true, + enableNotifications: false + } + }; + const sandbox = createSandbox(); + + beforeEach(() => { + // Create mock response object + mockResponse = { + toObject: sandbox.stub().returns(mockConfigData) + }; + + // Create stubs for HTTP client + httpSendStub = sandbox.stub().resolves(mockResponse); + mockHttpClient = { + send: httpSendStub + }; + + // Create stubs for event manager + eventAddListenerStub = sandbox.stub(); + eventDispatchStub = sandbox.stub(); + mockEventManager = { + addEventListener: eventAddListenerStub, + dispatchEvent: eventDispatchStub + }; + + // Create instance + settingsProvider = new DefaultSettingsProvider( + mockHttpClient as HttpClient, + mockEventManager as EventManager, + configFileUri, + 10 * 1000 // Set cache duration to 10 seconds for testing + ); + }); + + afterEach(() => { + sandbox.restore(); + }); + + describe("getSettings", () => { + it("should load settings from HTTP client on first call", async () => { + const result = await settingsProvider.getSettings(); + + expect(httpSendStub.calledWith({ url: configFileUri })).to.be.true; + expect(mockResponse.toObject.called).to.be.true; + expect(result).to.deep.equal(mockConfigData); + }); + + it("should cache settings and not reload on subsequent calls within cache duration", async () => { + const mockNow = 1000000; + dateNowStub = sandbox.stub(Date, 'now').returns(mockNow); + + // First call + await settingsProvider.getSettings(); + expect(httpSendStub.callCount).to.equal(1); + + // Second call within cache duration (9 seconds later) + dateNowStub.returns(mockNow + 9 * 1000); + await settingsProvider.getSettings(); + + expect(httpSendStub.callCount).to.equal(1); // Should still be 1 + }); + + it("should reload settings when cache expires", async () => { + const mockNow = 1000000; + dateNowStub = sandbox.stub(Date, 'now').returns(mockNow); + + // First call + await settingsProvider.getSettings(); + expect(httpSendStub.callCount).to.equal(1); + + // Second call after cache expiration (11 seconds later) + dateNowStub.returns(mockNow + 11 * 1000); + await settingsProvider.getSettings(); + + expect(httpSendStub.callCount).to.equal(2); + }); + + it("should reload only specific settings when cache expires", async () => { + const mockNow = 1000000; + dateNowStub = sandbox.stub(Date, 'now').returns(mockNow); + + // First call + await settingsProvider.setSetting("theme", "light"); + await settingsProvider.setSetting("testPersistent", true); + const resultBeforeReload = await settingsProvider.getSettings(); + + expect(resultBeforeReload["theme"]).to.equal("light"); + expect(resultBeforeReload["testPersistent"]).to.equal(true); + expect(httpSendStub.callCount).to.equal(1); + + // Second call after cache expiration (11 seconds later) + dateNowStub.returns(mockNow + 11 * 1000); + const resultAfterReload = await settingsProvider.getSettings(); + + expect(httpSendStub.callCount).to.equal(2); + expect(resultAfterReload["theme"]).to.equal("dark"); + expect(resultAfterReload["testPersistent"]).to.equal(true); + }); + + it("should handle HTTP client errors", async () => { + const error = new Error("Network error"); + httpSendStub.rejects(error); + + try { + await settingsProvider.getSettings(); + expect.fail("Should have thrown an error"); + } catch (err) { + expect(err.message).to.equal("Network error"); + } + }); + + it("should return the same promise for concurrent calls", async () => { + const promise1 = settingsProvider.getSettings(); + const promise2 = settingsProvider.getSettings(); + + expect(promise1).to.equal(promise2); + + const [result1, result2] = await Promise.all([promise1, promise2]); + expect(result1).to.deep.equal(result2); + expect(httpSendStub.callCount).to.equal(1); + }); + }); + + describe("getSetting", () => { + it("should return specific setting value", async () => { + const result = await settingsProvider.getSetting("apiUrl"); + + expect(result).to.equal("https://api.example.com"); + expect(httpSendStub.calledWith({ url: configFileUri })).to.be.true; + }); + + it("should return nested setting value", async () => { + const result = await settingsProvider.getSetting("features"); + + expect(result).to.deep.equal(mockConfigData.features); + }); + + it("should return undefined for non-existent setting", async () => { + const result = await settingsProvider.getSetting("nonExistentSetting"); + + expect(result).to.be.undefined; + }); + + it("should work with different data types", async () => { + const stringResult = await settingsProvider.getSetting("apiUrl"); + const numberResult = await settingsProvider.getSetting("timeout"); + const objectResult = await settingsProvider.getSetting("features"); + + expect(typeof stringResult).to.equal("string"); + expect(typeof numberResult).to.equal("number"); + expect(typeof objectResult).to.equal("object"); + expect(stringResult).to.equal("https://api.example.com"); + expect(numberResult).to.equal(5000); + expect(objectResult).to.deep.equal(mockConfigData.features); + }); + }); + + describe("setSetting", () => { + it("should set a new setting value", async () => { + await settingsProvider.setSetting("newSetting", "newValue"); + + const result = await settingsProvider.getSetting("newSetting"); + expect(result).to.equal("newValue"); + }); + + it("should update an existing setting value", async () => { + // First load the settings + await settingsProvider.getSettings(); + + // Update existing setting + await settingsProvider.setSetting("apiUrl", "https://new-api.example.com"); + + const result = await settingsProvider.getSetting("apiUrl"); + expect(result).to.equal("https://new-api.example.com"); + }); + + it("should dispatch setting change event", async () => { + await settingsProvider.setSetting("testSetting", "testValue"); + + expect(eventDispatchStub.calledWith("onSettingChange", { + name: "testSetting", + value: "testValue" + })).to.be.true; + }); + + it("should load settings first if not already loaded", async () => { + await settingsProvider.setSetting("newSetting", "value"); + + expect(httpSendStub.calledWith({ url: configFileUri })).to.be.true; + expect(eventDispatchStub.calledWith("onSettingChange", { + name: "newSetting", + value: "value" + })).to.be.true; + }); + }); + + describe("onSettingChange", () => { + it("should register event listener for setting changes", () => { + const eventHandler = sandbox.stub(); + + settingsProvider.onSettingChange("testSetting", eventHandler); + + expect(eventAddListenerStub.calledWith("onSettingChange")).to.be.true; + }); + + it("should call event handler when matching setting changes", () => { + const eventHandler = sandbox.stub(); + let registeredHandler: Function; + + eventAddListenerStub.callsFake((eventName, handler) => { + registeredHandler = handler; + }); + + settingsProvider.onSettingChange("testSetting", eventHandler); + + // Simulate event dispatch + registeredHandler({ name: "testSetting", value: "newValue" }); + + expect(eventHandler.calledWith("newValue")).to.be.true; + }); + + it("should not call event handler for different setting changes", () => { + const eventHandler = sandbox.stub(); + let registeredHandler: Function; + + eventAddListenerStub.callsFake((eventName, handler) => { + registeredHandler = handler; + }); + + settingsProvider.onSettingChange("testSetting", eventHandler); + + // Simulate event dispatch for different setting + registeredHandler({ name: "differentSetting", value: "newValue" }); + + expect(eventHandler.called).to.be.false; + }); + }); + + describe("caching behavior", () => { + it("should have 10 second cache duration", async () => { + const mockNow = 1000000; + dateNowStub = sandbox.stub(Date, 'now').returns(mockNow); + + // First call + await settingsProvider.getSettings(); + + // Call just before cache expiration (9.9 seconds) + dateNowStub.returns(mockNow + (10 * 1000) - 100); + await settingsProvider.getSettings(); + expect(httpSendStub.callCount).to.equal(1); + + // Call just after cache expiration (10.1 seconds) + dateNowStub.returns(mockNow + (10 * 1000) + 100); + await settingsProvider.getSettings(); + expect(httpSendStub.callCount).to.equal(2); + }); + + it("should reload settings immediately if no previous load time exists", async () => { + // This tests the isConfigurationExpired method when configurationLoadTime is not set + const result = await settingsProvider.getSettings(); + + expect(httpSendStub.callCount).to.equal(1); + expect(result).to.deep.equal(mockConfigData); + }); + }); + + describe("error handling", () => { + it("should handle malformed response from HTTP client", async () => { + mockResponse.toObject.throws(new Error("Invalid JSON")); + + try { + await settingsProvider.getSettings(); + expect.fail("Should have thrown an error"); + } catch (err) { + expect(err.message).to.equal("Invalid JSON"); + } + }); + + it("should handle null response", async () => { + httpSendStub.resolves(null); + + try { + await settingsProvider.getSettings(); + expect.fail("Should have thrown an error"); + } catch (err) { + expect(err).to.exist; + } + }); + }); + + describe("integration scenarios", () => { + it("should handle complete workflow: load, get, set, and listen for changes", async () => { + const changeHandler = sandbox.stub(); + let registeredHandler: Function; + + eventAddListenerStub.callsFake((eventName, handler) => { + registeredHandler = handler; + }); + + // Register for changes + settingsProvider.onSettingChange("apiUrl", changeHandler); + + // Get initial setting + const initialValue = await settingsProvider.getSetting("apiUrl"); + expect(initialValue).to.equal("https://api.example.com"); + + // Set new value + await settingsProvider.setSetting("apiUrl", "https://updated-api.example.com"); + + // Verify event was dispatched + expect(eventDispatchStub.calledWith("onSettingChange", { + name: "apiUrl", + value: "https://updated-api.example.com" + })).to.be.true; + + // Simulate event handling + registeredHandler({ name: "apiUrl", value: "https://updated-api.example.com" }); + expect(changeHandler.calledWith("https://updated-api.example.com")).to.be.true; + + // Verify new value is returned + const updatedValue = await settingsProvider.getSetting("apiUrl"); + expect(updatedValue).to.equal("https://updated-api.example.com"); + }); + }); +}); diff --git a/src/configuration/defaultSettingsProvider.ts b/src/configuration/defaultSettingsProvider.ts index 7a81cec49..96b9419db 100644 --- a/src/configuration/defaultSettingsProvider.ts +++ b/src/configuration/defaultSettingsProvider.ts @@ -5,21 +5,39 @@ import { ISettingsProvider } from "@paperbits/common/configuration"; export class DefaultSettingsProvider implements ISettingsProvider { private configuration: Object; private loadingPromise: Promise; + private configurationLoadTime: number; constructor( private readonly httpClient: HttpClient, private readonly eventManager: EventManager, - private readonly configFileUri: string - ) { - } + private readonly configFileUri: string, + private readonly configCacheDurationMs: number + ) { } private async loadSettings(): Promise { + this.configurationLoadTime = Date.now(); + if (!this.configuration) { + this.configuration = {}; + } + const response = await this.httpClient.send({ url: this.configFileUri }); - this.configuration = response.toObject(); + const configurationData = response.toObject(); + if (configurationData){ + for (const key in configurationData) { + this.configuration[key] = configurationData[key]; + } + } return this.configuration; } + private isConfigurationExpired(): boolean { + if (!this.configurationLoadTime) { + return true; + } + return (Date.now() - this.configurationLoadTime) > this.configCacheDurationMs; + } + public async getSetting(name: string): Promise { await this.getSettings(); return this.configuration[name]; @@ -42,7 +60,7 @@ export class DefaultSettingsProvider implements ISettingsProvider { } public getSettings(): Promise { - if (!this.loadingPromise) { + if (!this.loadingPromise || this.isConfigurationExpired()) { this.loadingPromise = this.loadSettings(); } diff --git a/src/configuration/staticSettingsProvider.ts b/src/configuration/staticSettingsProvider.ts index 90d3e643c..96aecf23f 100644 --- a/src/configuration/staticSettingsProvider.ts +++ b/src/configuration/staticSettingsProvider.ts @@ -1,8 +1,12 @@ import * as Objects from "@paperbits/common/objects"; import { ISettingsProvider } from "@paperbits/common/configuration"; +import { EventManager } from "@paperbits/common/events"; export class StaticSettingsProvider implements ISettingsProvider { - constructor(private readonly configuration: Object) { } + constructor( + private readonly configuration: Object, + private readonly eventManager?: EventManager + ) { } public getSetting(path: string): Promise { return Objects.getObjectAt(path, this.configuration); @@ -11,9 +15,20 @@ export class StaticSettingsProvider implements ISettingsProvider { public async setSetting(path: string, value: T): Promise { Objects.setValue(path, this.configuration, value); this.configuration[path] = value; + this.eventManager?.dispatchEvent("onSettingChange", { name: path, value: value }); } public async getSettings(): Promise { return this.configuration; } + + public onSettingChange(name: string, eventHandler: (value: T) => void): void { + this.eventManager?.addEventListener("onSettingChange", (setting) => { + if (setting.name !== name) { + return; + } + + eventHandler(setting.value); + }); + } } \ No newline at end of file diff --git a/src/constants.ts b/src/constants.ts index bea5812ce..124bed392 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -264,8 +264,8 @@ export enum SettingNames { // Should be equal to IEditorSettings property names armEndpoint = "armEndpoint", - aadClientId = "editorAadClientId", - aadAuthority = "editorAadAuthority", + aadClientId = "clientId", + aadAuthority = "tenantId", designTimeSettings = "designTimeSettings", } @@ -437,4 +437,6 @@ export const USER_ACTION = "data-action"; export const FEATURE_FLAGS = "featureFlags"; export const FEATURE_CLIENT_TELEMETRY = "clientTelemetry"; export const FEATURE_OLD_THEME = "oldDefaultTheme"; -export const USE_COMPRESSED_ASSETS = "useCompressedAssets"; \ No newline at end of file +export const USE_COMPRESSED_ASSETS = "useCompressedAssets"; + +export const DEFAULT_CONFIG_CACHE_DURATION_MS = 5 * 60 * 1000; // 5 minutes in milliseconds diff --git a/src/modules.d.ts b/src/modules.d.ts index 92fe623b4..09f6adf35 100644 --- a/src/modules.d.ts +++ b/src/modules.d.ts @@ -18,4 +18,6 @@ declare module "*.raw" { export default content; } -declare let DecompressionStream: any; \ No newline at end of file +declare let DecompressionStream: any; + +declare let ARM_TOKEN: string; \ No newline at end of file diff --git a/src/persistence/mapiBlobStorage.browser.ts b/src/persistence/mapiBlobStorage.browser.ts index a261e9b62..cab59baa9 100644 --- a/src/persistence/mapiBlobStorage.browser.ts +++ b/src/persistence/mapiBlobStorage.browser.ts @@ -5,7 +5,7 @@ import * as Utils from "@paperbits/common/utils"; import { StaticSettingsProvider } from "../configuration"; import { TenantService } from "../services/tenantService"; import { IStreamBlobStorage } from "./IStreamBlobStorage"; - +import { EventManager } from "@paperbits/common/events"; const defaultContainerName = "content"; @@ -15,6 +15,7 @@ export class MapiBlobStorage implements IStreamBlobStorage { constructor( private readonly logger: Logger, private readonly tenantService: TenantService, + private readonly eventManager: EventManager, private readonly settingsProvider: ISettingsProvider ) { } @@ -23,11 +24,29 @@ export class MapiBlobStorage implements IStreamBlobStorage { return this.azureStorageClient; } - let storageSettingsProvider: ISettingsProvider; + const storageSettingsProvider: ISettingsProvider = new StaticSettingsProvider({ blobStorageUrl: await this.getStorageUrl() }, this.eventManager); + setInterval(async () => { + try { + const url = await this.getStorageUrl(); + storageSettingsProvider.setSetting("blobStorageUrl", url); + } catch (error) { + this.logger.trackError(error, { message: "Failed to update blob storage URL" }); + } + }, 1000 * 60 * 5); // Keep the connection string updated every 5 minutes + this.azureStorageClient = new BrowserAzureBlobStorage(storageSettingsProvider, this.logger) + return this.azureStorageClient; + } + + /** + * Returns storage url. + */ + + private async getStorageUrl(): Promise { const blobStorageContainer = await this.settingsProvider.getSetting("blobStorageContainer"); const blobStorageUrl = await this.settingsProvider.getSetting("blobStorageUrl"); + let storageUri: string; if (blobStorageUrl) { const parsedUrl = new URL(blobStorageUrl); @@ -35,22 +54,13 @@ export class MapiBlobStorage implements IStreamBlobStorage { ? blobStorageContainer : defaultContainerName; - const normalizedBlobStorageUrl = `${parsedUrl.protocol}//${parsedUrl.hostname}${Utils.ensureLeadingSlash(containerSegment)}${parsedUrl.search}`; - - storageSettingsProvider = new StaticSettingsProvider({ - blobStorageUrl: normalizedBlobStorageUrl - }); + storageUri = `${parsedUrl.protocol}//${parsedUrl.hostname}${Utils.ensureLeadingSlash(containerSegment)}${parsedUrl.search}`; } else { - const containerSasUrl = await this.tenantService.getMediaContentBlobUrl(); - - storageSettingsProvider = new StaticSettingsProvider({ - blobStorageUrl: containerSasUrl - }); + storageUri = await this.tenantService.getMediaContentBlobUrl(); } - this.azureStorageClient = new BrowserAzureBlobStorage(storageSettingsProvider, this.logger) - return this.azureStorageClient; + return storageUri; } /** diff --git a/src/services/apiService.spec.ts b/src/services/apiService.spec.ts index bdb484e13..dce947bba 100644 --- a/src/services/apiService.spec.ts +++ b/src/services/apiService.spec.ts @@ -1,9 +1,9 @@ import { expect } from "chai"; import { describe, it } from "mocha"; import { ApiService } from "./apiService"; -import { DataApiClient, MapiClient } from "../clients"; +import { DataApiClient } from "../clients"; import { MockHttpClient, bookStoreApi, mapiApiBookStoreSchema, dataApiBookStoreSchema, bookStoreApiProductsWithNextLink, bookStoreApiProducts } from "./../../tests/mocks"; -import { StaticAuthenticator } from "./../authentication/staticAuthenticator"; +import { StaticAuthenticator } from "../authentication/staticAuthenticator"; import { StaticSettingsProvider } from "./../configuration/staticSettingsProvider"; import { NoRetryStrategy } from "../clients/retryStrategy/noRetryStrategy"; import { ConsoleLogger } from "@paperbits/common/logging"; @@ -12,7 +12,9 @@ import { SchemaContract } from "../contracts/schema"; import "fake-indexeddb/auto"; import { clear } from "idb-keyval"; import { AccessToken } from "../authentication"; -import { MapiClientSelfhosted } from "../clients/mapiClientSelfhosted"; +import { MapiClient } from "../clients/mapiClient"; +import { AzureResourceManagementService } from "./armService"; + describe("API service", async () => { const runs = [ @@ -100,21 +102,6 @@ describe("API service", async () => { expect(apiSchema.definitions).to.deep.equal(new Schema(dataApiBookStoreSchema).definitions); }) - it(`${run.it} - ManagementApi Returns ApiSchema`, async () => { - const schemaResource = buildResourceUri(null, "apis/book-store-api/schemas/test-schema", true); // for mapi do not add users/userId in url - const httpClient = new MockHttpClient(); - - httpClient.mock() - .get(schemaResource) - .reply(200, mapiApiBookStoreSchema); - - const apiClient = new MapiClientSelfhosted(httpClient, authenticator, settingsProvider, new NoRetryStrategy(), new ConsoleLogger()); - const apiService = new ApiService(apiClient); - const apiSchema = await apiService.getApiSchema("apis/book-store-api/schemas/test-schema"); - - expect(apiSchema).to.deep.equal(new Schema(mapiApiBookStoreSchema.properties)); - }) - it(`${run.it} - ManagementApi Returns ApiSchema`, async () => { const managementApiUrl = "https://contoso.management.azure-api.net"; const settingsProvider = new StaticSettingsProvider({ diff --git a/src/services/apiService.ts b/src/services/apiService.ts index 775a8690e..44bf09d90 100644 --- a/src/services/apiService.ts +++ b/src/services/apiService.ts @@ -427,7 +427,7 @@ export class ApiService { if (!contract) return null; // Request from MAPI case - if (contract["properties"]) + if (contract.hasOwnProperty("properties")) return contract["properties"]; // DataApi contract doesn't have properties property return contract; diff --git a/src/services/apimMediaService.ts b/src/services/apimMediaService.ts index af51906c8..64ba67664 100644 --- a/src/services/apimMediaService.ts +++ b/src/services/apimMediaService.ts @@ -1,6 +1,6 @@ import { IMediaService, MediaContract } from "@paperbits/common/media"; import { Query, Page } from "@paperbits/common/persistence"; -import { ArmService } from "./armService"; +import { AzureResourceManagementService } from "./armService"; import { HttpClient } from "@paperbits/common/http"; import { IAuthenticator } from "../authentication"; import { ISettingsProvider } from "@paperbits/common/configuration"; @@ -11,7 +11,7 @@ import { Logger } from "@paperbits/common/logging"; export class ApimMediaService implements IMediaService { public constructor( private readonly viewManager: ViewManager, - private readonly armService: ArmService, + private readonly armService: AzureResourceManagementService, private readonly authenticator: IAuthenticator, private readonly httpClient: HttpClient, private readonly mediaService: IMediaService, @@ -42,8 +42,8 @@ export class ApimMediaService implements IMediaService { public async createMedia(name: string, content: Uint8Array, contentType?: string): Promise { const formData = new FormData(); - const blob = new Blob([content], { type: contentType ?? "application/octet-stream" }); - formData.append("file", blob, name); + let blob = new Blob([content], { type: contentType ?? 'application/octet-stream' }); + formData.append('file', blob, name); const accessToken = await this.authenticator.getAccessToken(); @@ -61,12 +61,13 @@ export class ApimMediaService implements IMediaService { return result.toObject()[0]; } - if (result.statusCode === 400 && result.body) { - const reason = JSON.parse(new TextDecoder().decode(result.body)); - this.viewManager.notifyError(`Could not upload file ${name}`, reason.message); - return null; - } else { - throw new Error("Unable to upload file."); + switch (result.statusCode) { + case 400: + const reason = JSON.parse(new TextDecoder().decode(result.body)); + this.viewManager.notifyError(`Could not upload file ${name}`, reason.message); + return null; + default: + throw new Error("Unable to upload file."); } } diff --git a/src/services/armService.spec.ts b/src/services/armService.spec.ts index 753c94a38..479831251 100644 --- a/src/services/armService.spec.ts +++ b/src/services/armService.spec.ts @@ -1,70 +1,71 @@ -import { expect } from "chai"; -import { ArmService } from "./armService"; -import { MockHttpClient } from "../../tests/mocks"; -import { ConsoleLogger } from "@paperbits/common/logging"; -import { ISettingsProvider } from "@paperbits/common/configuration"; -import { DefaultSessionManager } from "@paperbits/common/persistence/defaultSessionManager"; +import { expect } from 'chai'; +import { AzureResourceManagementService } from './armService'; +import { MockHttpClient } from '../../tests/mocks'; +import { ConsoleLogger } from '@paperbits/common/logging'; +import { ISettingsProvider } from '@paperbits/common/configuration'; +import { DefaultSessionManager } from '@paperbits/common/persistence/defaultSessionManager'; -describe("ArmService", () => { - let service: ArmService; +describe('ArmService', () => { + let service: AzureResourceManagementService; - beforeEach(() => { - const httpClient = new MockHttpClient(); - service = new ArmService(httpClient, null, new DefaultSessionManager(), new ConsoleLogger()); - }); + beforeEach(() => { + const httpClient = new MockHttpClient(); + const sessionManager = new DefaultSessionManager(); + service = new AzureResourceManagementService(httpClient, null, new ConsoleLogger()); + }); - it("should return the correct tenant ARM URI", async () => { - // Arrange - const settingsProviderMock: ISettingsProvider = { - // Implement the necessary methods for the settings provider - getSetting: async (settingName: string) => { - switch (settingName) { - case "subscriptionId": - return "test_subscriptionId"; - case "resourceGroupName": - return "test_resourceGroupName"; - case "serviceName": - return "test_serviceName"; - case "authTenantId": - return "test_authTenantId"; - default: - throw new Error(`Unknown setting name: ${settingName}`); - } - } - }; + it('should return the correct tenant ARM URI', async () => { + // Arrange + const settingsProviderMock: ISettingsProvider = { + // Implement the necessary methods for the settings provider + getSetting: async (settingName: string) => { + switch (settingName) { + case 'subscriptionId': + return 'test_subscriptionId'; + case 'resourceGroupName': + return 'test_resourceGroupName'; + case 'serviceName': + return 'test_serviceName'; + case 'authTenantId': + return 'test_authTenantId'; + default: + throw new Error(`Unknown setting name: ${settingName}`); + } + } + }; - global.location = { href: "https://contoso.com" }; - global.sessionStorage = { - getItem: () => null, - setItem: (key, val) => {console.log(`Set ${key} to ${val}`); } - }; + global.location = { href: "https://contoso.com" }; + global.sessionStorage = { + getItem: () => null, + setItem: (key, val) => { } + }; - // Act - const result = await service.getTenantArmUriAsync(settingsProviderMock); + // Act + const result = await service.getTenantArmUriAsync(settingsProviderMock); - // Assert - expect(result).to.equal("/subscriptions/test_subscriptionId/resourceGroups/test_resourceGroupName/providers/Microsoft.ApiManagement/service/test_serviceName"); - }); + // Assert + expect(result).to.equal('/subscriptions/test_subscriptionId/resourceGroups/test_resourceGroupName/providers/Microsoft.ApiManagement/service/test_serviceName'); + }); - it("should throw an error if required service parameters are not provided", async () => { - // Arrange - const settingsProviderMock: ISettingsProvider = { - // Implement the necessary methods for the settings provider - getSetting: async (settingName: string) => null // Return null for all settings to simulate missing parameters - }; + it('should throw an error if required service parameters are not provided', async () => { + // Arrange + const settingsProviderMock: ISettingsProvider = { + // Implement the necessary methods for the settings provider + getSetting: async (settingName: string) => null // Return null for all settings to simulate missing parameters + }; - global.location = { href: "https://contoso.com" }; - global.sessionStorage = { - getItem: () => null, - setItem: (key, val) => {console.log(`Set ${key} to ${val}`); } - }; + global.location = { href: "https://contoso.com" }; + global.sessionStorage = { + getItem: () => null, + setItem: (key, val) => { } + }; - // Act and Assert - try { - await service.getTenantArmUriAsync(settingsProviderMock); - expect.fail("Expected an error to be thrown"); - } catch(error) { - expect(error.message).to.equal("Required service parameters (like subscription, resource group, service name) were not provided to start editor"); - } - }); + // Act and Assert + try { + await service.getTenantArmUriAsync(settingsProviderMock); + expect.fail('Expected an error to be thrown'); + } catch (error) { + expect(error.message).to.equal('Required service parameters (like subscription, resource group, service name, tenant Id) were not provided to start editor'); + } + }); }); \ No newline at end of file diff --git a/src/services/armService.ts b/src/services/armService.ts index 6748e0865..63642c68b 100644 --- a/src/services/armService.ts +++ b/src/services/armService.ts @@ -6,14 +6,14 @@ import { ServiceDescriptionContract } from "../contracts/service"; import { SettingNames } from "../constants"; import { Logger } from "@paperbits/common/logging/logger"; import { IAuthenticator } from "../authentication/IAuthenticator"; -import { SessionManager } from "@paperbits/common/persistence/sessionManager"; -export class ArmService { + +export class AzureResourceManagementService { private loadSettingsPromise: Promise; + constructor( private readonly httpClient: HttpClient, private readonly authenticator: IAuthenticator, - private readonly sessionManager: SessionManager, private readonly logger: Logger ) { } @@ -74,41 +74,31 @@ export class ArmService { } private async loadSettings(settingsProvider: ISettingsProvider): Promise { - const managementApiUrl = await settingsProvider.getSetting(SettingNames.managementApiUrl); - if (!managementApiUrl) { - let settingsForRuntime = await this.sessionManager.getItem(SettingNames.designTimeSettings) || {}; - const armEndpoint = await settingsProvider.getSetting(SettingNames.armEndpoint); - const armUri = await this.getTenantArmUriAsync(settingsProvider); - - if (!armUri || !armEndpoint) { - throw new Error("Required service parameters (like subscription, resource group, service name) were not provided to start editor"); - } - - const managementApiUrl = `https://${armEndpoint}${armUri}`; - await settingsProvider.setSetting(SettingNames.managementApiUrl, managementApiUrl); - this.logger.trackEvent("ArmService", { message: `Management API URL: ${managementApiUrl}` }); - - const userId = Constants.adminUserId; // Admin user ID for editor DataApi - const userTokenValue = await this.getUserAccessToken(userId, managementApiUrl); - const serviceDescription = await this.getServiceDescription(managementApiUrl); - const dataApiUrl = serviceDescription.properties.dataApiUrl; - const developerPortalUrl = serviceDescription.properties.developerPortalUrl; - const isMultitenant = serviceDescription.sku.name.includes("V2"); - - await settingsProvider.setSetting(SettingNames.backendUrl, developerPortalUrl); - await settingsProvider.setSetting(SettingNames.dataApiUrl, dataApiUrl); - await settingsProvider.setSetting(SettingNames.isMultitenant, isMultitenant); - - settingsForRuntime = { - [SettingNames.backendUrl]: developerPortalUrl, - [SettingNames.dataApiUrl]: dataApiUrl, - [SettingNames.directDataApi]: !!dataApiUrl, - [SettingNames.managementApiAccessToken]: userTokenValue, - [SettingNames.isMultitenant]: isMultitenant, - ...settingsForRuntime - }; - await this.sessionManager.setItem(SettingNames.designTimeSettings, settingsForRuntime); + const managementApiUrlDefined = await settingsProvider.getSetting(SettingNames.managementApiUrl); + + if (managementApiUrlDefined) { + return; } + + const armEndpoint = await settingsProvider.getSetting(SettingNames.armEndpoint) || "management.azure.com"; + const armUri = await this.getTenantArmUriAsync(settingsProvider); + + if (!armUri || !armEndpoint) { + throw new Error("Required service parameters (like subscription, resource group, service name) were not provided to start editor"); + } + + const managementApiUrl = `https://${armEndpoint}${armUri}`; + await settingsProvider.setSetting(SettingNames.managementApiUrl, managementApiUrl); + this.logger.trackEvent("ArmService", { message: `Management API URL: ${managementApiUrl}` }); + + const serviceDescription = await this.getServiceDescription(managementApiUrl); + const dataApiUrl = serviceDescription.properties.dataApiUrl; + const developerPortalUrl = serviceDescription.properties.developerPortalUrl; + const isMultitenant = serviceDescription.sku.name.includes("V2"); + + await settingsProvider.setSetting(SettingNames.backendUrl, developerPortalUrl); + await settingsProvider.setSetting(SettingNames.dataApiUrl, dataApiUrl); + await settingsProvider.setSetting(SettingNames.isMultitenant, isMultitenant); } public async getTenantArmUriAsync(settingsProvider: ISettingsProvider): Promise { @@ -117,7 +107,7 @@ export class ArmService { const serviceName = await settingsProvider.getSetting(SettingNames.serviceName); if (!subscriptionId || !resourceGroupName || !serviceName) { - throw new Error("Required service parameters (like subscription, resource group, service name) were not provided to start editor"); + throw new Error("Required service parameters (like subscription, resource group, service name, tenant Id) were not provided to start editor"); } return `/subscriptions/${subscriptionId}/resourceGroups/${resourceGroupName}/providers/Microsoft.ApiManagement/service/${serviceName}`; diff --git a/src/services/mapiClient.ts b/src/services/mapiClient.ts deleted file mode 100644 index 0eaab9d93..000000000 --- a/src/services/mapiClient.ts +++ /dev/null @@ -1,329 +0,0 @@ -import * as Constants from "./../constants"; -import { ISettingsProvider } from "@paperbits/common/configuration"; -import { Logger } from "@paperbits/common/logging"; -import { Utils } from "../utils"; -import { TtlCache } from "./ttlCache"; -import { HttpClient, HttpRequest, HttpResponse, HttpMethod, HttpHeader } from "@paperbits/common/http"; -import { MapiError } from "../errors/mapiError"; -import { IAuthenticator, AccessToken } from "../authentication"; -import { KnownHttpHeaders } from "../models/knownHttpHeaders"; -import { KnownMimeTypes } from "../models/knownMimeTypes"; -import { Page } from "../models/page"; - -export interface IHttpBatchResponses { - responses: IHttpBatchResponse[]; -} - -export interface IHttpBatchResponse { - httpStatusCode: number; - headers: { - [key: string]: string; - }; - content: any; -} - -export class MapiClient { - private managementApiUrl: string; - private environment: string; - private developerPortalType: string; - private initializePromise: Promise; - private requestCache: TtlCache = new TtlCache(); - - constructor( - private readonly httpClient: HttpClient, - private readonly authenticator: IAuthenticator, - private readonly settingsProvider: ISettingsProvider, - private readonly logger: Logger - ) { } - - private async ensureInitialized(): Promise { - if (!this.initializePromise) { - this.initializePromise = this.initialize(); - } - return this.initializePromise; - } - - private async initialize(): Promise { - const settings = await this.settingsProvider.getSettings(); - - this.developerPortalType = settings[Constants.SettingNames.developerPortalType] || Constants.DeveloperPortalType.selfHosted; - const managementApiUrl = settings[Constants.SettingNames.managementApiUrl]; - - if (!managementApiUrl) { - throw new Error(`Management API URL ("${Constants.SettingNames.managementApiUrl}") setting is missing in configuration file.`); - } - - this.managementApiUrl = Utils.ensureUrlArmified(managementApiUrl); - - const managementApiAccessToken = settings[Constants.SettingNames.managementApiAccessToken]; - - if (managementApiAccessToken) { - const accessToken = AccessToken.parse(managementApiAccessToken); - await this.authenticator.setAccessToken(accessToken); - } - else if (this.environment === "development") { - console.warn(`Development mode: Please specify ${Constants.SettingNames.managementApiAccessToken}" in configuration file.`); - return; - } - - this.environment = settings["environment"]; - } - - private async requestInternal(httpRequest: HttpRequest): Promise { - if (!httpRequest.url) { - throw new Error("Request URL cannot be empty."); - } - - await this.ensureInitialized(); - - httpRequest.headers = httpRequest.headers || []; - - if (httpRequest.body && !httpRequest.headers.some(x => x.name === KnownHttpHeaders.ContentType)) { - httpRequest.headers.push({ name: KnownHttpHeaders.ContentType, value: KnownMimeTypes.Json }); - } - - if (!httpRequest.headers.some(x => x.name === "Accept")) { - httpRequest.headers.push({ name: "Accept", value: "*/*" }); - } - - if (typeof (httpRequest.body) === "object") { - httpRequest.body = JSON.stringify(httpRequest.body); - } - - const call = () => this.makeRequest(httpRequest); - const requestKey = this.getRequestKey(httpRequest); - - if (requestKey) { - return this.requestCache.getOrAddAsync(requestKey, call, 1000); - } - - return call(); - } - - private getRequestKey(httpRequest: HttpRequest): string { - if (httpRequest.method !== HttpMethod.get && httpRequest.method !== HttpMethod.head && httpRequest.method !== "OPTIONS") { // TODO: HttpMethod.options) { - return null; - } - - let key = `${httpRequest.method}:${httpRequest.url}`; - - if (httpRequest.headers) { - key += ":" + httpRequest.headers.sort().map(k => `${k}=${httpRequest.headers.join(",")}`).join("&"); - } - - return key; - } - - protected async makeRequest(httpRequest: HttpRequest): Promise { - const authHeader = httpRequest.headers.find(header => header.name === KnownHttpHeaders.Authorization); - const portalHeader = httpRequest.headers.find(header => header.name === Constants.portalHeaderName); - - if (!authHeader?.value) { - const accessToken = await this.authenticator.getAccessTokenAsString(); - - if (accessToken) { - httpRequest.headers.push({ name: KnownHttpHeaders.Authorization, value: `${accessToken}` }); - } else { - if (!portalHeader) { - httpRequest.headers.push(await this.getPortalHeader("unauthorized")); - } else { - portalHeader.value = `${portalHeader.value}-unauthorized`; - } - } - } - - if (!portalHeader && httpRequest.method !== HttpMethod.head) { - httpRequest.headers.push(await this.getPortalHeader()); - } - - // Do nothing if absolute URL - if (!httpRequest.url.startsWith("https://") && !httpRequest.url.startsWith("http://")) { - httpRequest.url = `${this.managementApiUrl}${Utils.ensureLeadingSlash(httpRequest.url)}`; - } - - const url = new URL(httpRequest.url); - - if (!url.searchParams.has("api-version")) { - httpRequest.url = Utils.addQueryParameter(httpRequest.url, `api-version=${Constants.managementApiVersion}`); - } - - let response: HttpResponse; - - try { - response = await this.httpClient.send(httpRequest); - } - catch (error) { - throw new Error(`Unable to complete request. Error: ${error.message}`); - } - - return await this.handleResponse(response, httpRequest.url); - } - - private async handleResponse(response: HttpResponse, url: string): Promise { - let contentType = ""; - - if (response.headers) { - const contentTypeHeader = response.headers.find(h => h.name.toLowerCase() === "content-type"); - contentType = contentTypeHeader ? contentTypeHeader.value.toLowerCase() : ""; - } - - const text = response.toText(); - - if (response.statusCode >= 200 && response.statusCode < 300) { - if (contentType.includes("json") && text.length > 0) { - return JSON.parse(text) as T; - } - else { - return text; - } - } else { - await this.handleError(response, url); - } - } - - private async handleError(errorResponse: HttpResponse, requestedUrl: string): Promise { - if (errorResponse.statusCode === 429) { - throw new MapiError("too_many_logins", "Too many attempts. Please try later."); - } - - if (errorResponse.statusCode === 401) { - const authHeader = errorResponse.headers.find(h => h.name.toLowerCase() === "www-authenticate"); - - if (authHeader && authHeader.value.indexOf("Basic") !== -1) { - if (authHeader.value.indexOf("identity_not_confirmed") !== -1) { - throw new MapiError("identity_not_confirmed", "User status is Pending. Please check confirmation email."); - } - if (authHeader.value.indexOf("invalid_identity") !== -1) { - throw new MapiError("invalid_identity", "Invalid email or password."); - } - } - } - - const error = this.createMapiError(errorResponse.statusCode, requestedUrl, () => errorResponse.toObject().error); - - if (error) { - throw error; - } - - throw new MapiError("Unhandled", "Unhandled error"); - } - - private createMapiError(statusCode: number, url: string, getError: () => any): any { - switch (statusCode) { - case 400: - return getError(); - - case 401: - this.authenticator.clearAccessToken(); - return new MapiError("Unauthorized", "Unauthorized request."); - - case 403: - return new MapiError("Forbidden", "You're not authorized to perform this operation."); - - case 404: - return new MapiError("ResourceNotFound", `Resource not found: ${url}`); - - case 408: - return new MapiError("RequestTimeout", "Could not complete the request. Please try again later."); - - case 409: - return getError(); - - case 500: - return new MapiError("ServerError", "Internal server error."); - - default: - return new MapiError("Unhandled", `Unexpected status code in SMAPI response: ${statusCode}.`); - } - } - - public async getAll(url: string, headers?: HttpHeader[]): Promise { - const allItems: T[] = []; - const call = (requestUrl: string) => this.makeRequest>({ - method: HttpMethod.get, - url: requestUrl, - headers: headers - }); - - const takeResult = (result: Page): Promise => { - if (result) { - if (Array.isArray(result)) { - return Promise.resolve(result); - } - - if (result.value) { - allItems.push(...result.value); - } - if (result.nextLink) { - return call(result.nextLink).then(takeResult); - } - } - return Promise.resolve(allItems); - }; - - return this.get(url, headers).then(takeResult); - } - - public get(url: string, headers?: HttpHeader[]): Promise { - return this.requestInternal({ - method: HttpMethod.get, - url: url, - headers: headers - }); - } - - public post(url: string, headers?: HttpHeader[], body?: any): Promise { - return this.requestInternal({ - method: HttpMethod.post, - url: url, - headers: headers, - body: body - }); - } - - public patch(url: string, headers?: HttpHeader[], body?: any): Promise { - return this.requestInternal({ - method: HttpMethod.patch, - url: url, - headers: headers, - body: body - }); - } - - public put(url: string, headers?: HttpHeader[], body?: any): Promise { - return this.requestInternal({ - method: HttpMethod.put, - url: url, - headers: headers, - body: body - }); - } - - public delete(url: string, headers?: HttpHeader[]): Promise { - return this.requestInternal({ - method: HttpMethod.delete, - url: url, - headers: headers - }); - } - - public head(url: string, headers?: HttpHeader[]): Promise { - return this.requestInternal({ - method: HttpMethod.head, - url: url, - headers: headers - }); - } - - public async getPortalHeader(eventName?: string): Promise { - await this.ensureInitialized(); - let host = ""; - try { - host = window.location.host; - } catch (error) { - host = "publishing"; - } - - return { name: Constants.portalHeaderName, value: `${this.developerPortalType}|${host}|${eventName || ""}` }; - } -} \ No newline at end of file diff --git a/src/services/markdownService.spec.ts.example b/src/services/markdownService.spec.ts.example deleted file mode 100644 index 202d677a0..000000000 --- a/src/services/markdownService.spec.ts.example +++ /dev/null @@ -1,391 +0,0 @@ -/** Remove .example in the file name when the solution for an incompitability with ts-node is found */ - -import { expect } from "chai"; -import { describe, it } from "mocha"; -import { MarkdownService } from "./markdownService"; - -describe("Markdown service", async () => { - it("Combined Makrdown/HTML input processing", async () => { - const markdownService = new MarkdownService(); - - const input = `This API method allows you to send an SMS message to an end user. Note that the Sandbox version of this API will not work end-to-end, ie. an SMS will not arrive on a handset. - [This link](http://microsoft.com/) has no title attribute. www.microsoft.com - -

This is rendered!

-
    -
  • The
  • -
  • list
  • -
  • is
  • -
  • rendered
  • -
- - -

Test Scenarios

- To test various scenarios in the Sandbox environment, use the following values when making API requests. Any other combination of values will give a successful response. - link - - - This is [an example](http://microsoft.com/ "Title") inline link. - - * Red - * Green - * Blue - - This is an H1 - ============= - - Use the \`printf()\` function. - -
-
-
-
Input Field
-
Input Value
-
Status
-
User Friendly Message
-
-
-
username
-
{blank}
-
91
-
There is no account specified for the username and password
-
-
-
md5Hash
-
{blank}
-
92
-
The MD5 hash supplied was invalid
-
-
-
scheduleFor
-
{blank}
-
94
-
The ScheduleFor DateTime is invalid
-
-
-
`; - - - const expected = `

This API method allows you to send an SMS message to an end user. Note that the Sandbox version of this API will not work end-to-end, ie. an SMS will not arrive on a handset. - This link has no title attribute. www.microsoft.com

-

This is rendered!

-
    -
  • The
  • -
  • list
  • -
  • is
  • -
  • rendered
  • -
-

Test Scenarios

- To test various scenarios in the Sandbox environment, use the following values when making API requests. Any other combination of values will give a successful response. - link -

This is an example inline link.

-
    -
  • Red
  • -
  • Green
  • -
  • Blue
  • -
-

This is an H1

-

Use the printf() function.

-
-
-
-
Input Field
-
Input Value
-
Status
-
User Friendly Message
-
-
-
username
-
{blank}
-
91
-
There is no account specified for the username and password
-
-
-
md5Hash
-
{blank}
-
92
-
The MD5 hash supplied was invalid
-
-
-
scheduleFor
-
{blank}
-
94
-
The ScheduleFor DateTime is invalid
-
-
-
`; - - const output = markdownService.processMarkdown(input); - - expect(output).to.equals(expected); - }); - - it("Makrdown input processing", async () => { - const markdownService = new MarkdownService(); - - const input = `This is a sample paragraph. - - This is an H1 - ============= - This is an H2 - ------------- - - # This is an H1 # - ## This is an H2 ## - ### This is an H3 ###### - - > This is a blockquote with two paragraphs. Lorem ipsum dolor sit amet, - consectetuer adipiscing elit. Aliquam hendrerit mi posuere lectus. - Vestibulum enim wisi, viverra nec, fringilla in, laoreet vitae, risus. - - > ## This is a header. - > - > 1. This is the first list item. - > 2. This is the second list item. - - * Red - * Green - * Blue - - 1. Bird - 2. McHale - 3. Parish - - * Bird - - * Magic - - * This is a list item with two paragraphs. - - This is the second paragraph in the list item. You're - only required to indent the first line. Lorem ipsum dolor - sit amet, consectetuer adipiscing elit. - - * Another item in the same list. - - Here is an example of AppleScript: - - tell application "Foo" - beep - end tell - - - - - - - - - This is [an example](http://example.com/ "Title") inline link. - - [This link](http://example.net/) has no title attribute. - - I get 10 times more traffic from [Google][] than from - [Yahoo][] or [MSN][]. - - [google]: http://google.com/ "Google" - [yahoo]: http://search.yahoo.com/ "Yahoo Search" - [msn]: http://search.msn.com/ "MSN Search" - - - *single asterisks* - - _single underscores_ - - **double asterisks** - - __double underscores__ - - Use the \`printf()\` function. - - \`\`There is a literal backtick (\`) here.\`\` - - ![Alt text](https://miro.medium.com/max/1200/1*mk1-6aYaf_Bes1E3Imhc0A.jpeg "Optional title attribute") - - | abc | defghi | - :-: | -----------: - bar | baz - - ***** - - | abc | def | - | --- | --- | - | bar | - | bar | baz | boo |`; - - const expected = `

This is a sample paragraph.

-

This is an H1

-

This is an H2

-

This is an H1

-

This is an H2

-

This is an H3

-
-

This is a blockquote with two paragraphs. Lorem ipsum dolor sit amet, - consectetuer adipiscing elit. Aliquam hendrerit mi posuere lectus. - Vestibulum enim wisi, viverra nec, fringilla in, laoreet vitae, risus.

-
-
-

This is a header.

-
    -
  1. This is the first list item.
  2. -
  3. This is the second list item.
  4. -
-
-
    -
  • Red
  • -
  • Green
  • -
  • Blue
  • -
-
    -
  1. Bird
  2. -
  3. McHale
  4. -
  5. Parish
  6. -
-
    -
  • -

    Bird

    -
  • -
  • -

    Magic

    -
  • -
  • -

    This is a list item with two paragraphs.

    -

    This is the second paragraph in the list item. You're - only required to indent the first line. Lorem ipsum dolor - sit amet, consectetuer adipiscing elit.

    -
  • -
  • -

    Another item in the same list.

    -
  • -
-

Here is an example of AppleScript:

-
tell application "Foo"
-            beep
-        end tell
-        
-
-
<div class="footer">
-            &copy; 2004 Foo Corporation
-        </div>
-        
-

This is an example inline link.

-

This link has no title attribute.

-

I get 10 times more traffic from Google than from - Yahoo or MSN.

-

single asterisks

-

single underscores

-

double asterisks

-

double underscores

-

Use the printf() function.

-

There is a literal backtick (\`) here.

-

Alt text

-
abcdefghi
barbaz
-
-
abcdef
bar
barbaz
` - - const output = markdownService.processMarkdown(input); - - expect(output).to.equals(expected); - }); - - it("HTML input processing", async () => { - const markdownService = new MarkdownService(); - - const input = `

This is an H1

-

This is a paragraph

-
    -
  • Red
  • -
  • Green
  • -
  • Blue
  • -
-
-

This is a header.

-
    -
  1. This is the first list item.
  2. -
  3. This is the second list item.
  4. -
-
- there is some function()
- This is a sample link -
- Girl in a jacket -
-
-
-
Input Field
-
Input Value
-
Status
-
User Friendly Message
-
-
-
username
-
{blank}
-
91
-
There is no account specified for the username and password
-
-
-
md5Hash
-
{blank}
-
92
-
The MD5 hash supplied was invalid
-
-
-
scheduleFor
-
{blank}
-
94
-
The ScheduleFor DateTime is invalid
-
-
-
`; - - const expected = `

This is an H1

-

This is a paragraph

-
    -
  • Red
  • -
  • Green
  • -
  • Blue
  • -
-
-

This is a header.

-
    -
  1. This is the first list item.
  2. -
  3. This is the second list item.
  4. -
-
- there is some function()
- This is a sample link -
- Girl in a jacket -
-
-
-
Input Field
-
Input Value
-
Status
-
User Friendly Message
-
-
-
username
-
{blank}
-
91
-
There is no account specified for the username and password
-
-
-
md5Hash
-
{blank}
-
92
-
The MD5 hash supplied was invalid
-
-
-
scheduleFor
-
{blank}
-
94
-
The ScheduleFor DateTime is invalid
-
-
-
`; - - const output = markdownService.processMarkdown(input); - - expect(output).to.equals(expected); - }); -}); diff --git a/src/services/productService.ts b/src/services/productService.ts index 5daf96f20..658e23742 100644 --- a/src/services/productService.ts +++ b/src/services/productService.ts @@ -307,7 +307,7 @@ export class ProductService { throw new Error(`Parameter "productId" not specified.`); } - const contract = await this.apiClient.get(productId, [await this.apiClient.getPortalHeader("getProduct"), Utils.getIsUserResourceHeader()]); + const contract = await this.apiClient.get(`/products/${productId}`, [await this.apiClient.getPortalHeader("getProduct"), Utils.getIsUserResourceHeader()]); if (contract) { return new Product(contract); diff --git a/src/services/productsService.spec.ts b/src/services/productsService.spec.ts index a6a771eb1..ce7a81d69 100644 --- a/src/services/productsService.spec.ts +++ b/src/services/productsService.spec.ts @@ -101,7 +101,7 @@ describe("Product service", async () => { const delegationService = new DelegationService(apiClient, settingsProvider); const productService = new ProductService(apiClient, delegationService); - const product = await productService.getProduct("/products/starter"); + const product = await productService.getProduct("starter"); expect(starterProduct.name).to.equal(product.displayName); }); @@ -136,20 +136,20 @@ describe("Product service", async () => { const delegationService = new DelegationService(apiClient, settingsProvider); const productService = new ProductService(apiClient, delegationService); - const searchQuery: SearchQuery = { pattern: "test" } + var searchQuery: SearchQuery = { pattern: "test" } await productService.getSubscriptions("/users/1234", "/products/starter", searchQuery); const expectedUrl = "/users/1234/subscriptions?$top=50&$skip=0&$filter=(endswith(scope,'/products/starter')) and (contains(name,'test'))"; expect(apiClient.get.getCall(0).calledWith(expectedUrl)).to.be.true; }); - it("Automatically add products prefix filter", async () => { + it("Automaticaly add products prefix filter", async () => { const apiClient: SinonStubbedInstance = createStubInstance(DataApiClient); apiClient.get.resolves(new Page()); const delegationService = new DelegationService(apiClient, settingsProvider); const productService = new ProductService(apiClient, delegationService); - const searchQuery: SearchQuery = { pattern: "test" } + var searchQuery: SearchQuery = { pattern: "test" } await productService.getSubscriptions("/users/1234", "starter", searchQuery); const expectedUrl = "/users/1234/subscriptions?$top=50&$skip=0&$filter=(endswith(scope,'/products/starter')) and (contains(name,'test'))"; @@ -196,7 +196,7 @@ describe("Product service", async () => { const delegationService = new DelegationService(apiClient, settingsProvider); const productService = new ProductService(apiClient, delegationService); - const searchQuery: SearchQuery = { pattern: "test" } + var searchQuery: SearchQuery = { pattern: "test" } await productService.getProductsAllSubscriptions("book-store-api", [starterProduct], "/users/1234", searchQuery); const expectedUrl = "/users/1234/subscriptions?$top=100&$skip=0&$filter=(contains(name,'test'))"; diff --git a/src/services/runtimeConfigurator.ts b/src/services/runtimeConfigurator.ts index 2f50b8395..08135a209 100644 --- a/src/services/runtimeConfigurator.ts +++ b/src/services/runtimeConfigurator.ts @@ -1,11 +1,11 @@ import { ISettingsProvider } from "@paperbits/common/configuration"; import { SessionManager } from "@paperbits/common/persistence/sessionManager"; import { IdentityService } from "."; -import { adminUserId, SettingNames } from "../constants"; +import { SettingNames } from "../constants"; import { AadB2CClientConfig } from "../contracts/aadB2CClientConfig"; import { AadClientConfig } from "../contracts/aadClientConfig"; -import { ArmService } from "./armService"; -import { Logger } from "@paperbits/common/logging/logger"; +import { AzureResourceManagementService } from "./armService"; +import { Bag } from "@paperbits/common/bag"; /** @@ -16,53 +16,16 @@ export class RuntimeConfigurator { private readonly identityService: IdentityService, private readonly settingsProvider: ISettingsProvider, private readonly sessionManager: SessionManager, - private readonly armService: ArmService, - private readonly logger: Logger + private readonly armService: AzureResourceManagementService ) { this.loadConfiguration(); } - private async loadArmSettingsForRuntime(): Promise { - try { - await this.armService.loadSessionSettings(this.settingsProvider); - - const managementApiUrl = await this.settingsProvider.getSetting(SettingNames.managementApiUrl); - if (!managementApiUrl) { - this.logger.trackDependency("loadArmSettingsForRuntime", { message: "Warning: managementApiUrl missing" }); - return; - } - - const userId = adminUserId; // Admin user ID for editor DataApi - const userTokenValue = await this.armService.getUserAccessToken(userId, managementApiUrl); - const serviceDescription = await this.armService.getServiceDescription(managementApiUrl); - const dataApiUrl = serviceDescription.properties.dataApiUrl; - const developerPortalUrl = serviceDescription.properties.developerPortalUrl; - const isMultitenant = serviceDescription.sku.name.includes("V2"); - - const runtimeSettings = { - [SettingNames.backendUrl]: developerPortalUrl, - [SettingNames.dataApiUrl]: dataApiUrl, - [SettingNames.directDataApi]: !!dataApiUrl, - [SettingNames.managementApiAccessToken]: userTokenValue, - [SettingNames.isMultitenant]: isMultitenant - }; - // this.sessionManager.setItem(SettingNames.designTimeSettings, runtimeSettings); - - // await this.settingsProvider.setSetting(SettingNames.backendUrl, developerPortalUrl); - // await this.settingsProvider.setSetting(SettingNames.dataApiUrl, dataApiUrl); - // await this.settingsProvider.setSetting(SettingNames.directDataApi, !!dataApiUrl); - // await this.settingsProvider.setSetting(SettingNames.isMultitenant, isMultitenant); - this.logger.trackMetric("loadArmSettingsForRuntime", { message: `isMultitenant: ${isMultitenant}` }); - return runtimeSettings; - } catch (error) { - this.logger.trackError(error, { message: "Error loadArmSettingsForRuntime" }); - return; - } - } - public async loadConfiguration(): Promise { + const designTimeSettings = await this.settingsProvider.getSettings(); + await this.armService.loadSessionSettings(this.settingsProvider); - const designTimeSettings = await this.sessionManager.getItem(SettingNames.designTimeSettings) || {}; + await this.propagateRuntimeSettingsToSession(designTimeSettings); /* Identity providers */ const identityProviders = await this.identityService.getIdentityProviders(); @@ -103,4 +66,25 @@ export class RuntimeConfigurator { await this.sessionManager.setItem(SettingNames.designTimeSettings, designTimeSettings); } + + /** + * Propagates runtime settings to the session. + */ + private async propagateRuntimeSettingsToSession(settings: Bag): Promise { + let settingsForRuntime = settings || {}; + + const developerPortalUrl = await this.settingsProvider.getSetting(SettingNames.backendUrl); + const dataApiUrl = await this.settingsProvider.getSetting(SettingNames.dataApiUrl); + const isMultitenant = await this.settingsProvider.getSetting(SettingNames.isMultitenant); + + settingsForRuntime = { + [SettingNames.backendUrl]: developerPortalUrl, + [SettingNames.dataApiUrl]: dataApiUrl, + [SettingNames.directDataApi]: !!dataApiUrl, + [SettingNames.isMultitenant]: isMultitenant, + ...settingsForRuntime + }; + + await this.sessionManager.setItem(SettingNames.designTimeSettings, settingsForRuntime); + } } diff --git a/src/startup.design.ts b/src/startup.design.ts index 2889d1f5c..8b8134f0a 100644 --- a/src/startup.design.ts +++ b/src/startup.design.ts @@ -1,37 +1,70 @@ import "./polyfills"; import * as ko from "knockout"; +import { ComponentBinder } from "@paperbits/common/components"; +import { XmlHttpRequestClient } from "@paperbits/common/http/xmlHttpRequestClient"; import { InversifyInjector } from "@paperbits/common/injection"; +import { ConsoleLogger } from "@paperbits/common/logging/consoleLogger"; +import { Logger } from "@paperbits/common/logging/logger"; import { OfflineModule } from "@paperbits/common/persistence/offline.module"; import { CoreDesignModule } from "@paperbits/core/core.design.module"; import { FormsDesignModule } from "@paperbits/forms/forms.design.module"; -import { StylesDesignModule } from "@paperbits/styles/styles.design.module"; -import { ApimDesignModule } from "./apim.design.module"; -import { SessionExpirationErrorHandler } from "./errors/sessionExpirationErrorHandler"; -import { ComponentBinder } from "@paperbits/common/components"; import { ReactModule } from "@paperbits/react/react.module"; +import { StylesDesignModule } from "@paperbits/styles/styles.design.module"; import { LeftPanel } from "./admin/leftPanel"; import { RightPanel } from "./admin/rightPanel"; -import { SelfHostedArmAuthenticator } from "./authentication/armAuthenticator"; +import { ApimDesignModule } from "./apim.design.module"; +import { AuthenticatorResolver } from "./authentication/authenticatorResolver"; import { MapiClient } from "./clients/mapiClient"; +import { UnhandledErrorHandler } from "./errors"; +import { SessionExpirationErrorHandler } from "./errors/sessionExpirationErrorHandler"; /* Initializing dependency injection container */ const injector = new InversifyInjector(); -injector.bindToCollection("autostart", SessionExpirationErrorHandler); -injector.bindSingleton("authenticator", SelfHostedArmAuthenticator); -injector.bindModule(new CoreDesignModule()); -injector.bindModule(new StylesDesignModule()); -injector.bindModule(new FormsDesignModule()); +async function startApp() { + /* Initializing dependency injection container */ + injector.bindToCollection("autostart", SessionExpirationErrorHandler); + injector.bindToCollection("autostart", UnhandledErrorHandler); + + injector.bindSingleton("httpClient", XmlHttpRequestClient); + injector.bindSingleton("logger", ConsoleLogger); + injector.bindSingleton("authenticatorResolver", AuthenticatorResolver); + const authenticatorResolver = injector.resolve("authenticatorResolver"); + const authenticator = await authenticatorResolver.getAuthenticator(); + injector.bindInstance("authenticator", authenticator); + + injector.bindModule(new CoreDesignModule()); + injector.bindModule(new StylesDesignModule()); + injector.bindModule(new FormsDesignModule()); + + injector.bindSingleton("apiClient", MapiClient); + injector.bindModule(new ApimDesignModule()); + injector.bindModule(new ReactModule()); + injector.bindModule(new OfflineModule({ autosave: false })); +} + +startApp() + .then(() => { + injector.resolve("autostart"); -injector.bindSingleton("apiClient", MapiClient); -injector.bindModule(new ApimDesignModule()); -injector.bindModule(new ReactModule()); -injector.bindModule(new OfflineModule({ autosave: false })); -injector.resolve("autostart"); + /* Bootstrapping the application */ + if (document.readyState === "complete") { + applyBindings(); + } else { + document.addEventListener("DOMContentLoaded", () => { + applyBindings(); + }); + } + const logger = injector.resolve("logger"); + logger.trackEvent("App", { message: "App starting..." }); + }) + .catch((error) => { + const logger = injector.resolve("logger"); + logger.trackError(error, { message: "App failed to start." }); + }); -/* Bootstrapping the application */ -document.addEventListener("DOMContentLoaded", () => { +function applyBindings(): void { setImmediate(() => ko.applyBindings(undefined, document.body)); // Binding the React app to element along with container @@ -40,4 +73,4 @@ document.addEventListener("DOMContentLoaded", () => { componentBinder.bind(leftPanel, LeftPanel); const rightPanel = document.getElementById("admin-right-panel"); componentBinder.bind(rightPanel, RightPanel); -}); \ No newline at end of file +} diff --git a/src/startup.publish.ts b/src/startup.publish.ts index 005b8d4f8..0bb86c980 100644 --- a/src/startup.publish.ts +++ b/src/startup.publish.ts @@ -11,38 +11,21 @@ import { ApimPublishModule } from "./apim.publish.module"; import { FileSystemBlobStorage } from "./components/filesystemBlobStorage"; import { StaticSettingsProvider } from "./configuration/staticSettingsProvider"; import { PublishingCacheModule } from "./persistence/publishingCacheModule"; -import { SettingNames } from "./constants"; - /* Reading settings from configuration file */ let settingsProvider: ISettingsProvider; -function getMapiUrl(configuration: object): string { - const armEndpoint = configuration[SettingNames.armEndpoint]; - const subscriptionId = configuration[SettingNames.subscriptionId]; - const resourceGroupName = configuration[SettingNames.resourceGroupName]; - const serviceName = configuration[SettingNames.serviceName]; - - if (!subscriptionId || !resourceGroupName || !serviceName) { - throw new Error("Required service parameters (like subscription, resource group, service name) were not provided to start publisher"); - } - - return `https://${armEndpoint}/subscriptions/${subscriptionId}/resourceGroups/${resourceGroupName}/providers/Microsoft.ApiManagement/service/${serviceName}`; -} - if (process.env.NODE_ENV === staticDataEnvironment || process.env.NODE_ENV === mockStaticDataEnvironment) { settingsProvider = new StaticSettingsProvider({ "environment": "publishing", "backendUrl": "https://contoso.developer.azure-api.net", "managementApiAccessToken": "SharedAccessSignature&1&", - "useHipCaptcha": false, - "managementApiUrl": "https://contoso.developer.azure-api.net/mapi" + "useHipCaptcha": false }); } else { const configFile = path.resolve(__dirname, "./config.json"); const configuration = JSON.parse(fs.readFileSync(configFile, "utf8").toString()); - configuration[SettingNames.managementApiUrl] = getMapiUrl(configuration); settingsProvider = new StaticSettingsProvider(configuration); } diff --git a/src/startup.runtime.ts b/src/startup.runtime.ts index cdb3b71d3..f1c7e379c 100644 --- a/src/startup.runtime.ts +++ b/src/startup.runtime.ts @@ -65,7 +65,7 @@ function initFeatures() { logger.trackEvent("FeatureFlag", { feature: isRedesignEnabledSetting, enabled: isEnabled.toString(), - message: `Feature flag '${isRedesignEnabledSetting}' - ${isEnabled ? "enabled" : "disabled"}` + message: `Feature flag '${isRedesignEnabledSetting}' - ${isEnabled ? 'enabled' : 'disabled'}` }); }); } diff --git a/src/telemetry/serviceWorker.ts b/src/telemetry/serviceWorker.ts index 0efe04732..c71bc38b7 100644 --- a/src/telemetry/serviceWorker.ts +++ b/src/telemetry/serviceWorker.ts @@ -71,33 +71,57 @@ if (isServiceWorker) { event.respondWith( (async () => { - const response = await fetch(request); + try { + const response = await fetch(request); - if (request.url.endsWith("/trace")) { - return response; - } + if (request.url.endsWith("/trace")) { + return response; + } - const cleanedUrl = logSanitizer.sanitizeUrl(request.url); + const cleanedUrl = logSanitizer.sanitizeUrl(request.url); - const telemetryData = { - url: cleanedUrl, - method: request.method.toUpperCase(), - status: response.status.toString(), - responseHeaders: "" - }; + const telemetryData = { + url: cleanedUrl, + method: request.method.toUpperCase(), + status: response.status.toString(), + responseHeaders: "" + }; - const headers: { [key: string]: string } = {}; + const headers: { [key: string]: string } = {}; - response.headers.forEach((value, key) => { - if (allowedHeaders.has(key.toLowerCase())) { - headers[key] = logSanitizer.cleanUrlSensitiveDataFromValue(value); - } - }); - telemetryData.responseHeaders = JSON.stringify(headers); + response.headers.forEach((value, key) => { + if (allowedHeaders.has(key.toLowerCase())) { + headers[key] = logSanitizer.cleanUrlSensitiveDataFromValue(value); + } + }); + telemetryData.responseHeaders = JSON.stringify(headers); + + sendMessageToClients(telemetryData); - sendMessageToClients(telemetryData); + return response; + } catch (error) { + console.error("Error in service worker fetch handler:", error); + + // Send telemetry about the error + const errorTelemetry = { + url: logSanitizer.sanitizeUrl(request.url), + method: request.method.toUpperCase(), + status: "error", + error: error.message || "Network error" + }; - return response; + try { + sendMessageToClients(errorTelemetry); + } catch (e) { + // Ignore errors in sending telemetry + } + + // Return a fallback response + return new Response("Network error occurred", { + status: 503, + headers: { "Content-Type": "text/plain" } + }); + } })() ); }); diff --git a/src/themes/website/styles/widgets/accordion.scss b/src/themes/website/styles/widgets/accordion.scss new file mode 100644 index 000000000..d0bf16d26 --- /dev/null +++ b/src/themes/website/styles/widgets/accordion.scss @@ -0,0 +1,38 @@ + +.icon-emb-ctrl-right:before { + @extend .icon-emb; + content: "\e90a"; +} + + +.accordion { + border: 1px solid #ccc; + margin-bottom: 12px; + display: flex; + flex-direction: column; + width: 100%; + + .accordion-item { + border-bottom: 1px solid #ccc; + + .accordion-header { + border-bottom: 1px solid #ccc; + padding: 8px 16px; + cursor: pointer; + display: flex; + align-items: center; + justify-content: space-between; + color: #333; + + &:last-child { + border-bottom: none; + } + } + + &[open] { + .icon-emb-ctrl-right { + transform: rotate(180deg); + } + } + } +} diff --git a/src/themes/website/styles/widgets/widgets.scss b/src/themes/website/styles/widgets/widgets.scss index 301ff28b5..f3351201e 100644 --- a/src/themes/website/styles/widgets/widgets.scss +++ b/src/themes/website/styles/widgets/widgets.scss @@ -10,6 +10,7 @@ @import "tables.scss"; @import "tabs.scss"; @import "picture.scss"; +@import "accordion.scss"; @import "fui/fluentui.scss"; @import "fui/table.scss"; diff --git a/webpack.designer.arm.js b/webpack.designer.arm.js deleted file mode 100644 index 76adf8c0f..000000000 --- a/webpack.designer.arm.js +++ /dev/null @@ -1,122 +0,0 @@ -const path = require("path"); -const webpack = require("webpack"); -const { merge } = require("webpack-merge"); -const CopyWebpackPlugin = require("copy-webpack-plugin"); -const MiniCssExtractPlugin = require("mini-css-extract-plugin"); -const runtimeConfig = require("./webpack.runtime"); -const packageJson = require("./package.json"); -const { getArmToken } = require("./auth/arm-auth"); -const config = require("./src/config.design.json"); - -async function generateWebpackConfig() { - const tokenOptions = {}; - if (config.tenantId) { - console.log(`Using tenantId: ${config.tenantId}`); - tokenOptions.tenantId = config.tenantId; - } - if (config.clientId) { - console.log(`Using clientId: ${config.clientId}`); - tokenOptions.clientId = config.clientId; - } - const armToken = await getArmToken(tokenOptions); - - const designerConfig = { - mode: "development", - target: "web", - entry: { - "editors/scripts/paperbits": ["./src/startup.design.ts"], - "editors/styles/paperbits": [`./src/themes/designer/styles/styles.scss`], - }, - output: { - filename: "./[name].js", - path: path.resolve(__dirname, "./dist/designer") - }, - module: { - rules: [ - { - test: /\.scss$/, - use: [ - MiniCssExtractPlugin.loader, - { - loader: "css-loader", - options: { url: { filter: (url) => /\/icon-.*\.svg$/.test(url) } } - }, - { loader: "postcss-loader" }, - { loader: "sass-loader" } - ] - }, - { - test: /\.tsx?$/, - loader: "ts-loader", - options: { - allowTsInNodeModules: true - } - }, - { - test: /\.html$/, - loader: "html-loader", - options: { - esModule: true, - sources: false, - minimize: { - removeComments: false, - collapseWhitespace: false - } - } - }, - { - test: /\.(svg)$/i, - type: "asset/inline" - }, - { - test: /\.(raw|liquid)$/, - loader: "raw-loader" - } - ] - }, - plugins: [ - new MiniCssExtractPlugin({ - filename: "[name].css", - chunkFilename: "[id].css" - }), - new CopyWebpackPlugin({ - patterns: [ - { from: `./src/libraries`, to: "data" }, - { from: `./src/config.design.json`, to: `./config.json` }, - { from: `./src/themes/designer/assets/index.html`, to: "index.html" }, - { from: `./src/themes/designer/styles/fonts`, to: "editors/styles/fonts" }, - { from: `./templates/default.json`, to: "editors/templates/default.json" }, - { from: `./templates/default-old.json`, to: "editors/templates/default-old.json" } - ] - }), - new webpack.ProvidePlugin({ Buffer: ["buffer", "Buffer"] }), - new webpack.DefinePlugin({ - "process.env.VERSION": JSON.stringify(packageJson.version), - "process.env.ARM_TOKEN": JSON.stringify(armToken) - }) - ], - resolve: { - extensions: [".ts", ".tsx", ".js", ".jsx", ".html", ".scss"], - fallback: { - buffer: require.resolve("buffer"), - stream: require.resolve("stream-browserify"), - querystring: require.resolve("querystring-es3"), - crypto: false - } - } - }; - - const designerRuntimeConfig = merge(runtimeConfig, { - entry: { "styles/theme": `./src/themes/website/styles/styles.design.scss` }, - output: { "path": path.resolve(__dirname, "dist/designer") } - }); - - return { - default: [designerConfig, designerRuntimeConfig], - designerRuntimeConfig, - designerConfig - }; -} - -// Export the async function directly -module.exports = async () => generateWebpackConfig(); \ No newline at end of file diff --git a/webpack.designer.js b/webpack.designer.js index d0e431ae1..3942b0e00 100644 --- a/webpack.designer.js +++ b/webpack.designer.js @@ -34,10 +34,14 @@ const designerConfig = { }, { test: /\.tsx?$/, - loader: "ts-loader", - options: { - allowTsInNodeModules: true - } + use: [ + { loader: "ts-loader", options: { allowTsInNodeModules: true } }, + { loader: "ifdef-loader", options: { + SkuV2: false, + "ifdef-verbose": true, + "ifdef-fill-with-blanks": true + } } + ] }, { test: /\.html$/, @@ -99,4 +103,4 @@ module.exports = { default: [designerConfig, designerRuntimeConfig], designerRuntimeConfig, designerConfig -}; \ No newline at end of file +}; diff --git a/webpack.develop.js b/webpack.develop.js index 60d8ea4de..34393a578 100644 --- a/webpack.develop.js +++ b/webpack.develop.js @@ -1,27 +1,49 @@ const { merge } = require("webpack-merge"); -const CopyWebpackPlugin = require("copy-webpack-plugin"); -const asyncDesignerConfig = require("./webpack.designer.arm.js"); +const webpack = require("webpack"); +const { + designerConfig, + designerRuntimeConfig, +} = require("./webpack.designer.js"); +const { getArmToken } = require("./auth/arm-auth"); -const developmentConfig = { - mode: "development", - devtool: "inline-source-map", - devServer: { - hot: true, - historyApiFallback: true - }, - plugins: [ - new CopyWebpackPlugin({ - patterns: [ - { from: `./src/config.design.json`, to: `./config.json` } - ] +module.exports = async (env) => { + const armToken = await getArmToken({}); + const patterns = designerConfig.plugins[1].patterns; + const rules = designerConfig.module.rules; + + patterns.push({ + from: `./src/config.design.json`, + to: `./editor-config.json`, + }); + + for (let i = 0; i < rules.length; i++) { + if (rules[i].test.source === "\\.tsx?$") { + rules[i].use = [ + { + loader: "ts-loader", + options: { allowTsInNodeModules: true }, + } + ]; + } + } + + const developmentConfig = { + mode: "development", + devtool: "inline-source-map", + devServer: { + hot: true, + historyApiFallback: true, + }, + }; + + const resultDesignerConfig = merge(designerConfig, developmentConfig); + + // Comment out if you need to sumulate SKUv2 editor sign-in flow + resultDesignerConfig.plugins.push( + new webpack.DefinePlugin({ + "ARM_TOKEN": JSON.stringify(armToken), }) - ] -} + ); -module.exports = async () => { - const resolvedDesignerConfig = await asyncDesignerConfig(); - return [ - merge(resolvedDesignerConfig.designerConfig, developmentConfig), - resolvedDesignerConfig.designerRuntimeConfig - ]; -}; \ No newline at end of file + return [resultDesignerConfig, designerRuntimeConfig]; +}; diff --git a/webpack.publisher.arm.js b/webpack.publisher.arm.js index 58b3cc398..db311e5f7 100644 --- a/webpack.publisher.arm.js +++ b/webpack.publisher.arm.js @@ -93,7 +93,7 @@ async function generateWebpackConfig() { ] }), new webpack.ProvidePlugin({ Buffer: ['buffer', 'Buffer'] }), - new webpack.DefinePlugin({ 'process.env.ARM_TOKEN': JSON.stringify(armToken) }) + new webpack.DefinePlugin({ 'ARM_TOKEN': JSON.stringify(armToken) }) ], resolve: { extensions: [".ts", ".tsx", ".js", ".jsx", ".html", ".scss"], diff --git a/webpack.publisher.js b/webpack.publisher.js index 89223afba..0f7a08470 100644 --- a/webpack.publisher.js +++ b/webpack.publisher.js @@ -4,94 +4,113 @@ const { merge } = require("webpack-merge"); const CopyWebpackPlugin = require("copy-webpack-plugin"); const MiniCssExtractPlugin = require("mini-css-extract-plugin"); const runtimeConfig = require("./webpack.runtime"); - +const { getArmToken } = require("./auth/arm-auth"); +const config = require("./src/config.publish.json"); const publisherRuntimeConfig = merge(runtimeConfig, { entry: { "styles/theme": ["./src/themes/website/styles/styles.scss"] }, output: { "path": path.resolve(__dirname, "dist/publisher/assets") } }); -const publisherConfig = { - mode: "development", - target: "node", - node: { - __dirname: false, - __filename: false, - }, - entry: { - "index": ["./src/startup.publish.ts"] - }, - output: { - filename: "./[name].js", - path: path.resolve(__dirname, "dist/publisher") - }, - module: { - rules: [ - { - test: /\.scss$/, - use: [ - MiniCssExtractPlugin.loader, - { loader: "css-loader", options: { url: false } }, - { loader: "postcss-loader" }, - { loader: "sass-loader" } - ] - }, - { - test: /\.tsx?$/, - loader: "ts-loader", - options: { - allowTsInNodeModules: true - } - }, - { - test: /\.html$/, - loader: "html-loader", - options: { - esModule: true, - sources: false, - minimize: { - removeComments: false, - collapseWhitespace: false +async function generateWebpackConfig() { + const tokenOptions = {}; + + if (config.tenantId) { + console.log(`Using tenantId: ${config.tenantId}`); + tokenOptions.tenantId = config.tenantId; + } + if (config.clientId) { + console.log(`Using clientId: ${config.clientId}`); + tokenOptions.clientId = config.clientId; + } + const armToken = await getArmToken(tokenOptions); + + const publisherConfig = { + mode: "development", + target: "node", + node: { + __dirname: false, + __filename: false, + }, + entry: { + "index": ["./src/startup.publish.ts"] + }, + output: { + filename: "./[name].js", + path: path.resolve(__dirname, "dist/publisher") + }, + module: { + rules: [ + { + test: /\.scss$/, + use: [ + MiniCssExtractPlugin.loader, + { loader: "css-loader", options: { url: false } }, + { loader: "postcss-loader" }, + { loader: "sass-loader" } + ] + }, + { + test: /\.tsx?$/, + loader: "ts-loader", + options: { + allowTsInNodeModules: true } + }, + { + test: /\.html$/, + loader: "html-loader", + options: { + esModule: true, + sources: false, + minimize: { + removeComments: false, + collapseWhitespace: false + } + } + }, + { + test: /\.(png|woff|woff2|eot|ttf|svg)$/, + loader: "url-loader", + options: { + limit: 10000 + } + }, + { + test: /\.(raw|liquid)$/, + loader: "raw-loader" } - }, - { - test: /\.(png|woff|woff2|eot|ttf|svg)$/, - loader: "url-loader", - options: { - limit: 10000 - } - }, - { - test: /\.(raw|liquid)$/, - loader: "raw-loader" - } - ] - }, - plugins: [ - new webpack.IgnorePlugin({ resourceRegExp: /canvas/ }, { resourceRegExp: /jsdom$/ }), - new MiniCssExtractPlugin({ filename: "[name].css", chunkFilename: "[id].css" }), - new CopyWebpackPlugin({ - patterns: [ - { from: `./src/config.publish.json`, to: `config.json` }, - { from: `./src/config.runtime.json`, to: `assets/config.json` }, - { from: `./templates/default-old.json`, to: "editors/templates/default.json" } ] - }), - new webpack.ProvidePlugin({ Buffer: ['buffer', 'Buffer'] }) - ], - resolve: { - extensions: [".ts", ".tsx", ".js", ".jsx", ".html", ".scss"], - fallback: { - buffer: require.resolve("buffer"), - stream: require.resolve("stream-browserify"), - querystring: require.resolve("querystring-es3") + }, + plugins: [ + new webpack.IgnorePlugin({ resourceRegExp: /canvas/ }, { resourceRegExp: /jsdom$/ }), + new MiniCssExtractPlugin({ filename: "[name].css", chunkFilename: "[id].css" }), + new CopyWebpackPlugin({ + patterns: [ + { from: `./src/config.publish.json`, to: `config.json` }, + { from: `./src/config.runtime.json`, to: `assets/config.json` }, + { from: `./templates/default.json`, to: "editors/templates/default.json" }, + { from: `./templates/default-old.json`, to: "editors/templates/default-old.json" } + ] + }), + new webpack.ProvidePlugin({ Buffer: ['buffer', 'Buffer'] }), + new webpack.DefinePlugin({ 'ARM_TOKEN': JSON.stringify(armToken) }) + ], + resolve: { + extensions: [".ts", ".tsx", ".js", ".jsx", ".html", ".scss"], + fallback: { + buffer: require.resolve("buffer"), + stream: require.resolve("stream-browserify"), + querystring: require.resolve("querystring-es3") + } } - } -}; + }; + + + return publisherConfig; +} -module.exports = { - default: [publisherConfig, publisherRuntimeConfig], - publisherConfig, - publisherRuntimeConfig -} \ No newline at end of file +module.exports = async () => { + const publisherConfig = await generateWebpackConfig(); + return [publisherConfig, publisherRuntimeConfig]; +}; \ No newline at end of file diff --git a/webpack.runtime.js b/webpack.runtime.js index 3b9e9eec9..58c08e183 100644 --- a/webpack.runtime.js +++ b/webpack.runtime.js @@ -4,9 +4,10 @@ const MiniCssExtractPlugin = require("mini-css-extract-plugin"); const CopyWebpackPlugin = require("copy-webpack-plugin"); const packageJson = require("./package.json"); +const NODE_ENV = process.env.NODE_ENV || "development"; const runtimeConfig = { - mode: "development", + mode: NODE_ENV, target: "web", entry: { "scripts/theme": ["./src/startup.runtime.ts"],