diff --git a/.vscode/launch.json b/.vscode/launch.json index 5504949bca..ec120d4404 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -83,6 +83,7 @@ "env": { "NODE_ENV": "development", "DEBUG": "composer*", + "COMPOSER_DEV_TOOLS": "true", "COMPOSER_ENABLE_ONEAUTH": "false" }, "outputCapture": "std", diff --git a/.vscode/tasks.json b/.vscode/tasks.json index 96c80556cf..1b32b6a539 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -9,6 +9,14 @@ "command": "yarn build", "options": { "cwd": "Composer/packages/server" + }, + "presentation": { + "echo": true, + "reveal": "silent", + "focus": false, + "panel": "shared", + "showReuseMessage": false, + "clear": false } }, { @@ -18,7 +26,15 @@ "options": { "cwd": "Composer/packages/electron-server" }, - "dependsOn": ["server: build", "client: ping"] + "dependsOn": ["server: build", "client: ping"], + "presentation": { + "echo": true, + "reveal": "silent", + "focus": false, + "panel": "shared", + "showReuseMessage": false, + "clear": false + } }, { "label": "client: ping", @@ -27,6 +43,14 @@ "args": ["-t", "300000", "http://localhost:3000"], "options": { "cwd": "Composer" + }, + "presentation": { + "echo": true, + "reveal": "silent", + "focus": false, + "panel": "shared", + "showReuseMessage": false, + "clear": false } } ] diff --git a/Composer/packages/client/src/pages/botProject/create-publish-profile/PublishProfileDialog.tsx b/Composer/packages/client/src/pages/botProject/create-publish-profile/PublishProfileDialog.tsx index ee38c09061..cfdf46df12 100644 --- a/Composer/packages/client/src/pages/botProject/create-publish-profile/PublishProfileDialog.tsx +++ b/Composer/packages/client/src/pages/botProject/create-publish-profile/PublishProfileDialog.tsx @@ -19,6 +19,7 @@ import TelemetryClient from '../../../telemetry/TelemetryClient'; import { AuthClient } from '../../../utils/authClient'; import { graphScopes } from '../../../constants'; import { dispatcherState } from '../../../recoilModel'; +import { createNotification } from '../../../recoilModel/dispatchers/notification'; import { ProfileFormDialog } from './ProfileFormDialog'; @@ -43,7 +44,7 @@ export const PublishProfileDialog: React.FC = (props) const [page, setPage] = useState(Page.ProfileForm); const [publishSurfaceStyles, setStyles] = useState(defaultPublishSurface); - const { provisionToTarget } = useRecoilValue(dispatcherState); + const { provisionToTarget, addNotification } = useRecoilValue(dispatcherState); const [dialogTitle, setTitle] = useState({ title: current ? formatMessage('Edit a publishing profile') : formatMessage('Add a publishing profile'), @@ -157,15 +158,24 @@ export const PublishProfileDialog: React.FC = (props) }; PluginAPI.publish.startProvision = async (config) => { const fullConfig = { ...config, name: name, type: targetType }; + let arm, graph; if (!isGetTokenFromUser()) { - // login or get token implicit - let tenantId = getTenantIdFromCache(); + const tenantId = getTenantIdFromCache(); + // require tenant id to be set by plugin (handles multiple tenant scenario) if (!tenantId) { - const tenants = await AuthClient.getTenants(); - tenantId = tenants?.[0]?.tenantId; - setTenantId(tenantId); + const notification = createNotification({ + type: 'error', + title: formatMessage('Error provisioning.'), + description: formatMessage( + 'An Azure tenant must be set in order to provision resources. Try recreating the publish profile and try again.' + ), + }); + addNotification(notification); + return; } + + // login or get token implicit arm = await AuthClient.getARMTokenForTenant(tenantId); graph = await AuthClient.getAccessToken(graphScopes); } else { diff --git a/Composer/packages/client/src/pages/publish/Publish.tsx b/Composer/packages/client/src/pages/publish/Publish.tsx index 083a1621cd..90e0b4ef41 100644 --- a/Composer/packages/client/src/pages/publish/Publish.tsx +++ b/Composer/packages/client/src/pages/publish/Publish.tsx @@ -7,7 +7,7 @@ import { useState, useEffect, useMemo, Fragment, useRef } from 'react'; import { RouteComponentProps } from '@reach/router'; import formatMessage from 'format-message'; import { useRecoilValue } from 'recoil'; -import { PublishResult } from '@bfc/shared'; +import { PublishResult, PublishTarget } from '@bfc/shared'; import { dispatcherState, localBotPublishHistorySelector, localBotsDataSelector } from '../../recoilModel'; import { AuthDialog } from '../../components/Auth/AuthDialog'; @@ -234,40 +234,63 @@ const Publish: React.FC { - for (const bot of items) { - const setting = botPropertyData[bot.id].setting; - const publishTargets = botPropertyData[bot.id].publishTargets; - if (!(bot.publishTarget && publishTargets && setting)) { - continue; - } - if (bot.publishTarget && publishTargets) { - const selectedTarget = publishTargets.find((target) => target.name === bot.publishTarget); - if (selectedTarget?.type === 'azurePublish' || selectedTarget?.type === 'azureFunctionsPublish') { - return true; - } - } - } - return false; + const isPublishingToAzure = (target?: PublishTarget) => { + return target?.type === 'azurePublish' || target?.type === 'azureFunctionsPublish'; }; const publish = async (items: BotStatus[]) => { + const tenantTokenMap = new Map(); // get token - let token = ''; - if (isPublishingToAzure(items)) { - // TODO: this logic needs to be moved into the Azure publish extensions - if (isGetTokenFromUser()) { - token = getTokenFromCache('accessToken'); - } else { - let tenant = getTenantIdFromCache(); - if (!tenant) { - const tenants = await AuthClient.getTenants(); - tenant = tenants?.[0]?.tenantId; - setTenantId(tenant); + const getTokenForTarget = async (target?: PublishTarget) => { + let token = ''; + if (target && isPublishingToAzure(target)) { + const { tenantId } = JSON.parse(target.configuration); + + if (tenantId) { + token = tenantTokenMap.get(tenantId) ?? (await AuthClient.getARMTokenForTenant(tenantId)); + tenantTokenMap.set(tenantId, token); + } else if (isGetTokenFromUser()) { + token = getTokenFromCache('accessToken'); + // old publish profile without tenant id + } else { + let tenant = getTenantIdFromCache(); + let tenants; + if (!tenant) { + try { + tenants = await AuthClient.getTenants(); + + tenant = tenants?.[0]?.tenantId; + setTenantId(tenant); + + token = tenantTokenMap.get(tenant) ?? (await AuthClient.getARMTokenForTenant(tenant)); + } catch (err) { + let notification; + if (err?.message.includes('does not exist in tenant') && tenants.length > 1) { + notification = createNotification({ + type: 'error', + title: formatMessage('Unsupported publishing profile'), + description: formatMessage( + "This publishing profile ({ profileName }) is no longer supported. You are a member of multiple Azure tenants and the profile needs to have a tenant id associated with it. You can either edit the profile by adding the `tenantId` property to it's configuration or create a new one.", + { profileName: target.name } + ), + }); + } else { + notification = createNotification({ + type: 'error', + title: formatMessage('Authentication Error'), + description: formatMessage('There was an error accessing your Azure account: {errorMsg}', { + errorMsg: err.message, + }), + }); + } + addNotification(notification); + } + } } - token = await AuthClient.getARMTokenForTenant(tenant); } - } + + return token; + }; setPublishDialogVisiblity(false); // notifications @@ -286,11 +309,12 @@ const Publish: React.FC target.name === bot.publishTarget); + const selectedTarget = publishTargets.find((target) => target.name === bot.publishTarget); + if (selectedTarget) { const botProjectId = bot.id; setting.qna.subscriptionKey && (await setQnASettings(botProjectId, setting.qna.subscriptionKey)); const sensitiveSettings = getSensitiveProperties(setting); + const token = await getTokenForTarget(selectedTarget); await publishToTarget(botProjectId, selectedTarget, { comment: bot.comment }, sensitiveSettings, token); // update the target with a lastPublished date diff --git a/Composer/packages/client/src/recoilModel/dispatchers/publisher.ts b/Composer/packages/client/src/recoilModel/dispatchers/publisher.ts index 76a5786352..0ce78ea1f6 100644 --- a/Composer/packages/client/src/recoilModel/dispatchers/publisher.ts +++ b/Composer/packages/client/src/recoilModel/dispatchers/publisher.ts @@ -4,7 +4,7 @@ import formatMessage from 'format-message'; import { CallbackInterface, useRecoilCallback } from 'recoil'; -import { defaultPublishConfig, isSkillHostUpdateRequired, PublishResult } from '@bfc/shared'; +import { defaultPublishConfig, isSkillHostUpdateRequired, PublishResult, PublishTarget } from '@bfc/shared'; import { publishTypesState, @@ -182,7 +182,7 @@ export const publisherDispatcher = () => { const publishToTarget = useRecoilCallback( (callbackHelpers: CallbackInterface) => async ( projectId: string, - target: any, + target: PublishTarget, metadata: any, sensitiveSettings, token = '' diff --git a/extensions/azurePublish/src/components/azureProvisionDialog.tsx b/extensions/azurePublish/src/components/azureProvisionDialog.tsx index d996e54360..0e2b6e7023 100644 --- a/extensions/azurePublish/src/components/azureProvisionDialog.tsx +++ b/extensions/azurePublish/src/components/azureProvisionDialog.tsx @@ -8,8 +8,9 @@ import { Dropdown, IDropdownOption } from 'office-ui-fabric-react/lib/Dropdown'; import { DefaultButton, PrimaryButton } from 'office-ui-fabric-react/lib/Button'; import { logOut, usePublishApi, getTenants, getARMTokenForTenant, useLocalStorage } from '@bfc/extension-client'; import { Subscription } from '@azure/arm-subscriptions/esm/models'; -import { DeployLocation } from '@botframework-composer/types'; +import { DeployLocation, AzureTenant } from '@botframework-composer/types'; import { FluentTheme, NeutralColors } from '@uifabric/fluent-theme'; +import { LoadingSpinner } from '@bfc/ui-shared'; import { ScrollablePane, ScrollbarVisibility, @@ -18,8 +19,6 @@ import { DetailsList, DetailsListLayoutMode, IColumn, - IGroup, - CheckboxVisibility, TooltipHost, Icon, TextField, @@ -71,12 +70,15 @@ const choiceOptions: IChoiceGroupOption[] = [ { key: 'create', text: 'Create new Azure resources' }, { key: 'import', text: 'Import existing Azure resources' }, ]; + const PageTypes = { + SelectTenant: 'tenant', ConfigProvision: 'config', AddResources: 'add', ReviewResource: 'review', EditJson: 'edit', }; + const DialogTitle = { CONFIG_RESOURCES: { title: formatMessage('Configure resources'), @@ -227,6 +229,8 @@ export const AzureProvisionDialog: React.FC = () => { const [deployLocations, setDeployLocations] = useState([]); const [luisLocations, setLuisLocations] = useState([]); + const [allTenants, setAllTenants] = useState([]); + const [selectedTenant, setSelectedTenant] = useState(); const [token, setToken] = useState(); const [currentUser, setCurrentUser] = useState(undefined); const [loginErrorMsg, setLoginErrorMsg] = useState(''); @@ -248,7 +252,7 @@ export const AzureProvisionDialog: React.FC = () => { const [isEditorError, setEditorError] = useState(false); const [importConfig, setImportConfig] = useState(); - const [page, setPage] = useState(PageTypes.ConfigProvision); + const [page, setPage] = useState(); const [listItems, setListItems] = useState<(ResourcesItem & { icon?: string })[]>(); const [reviewListItems, setReviewListItems] = useState([]); const isMounted = useRef(); @@ -262,8 +266,34 @@ export const AzureProvisionDialog: React.FC = () => { }; }, []); + const getTokenForTenant = (tenantId: string) => { + // set tenantId in cache. + setTenantId(tenantId); + getARMTokenForTenant(tenantId) + .then((token) => { + setToken(token); + const decoded = decodeToken(token); + setCurrentUser({ + token: token, + email: decoded.upn, + name: decoded.name, + expiration: (decoded.exp || 0) * 1000, // convert to ms, + sessionExpired: false, + }); + setPage(PageTypes.ConfigProvision); + setTitle(DialogTitle.CONFIG_RESOURCES); + setLoginErrorMsg(undefined); + }) + .catch((err) => { + setTenantId(undefined); + setCurrentUser(undefined); + setLoginErrorMsg(err.message || err.toString()); + }); + }; + useEffect(() => { - setTitle(DialogTitle.CONFIG_RESOURCES); + // TODO: need to get the tenant id from the auth config when running as web app, + // for electron we will always fetch tenants. if (isGetTokenFromUser()) { const { accessToken } = getTokenFromCache(); @@ -280,54 +310,37 @@ export const AzureProvisionDialog: React.FC = () => { }); } } else { - if (!getTenantIdFromCache()) { - getTenants().then((tenants) => { + getTenants().then((tenants) => { + setAllTenants(tenants); + if (!getTenantIdFromCache()) { if (isMounted.current && tenants?.length > 0) { - // set tenantId in cache. - setTenantId(tenants[0].tenantId); - getARMTokenForTenant(tenants[0].tenantId) - .then((token) => { - setToken(token); - const decoded = decodeToken(token); - setCurrentUser({ - token: token, - email: decoded.upn, - name: decoded.name, - expiration: (decoded.exp || 0) * 1000, // convert to ms, - sessionExpired: false, - }); - }) - .catch((err) => { - setCurrentUser(undefined); - setLoginErrorMsg(err.message || err.toString()); - }); - } - }); - } else { - getARMTokenForTenant(getTenantIdFromCache()) - .then((token) => { - if (isMounted.current) { - setToken(token); - const decoded = decodeToken(token); - setCurrentUser({ - token: token, - email: decoded.upn, - name: decoded.name, - expiration: (decoded.exp || 0) * 1000, // convert to ms, - sessionExpired: false, - }); + // if there is only 1 tenant, go ahead and fetch the token and store it in the cache + if (tenants.length === 1) { + setSelectedTenant(tenants[0].tenantId); + // getTokenForTenant(tenants[0].tenantId); + } else { + // seed tenant selection with first tenant + setSelectedTenant(tenants[0].tenantId); } - }) - .catch((err) => { - setCurrentUser(undefined); - setLoginErrorMsg(err.message || err.toString()); - }); - } + } + } else { + setSelectedTenant(getTenantIdFromCache()); + } + }); } }, []); + useEffect(() => { + if (selectedTenant) { + getTokenForTenant(selectedTenant); + } + }, [selectedTenant]); + useEffect(() => { if (currentConfig) { + if (currentConfig.tennantId) { + setSelectedTenant(currentConfig.tennantId); + } if (currentConfig.subscriptionId) { setSubscription(currentConfig.subscriptionId); } @@ -377,17 +390,6 @@ export const AzureProvisionDialog: React.FC = () => { return luisLocations.map((t) => ({ key: t.name, text: t.displayName })); }, [luisLocations]); - const updateCurrentSubscription = useMemo( - () => (_e, option?: IDropdownOption) => { - const sub = subscriptionOption?.find((t) => t.key === option?.key); - - if (sub) { - setSubscription(sub.key); - } - }, - [subscriptionOption] - ); - const checkNameAvailability = useCallback( (newName: string) => { if (timerRef.current) { @@ -512,15 +514,12 @@ export const AzureProvisionDialog: React.FC = () => { [extensionResourceOptions] ); - const onSubmit = useMemo( - () => (options) => { - // call back to the main Composer API to begin this process... - startProvision(options); - clearAll(); - closeDialog(); - }, - [] - ); + const onSubmit = useCallback((options) => { + // call back to the main Composer API to begin this process... + startProvision(options); + clearAll(); + closeDialog(); + }, []); const onSave = useMemo( () => () => { @@ -531,13 +530,6 @@ export const AzureProvisionDialog: React.FC = () => { [importConfig] ); - const updateChoice = useMemo( - () => (ev, option) => { - setChoice(option); - }, - [] - ); - const onRenderSecondaryText = useMemo( () => (props: IPersonaProps) => { return ( @@ -570,128 +562,153 @@ export const AzureProvisionDialog: React.FC = () => { }, [enabledResources]); const PageFormConfig = ( - - - }> - {subscriptionOption?.length > 0 && choice.key === 'create' && ( -
- - - - {currentConfig?.region ? ( - +
+ { + setChoice(option); + }} + /> +
+
+ }> + {subscriptionOption?.length > 0 && choice.key === 'create' && ( + + ({ key: t.tenantId, text: t.displayName }))} + selectedKey={selectedTenant} styles={{ root: { paddingBottom: '8px' } }} - onRenderLabel={onRenderLabel} + onChange={(_e, o) => { + setSelectedTenant(o.key as string); + }} /> - ) : ( { + setSubscription(o.key as string); + }} + onRenderLabel={onRenderLabel} /> - )} - {currentConfig?.settings?.luis?.region && currentLocation !== currentLuisLocation && ( - )} - {!currentConfig?.settings?.luis?.region && currentLocation !== currentLuisLocation && ( - - )} - - )} - {choice.key === 'create' && loginErrorMsg !== '' &&
{loginErrorMsg}
} - {choice.key === 'create' && currentUser && subscriptionOption?.length < 1 && ( -
- Your subscription list is empty, please add your subscription, or login with another account. -
- )} -
- {choice.key === 'import' && ( -
-
- {formatMessage('Publish Configuration')} -
- { - setEditorError(false); - setImportConfig(value); - }} - onError={() => { - setEditorError(true); - }} - /> -
- )} - + {currentConfig?.region ? ( + + ) : ( + + )} + {currentConfig?.settings?.luis?.region && currentLocation !== currentLuisLocation && ( + + )} + {!currentConfig?.settings?.luis?.region && currentLocation !== currentLuisLocation && ( + + )} + + )} + {choice.key === 'import' && ( +
+
+ {formatMessage('Publish Configuration')} +
+ { + setEditorError(false); + setImportConfig(value); + }} + onError={() => { + setEditorError(true); + }} + /> +
+ )} + +
+ ); useEffect(() => { @@ -763,7 +780,7 @@ export const AzureProvisionDialog: React.FC = () => {
{currentUser ? ( { { onNext(currentHostName); }} /> ) : ( - + )}
@@ -819,7 +841,7 @@ export const AzureProvisionDialog: React.FC = () => {
{currentUser ? ( {
{ setPage(PageTypes.ConfigProvision); setTitle(DialogTitle.CONFIG_RESOURCES); @@ -837,7 +859,7 @@ export const AzureProvisionDialog: React.FC = () => { { setPage(PageTypes.ReviewResource); setTitle(DialogTitle.REVIEW); @@ -873,7 +895,7 @@ export const AzureProvisionDialog: React.FC = () => {
{ setPage(PageTypes.AddResources); setTitle(DialogTitle.ADD_RESOURCES); @@ -882,10 +904,10 @@ export const AzureProvisionDialog: React.FC = () => { { + text={formatMessage('Done')} + onClick={() => { const selectedResources = requireResources.concat(enabledResources); - await onSubmit({ + onSubmit({ subscription: currentSubscription, resourceGroup: currentResourceGroup, hostname: currentHostName, @@ -904,12 +926,17 @@ export const AzureProvisionDialog: React.FC = () => { <> { closeDialog(); }} /> - + ); } @@ -929,35 +956,48 @@ export const AzureProvisionDialog: React.FC = () => { enabledResources, requireResources, currentLuisLocation, + selectedTenant, ]); + // if we haven't loaded the token yet, show a loading spinner + // unless we need to select the tenant first + if (!token) { + return ( +
+ +
+ ); + } + return ( -
- {page === PageTypes.ConfigProvision && PageFormConfig} - {page === PageTypes.AddResources && PageAddResources()} - {page === PageTypes.ReviewResource && PageReview} - {page === PageTypes.EditJson && ( - { - setEditorError(false); - setImportConfig(value); - }} - onError={() => { - setEditorError(true); - }} - /> - )} +
+
+ {page === PageTypes.ConfigProvision && PageFormConfig} + {page === PageTypes.AddResources && PageAddResources()} + {page === PageTypes.ReviewResource && PageReview} + {page === PageTypes.EditJson && ( + { + setEditorError(false); + setImportConfig(value); + }} + onError={() => { + setEditorError(true); + }} + /> + )} +
=> { const publishProfile = { name: currentProfile?.name ?? config.hostname, environment: currentProfile?.environment ?? 'composer', + tenantId: provisionResults?.tenantId ?? currentProfile?.tenantId, subscriptionId: provisionResults.subscriptionId ?? currentProfile?.subscriptionId, resourceGroup: currentProfile?.resourceGroup ?? provisionResults.resourceGroup?.name, botName: currentProfile?.botName ?? provisionResults.botName, diff --git a/extensions/azurePublish/src/node/provision.ts b/extensions/azurePublish/src/node/provision.ts index 94027ded7b..df11bb9328 100644 --- a/extensions/azurePublish/src/node/provision.ts +++ b/extensions/azurePublish/src/node/provision.ts @@ -215,6 +215,7 @@ export class BotProjectProvision { appInsights: null, qna: null, botName: null, + tenantId: this.tenantId, }; const resourceGroupName = config.resourceGroup ?? config.hostname; diff --git a/extensions/azurePublish/src/node/schema.ts b/extensions/azurePublish/src/node/schema.ts index 0637009366..4ec369b647 100644 --- a/extensions/azurePublish/src/node/schema.ts +++ b/extensions/azurePublish/src/node/schema.ts @@ -13,6 +13,11 @@ const schema: JSONSchema7 = { type: 'string', title: 'Environment', }, + tenantId: { + type: 'string', + title: 'Tenant Id', + description: 'The tenant id of Azure account.', + }, hostname: { type: 'string', title: 'Custom webapp hostname (if not -)', @@ -135,6 +140,7 @@ const schema: JSONSchema7 = { default: { name: '', environment: 'dev', + tenantId: '', runtimeIdentifier: 'win-x64', resourceGroup: '', botName: '',