From 57c8910a9b0157c649c2edcea74aefe5f54db66e Mon Sep 17 00:00:00 2001 From: HuihuiWu-Microsoft <73154171+HuihuiWu-Microsoft@users.noreply.github.com> Date: Thu, 24 Oct 2024 16:24:46 +0800 Subject: [PATCH] feat: list tenant when user clicks switch tenant button (#12580) * feat: add switch tenant command in context item * feat: add switch tenant context button for Azure account * refactor: use dynamic option item instead of static to avoid silent long time loading * test: add ut * refactor: update error and related msg * test: add ut to raise code coverage --- packages/vscode-extension/package.json | 36 +++- packages/vscode-extension/package.nls.json | 4 + packages/vscode-extension/src/extension.ts | 19 ++ .../handlers/accounts/switchTenantHandler.ts | 113 ++++++++++++ .../src/telemetry/extTelemetryEvents.ts | 3 + .../accounts/switchTenantHandler.test.ts | 167 ++++++++++++++++++ 6 files changed, 338 insertions(+), 4 deletions(-) create mode 100644 packages/vscode-extension/src/handlers/accounts/switchTenantHandler.ts create mode 100644 packages/vscode-extension/test/handlers/accounts/switchTenantHandler.test.ts diff --git a/packages/vscode-extension/package.json b/packages/vscode-extension/package.json index 93b3f2bd15..7298a38111 100644 --- a/packages/vscode-extension/package.json +++ b/packages/vscode-extension/package.json @@ -336,20 +336,30 @@ } ], "view/item/context": [ + { + "command": "fx-extension.m365SwitchTenant", + "when": "fx-extension.isMultiTenantEnabled && view == teamsfx-accounts && viewItem == signedinM365", + "group": "inline@1" + }, + { + "command": "fx-extension.azureSwitchTenant", + "when": "fx-extension.isMultiTenantEnabled && view == teamsfx-accounts && viewItem == signedinAzure", + "group": "inline@1" + }, { "command": "fx-extension.signOut", "when": "view == teamsfx-accounts && viewItem == signedinM365", - "group": "inline@1" + "group": "inline@2" }, { "command": "fx-extension.azureAccountSignOutHelp", "when": "view == teamsfx-accounts && viewItem == signedinAzure", - "group": "inline@1" + "group": "inline@2" }, { "command": "fx-extension.m365AccountSettings", "when": "view == teamsfx-accounts && viewItem == signedinM365", - "group": "inline@2" + "group": "inline@3" }, { "command": "fx-extension.refreshSideloading", @@ -374,7 +384,7 @@ { "command": "fx-extension.azureAccountSettings", "when": "view == teamsfx-accounts && viewItem == signedinAzure", - "group": "inline@2" + "group": "inline@3" }, { "command": "fx-extension.openDocumentLink", @@ -570,6 +580,14 @@ "command": "fx-extension.selectAndDebug", "when": "false" }, + { + "command": "fx-extension.m365SwitchTenant", + "when": "false" + }, + { + "command": "fx-extension.azureSwitchTenant", + "when": "false" + }, { "command": "fx-extension.signOut", "when": "false" @@ -891,6 +909,16 @@ "title": "%teamstoolkit.commands.checkCopilotAccess%", "icon": "$(info)" }, + { + "command": "fx-extension.m365SwitchTenant", + "title": "%teamstoolkit.commands.switchTenant.m365.title%", + "icon": "$(arrow-swap)" + }, + { + "command": "fx-extension.azureSwitchTenant", + "title": "%teamstoolkit.commands.switchTenant.azure.title%", + "icon": "$(arrow-swap)" + }, { "command": "fx-extension.signOut", "title": "%teamstoolkit.commands.signOut.title%", diff --git a/packages/vscode-extension/package.nls.json b/packages/vscode-extension/package.nls.json index 7547e9b1fb..08e00159b0 100644 --- a/packages/vscode-extension/package.nls.json +++ b/packages/vscode-extension/package.nls.json @@ -96,6 +96,8 @@ "teamstoolkit.commands.refresh.title": "Refresh", "teamstoolkit.commands.reportIssue.title": "Report Issues on GitHub", "teamstoolkit.commands.selectTutorials.title": "View How-to Guides", + "teamstoolkit.commands.switchTenant.m365.title": "Switch between your available tenants for Microsoft 365 account", + "teamstoolkit.commands.switchTenant.azure.title": "Switch between your available tenants for Azure account", "teamstoolkit.commands.signOut.title": "Sign Out", "teamstoolkit.commands.checkCopilotAccess.title": "Check Copilot Access", "teamstoolkit.commands.updateManifest.title": "Update Teams App", @@ -213,6 +215,8 @@ "teamstoolkit.envTree.subscriptionTooltipWithoutName": "'%s' environment is provisioned in Azure subscription '%s'", "teamstoolkit.handlers.azureSignIn": "Successfully signed in to Azure account.", "teamstoolkit.handlers.azureSignOut": "Successfully signed out of Azure account.", + "teamstoolkit.handlers.switchtenant.quickpick.title": "Switch Tenant", + "teamstoolkit.handlers.switchtenant.error": "Unable to obtain your Azure credentials. Make sure your Azure account is properly authenticated and try again", "teamstoolkit.handlers.coreNotReady": "Core module is loading", "teamstoolkit.handlers.createProjectNotification": "Create a new app or open an existing one to open the README file.", "teamstoolkit.handlers.createProjectTitle": "Create New App", diff --git a/packages/vscode-extension/src/extension.ts b/packages/vscode-extension/src/extension.ts index 9817b69b53..ad5a62cfaf 100644 --- a/packages/vscode-extension/src/extension.ts +++ b/packages/vscode-extension/src/extension.ts @@ -200,6 +200,7 @@ import { ExtensionSurvey } from "./utils/survey"; import { getSettingsVersion, projectVersionCheck } from "./utils/telemetryUtils"; import { createPluginWithManifest } from "./handlers/createPluginWithManifestHandler"; import { manifestListener } from "./manifestListener"; +import { onSwitchAzureTenant, onSwitchM365Tenant } from "./handlers/accounts/switchTenantHandler"; export async function activate(context: vscode.ExtensionContext) { const value = IsChatParticipantEnabled && semver.gte(vscode.version, "1.90.0"); @@ -974,9 +975,27 @@ function registerAccountMenuCommands(context: vscode.ExtensionContext) { } }) ); + + const m365SwitchTenant = vscode.commands.registerCommand( + "fx-extension.m365SwitchTenant", + (...args) => Correlator.run(onSwitchM365Tenant, [TelemetryTriggerFrom.SideBar]) + ); + context.subscriptions.push(m365SwitchTenant); + + const azureSwitchTenant = vscode.commands.registerCommand( + "fx-extension.azureSwitchTenant", + (...args) => Correlator.run(onSwitchAzureTenant, [TelemetryTriggerFrom.SideBar]) + ); + context.subscriptions.push(azureSwitchTenant); } async function initializeContextKey(context: vscode.ExtensionContext, isTeamsFxProject: boolean) { + await vscode.commands.executeCommand( + "setContext", + "fx-extension.isMultiTenantEnabled", + featureFlagManager.getBooleanValue(CoreFeatureFlags.MultiTenant) + ); + await vscode.commands.executeCommand("setContext", "fx-extension.isSPFx", isSPFxProject); await vscode.commands.executeCommand( diff --git a/packages/vscode-extension/src/handlers/accounts/switchTenantHandler.ts b/packages/vscode-extension/src/handlers/accounts/switchTenantHandler.ts new file mode 100644 index 0000000000..56d20c0961 --- /dev/null +++ b/packages/vscode-extension/src/handlers/accounts/switchTenantHandler.ts @@ -0,0 +1,113 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import { listAllTenants } from "@microsoft/teamsfx-core/build/common/tools"; +import { ExtTelemetry } from "../../telemetry/extTelemetry"; +import { AccountType, TelemetryEvent, TelemetryProperty } from "../../telemetry/extTelemetryEvents"; +import { getTriggerFromProperty } from "../../utils/telemetryUtils"; +import M365TokenInstance from "../../commonlib/m365Login"; +import azureAccountManager from "../../commonlib/azureLogin"; +import { AzureScopes, isUserCancelError } from "@microsoft/teamsfx-core"; +import { FxError, SingleSelectConfig, SystemError } from "@microsoft/teamsfx-api"; +import { localize } from "../../utils/localizeUtils"; +import { VS_CODE_UI } from "../../qm/vsc_ui"; +import { ExtensionSource } from "../../error/error"; +import { showError } from "../../error/common"; + +export async function onSwitchM365Tenant(...args: unknown[]): Promise { + ExtTelemetry.sendTelemetryEvent(TelemetryEvent.SwitchTenantStart, { + [TelemetryProperty.AccountType]: AccountType.M365, + ...getTriggerFromProperty(args), + }); + + let error: FxError | undefined = undefined; + const tokenRes = await M365TokenInstance.getAccessToken({ + scopes: AzureScopes, + }); + if (tokenRes.isOk()) { + const config: SingleSelectConfig = { + name: "SwitchTenant", + title: localize("teamstoolkit.handlers.switchtenant.quickpick.title"), + options: async () => { + const tenants = await listAllTenants(tokenRes.value); + return tenants.map((tenant: any) => { + return { + id: tenant.tenantId, + label: tenant.displayName, + description: tenant.defaultDomain, + }; + }); + }, + }; + const result = await VS_CODE_UI.selectOption(config); + if (result.isOk()) { + // TODO: set tenant + ExtTelemetry.sendTelemetryEvent(TelemetryEvent.SwitchTenant, { + [TelemetryProperty.AccountType]: AccountType.M365, + ...getTriggerFromProperty(args), + }); + return; + } else { + error = result.error; + } + } else { + error = tokenRes.error; + } + + if (!isUserCancelError(error)) { + void showError(error); + } + ExtTelemetry.sendTelemetryErrorEvent(TelemetryEvent.SwitchTenant, error, { + [TelemetryProperty.AccountType]: AccountType.M365, + ...getTriggerFromProperty(args), + }); +} + +export async function onSwitchAzureTenant(...args: unknown[]): Promise { + ExtTelemetry.sendTelemetryEvent(TelemetryEvent.SwitchTenantStart, { + [TelemetryProperty.AccountType]: AccountType.Azure, + ...getTriggerFromProperty(args), + }); + + const config: SingleSelectConfig = { + name: "SwitchTenant", + title: localize("teamstoolkit.handlers.switchtenant.quickpick.title"), + options: async () => { + const tokenCredential = await azureAccountManager.getIdentityCredentialAsync(false); + const token = tokenCredential ? await tokenCredential.getToken(AzureScopes) : undefined; + if (token && token.token) { + const tenants = await listAllTenants(token.token); + return tenants.map((tenant: any) => { + return { + id: tenant.tenantId, + label: tenant.displayName, + description: tenant.defaultDomain, + }; + }); + } else { + throw new SystemError( + ExtensionSource, + "SwitchTenantFailed", + localize("teamstoolkit.handlers.switchtenant.error") + ); + } + }, + }; + const result = await VS_CODE_UI.selectOption(config); + if (result.isOk()) { + // TODO: set tenant + ExtTelemetry.sendTelemetryEvent(TelemetryEvent.SwitchTenant, { + [TelemetryProperty.AccountType]: AccountType.Azure, + ...getTriggerFromProperty(args), + }); + return; + } else { + if (!isUserCancelError(result.error)) { + void showError(result.error); + } + ExtTelemetry.sendTelemetryErrorEvent(TelemetryEvent.SwitchTenant, result.error, { + [TelemetryProperty.AccountType]: AccountType.Azure, + ...getTriggerFromProperty(args), + }); + } +} diff --git a/packages/vscode-extension/src/telemetry/extTelemetryEvents.ts b/packages/vscode-extension/src/telemetry/extTelemetryEvents.ts index 4967b0753e..da014a72d2 100644 --- a/packages/vscode-extension/src/telemetry/extTelemetryEvents.ts +++ b/packages/vscode-extension/src/telemetry/extTelemetryEvents.ts @@ -20,6 +20,9 @@ export enum TelemetryEvent { SignOutStart = "sign-out-start", SignOut = "sign-out", + SwitchTenantStart = "switch-tenant-start", + SwitchTenant = "switch-tenant", + SelectSubscription = "select-subscription", CreateProjectStart = "create-project-start", diff --git a/packages/vscode-extension/test/handlers/accounts/switchTenantHandler.test.ts b/packages/vscode-extension/test/handlers/accounts/switchTenantHandler.test.ts new file mode 100644 index 0000000000..5320d93708 --- /dev/null +++ b/packages/vscode-extension/test/handlers/accounts/switchTenantHandler.test.ts @@ -0,0 +1,167 @@ +import * as sinon from "sinon"; +import * as chai from "chai"; +import * as vscode from "vscode"; + +import { ExtTelemetry } from "../../../src/telemetry/extTelemetry"; +import M365TokenInstance from "../../../src/commonlib/m365Login"; +import azureAccountManager from "../../../src/commonlib/azureLogin"; +import { err, ok, SystemError } from "@microsoft/teamsfx-api"; +import { NetworkError } from "@microsoft/teamsfx-core"; +import { + onSwitchM365Tenant, + onSwitchAzureTenant, +} from "../../../src/handlers/accounts/switchTenantHandler"; +import { TelemetryTriggerFrom } from "../../../src/telemetry/extTelemetryEvents"; +import * as tool from "@microsoft/teamsfx-core/build/common/tools"; +import * as vsc_ui from "../../../src/qm/vsc_ui"; + +describe("onSwitchM365Tenant", () => { + const sandbox = sinon.createSandbox(); + let sendTelemetryEventStub: sinon.SinonStub; + let sendTelemetryErrorEventStub: sinon.SinonStub; + let selectOptionStub: sinon.SinonStub; + + beforeEach(() => { + sendTelemetryEventStub = sandbox.stub(ExtTelemetry, "sendTelemetryEvent"); + sendTelemetryErrorEventStub = sandbox.stub(ExtTelemetry, "sendTelemetryErrorEvent"); + sandbox.stub(vsc_ui, "VS_CODE_UI").value(new vsc_ui.VsCodeUI({})); + selectOptionStub = sandbox + .stub(vsc_ui.VS_CODE_UI, "selectOption") + .resolves(ok({ type: "success" })); + }); + + afterEach(() => { + sandbox.restore(); + }); + + it("Failed to retrieve access token", async () => { + sandbox + .stub(M365TokenInstance, "getAccessToken") + .resolves(err(new NetworkError("extension", ""))); + + await onSwitchM365Tenant(TelemetryTriggerFrom.SideBar); + + chai.assert.isTrue(sendTelemetryEventStub.calledOnce); + chai.assert.isTrue(sendTelemetryErrorEventStub.calledOnce); + chai.assert.isTrue(sendTelemetryErrorEventStub.args[0][1] instanceof NetworkError); + }); + + it("Succeed to switch tenant", async () => { + sandbox.stub(M365TokenInstance, "getAccessToken").resolves(ok("faked token")); + sandbox.stub(tool, "listAllTenants").resolves([ + { + tenantId: "0022fd51-06f5-4557-8a34-69be98de6e20", + displayName: "MSFT", + defaultDomain: "t815h.onmicrosoft.com", + }, + { + tenantId: "313ef12c-d7cb-4f01-af90-1b113db5aa9a", + displayName: "Cisco", + defaultDomain: "Cisco561.onmicrosoft.com", + }, + ]); + + await onSwitchM365Tenant(TelemetryTriggerFrom.SideBar); + + chai.assert.isTrue(sendTelemetryEventStub.calledTwice); + chai.assert.isTrue(sendTelemetryErrorEventStub.notCalled); + const items = await selectOptionStub.args[0][0].options(); + chai.assert.deepEqual(items, [ + { + id: "0022fd51-06f5-4557-8a34-69be98de6e20", + label: "MSFT", + description: "t815h.onmicrosoft.com", + }, + { + id: "313ef12c-d7cb-4f01-af90-1b113db5aa9a", + label: "Cisco", + description: "Cisco561.onmicrosoft.com", + }, + ]); + }); +}); + +describe("onSwitchAzureTenant", () => { + const sandbox = sinon.createSandbox(); + let sendTelemetryEventStub: sinon.SinonStub; + let sendTelemetryErrorEventStub: sinon.SinonStub; + let selectOptionStub: sinon.SinonStub; + + beforeEach(() => { + sendTelemetryEventStub = sandbox.stub(ExtTelemetry, "sendTelemetryEvent"); + sendTelemetryErrorEventStub = sandbox.stub(ExtTelemetry, "sendTelemetryErrorEvent"); + sandbox.stub(vsc_ui, "VS_CODE_UI").value(new vsc_ui.VsCodeUI({})); + }); + + afterEach(() => { + sandbox.restore(); + }); + + it("Failed to retrieve access token", async () => { + sandbox.stub(azureAccountManager, "getIdentityCredentialAsync").resolves({ + getToken: () => { + return Promise.resolve(null); + }, + }); + selectOptionStub = sandbox.stub(vsc_ui.VS_CODE_UI, "selectOption").resolves( + err({ + name: "switchTenantFailed", + source: "extension", + timestamp: new Date(), + message: "failed", + }) + ); + + await onSwitchAzureTenant(TelemetryTriggerFrom.SideBar); + + chai.assert.isTrue(sendTelemetryEventStub.calledOnce); + chai.assert.isTrue(sendTelemetryErrorEventStub.calledOnce); + try { + await selectOptionStub.args[0][0].options(); + } catch (e) { + chai.assert.isTrue(e instanceof SystemError); + } + }); + + it("Succeed to switch tenant", async () => { + sandbox.stub(azureAccountManager, "getIdentityCredentialAsync").resolves({ + getToken: () => { + return Promise.resolve({ token: "faked token", expiresOnTimestamp: 0 }); + }, + }); + sandbox.stub(tool, "listAllTenants").resolves([ + { + tenantId: "0022fd51-06f5-4557-8a34-69be98de6e20", + displayName: "MSFT", + defaultDomain: "t815h.onmicrosoft.com", + }, + { + tenantId: "313ef12c-d7cb-4f01-af90-1b113db5aa9a", + displayName: "Cisco", + defaultDomain: "Cisco561.onmicrosoft.com", + }, + ]); + selectOptionStub = sandbox + .stub(vsc_ui.VS_CODE_UI, "selectOption") + .resolves(ok({ type: "success" })); + + await onSwitchAzureTenant(TelemetryTriggerFrom.SideBar); + + chai.assert.isTrue(sendTelemetryEventStub.calledTwice); + chai.assert.isTrue(sendTelemetryErrorEventStub.notCalled); + chai.assert.isTrue(selectOptionStub.calledOnce); + const items = await selectOptionStub.args[0][0].options(); + chai.assert.deepEqual(items, [ + { + id: "0022fd51-06f5-4557-8a34-69be98de6e20", + label: "MSFT", + description: "t815h.onmicrosoft.com", + }, + { + id: "313ef12c-d7cb-4f01-af90-1b113db5aa9a", + label: "Cisco", + description: "Cisco561.onmicrosoft.com", + }, + ]); + }); +});