diff --git a/.azure-pipelines/release.yml b/.azure-pipelines/release.yml new file mode 100644 index 000000000..68735d1e1 --- /dev/null +++ b/.azure-pipelines/release.yml @@ -0,0 +1,306 @@ +parameters: + # The intended extension version to publish. + # This is used to verify the version in package.json matches the version to publish to avoid accidental publishing. + - name: publishVersion + displayName: "Publish Version" + type: string + + # Customize the environment to associate the deployment with. + # Useful to control which group of people should be required to approve the deployment. + # Deprecated on OneBranch pipelines, use `ob_release_environment` variable and ApprovalService instead. + #- name: environmentName + # type: string + # default: AzCodeDeploy + + # When true, skips the deployment job which actually publishes the extension + - name: dryRun + displayName: "Dry Run without publishing" + type: boolean + default: true + + - name: "debug" + displayName: "Enable debug output" + type: boolean + default: false + +resources: + repositories: + - repository: templates + type: git + name: OneBranch.Pipelines/GovernedTemplates + ref: refs/heads/main + pipelines: + - pipeline: build # Alias for your build pipeline source + project: "CosmosDB" + source: '\VSCode Extensions\vscode-documentdb Build VSIX' # name of the pipeline that produces the artifacts + +variables: + system.debug: ${{ parameters.debug }} + # Required by MicroBuild template + TeamName: "Desktop Tools" + WindowsContainerImage: "onebranch.azurecr.io/windows/ltsc2022/vse2022:latest" # Docker image which is used to build the project https://aka.ms/obpipelines/containers + +extends: + template: v2/OneBranch.Official.CrossPlat.yml@templates + + parameters: + # remove for release pipeline? + cloudvault: # https://aka.ms/obpipelines/cloudvault + enabled: false + globalSdl: # https://aka.ms/obpipelines/sdl + asyncSdl: + enabled: false + tsa: + enabled: false # onebranch publish all sdl results to TSA. If TSA is disabled all SDL tools will forced into'break' build mode. + #configFile: '$(Build.SourcesDirectory)/.azure-pipelines/compliance/tsaoptions.json' + credscan: + suppressionsFile: $(Build.SourcesDirectory)/.azure-pipelines/compliance/CredScanSuppressions.json + policheck: + break: true # always break the build on policheck issues. You can disable it by setting to 'false' + suppression: + suppressionFile: $(Build.SourcesDirectory)/.config/guardian/.gdnsuppress + codeql: + excludePathPatterns: "**/.vscode-test, dist" # Exclude .vscode-test and dist directories from CodeQL alerting + compiled: + ${{ if eq(variables['Build.SourceBranch'], 'refs/heads/main') }}: + enabled: true + ${{ else }}: + enabled: false + tsaEnabled: false # See 'Codeql.TSAEnabled' in the Addition Options section below + componentgovernance: + ignoreDirectories: $(Build.SourcesDirectory)/.vscode-test + featureFlags: + linuxEsrpSigning: true + WindowsHostVersion: + Version: 2022 + # end of remove for release pipeline + + release: + category: NonAzure # NonAzure category is used to indicate that this is not an Azure service + + stages: + ## Uncomment this stage to validate the service connection and retrieve the user ID of the Azure DevOps Service Connection user. + ## NOTE: this has to be a separate stage with pool type 'windows' to ensure that the Azure CLI task can run successfully, + ## which is not supported on 'release' pool type. + ## See https://aka.ms/VSM-MS-Publisher-Automate for more details. + #- stage: ValidateServiceConnection + # displayName: Validate Service Connection + # jobs: + # - job: ValidateServiceConnection + # displayName: "\U00002713 Validate Service Connection" + # pool: + # type: windows + # variables: + # ob_outputDirectory: '$(Build.ArtifactStagingDirectory)' # this directory is uploaded to pipeline artifacts, reddog and cloudvault. More info at https://aka.ms/obpipelines/artifacts + # steps: + # # Get the user ID of the Azure DevOps Service Connection user to use for publishing + # - task: AzureCLI@2 + # displayName: 'Get AzDO User ID' + # inputs: + # azureSubscription: 'CosmosDB VSCode Publishing' + # scriptType: pscore + # scriptLocation: inlineScript + # inlineScript: | + # az rest -u https://app.vssps.visualstudio.com/_apis/profile/profiles/me --resource 499b84ac-1321-427f-aa17-267ca6975798 + ## END of ValidateServiceConnection stage + + - stage: Release + displayName: Release extension + variables: + - name: ob_release_environment + #value: Test # should be Test, PPE or Production + value: Production # should be Test, PPE or Production + jobs: + - job: ReleaseValidation + displayName: "\U00002713 Validate Artifacts" + templateContext: + inputs: + - input: pipelineArtifact + pipeline: build + targetPath: $(System.DefaultWorkingDirectory) + artifactName: drop_BuildStage_Main + pool: + type: release + variables: + ob_outputDirectory: "$(Build.ArtifactStagingDirectory)" # this directory is uploaded to pipeline artifacts, reddog and cloudvault. More info at https://aka.ms/obpipelines/artifacts + steps: + # Modify the build number to include repo name, extension version, and if dry run is true + - task: PowerShell@2 + displayName: "\U0001F449 Prepend version from package.json to build number" + env: + dryRun: ${{ parameters.dryRun }} + inputs: + targetType: "inline" + script: | + # Get the version from package.json + $packageJsonPath = "$(System.DefaultWorkingDirectory)/package.json" + if (-not (Test-Path $packageJsonPath)) { + Write-Error "[Error] package.json not found at $packageJsonPath" + exit 1 + } + + $packageJson = Get-Content $packageJsonPath | ConvertFrom-Json + $npmVersionString = $packageJson.version + if (-not $npmVersionString) { + Write-Error "[Error] Version not found in package.json" + exit 1 + } + + $isDryRun = "$env:dryRun" + $currentBuildNumber = "$(Build.BuildId)" + + $repoName = "$(Build.Repository.Name)" + $repoNameParts = $repoName -split '/' + $repoNameWithoutOwner = $repoNameParts[-1] + + $dryRunSuffix = "" + if ($isDryRun -eq 'True') { + Write-Output "Dry run was set to True. Adding 'dry' to the build number." + $dryRunSuffix = "-dry" + } + + $newBuildNumber = "$repoNameWithoutOwner-$npmVersionString$dryRunSuffix-$currentBuildNumber" + Write-Output "Setting build number to: $newBuildNumber" + Write-Output "##vso[build.updatebuildnumber]$newBuildNumber" + + # For safety, verify the version in package.json matches the version to publish entered by the releaser + # If they don't match, this step fails + - task: PowerShell@2 + displayName: "\U0001F449 Verify publish version" + env: + publishVersion: ${{ parameters.publishVersion }} + inputs: + targetType: "inline" + script: | + # Get the version from package.json + $packageJsonPath = "$(System.DefaultWorkingDirectory)/package.json" + if (-not (Test-Path $packageJsonPath)) { + Write-Error "[Error] package.json not found at $packageJsonPath" + exit 1 + } + + $packageJson = Get-Content $packageJsonPath | ConvertFrom-Json + $npmVersionString = $packageJson.version + $publishVersion = "$env:publishVersion" + + Write-Output "Package.json version: $npmVersionString" + Write-Output "Requested publish version: $publishVersion" + + # Validate both versions are semantic versions + $semverPattern = '^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?(?:\+([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?$' + if ($npmVersionString -notmatch $semverPattern) { + Write-Error "[Error] Version in package.json ($npmVersionString) is not a valid semantic version" + exit 1 + } + if ($publishVersion -notmatch $semverPattern) { + Write-Error "[Error] Publish version ($publishVersion) is not a valid semantic version" + exit 1 + } + + if ($npmVersionString -eq $publishVersion) { + Write-Output "[Success] Publish version matches package.json version. Proceeding with release." + } else { + Write-Error "[Error] Publish version '$publishVersion' doesn't match version found in package.json '$npmVersionString'. Cancelling release." + exit 1 + } + + # Find the vsix to release and set the vsix file name variable + # Fails with an error if more than one .vsix file is found, or if no .vsix file is found + - task: PowerShell@2 + displayName: "\U0001F449 Find and Set .vsix File Variable" + name: setVsixFileNameStep + inputs: + targetType: "inline" + script: | + # Get all .vsix files in the current directory + Write-Output "Searching for .vsix files in: $(System.DefaultWorkingDirectory)" + Write-Output "Directory contents:" + Get-ChildItem -Path $(System.DefaultWorkingDirectory) -File | Where-Object { $_.Extension -in @('.vsix', '.json', '.p7s', '.manifest') } | Select-Object Name, Length, LastWriteTime | Format-Table + + $vsixFiles = Get-ChildItem -Path $(System.DefaultWorkingDirectory) -Filter *.vsix -File + + # Check if more than one .vsix file is found + if ($vsixFiles.Count -gt 1) { + Write-Error "[Error] More than one .vsix file found: $($vsixFiles.Name -join ', ')" + exit 1 + } elseif ($vsixFiles.Count -eq 0) { + Write-Error "[Error] No .vsix files found in $(System.DefaultWorkingDirectory)" + exit 1 + } else { + # Set the pipeline variable + $vsixFileName = $vsixFiles.Name + $vsixFileSize = [math]::Round($vsixFiles.Length / 1MB, 2) + Write-Output "##vso[task.setvariable variable=vsixFileName;isOutput=true]$vsixFileName" + Write-Output "[Success] Found .vsix file: $vsixFileName (${vsixFileSize} MB)" + } + + - task: PowerShell@2 + displayName: "\U0001F449 Verify Publishing Files" + inputs: + targetType: "inline" + script: | + $vsixFileName = "$(setVsixFileNameStep.vsixFileName)" + if (-not $vsixFileName) { + Write-Error "[Error] vsixFileName variable not defined." + exit 1 + } + + $vsixPath = "$(System.DefaultWorkingDirectory)/$vsixFileName" + $manifestPath = "$(System.DefaultWorkingDirectory)/extension.manifest" + $signaturePath = "$(System.DefaultWorkingDirectory)/extension.signature.p7s" + + Write-Output "Validating required files for publishing:" + + if (Test-Path -Path $vsixPath) { + $vsixSize = [math]::Round((Get-Item $vsixPath).Length / 1MB, 2) + Write-Output "βœ“ VSIX file found: $vsixFileName (${vsixSize} MB)" + } else { + Write-Error "[Error] The specified VSIX file does not exist: $vsixPath" + exit 1 + } + + if (Test-Path -Path $manifestPath) { + Write-Output "βœ“ Manifest file found: extension.manifest" + } else { + Write-Warning "[Warning] Manifest file not found: $manifestPath" + } + + if (Test-Path -Path $signaturePath) { + Write-Output "βœ“ Signature file found: extension.signature.p7s" + } else { + Write-Warning "[Warning] Signature file not found: $signaturePath" + } + + Write-Output "[Success] $vsixFileName is ready for publishing." + + - job: PublishExtension + displayName: "\U00002713 Publish Extension" + condition: and(succeeded(), ${{ eq(parameters.dryRun, false) }}) + dependsOn: ReleaseValidation + pool: + type: release + variables: + vsixFileName: $[ dependencies.ReleaseValidation.outputs['setVsixFileNameStep.vsixFileName'] ] + templateContext: + inputs: + - input: pipelineArtifact + pipeline: build + targetPath: $(System.DefaultWorkingDirectory) + artifactName: drop_BuildStage_Main + workflow: vsce + vsce: + serviceConnection: "CosmosDB VSCode Publishing" # azureRM service connection for the managed identity used to publish the extension. Only this publishing auth method is supported. + vsixPath: "$(vsixFileName)" # Path to VSIX file in artifact + #preRelease: true # default false. Whether the extension is a pre-release. + signaturePath: $(System.DefaultWorkingDirectory)/extension.signature.p7s # optional + manifestPath: $(System.DefaultWorkingDirectory)/extension.manifest # optional + useCustomVSCE: true # for the time being, you must supply a feed in your project with @vscode/vsce@3.3.2 + feed: + organization: msdata + project: CosmosDB + feedName: vscode-documentdb + steps: + # we need a noop step otherwise the vsce template won't run + - pwsh: Write-Output "Done" + condition: ${{ eq(parameters.dryRun, true) }} # noop this condition is always false + displayName: "\U0001F449 Post-Publishing" diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index a8fd8e834..0e5e2ade5 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -68,7 +68,7 @@ This document provides comprehensive guidelines and context for GitHub Copilot t - Use l10n for any user-facing strings with `vscode.l10n.t()`. - Use `npm run prettier-fix` to format your code before committing. - Use `npm run lint` to check for linting errors. -- Use `npm run build` to ensure the project builds successfully (do not use `npm run compile`). +- Use `npm run build` to ensure the project builds successfully. - Use `npm run l10n` to update localization files in case you change any user-facing strings. - Ensure TypeScript compilation passes without errors. diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 879da1ee7..ac02668fb 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -45,22 +45,15 @@ jobs: - name: βœ… Checkout Repository uses: actions/checkout@v4 - - name: πŸ›  Setup Node.js Environment + - name: πŸ›  Setup Node.js Environment (with cache) uses: actions/setup-node@v4 with: node-version-file: .nvmrc - cache: 'npm' - - - name: πŸ’Ύ Restore npm Cache - uses: actions/cache@v3 - with: - path: ~/.npm - key: ${{ runner.os }}-node-${{ hashFiles('package-lock.json') }} - restore-keys: | - ${{ runner.os }}-node- + cache: npm + cache-dependency-path: '**/package-lock.json' - name: πŸ“¦ Install Dependencies (npm ci) - run: npm ci --verbose + run: npm ci --prefer-offline --no-audit --no-fund --progress=false --verbose - name: 🌐 Check Localization Files run: npm run l10n:check @@ -91,22 +84,15 @@ jobs: - name: βœ… Checkout Repository uses: actions/checkout@v4 - - name: πŸ›  Setup Node.js Environment + - name: πŸ›  Setup Node.js Environment (with cache) uses: actions/setup-node@v4 with: node-version-file: .nvmrc - cache: 'npm' - - - name: πŸ’Ύ Restore npm Cache - uses: actions/cache@v3 - with: - path: ~/.npm - key: ${{ runner.os }}-node-${{ hashFiles('package-lock.json') }} - restore-keys: | - ${{ runner.os }}-node- + cache: npm + cache-dependency-path: '**/package-lock.json' - name: πŸ“¦ Install Dependencies (npm ci) - run: npm ci + run: npm ci --prefer-offline --no-audit --no-fund --progress=false - name: πŸ”„ Run Integration Tests (Headless UI) run: xvfb-run -a npm test @@ -123,7 +109,6 @@ jobs: github.base_ref == 'main' || github.base_ref == 'next' )) - defaults: run: working-directory: '.' @@ -131,22 +116,15 @@ jobs: - name: βœ… Checkout Repository uses: actions/checkout@v4 - - name: πŸ›  Setup Node.js Environment + - name: πŸ›  Setup Node.js Environment (with cache) uses: actions/setup-node@v4 with: node-version-file: .nvmrc - cache: 'npm' - - - name: πŸ’Ύ Restore npm Cache - uses: actions/cache@v3 - with: - path: ~/.npm - key: ${{ runner.os }}-node-${{ hashFiles('package-lock.json') }} - restore-keys: | - ${{ runner.os }}-node- + cache: npm + cache-dependency-path: '**/package-lock.json' - name: πŸ“¦ Install Dependencies (npm ci) - run: npm ci + run: npm ci --prefer-offline --no-audit --no-fund --progress=false - name: πŸ— Build Project run: npm run build diff --git a/.nvmrc b/.nvmrc index cecb93628..10fef252a 100644 --- a/.nvmrc +++ b/.nvmrc @@ -1 +1 @@ -20.15 +20.18 diff --git a/CHANGELOG.md b/CHANGELOG.md index 073a2a527..f7a2d8213 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,18 @@ # Change Log +## 0.5.0 + +### New Features & Improvements + +- **Enhanced Microsoft Entra ID Support**: Overhauled Microsoft Entra ID integration for Azure Cosmos DB for MongoDB (vCore) to fully support multi-account and multi-tenant environments, enabling uninterrupted workflows for developers working across different organizations. This includes multi-account management and multi-tenant filtering. [#277](https://github.com/microsoft/vscode-documentdb/pull/277), [#285](https://github.com/microsoft/vscode-documentdb/issues/285), [#284](https://github.com/microsoft/vscode-documentdb/issues/284), [#265](https://github.com/microsoft/vscode-documentdb/issues/265), [#243](https://github.com/microsoft/vscode-documentdb/issues/243) +- **New "Help and Feedback" View**: Added a new view to the extension sidebar, providing a central place to access documentation, see the changelog, report issues, and request features. [#289](https://github.com/microsoft/vscode-documentdb/pull/289) + +### Fixes + +- **Password Re-entry on Shell Launch**: Fixed a regression where users with saved credentials were still prompted for a password when launching the shell. [#285](https://github.com/microsoft/vscode-documentdb/issues/285) +- **Tenant Information in Service Discovery**: Resolved an issue where the extension would fail to respect the tenant context when interacting with Azure resources from a non-default tenant. [#276](https://github.com/microsoft/vscode-documentdb/issues/276) +- **Connection Authentication Update**: Corrected a failure that occurred when updating a connection's authentication method from Entra ID to a username/password. [#284](https://github.com/microsoft/vscode-documentdb/issues/284) + ## 0.4.1 ### Improvement diff --git a/docs/index.md b/docs/index.md index c3183d3b4..8deaa9f25 100644 --- a/docs/index.md +++ b/docs/index.md @@ -42,29 +42,31 @@ Your feedback, contributions, and ideas shape the future of the extension. ## User Manual -The User Manual provides guidance on using DocumentDB for VS Code: +The User Manual provides guidance on using DocumentDB for VS Code. It contains detailed documentation for specific features and concepts. These documents provide additional context and examples for features you encounter while using the extension: -- [How to Construct a URL That Opens a Connection in the Extension](./manual/how-to-construct-url.md) +### Connecting to Databases -## Learn More +- [Connecting with a URL](./user-manual/how-to-construct-url) +- [Service Discovery](./user-manual/service-discovery) + - [Azure Cosmos DB for MongoDB (vCore)](./user-manual/service-discovery-azure-cosmosdb-for-mongodb-vcore) + - [Azure Cosmos DB for MongoDB (RU)](./user-manual/service-discovery-azure-cosmosdb-for-mongodb-ru) + - [Azure VMs (DocumentDB)](./user-manual/service-discovery-azure-vms) + - [Managing Azure Subscriptions](./user-manual/managing-azure-discovery) +- [Connecting to Local Instances](./user-manual/local-connection) + - [Azure Cosmos DB for MongoDB (RU) Emulator](./user-manual/local-connection-mongodb-ru) + - [DocumentDB Local](./user-manual/local-connection-documentdb-local) -This section contains detailed documentation for specific features and concepts that are directly accessible from within the DocumentDB for VS Code extension. These documents provide additional context and examples for features you encounter while using the extension: +### Data Management -- [Service Discovery](./learn-more/service-discovery.md) - - [Service Discovery: Azure CosmosDB for MongoDB (vCore)](./learn-more/service-discovery-azure-cosmosdb-for-mongodb-vcore.md) - - [Service Discovery: Azure VMs (DocumentDB)](./learn-more/service-discovery-azure-vms.md) -- [Local Connection](./learn-more/local-connection.md) - - [Local Connection: Azure CosmosDB for MongoDB (RU) Emulator](./learn-more/local-connection-mongodb-ru.md) - - [Local Connection: DocumentDB Local](./learn-more/local-connection-documentdb-local.md) -- [Data Migrations](./learn-more/data-migrations.md) ⚠️ _Experimental_ +- [Data Migrations (Experimental)](./user-manual/data-migrations) ## Release Notes Explore the history of updates and improvements to the DocumentDB for VS Code extension. Each release brings new features, enhancements, and fixes to improve your experience. -- [0.4](./release-notes/0.4.md) -- [0.3, 0.3.1](./release-notes/0.3.md) -- [0.2.4](./release-notes/0.2.4.md) -- [0.2.3](./release-notes/0.2.3.md) -- [0.2.2](./release-notes/0.2.2.md) -- [0.2.1](./release-notes/0.2.1.md) +- [0.4](./release-notes/0.4) +- [0.3, 0.3.1](./release-notes/0.3) +- [0.2.4](./release-notes/0.2.4) +- [0.2.3](./release-notes/0.2.3) +- [0.2.2](./release-notes/0.2.2) +- [0.2.1](./release-notes/0.2.1) diff --git a/docs/learn-more/index.md b/docs/learn-more/index.md deleted file mode 100644 index 18fe4bcd4..000000000 --- a/docs/learn-more/index.md +++ /dev/null @@ -1,19 +0,0 @@ - - -> **Manual** — [Back to Home](../index.md) - ---- - -# Learn More - -This section contains additional documentation for features and concepts in DocumentDB for VS Code. These documents are linked from within the DocumentDB for VS Code Extension, but we encourage you to explore this documentation directly for more details and context. - -## Available Topics - -- [Service Discovery](./service-discovery.md) - - [Service Discovery: Azure CosmosDB for MongoDB (vCore)](./service-discovery-azure-cosmosdb-for-mongodb-vcore.md) - - [Service Discovery: Azure VMs (DocumentDB)](./service-discovery-azure-vms.md) -- [Local Connection](./local-connection.md) - - [Local Connection: Azure CosmosDB for MongoDB (RU) Emulator](./local-connection-mongodb-ru.md) - - [Local Connection: DocumentDB Local](./local-connection-documentdb-local.md) -- [Data Migrations](./data-migrations.md) ⚠️ _Experimental_ diff --git a/docs/learn-more/service-discovery-azure-cosmosdb-for-mongodb-ru.md b/docs/learn-more/service-discovery-azure-cosmosdb-for-mongodb-ru.md deleted file mode 100644 index 3df3b4ff5..000000000 --- a/docs/learn-more/service-discovery-azure-cosmosdb-for-mongodb-ru.md +++ /dev/null @@ -1,49 +0,0 @@ - - - -> **Learn More** — [Back to Learn More Index](./index) - ---- - -# Azure CosmosDB for MongoDB (RU) Service Discovery Plugin - -The **Azure CosmosDB for MongoDB (RU)** plugin is available as part of the [Service Discovery](./service-discovery) feature in DocumentDB for VS Code. This plugin helps you find and connect to Azure Cosmos DB accounts provisioned with Request Units (RU) for the MongoDB API by handling authentication, resource discovery, and connection creation from inside the extension. - -## How to Access - -You can access this plugin in two ways: - -- From the `Service Discovery` panel in the extension sidebar. -- When adding a new connection, select the `Azure CosmosDB for MongoDB (RU)` option. - -![Service Discovery Activation](./images/service-discovery-activation.png) - -## How It Works - -When you use the Azure CosmosDB for MongoDB (RU) plugin, the extension performs the following steps: - -1. **Authentication:** - The plugin uses your Azure credentials available in VS Code. If needed, it will prompt you to sign in via the standard Azure sign-in flows. - -2. **Subscription Discovery:** - The plugin lists subscriptions available to your account so you can pick where to search for resources. - -3. **Account Discovery:** - The provider queries Azure using the CosmosDB Management Client and filters results by the MongoDB "kind" for RU-based accounts. This ensures the list contains accounts that support the MongoDB API under RU provisioning. - -4. **Connection Options:** - - Expand an account entry to view databases and connection options. - - Save an account to your `DocumentDB Connections` list using the context menu or the save icon next to its name. - - When connecting or saving, the extension will extract credentials or connection details from Azure where available. If multiple authentication methods are supported, you will be prompted to choose one. - -## Additional Notes - -- You can filter subscriptions in the Service Discovery panel to limit the scope of discovery if you have access to many subscriptions. -- The provider reuses shared authentication and subscription selection flows used across other Service Discovery plugins. -- If you save a discovered account, the saved connection will appear in your Connections view for later use. - -## Feedback and Contributions - -If you have suggestions for improving this provider or would like to add support for additional resource types, please [join the discussion board](https://github.com/microsoft/vscode-documentdb/discussions) and share your feedback. - ---- diff --git a/docs/learn-more/service-discovery-azure-cosmosdb-for-mongodb-vcore.md b/docs/learn-more/service-discovery-azure-cosmosdb-for-mongodb-vcore.md deleted file mode 100644 index 6d9d91aaf..000000000 --- a/docs/learn-more/service-discovery-azure-cosmosdb-for-mongodb-vcore.md +++ /dev/null @@ -1,51 +0,0 @@ - - -> **Learn More** — [Back to Learn More Index](./index) - ---- - -# Azure CosmosDB for MongoDB (vCore) Service Discovery Plugin - -The **Azure CosmosDB for MongoDB (vCore)** plugin is available as part of the [Service Discovery](./service-discovery) feature in DocumentDB for VS Code. This plugin helps you connect to your Azure CosmosDB for MongoDB (vCore) clusters by handling authentication, resource discovery, and connection management within the extension. - -## How to Access - -You can access this plugin in two ways: - -- Through the `Service Discovery` panel in the extension sidebar. -- When adding a new connection, select the `Azure CosmosDB for MongoDB (vCore)` option. - -![Service Discovery Activation](./images/service-discovery-activation.png) - -## How It Works - -When you use the Azure CosmosDB for MongoDB (vCore) plugin, the following steps are performed: - -1. **Authentication:** - The plugin authenticates you with Azure using your credentials. - -2. **Subscription Discovery:** - All available Azure subscriptions are listed. - - > **Tip:** You can `filter` which subscriptions are shown in the `Service Discovery` panel. Click the funnel icon next to the service discovery provider name, wait for the list to populate, and select the subscriptions you want to include. - > - > ![Service Discovery Filter Feature Location](./images/service-discovery-filter-azure-vcore.png) - -3. **Cluster Discovery:** - The plugin enumerates all Azure CosmosDB for MongoDB (vCore) clusters available in your selected subscriptions. - -4. **Connection Options:** - - You can connect to a cluster by expanding its entry in the tree view. - - You can save a cluster to your `DocumentDB Connections` list using the context menu or by clicking the save icon next to its name. - - When connecting or saving, the extension detects the authentication methods supported by the cluster (e.g., **Username/Password** or **Entra ID**). If multiple are available, you will be prompted to choose your preferred method. - -## Additional Notes - -- Subscription filtering helps you focus on relevant resources, especially if you have access to many Azure subscriptions. -- All authentication and discovery steps are handled within the extension, so you do not need to manually gather connection strings or resource details. - -## Feedback and Contributions - -If you have suggestions for improving this plugin or would like to see support for additional Azure resource types, please [join the discussion board](https://github.com/microsoft/vscode-documentdb/discussions) and share your feedback. - ---- diff --git a/docs/learn-more/service-discovery-azure-vms.md b/docs/learn-more/service-discovery-azure-vms.md deleted file mode 100644 index 2cf9f55f8..000000000 --- a/docs/learn-more/service-discovery-azure-vms.md +++ /dev/null @@ -1,53 +0,0 @@ - - -> **Learn More** — [Back to Learn More Index](./index) - ---- - -# Azure VMs (DocumentDB) Service Discovery Plugin - -The **Azure VMs (DocumentDB)** plugin is available as part of the [Service Discovery](./service-discovery) feature in DocumentDB for VS Code. This plugin helps you locate and connect to your virtual machines hosted in Azure that are running self-hosted DocumentDB or MongoDB instances. - -## How to Access - -You can access this plugin in two ways: - -- Through the `Service Discovery` panel in the extension sidebar. -- When adding a new connection, select the `Azure VMs (DocumentDB)` option. - -![Service Discovery Activation](./images/service-discovery-activation-vm.png) - -## How It Works - -When you use the Azure VMs (DocumentDB) plugin, the following steps are performed: - -1. **Authentication:** - The plugin authenticates you with Azure using your credentials. - -2. **Subscription Discovery:** - All available Azure subscriptions are listed. - - > **Tip:** You can `filter` which subscriptions are shown in the `Service Discovery` panel. Click the funnel icon next to the service discovery provider name, wait for the list to populate, and select the subscriptions you want to include. - > - > ![Service Discovery Filter Feature Location](./images/service-discovery-filter-vm.png) - -3. **VM Filtering by Tag:** - The plugin searches for virtual machines within your selected subscriptions that have a specific tag assigned. By default, the tag is set to `DocumentDB`, but you can change this in the filter function as needed. - - > **Tip:** When using Service Discovery from within the `DocumentDB Connections` area, you'll always be asked to confirm the `tag` used. The `Service Discovery` area works with the default `DocumentDB` tag. Changing it is possible using the `filter` feature. - -4. **Connection Options:** - - You can connect to a VM by expanding its entry in the tree view. - - You can save a VM to your `DocumentDB Connections` list using the context menu or by clicking the save icon next to its name. - -## Additional Notes - -- Tag-based filtering helps you focus on relevant virtual machines, especially if you have many resources in your Azure environment. -- All authentication and discovery steps are handled within the extension, so you do not need to manually gather connection strings or VM details. -- You can change the tag used for filtering in the filter function if your environment uses a different tagging convention. - -## Feedback and Contributions - -If you have suggestions for improving this plugin or would like to see support for additional VM filtering options, please [join the discussion board](https://github.com/microsoft/vscode-documentdb/discussions) and share your feedback. - ---- diff --git a/docs/manual/index.md b/docs/manual/index.md deleted file mode 100644 index 8013d6a80..000000000 --- a/docs/manual/index.md +++ /dev/null @@ -1,11 +0,0 @@ - - -> **Manual** — [Back to Home](../index.md) - ---- - -# Manual - -## How to Construct a URL That Opens a Connection in the Extension - -[Learn how to construct a URL that opens a connection in the extension](./how-to-construct-url.md) diff --git a/docs/release-notes/0.2.1.md b/docs/release-notes/0.2.1.md index 949a4c94a..34ccba08c 100644 --- a/docs/release-notes/0.2.1.md +++ b/docs/release-notes/0.2.1.md @@ -1,6 +1,6 @@ -> **Release Notes** — [Back to Home](../index.md) +> **Release Notes** β€” [Back to Release Notes](../index#release-notes) --- @@ -31,7 +31,7 @@ This release introduces two new **Service Discovery Providers**, making it easie These providers help reduce manual configuration so you can focus more on your data and applications. -[Learn more about Service Discovery Providers in DocumentDB for VS Code β†’](https://microsoft.github.io/vscode-documentdb/learn-more/service-discovery.html) +[Learn more about Service Discovery Providers in DocumentDB for VS Code β†’](../user-manual/service-discovery) ### 3️⃣ **Developer Productivity Features** diff --git a/docs/release-notes/0.2.2.md b/docs/release-notes/0.2.2.md index 8905c45f8..ec24e5f76 100644 --- a/docs/release-notes/0.2.2.md +++ b/docs/release-notes/0.2.2.md @@ -1,6 +1,6 @@ -> **Release Notes** β€” [Back to Home](../index.md) +> **Release Notes** β€” [Back to Release Notes](../index#release-notes) --- @@ -20,7 +20,7 @@ We're introducing an **experimental data migration framework** that enables thir - Rich context-aware workflows that pass database and collection info directly to the provider. - Custom UI integration, authentication handling, and progress tracking. -This is an opt-in preview aimed at extension authors and early adopters. [Learn how to participate in the preview β†’](https://microsoft.github.io/vscode-documentdb/data-migrations) +This is an opt-in preview aimed at extension authors and early adopters. ### 2️⃣ **URL Handler for Direct Database Navigation** diff --git a/docs/release-notes/0.2.3.md b/docs/release-notes/0.2.3.md index 25e50ef50..841883c57 100644 --- a/docs/release-notes/0.2.3.md +++ b/docs/release-notes/0.2.3.md @@ -1,6 +1,6 @@ -> **Release Notes** β€” [Back to Home](../index.md) +> **Release Notes** β€” [Back to Release Notes](../index#release-notes) --- diff --git a/docs/release-notes/0.2.4.md b/docs/release-notes/0.2.4.md index 1a1d39000..ab8ece60d 100644 --- a/docs/release-notes/0.2.4.md +++ b/docs/release-notes/0.2.4.md @@ -1,6 +1,6 @@ -> **Release Notes** β€” [Back to Home](../index.md) +> **Release Notes** β€” [Back to Release Notes](../index#release-notes) --- diff --git a/docs/release-notes/0.3.md b/docs/release-notes/0.3.md index bde2492a1..1f65a2a19 100644 --- a/docs/release-notes/0.3.md +++ b/docs/release-notes/0.3.md @@ -1,6 +1,6 @@ -> **Release Notes** β€” [Back to Home](../index.md) +> **Release Notes** β€” [Back to Release Notes](../index#release-notes) --- diff --git a/docs/release-notes/0.4.md b/docs/release-notes/0.4.md index be07f3239..a9820ef86 100644 --- a/docs/release-notes/0.4.md +++ b/docs/release-notes/0.4.md @@ -1,6 +1,6 @@ -> **Release Notes** β€” [Back to Home](../index.md) +> **Release Notes** β€” [Back to Release Notes](../index#release-notes) --- @@ -40,7 +40,7 @@ We've expanded our service discovery capabilities by adding a dedicated provider - **Consistent User Experience**: The new provider uses the same authentication and wizard-based workflow (select subscription β†’ select cluster β†’ connect) that users are already familiar with. - **Optimized for RU**: The provider uses RU-specific Azure APIs to ensure accurate and reliable discovery. -[Learn more about Service Discovery β†’](../learn-more/service-discovery.md) +[Learn more about Service Discovery β†’](../user-manual/service-discovery) ### 3️⃣ **Official DocumentDB Logo and Branding** ([#246](https://github.com/microsoft/vscode-documentdb/pull/246)) diff --git a/docs/release-notes/0.5.md b/docs/release-notes/0.5.md new file mode 100644 index 000000000..487b41ada --- /dev/null +++ b/docs/release-notes/0.5.md @@ -0,0 +1,53 @@ + + +> **Release Notes** β€” [Back to Release Notes](../index.md#release-notes) + +--- + +# DocumentDB for VS Code Extension v0.5 + +We are excited to announce the release of **DocumentDB for VS Code Extension v0.5.0**. This update significantly enhances security and multi-tenant workflows with improved Microsoft Entra ID support, introduces a new "Help and Feedback" view, and delivers several key bug fixes to improve stability and user experience. + +## What's New in v0.5 + +### ⭐ Enhanced Microsoft Entra ID Support for Multi-Account and Multi-Tenant Scenarios + +Continuing our focus on enterprise-grade security from release 0.3.0, we have overhauled our Microsoft Entra ID integration for Azure Cosmos DB for MongoDB (vCore) to fully support multi-account and multi-tenant environments. This enables uninterrupted workflows for developers working across different organizations and directories. + +- **Multi-Account Management**: You can now sign in with multiple Azure accounts and easily switch between them without leaving VS Code. A new **Manage Credentials** feature allows you to view all authenticated accounts and add new ones on the fly. +- **Multi-Tenant Filtering**: For users with access to multiple Azure tenants, a new filtering wizard lets you select exactly which tenants and subscriptions should be visible in the Service Discovery panel. Your selections are saved and persisted across sessions, ensuring a clean and relevant view of your resources. +- **Consistent Context**: The extension now correctly respects the tenant context associated with each resource, resolving previous inconsistencies and ensuring a reliable experience when interacting with clusters across different tenants. + +This work was completed as part of PR [#277](https://github.com/microsoft/vscode-documentdb/pull/277) and addresses the following issues: [#285](https://github.com/microsoft/vscode-documentdb/issues/285), [#284](https://github.com/microsoft/vscode-documentdb/issues/284), [#265](https://github.com/microsoft/vscode-documentdb/issues/265), [#243](https://github.com/microsoft/vscode-documentdb/issues/243) + +For more details, please see our new documentation on [Managing Azure Discovery (Accounts, Tenants, and Subscriptions)](../user-manual/managing-azure-discovery.md). + +### ⭐ New "Help and Feedback" View + +We've added a new **Help and Feedback** view to the extension sidebar, providing a central place to access important resources. This view makes it easier than ever to get help, provide feedback, and stay up-to-date with the latest changes. + +The new view includes quick links to: + +- **Documentation**: Access the full user manual. +- **Changelog**: See what's new in the latest release. +- **Report an Issue**: Quickly file a bug report on GitHub. +- **Request a Feature**: Share your ideas for new features. +- **Join the Discussion**: Connect with the community on our GitHub discussion board. + +This feature was introduced in PR [#289](https://github.com/microsoft/vscode-documentdb/pull/289). + +## Key Fixes and Improvements + +- **Users are asked to re-enter password when launching the shell ([#285](https://github.com/microsoft/vscode-documentdb/issues/285))** + - Fixed a regression where users with saved credentials were still prompted for a password when launching the shell. The original functionality has been restored. + +- **Service Discovery + Entra ID implementation does not use tenant information ([#276](https://github.com/microsoft/vscode-documentdb/issues/276))** + - Resolved an issue where the extension would fail to respect the tenant context when interacting with Azure resources from a non-default tenant. The extension now correctly handles tenant information, preventing inconsistent states. + +- **Updating connection authentication from EntraID to UserName/Password fails ([#284](https://github.com/microsoft/vscode-documentdb/issues/284))** + - Corrected a failure that occurred when updating a connection's authentication method from Entra ID to a username/password. The connection now updates and connects successfully. + +## Changelog + +See the full changelog entry for this release: +➑️ [CHANGELOG.md#050](https://github.com/microsoft/vscode-documentdb/blob/main/CHANGELOG.md#050) diff --git a/docs/learn-more/data-migrations.md b/docs/user-manual/data-migrations.md similarity index 96% rename from docs/learn-more/data-migrations.md rename to docs/user-manual/data-migrations.md index 96453fc49..509a506ef 100644 --- a/docs/learn-more/data-migrations.md +++ b/docs/user-manual/data-migrations.md @@ -1,6 +1,4 @@ - - -> **Learn More** — [Back to Learn More Index](./index.md) +> **User Manual** — [Back to User Manual](../index#user-manual) --- @@ -81,7 +79,7 @@ This context allows providers to offer intelligent, targeted migration options b For detailed API documentation, plugin development information, and technical specifications, please refer to: -**[Migration API Documentation](https://github.com/microsoft/vscode-documentdb/tree/main/api/README.md)** +**[Migration API Documentation](https://github.com/microsoft/vscode-documentdb/tree/main/api/README)** The API documentation includes: diff --git a/docs/manual/how-to-construct-url.md b/docs/user-manual/how-to-construct-url.md similarity index 98% rename from docs/manual/how-to-construct-url.md rename to docs/user-manual/how-to-construct-url.md index 3bdf61efa..a6892d801 100644 --- a/docs/manual/how-to-construct-url.md +++ b/docs/user-manual/how-to-construct-url.md @@ -1,6 +1,4 @@ - - -> **Manual** — [Back to Documentation Index](./index.md) +> **User Manual** — [Back to User Manual](../index#user-manual) --- @@ -115,7 +113,6 @@ When you click a DocumentDB for VS Code URL, the following process occurs: 1. **Activation**: The `vscode://` prefix tells the operating system to activate VS Code. The `ms-azuretools.vscode-documentdb` segment activates the **DocumentDB for VS Code** extension. 2. **Connection Handling**: - - The extension parses the `connectionString` parameter and creates a new connection in the Connections View. - If a connection with the same host and username already exists, the existing connection will be selected instead of creating a duplicate. diff --git a/docs/learn-more/images/local-connection.png b/docs/user-manual/images/local-connection.png similarity index 100% rename from docs/learn-more/images/local-connection.png rename to docs/user-manual/images/local-connection.png diff --git a/docs/learn-more/images/service-discovery-activation-vm.png b/docs/user-manual/images/service-discovery-activation-vm.png similarity index 100% rename from docs/learn-more/images/service-discovery-activation-vm.png rename to docs/user-manual/images/service-discovery-activation-vm.png diff --git a/docs/learn-more/images/service-discovery-activation.png b/docs/user-manual/images/service-discovery-activation.png similarity index 100% rename from docs/learn-more/images/service-discovery-activation.png rename to docs/user-manual/images/service-discovery-activation.png diff --git a/docs/learn-more/images/service-discovery-filter-azure-vcore.png b/docs/user-manual/images/service-discovery-filter-azure-vcore.png similarity index 100% rename from docs/learn-more/images/service-discovery-filter-azure-vcore.png rename to docs/user-manual/images/service-discovery-filter-azure-vcore.png diff --git a/docs/learn-more/images/service-discovery-filter-vm.png b/docs/user-manual/images/service-discovery-filter-vm.png similarity index 100% rename from docs/learn-more/images/service-discovery-filter-vm.png rename to docs/user-manual/images/service-discovery-filter-vm.png diff --git a/docs/learn-more/images/service-discovery-filter.png b/docs/user-manual/images/service-discovery-filter.png similarity index 100% rename from docs/learn-more/images/service-discovery-filter.png rename to docs/user-manual/images/service-discovery-filter.png diff --git a/docs/learn-more/images/service-discovery-introduction.png b/docs/user-manual/images/service-discovery-introduction.png similarity index 100% rename from docs/learn-more/images/service-discovery-introduction.png rename to docs/user-manual/images/service-discovery-introduction.png diff --git a/docs/learn-more/local-connection-documentdb-local.md b/docs/user-manual/local-connection-documentdb-local.md similarity index 84% rename from docs/learn-more/local-connection-documentdb-local.md rename to docs/user-manual/local-connection-documentdb-local.md index 1798e66c2..15bc234cb 100644 --- a/docs/learn-more/local-connection-documentdb-local.md +++ b/docs/user-manual/local-connection-documentdb-local.md @@ -1,6 +1,4 @@ - - -> **Learn More** — [Back to Local Connection](./local-connection) +> **User Manual** — [Back to User Manual](../index#user-manual) --- diff --git a/docs/learn-more/local-connection-mongodb-ru.md b/docs/user-manual/local-connection-mongodb-ru.md similarity index 91% rename from docs/learn-more/local-connection-mongodb-ru.md rename to docs/user-manual/local-connection-mongodb-ru.md index 75a2cb429..92497bd35 100644 --- a/docs/learn-more/local-connection-mongodb-ru.md +++ b/docs/user-manual/local-connection-mongodb-ru.md @@ -1,6 +1,4 @@ - - -> **Learn More** — [Back to Local Connection](./local-connection) +> **User Manual** — [Back to User Manual](../index#user-manual) --- diff --git a/docs/learn-more/local-connection.md b/docs/user-manual/local-connection.md similarity index 95% rename from docs/learn-more/local-connection.md rename to docs/user-manual/local-connection.md index 52a114200..0ca85f317 100644 --- a/docs/learn-more/local-connection.md +++ b/docs/user-manual/local-connection.md @@ -1,6 +1,4 @@ - - -> **Learn More** — [Back to Learn More Index](./index) +> **User Manual** — [Back to User Manual](../index#user-manual) --- @@ -16,7 +14,6 @@ You have two main options for connecting to a local instance: - **Use Preconfigured Options:** The extension provides ready-to-use configurations for popular local setups: - - **[Azure CosmosDB for MongoDB (RU) Emulator](./local-connection-mongodb-ru)** - **[DocumentDB Local](./local-connection-documentdb-local)** diff --git a/docs/user-manual/managing-azure-discovery.md b/docs/user-manual/managing-azure-discovery.md new file mode 100644 index 000000000..06a2155c4 --- /dev/null +++ b/docs/user-manual/managing-azure-discovery.md @@ -0,0 +1,155 @@ +> **User Manual** — [Back to User Manual](../index#user-manual) + +--- + +# Managing Azure Discovery (Accounts, Tenants, and Subscriptions) + +When using Azure-based service discovery providers in DocumentDB for VS Code, you have access to shared features for managing your Azure credentials and filtering which resources are displayed. These features are consistent across all Azure service discovery providers: + +- [Azure Cosmos DB for MongoDB (RU)](./service-discovery-azure-cosmosdb-for-mongodb-ru) +- [Azure Cosmos DB for MongoDB (vCore)](./service-discovery-azure-cosmosdb-for-mongodb-vcore) +- [Azure VMs (DocumentDB)](./service-discovery-azure-vms) + +For a general overview of service discovery, see the [Service Discovery](./service-discovery) documentation. + +--- + +## Managing Azure Accounts + +The **Manage Credentials** feature allows you to view and manage which Azure accounts are being used for service discovery within the extension. + +### How to Access + +You can access the credential management feature in two ways: + +1. **From the context menu**: Right-click on an Azure service discovery provider and select `Manage Credentials...` +2. **From the Service Discovery panel**: Click the `key icon` next to the service discovery provider name + +### Available Actions + +When you open the credential management wizard, you can: + +1. **View signed-in accounts**: See all Azure accounts currently authenticated in VS Code and available for service discovery +2. **Sign in with a different account**: Add additional Azure accounts for accessing more resources +3. **View active account details**: See which account is currently being used for a specific service discovery provider +4. **Exit without changes**: Close the wizard without making modifications + +### Account Selection + +``` +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ Azure accounts used for service discovery β”‚ +β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€ +β”‚ πŸ‘€ user1@contoso.com β”‚ +β”‚ πŸ‘€ user2@fabrikam.com β”‚ +β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€ +β”‚ πŸ” Sign in with a different account… β”‚ +β”‚ βœ–οΈ Exit without making changes β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ +``` + +### Signing Out from an Azure Account + +The credential management wizard does **not** provide a sign-out option. If you need to sign out from an Azure account: + +1. Click on the **"Accounts"** icon in the VS Code Activity Bar (bottom left corner) +2. Select the account you want to sign out from +3. Choose **"Sign Out"** + +> **⚠️ Important**: Signing out from an Azure account in VS Code will sign you out globally across VS Code, not just from the DocumentDB for VS Code extension. This may affect other extensions that use the same Azure account. + +--- + +## Filtering Azure Resources + +The **Filter** feature allows you to control which Azure resources are displayed in the Service Discovery panel by selecting specific tenants and subscriptions. + +### How to Access + +You can access the filtering feature by clicking the **funnel icon** next to the service discovery provider name in the Service Discovery panel. + +### Filtering Flow + +The filtering wizard guides you through selecting which Azure resources to display: + +#### Single-Tenant Scenario + +If you have access to only one Azure tenant, the wizard will skip tenant selection and proceed directly to subscription filtering: + +``` +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ Select subscriptions to include in β”‚ +β”‚ service discovery β”‚ +β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€ +β”‚ β˜‘οΈ Production Subscription β”‚ +β”‚ (sub-id-123) (Contoso) β”‚ +β”‚ β˜‘οΈ Development Subscription β”‚ +β”‚ (sub-id-456) (Contoso) β”‚ +β”‚ ☐ Test Subscription β”‚ +β”‚ (sub-id-789) (Contoso) β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ +``` + +#### Multi-Tenant Scenario + +If you have access to multiple Azure tenants, the wizard will first ask you to select tenants, then filter subscriptions based on your tenant selection: + +``` +Step 1: Select Tenants +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ Select tenants to include in subscription β”‚ +β”‚ discovery β”‚ +β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€ +β”‚ β˜‘οΈ Contoso β”‚ +β”‚ (tenant-id-123) contoso.onmicrosoft.com β”‚ +β”‚ β˜‘οΈ Fabrikam β”‚ +β”‚ (tenant-id-456) fabrikam.onmicrosoft.com β”‚ +β”‚ ☐ Adventure Works β”‚ +β”‚ (tenant-id-789) adventureworks.com β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ + +Step 2: Select Subscriptions (filtered by selected tenants) +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ Select subscriptions to include in β”‚ +β”‚ service discovery β”‚ +β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€ +β”‚ β˜‘οΈ Contoso Production β”‚ +β”‚ (sub-id-123) (Contoso) β”‚ +β”‚ β˜‘οΈ Contoso Development β”‚ +β”‚ (sub-id-456) (Contoso) β”‚ +β”‚ β˜‘οΈ Fabrikam Production β”‚ +β”‚ (sub-id-789) (Fabrikam) β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ +``` + +### Filter Persistence + +Your filtering selections are **automatically saved and persisted** across VS Code sessions. When you reopen the filtering wizard, your previous selections will be pre-selected, making it easy to adjust your filters incrementally. + +### How Filtering Works in Different Contexts + +The filtering behavior differs depending on how you access service discovery: + +#### From the Service Discovery Panel + +When working within the **Service Discovery** panel in the sidebar: + +- Your filter selections (tenants and subscriptions) are **applied automatically** +- Only resources from selected tenants and subscriptions are displayed +- The filter persists until you change it + +#### From the "Add New Connection" Wizard + +When adding a new connection via the **"Add New Connection"** wizard: + +- **No filtering is applied** by default +- You will see **all subscriptions from all tenants** you have access to +- You must select one subscription to continue, but the full list is available +- This ensures you can always access any resource when explicitly adding a connection + +## Related Documentation + +- [Service Discovery Overview](./service-discovery) +- [Azure CosmosDB for MongoDB (RU) Service Discovery](./service-discovery-azure-cosmosdb-for-mongodb-ru) +- [Azure CosmosDB for MongoDB (vCore) Service Discovery](./service-discovery-azure-cosmosdb-for-mongodb-vcore) +- [Azure VMs (DocumentDB) Service Discovery](./service-discovery-azure-vms) diff --git a/docs/user-manual/service-discovery-azure-cosmosdb-for-mongodb-ru.md b/docs/user-manual/service-discovery-azure-cosmosdb-for-mongodb-ru.md new file mode 100644 index 000000000..4f5b7b660 --- /dev/null +++ b/docs/user-manual/service-discovery-azure-cosmosdb-for-mongodb-ru.md @@ -0,0 +1,62 @@ +> **User Manual** — [Back to User Manual](../index#user-manual) + +--- + +# Azure CosmosDB for MongoDB (RU) Service Discovery Plugin + +The **Azure CosmosDB for MongoDB (RU)** plugin is available as part of the [Service Discovery](./service-discovery) feature in DocumentDB for VS Code. This plugin helps you find and connect to Azure Cosmos DB accounts provisioned with Request Units (RU) for the MongoDB API by handling authentication, resource discovery, and connection creation from inside the extension. + +> **πŸ“˜ Managing Azure Resources**: This provider shares common Azure management features with other Azure-based providers. See [Managing Azure Discovery (Accounts, Tenants, and Subscriptions)](./managing-azure-discovery) for detailed information about: +> +> - Managing Azure accounts and credentials +> - Filtering by tenants and subscriptions +> - Troubleshooting common issues + +## How to Access + +You can access this plugin in two ways: + +- From the `Service Discovery` panel in the extension sidebar. +- When adding a new connection, select the `Azure CosmosDB for MongoDB (RU)` option. + +![Service Discovery Activation](./images/service-discovery-activation.png) + +## How It Works + +When you use the Azure CosmosDB for MongoDB (RU) plugin, the extension performs the following steps: + +1. **Authentication:** + The plugin uses your Azure credentials available in VS Code. If needed, it will prompt you to sign in via the standard Azure sign-in flows. See [Managing Azure Accounts](./managing-azure-discovery#managing-azure-accounts) for details on managing your credentials. + +2. **Resource Filtering (Service Discovery Panel):** + When accessing this plugin from the Service Discovery panel, you can control which resources are displayed by filtering tenants and subscriptions. Click the funnel icon next to the provider name to configure filters. See [Filtering Azure Resources](./managing-azure-discovery#filtering-azure-resources) for more information. + +3. **Subscription and Account Discovery:** + - **From Service Discovery Panel**: The plugin lists subscriptions based on your configured filters, allowing you to browse RU-based Cosmos DB accounts within selected subscriptions. + - **From Add New Connection Wizard**: All subscriptions from all tenants are shown without pre-filtering. You select one subscription to view its resources. + +4. **Account Discovery:** + The provider queries Azure using the CosmosDB Management Client and filters results by the MongoDB "kind" for RU-based accounts. This ensures the list contains accounts that support the MongoDB API under RU provisioning. + +5. **Connection Options:** + - Expand an account entry to view databases and connection options. + - Save an account to your `DocumentDB Connections` list using the context menu or the save icon next to its name. + - When connecting or saving, the extension will extract credentials or connection details from Azure where available. If multiple authentication methods are supported, you will be prompted to choose one. + +For an overview of how service discovery works, see the [Service Discovery](./service-discovery) documentation. For details on managing your Azure accounts and subscriptions, refer to the [Managing Azure Subscriptions](./managing-azure-discovery) guide. + +## Managing Credentials and Filters + +This provider supports the following management features: + +- **Manage Credentials**: View and manage Azure accounts used for service discovery. Right-click the provider or click the gear icon. +- **Filter Resources**: Control which tenants and subscriptions are displayed. Click the funnel icon next to the provider name. +- **Refresh**: Reload the resource list after making changes. Click the refresh icon. + +For detailed instructions on these features, see [Managing Azure Discovery (Accounts, Tenants, and Subscriptions)](./managing-azure-discovery). + +## Feedback and Contributions + +If you have suggestions for improving this provider or would like to add support for additional resource types, please [join the discussion board](https://github.com/microsoft/vscode-documentdb/discussions) and share your feedback. + +--- diff --git a/docs/user-manual/service-discovery-azure-cosmosdb-for-mongodb-vcore.md b/docs/user-manual/service-discovery-azure-cosmosdb-for-mongodb-vcore.md new file mode 100644 index 000000000..8211faaad --- /dev/null +++ b/docs/user-manual/service-discovery-azure-cosmosdb-for-mongodb-vcore.md @@ -0,0 +1,62 @@ +> **User Manual** — [Back to User Manual](../index#user-manual) + +--- + +# Azure CosmosDB for MongoDB (vCore) Service Discovery Plugin + +The **Azure CosmosDB for MongoDB (vCore)** plugin is available as part of the [Service Discovery](./service-discovery) feature in DocumentDB for VS Code. This plugin helps you connect to your Azure CosmosDB for MongoDB (vCore) clusters by handling authentication, resource discovery, and connection management within the extension. + +> **πŸ“˜ Managing Azure Resources**: This provider shares common Azure management features with other Azure-based providers. See [Managing Azure Discovery (Accounts, Tenants, and Subscriptions)](./managing-azure-discovery) for detailed information about: +> +> - Managing Azure accounts and credentials +> - Filtering by tenants and subscriptions +> - Troubleshooting common issues + +## How to Access + +You can access this plugin in two ways: + +- Through the `Service Discovery` panel in the extension sidebar. +- When adding a new connection, select the `Azure CosmosDB for MongoDB (vCore)` option. + +![Service Discovery Activation](./images/service-discovery-activation.png) + +## How It Works + +When you use the Azure CosmosDB for MongoDB (vCore) plugin, the following steps are performed: + +1. **Authentication:** + The plugin authenticates you with Azure using your credentials. See [Managing Azure Accounts](./managing-azure-discovery#managing-azure-accounts) for details on managing your Azure accounts. + +2. **Resource Filtering (Service Discovery Panel):** + When accessing this plugin from the Service Discovery panel, you can control which resources are displayed by filtering tenants and subscriptions. Click the funnel icon next to the provider name to configure filters. See [Filtering Azure Resources](./managing-azure-discovery#filtering-azure-resources) for more information. + +3. **Subscription and Cluster Discovery:** + - **From Service Discovery Panel**: The plugin lists subscriptions based on your configured filters, allowing you to browse vCore clusters within selected subscriptions. + - **From Add New Connection Wizard**: All subscriptions from all tenants are shown without pre-filtering. You select one subscription to view its resources. + +4. **Cluster Discovery:** + The plugin enumerates all Azure CosmosDB for MongoDB (vCore) clusters available in your selected subscriptions. + +5. **Connection Options:** + - You can connect to a cluster by expanding its entry in the tree view. + - You can save a cluster to your `DocumentDB Connections` list using the context menu or by clicking the save icon next to its name. + - When connecting or saving, the extension detects the authentication methods supported by the cluster (e.g., **Username/Password** or **Entra ID**). If multiple are available, you will be prompted to choose your preferred method. + +For an overview of how service discovery works, see the [Service Discovery](./service-discovery) documentation. For details on managing your Azure accounts and subscriptions, refer to the [Managing Azure Subscriptions](./managing-azure-discovery) guide. + +## Managing Credentials and Filters + +This provider supports the following management features: + +- **Manage Credentials**: View and manage Azure accounts used for service discovery. Right-click the provider or click the gear icon. +- **Filter Resources**: Control which tenants and subscriptions are displayed. Click the funnel icon next to the provider name. +- **Refresh**: Reload the resource list after making changes. Click the refresh icon. + +For detailed instructions on these features, see [Managing Azure Discovery (Accounts, Tenants, and Subscriptions)](./managing-azure-discovery). + +## Feedback and Contributions + +If you have suggestions for improving this plugin or would like to see support for additional Azure resource types, please [join the discussion board](https://github.com/microsoft/vscode-documentdb/discussions) and share your feedback. + +--- diff --git a/docs/user-manual/service-discovery-azure-vms.md b/docs/user-manual/service-discovery-azure-vms.md new file mode 100644 index 000000000..585765522 --- /dev/null +++ b/docs/user-manual/service-discovery-azure-vms.md @@ -0,0 +1,86 @@ +> **User Manual** — [Back to User Manual](../index#user-manual) + +--- + +# Azure VMs (DocumentDB) Service Discovery Plugin + +The **Azure VMs (DocumentDB)** plugin is available as part of the [Service Discovery](./service-discovery) feature in DocumentDB for VS Code. This plugin helps you locate and connect to your virtual machines hosted in Azure that are running self-hosted DocumentDB or MongoDB instances. + +> **πŸ“˜ Managing Azure Resources**: This provider shares common Azure management features with other Azure-based providers. See [Managing Azure Discovery (Accounts, Tenants, and Subscriptions)](./managing-azure-discovery) for detailed information about: +> +> - Managing Azure accounts and credentials +> - Filtering by tenants and subscriptions +> - Troubleshooting common issues + +## How to Access + +You can access this plugin in two ways: + +- Through the `Service Discovery` panel in the extension sidebar. +- When adding a new connection, select the `Azure VMs (DocumentDB)` option. + +![Service Discovery Activation](./images/service-discovery-activation-vm.png) + +## How It Works + +When you use the Azure VMs (DocumentDB) plugin, the following steps are performed: + +1. **Authentication:** + The plugin authenticates you with Azure using your credentials. See [Managing Azure Accounts](./managing-azure-discovery#managing-azure-accounts) for details on managing your Azure accounts. + +2. **Resource Filtering (Service Discovery Panel):** + When accessing this plugin from the Service Discovery panel, you can control which resources are displayed by filtering tenants and subscriptions. Click the funnel icon next to the provider name to configure filters. See [Filtering Azure Resources](./managing-azure-discovery#filtering-azure-resources) for more information. + +3. **Subscription Discovery:** + - **From Service Discovery Panel**: The plugin lists subscriptions based on your configured filters, allowing you to browse VMs within selected subscriptions. + - **From Add New Connection Wizard**: All subscriptions from all tenants are shown without pre-filtering. You select one subscription to view its resources. + +4. **VM Filtering by Tag:** + The plugin searches for virtual machines within your selected subscriptions that have a specific tag assigned. + - **Default Tag**: By default, the tag is set to `DocumentDB` + - **Custom Tags**: When using Service Discovery from within the `DocumentDB Connections` area (via "Add New Connection"), you'll be prompted to confirm or change the tag used for filtering + - **Service Discovery Panel**: The Service Discovery panel works with the default `DocumentDB` tag, but you can change this using the filter feature + + > **πŸ’‘ Tip**: To use this plugin effectively, ensure your Azure VMs running DocumentDB or MongoDB instances are tagged appropriately. You can add or modify tags in the Azure Portal under the VM's "Tags" section. + +5. **Connection Options:** + - You can connect to a VM by expanding its entry in the tree view. + - You can save a VM to your `DocumentDB Connections` list using the context menu or by clicking the save icon next to its name. + - When connecting, you'll be prompted to provide connection details for the DocumentDB/MongoDB instance running on the VM. + +For an overview of how service discovery works, see the [Service Discovery](./service-discovery) documentation. For details on managing your Azure accounts and subscriptions, refer to the [Managing Azure Subscriptions](./managing-azure-discovery) guide. + +## Managing Credentials and Filters + +This provider supports the following management features: + +- **Manage Credentials**: View and manage Azure accounts used for service discovery. Right-click the provider or click the gear icon. +- **Filter Resources**: Control which tenants and subscriptions are displayed, and customize the VM tag filter. Click the funnel icon next to the provider name. +- **Refresh**: Reload the resource list after making changes. Click the refresh icon. + +For detailed instructions on account and subscription management, see [Managing Azure Discovery (Accounts, Tenants, and Subscriptions)](./managing-azure-discovery). + +### VM-Specific Filtering + +In addition to the standard tenant and subscription filtering, the Azure VMs provider includes tag-based filtering: + +``` +Filter Flow for Azure VMs: + +Step 1: Select Tenants (if multi-tenant) +Step 2: Select Subscriptions +Step 3: Configure VM Tag Filter +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ Enter the tag name to filter VMs β”‚ +β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€ +β”‚ DocumentDB β”‚ ← Default value +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ +``` + +The tag filter is also persisted and will be pre-filled with your last selection when you reopen the filter wizard. + +## Feedback and Contributions + +If you have suggestions for improving this plugin or would like to see support for additional VM filtering options, please [join the discussion board](https://github.com/microsoft/vscode-documentdb/discussions) and share your feedback. + +--- diff --git a/docs/learn-more/service-discovery.md b/docs/user-manual/service-discovery.md similarity index 95% rename from docs/learn-more/service-discovery.md rename to docs/user-manual/service-discovery.md index 07b621ffb..45fef396f 100644 --- a/docs/learn-more/service-discovery.md +++ b/docs/user-manual/service-discovery.md @@ -1,6 +1,4 @@ - - -> **Learn More** — [Back to Learn More Index](./index) +> **User Manual** — [Back to User Manual](../index#user-manual) --- diff --git a/l10n/bundle.l10n.json b/l10n/bundle.l10n.json index a55a2f9d0..9c0cf9f31 100644 --- a/l10n/bundle.l10n.json +++ b/l10n/bundle.l10n.json @@ -6,6 +6,7 @@ "\"registerAzureUtilsExtensionVariables\" must be called before using the vscode-azext-azureutils package.": "\"registerAzureUtilsExtensionVariables\" must be called before using the vscode-azext-azureutils package.", "\"registerUIExtensionVariables\" must be called before using the vscode-azureextensionui package.": "\"registerUIExtensionVariables\" must be called before using the vscode-azureextensionui package.", "(recently used)": "(recently used)", + "{0} is currently being used for Azure service discovery": "{0} is currently being used for Azure service discovery", "{countMany} documents have been deleted.": "{countMany} documents have been deleted.", "{countOne} document has been deleted.": "{countOne} document has been deleted.", "{documentCount} documents exported…": "{documentCount} documents exported…", @@ -36,6 +37,7 @@ "A new connection will be added to your Connections View.\nDo you want to continue?\n\nNote: You can disable these URL handling confirmations in the exension settings.": "A new connection will be added to your Connections View.\nDo you want to continue?\n\nNote: You can disable these URL handling confirmations in the exension settings.", "A value is required to proceed.": "A value is required to proceed.", "Account information is incomplete.": "Account information is incomplete.", + "Account Management Completed": "Account Management Completed", "Add new document": "Add new document", "Advanced": "Advanced", "All available providers have been added already.": "All available providers have been added already.", @@ -45,10 +47,11 @@ "An item with id \"{0}\" already exists for workspace \"{1}\".": "An item with id \"{0}\" already exists for workspace \"{1}\".", "API version \"{0}\" for extension id \"{1}\" is no longer supported. Minimum version is \"{2}\".": "API version \"{0}\" for extension id \"{1}\" is no longer supported. Minimum version is \"{2}\".", "API: Registered new migration provider: \"{providerId}\" - \"{providerLabel}\"": "API: Registered new migration provider: \"{providerId}\" - \"{providerLabel}\"", + "Applying Azure discovery filters…": "Applying Azure discovery filters…", "Are you sure?": "Are you sure?", "Attempting to authenticate with \"{cluster}\"…": "Attempting to authenticate with \"{cluster}\"…", "Authenticate to connect with your DocumentDB cluster": "Authenticate to connect with your DocumentDB cluster", - "Authenticate to connect with your MongoDB cluster": "Authenticate to connect with your MongoDB cluster", + "Authenticate to Connect with Your DocumentDB Cluster": "Authenticate to Connect with Your DocumentDB Cluster", "Authenticate using a username and password": "Authenticate using a username and password", "Authenticate using Microsoft Entra ID (Azure AD)": "Authenticate using Microsoft Entra ID (Azure AD)", "Authentication configuration is missing for \"{cluster}\".": "Authentication configuration is missing for \"{cluster}\".", @@ -56,25 +59,36 @@ "Authentication data (properties.connectionString) is missing for \"{cluster}\".": "Authentication data (properties.connectionString) is missing for \"{cluster}\".", "Authentication is required to run this action.": "Authentication is required to run this action.", "Authentication is required to use this migration provider.": "Authentication is required to use this migration provider.", + "Azure account added successfully.": "Azure account added successfully.", + "Azure account management failed: {0}": "Azure account management failed: {0}", + "Azure account management was cancelled by user.": "Azure account management was cancelled by user.", + "Azure account management wizard completed.": "Azure account management wizard completed.", + "Azure accounts used for service discovery": "Azure accounts used for service discovery", "Azure Activity": "Azure Activity", "Azure Cosmos DB for MongoDB (RU)": "Azure Cosmos DB for MongoDB (RU)", "Azure Cosmos DB for MongoDB (RU) Emulator": "Azure Cosmos DB for MongoDB (RU) Emulator", "Azure Cosmos DB for MongoDB (vCore)": "Azure Cosmos DB for MongoDB (vCore)", + "Azure discovery filters applied successfully.": "Azure discovery filters applied successfully.", "Azure Service Discovery": "Azure Service Discovery", "Azure Service Discovery for MongoDB RU": "Azure Service Discovery for MongoDB RU", + "Azure sign-in completed successfully": "Azure sign-in completed successfully", + "Azure sign-in failed: {0}": "Azure sign-in failed: {0}", + "Azure sign-in was cancelled or failed": "Azure sign-in was cancelled or failed", "Azure VM Service Discovery": "Azure VM Service Discovery", "Azure VM: Attempting to authenticate with \"{vmName}\"…": "Azure VM: Attempting to authenticate with \"{vmName}\"…", "Azure VM: Connected to \"{vmName}\" as \"{username}\".": "Azure VM: Connected to \"{vmName}\" as \"{username}\".", "Azure VM: Connecting to \"{vmName}\" as \"{username}\"…": "Azure VM: Connecting to \"{vmName}\" as \"{username}\"…", "Azure VMs (DocumentDB)": "Azure VMs (DocumentDB)", "Back": "Back", + "Back to account selection": "Back to account selection", "Browse to {mongoExecutableFileName}": "Browse to {mongoExecutableFileName}", "Cancel": "Cancel", "Change page size": "Change page size", + "Changelog": "Changelog", "Check document syntax": "Check document syntax", "Choose a cluster…": "Choose a cluster…", "Choose a RU cluster…": "Choose a RU cluster…", - "Choose a subscription…": "Choose a subscription…", + "Choose a Subscription…": "Choose a Subscription…", "Choose a Virtual Machine…": "Choose a Virtual Machine…", "Choose the data migration provider…": "Choose the data migration provider…", "Choose the migration action…": "Choose the migration action…", @@ -83,6 +97,7 @@ "Click here to retry": "Click here to retry", "Click here to update credentials": "Click here to update credentials", "Click to view resource": "Click to view resource", + "Close the account management wizard": "Close the account management wizard", "Cluster support unknown $(info)": "Cluster support unknown $(info)", "Collection name cannot begin with the system. prefix (Reserved for internal use).": "Collection name cannot begin with the system. prefix (Reserved for internal use).", "Collection name cannot contain .system.": "Collection name cannot contain .system.", @@ -90,8 +105,13 @@ "Collection name cannot contain the null character.": "Collection name cannot contain the null character.", "Collection name is required.": "Collection name is required.", "Collection names should begin with an underscore or a letter character.": "Collection names should begin with an underscore or a letter character.", + "Configure Azure Discovery Filters": "Configure Azure Discovery Filters", "Configure Azure VM Discovery Filters": "Configure Azure VM Discovery Filters", + "Configure Subscription Filter": "Configure Subscription Filter", + "Configure Tenant & Subscription Filters": "Configure Tenant & Subscription Filters", "Configure TLS/SSL Security": "Configure TLS/SSL Security", + "Configuring subscription filtering…": "Configuring subscription filtering…", + "Configuring tenant filtering…": "Configuring tenant filtering…", "Connect to a database": "Connect to a database", "Connected to \"{name}\"": "Connected to \"{name}\"", "Connected to the cluster \"{cluster}\".": "Connected to the cluster \"{cluster}\".", @@ -111,6 +131,7 @@ "Create Collection…": "Create Collection…", "Create database": "Create database", "Create Database…": "Create Database…", + "Create Free Azure DocumentDB Cluster": "Create Free Azure DocumentDB Cluster", "Create new {0}...": "Create new {0}...", "Creating \"{nodeName}\"…": "Creating \"{nodeName}\"…", "Creating {0}...": "Creating {0}...", @@ -139,12 +160,15 @@ "Document must be an object.": "Document must be an object.", "Document must be an object. Skipping…": "Document must be an object. Skipping…", "DocumentDB and MongoDB Accounts": "DocumentDB and MongoDB Accounts", + "DocumentDB Documentation": "DocumentDB Documentation", + "DocumentDB for VS Code is not signed in to Azure": "DocumentDB for VS Code is not signed in to Azure", "DocumentDB Local": "DocumentDB Local", "Documents": "Documents", "Does this occur consistently? ": "Does this occur consistently? ", "Don't Ask Again": "Don't Ask Again", "Don't upload": "Don't upload", "Don't warn again": "Don't warn again", + "e.g., 12345678-1234-1234-1234-123456789012 or 12345678123412341234123456789012": "e.g., 12345678-1234-1234-1234-123456789012 or 12345678123412341234123456789012", "e.g., DocumentDB, Environment, Project": "e.g., DocumentDB, Environment, Project", "Edit selected document": "Edit selected document", "Element with id of {rootId} not found.": "Element with id of {rootId} not found.", @@ -160,6 +184,7 @@ "Enter the password for {experience}": "Enter the password for {experience}", "Enter the port number": "Enter the port number", "Enter the port number your DocumentDB uses. The default port: {defaultPort}.": "Enter the port number your DocumentDB uses. The default port: {defaultPort}.", + "Enter the tenant ID (GUID)": "Enter the tenant ID (GUID)", "Enter the username": "Enter the username", "Enter the username for {experience}": "Enter the username for {experience}", "Entra ID for Azure Cosmos DB for MongoDB (vCore)": "Entra ID for Azure Cosmos DB for MongoDB (vCore)", @@ -182,6 +207,8 @@ "Executing the command in shell…": "Executing the command in shell…", "Execution timed out": "Execution timed out", "Execution timed out.": "Execution timed out.", + "Exit": "Exit", + "Exit without making changes": "Exit without making changes", "Expected a file name \"{0}\", but the selected filename is \"{1}\"": "Expected a file name \"{0}\", but the selected filename is \"{1}\"", "Expecting parentheses or quotes at \"{text}\"": "Expecting parentheses or quotes at \"{text}\"", "Export": "Export", @@ -192,6 +219,7 @@ "Exporting documents": "Exporting documents", "Exporting…": "Exporting…", "Extension dependency with id \"{0}\" must be updated.": "Extension dependency with id \"{0}\" must be updated.", + "Extension Documentation": "Extension Documentation", "Failed to access Azure Databases VS Code Extension storage for migration: {error}": "Failed to access Azure Databases VS Code Extension storage for migration: {error}", "Failed to connect to \"{cluster}\"": "Failed to connect to \"{cluster}\"", "Failed to connect to VM \"{vmName}\"": "Failed to connect to VM \"{vmName}\"", @@ -211,6 +239,7 @@ "Failed to parse secrets for key {0}:": "Failed to parse secrets for key {0}:", "Failed to process URI: {0}": "Failed to process URI: {0}", "Failed to rename the connection.": "Failed to rename the connection.", + "Failed to retrieve Azure accounts: {0}": "Failed to retrieve Azure accounts: {0}", "Failed to save credentials for \"{cluster}\".": "Failed to save credentials for \"{cluster}\".", "Failed to save credentials.": "Failed to save credentials.", "Failed to store secrets for key {0}:": "Failed to store secrets for key {0}:", @@ -269,17 +298,23 @@ "Level up": "Level up", "Load More...": "Load More...", "Loading \"{0}\"...": "Loading \"{0}\"...", + "Loading Azure Accounts Used for Service Discovery…": "Loading Azure Accounts Used for Service Discovery…", "Loading cluster details for \"{cluster}\"": "Loading cluster details for \"{cluster}\"", - "Loading clusters…": "Loading clusters…", + "Loading Clusters…": "Loading Clusters…", "Loading Content": "Loading Content", "Loading document {num} of {countUri}": "Loading document {num} of {countUri}", "Loading documents…": "Loading documents…", + "Loading migration actions…": "Loading migration actions…", "Loading resources...": "Loading resources...", - "Loading RU clusters…": "Loading RU clusters…", - "Loading subscriptions…": "Loading subscriptions…", + "Loading Subscriptions…": "Loading Subscriptions…", + "Loading Tenant Filter Options…": "Loading Tenant Filter Options…", + "Loading Tenants and Subscription Data…": "Loading Tenants and Subscription Data…", + "Loading Tenants…": "Loading Tenants…", "Loading Virtual Machines…": "Loading Virtual Machines…", "Loading...": "Loading...", "Location": "Location", + "Manage Azure Accounts": "Manage Azure Accounts", + "Manually enter a custom tenant ID": "Manually enter a custom tenant ID", "Migration of connections from the Azure Databases VS Code Extension to the DocumentDB for VS Code Extension completed: {migratedCount} connections migrated.": "Migration of connections from the Azure Databases VS Code Extension to the DocumentDB for VS Code Extension completed: {migratedCount} connections migrated.", "Mongo Shell connected.": "Mongo Shell connected.", "Mongo Shell Error: {error}": "Mongo Shell Error: {error}", @@ -294,6 +329,7 @@ "No authentication method selected.": "No authentication method selected.", "No authentication methods available for \"{cluster}\".": "No authentication methods available for \"{cluster}\".", "No Azure subscription found for this tree item.": "No Azure subscription found for this tree item.", + "No Azure Subscriptions Found": "No Azure Subscriptions Found", "No Azure VMs found with tag \"{tagName}\" in subscription \"{subscriptionName}\".": "No Azure VMs found with tag \"{tagName}\" in subscription \"{subscriptionName}\".", "No collection selected.": "No collection selected.", "No commands found in this document.": "No commands found in this document.", @@ -308,9 +344,13 @@ "No scope was provided for the role assignment.": "No scope was provided for the role assignment.", "No session found for id {sessionId}": "No session found for id {sessionId}", "No subscriptions found": "No subscriptions found", + "No subscriptions found for the selected tenants. Please adjust your tenant selection or check your Azure permissions.": "No subscriptions found for the selected tenants. Please adjust your tenant selection or check your Azure permissions.", + "No tenants found. Please try signing in again or check your Azure permissions.": "No tenants found. Please try signing in again or check your Azure permissions.", + "No tenants selected. Azure discovery will be filtered to exclude all tenant results.": "No tenants selected. Azure discovery will be filtered to exclude all tenant results.", "Not connected to any MongoDB database.": "Not connected to any MongoDB database.", "Note: This confirmation type can be configured in the extension settings.": "Note: This confirmation type can be configured in the extension settings.", "Note: You can disable these URL handling confirmations in the extension settings.": "Note: You can disable these URL handling confirmations in the extension settings.", + "OK": "OK", "Open Collection": "Open Collection", "Open installation page": "Open installation page", "Opening DocumentDB connection…": "Opening DocumentDB connection…", @@ -322,6 +362,7 @@ "Please connect to a MongoDB database before running a Scrapbook command.": "Please connect to a MongoDB database before running a Scrapbook command.", "Please edit the connection string.": "Please edit the connection string.", "Please enter a new connection name.": "Please enter a new connection name.", + "Please enter a valid tenant ID in GUID format (e.g., 12345678-1234-1234-1234-123456789012 or 12345678123412341234123456789012)": "Please enter a valid tenant ID in GUID format (e.g., 12345678-1234-1234-1234-123456789012 or 12345678123412341234123456789012)", "Please enter the password for the user \"{username}\"": "Please enter the password for the user \"{username}\"", "Please enter the username": "Please enter the username", "Please enter the word \"{expectedConfirmationWord}\" to confirm the operation.": "Please enter the word \"{expectedConfirmationWord}\" to confirm the operation.", @@ -335,13 +376,16 @@ "Provider \"{0}\" does not have resource type \"{1}\".": "Provider \"{0}\" does not have resource type \"{1}\".", "Refresh": "Refresh", "Refresh current view": "Refresh current view", + "Refreshing Azure discovery tree…": "Refreshing Azure discovery tree…", "Registering Providers...": "Registering Providers...", "Reload original document from the database": "Reload original document from the database", "Reload Window": "Reload Window", "Remind Me Later": "Remind Me Later", "Rename Connection": "Rename Connection", + "Report a Bug": "Report a Bug", "Report an issue": "Report an issue", "Resource group \"{0}\" already exists in subscription \"{1}\".": "Resource group \"{0}\" already exists in subscription \"{1}\".", + "Return to the account list": "Return to the account list", "Reusing active connection for \"{cluster}\".": "Reusing active connection for \"{cluster}\".", "Revisit connection details and try again.": "Revisit connection details and try again.", "Role assignment \"{0}\" created for the {2} resource \"{1}\".": "Role assignment \"{0}\" created for the {2} resource \"{1}\".", @@ -357,41 +401,59 @@ "Select {0}": "Select {0}", "Select {mongoExecutableFileName}": "Select {mongoExecutableFileName}", "Select a location for new resources.": "Select a location for new resources.", + "Select a tenant for Microsoft Entra ID authentication": "Select a tenant for Microsoft Entra ID authentication", "Select a workspace folder": "Select a workspace folder", "Select an authentication method": "Select an authentication method", "Select an authentication method for \"{resourceName}\"": "Select an authentication method for \"{resourceName}\"", "Select Existing": "Select Existing", "Select resource": "Select resource", "Select subscription": "Select subscription", - "Select Subscriptions": "Select Subscriptions", - "Select Subscriptions to Display": "Select Subscriptions to Display", + "Select subscriptions to include in service discovery": "Select subscriptions to include in service discovery", "Select Subscriptions...": "Select Subscriptions...", + "Select tenants to include in subscription discovery": "Select tenants to include in subscription discovery", "Select the error you would like to report": "Select the error you would like to report", "Select the local connection type…": "Select the local connection type…", + "Selected subscriptions: {0}": "Selected subscriptions: {0}", + "Selected tenants: {0}": "Selected tenants: {0}", "Service Discovery": "Service Discovery", - "Sign In": "Sign In", + "Sign in to Azure to continue…": "Sign in to Azure to continue…", "Sign in to Azure...": "Sign in to Azure...", + "Sign in to other Azure accounts to access more subscriptions": "Sign in to other Azure accounts to access more subscriptions", + "Sign in to other Azure accounts to access more tenants": "Sign in to other Azure accounts to access more tenants", + "Sign in with a different account…": "Sign in with a different account…", "Signing out programmatically is not supported. You must sign out by selecting the account in the Accounts menu and choosing Sign Out.": "Signing out programmatically is not supported. You must sign out by selecting the account in the Accounts menu and choosing Sign Out.", "Skip for now": "Skip for now", "Small breadcrumb example with buttons": "Small breadcrumb example with buttons", "Some items could not be displayed": "Some items could not be displayed", "Specified character lengths should be 1 character or greater.": "Specified character lengths should be 1 character or greater.", "Started executable: \"{command}\". Connecting to host…": "Started executable: \"{command}\". Connecting to host…", + "Starting Azure account management wizard": "Starting Azure account management wizard", + "Starting Azure sign-in process…": "Starting Azure sign-in process…", "Starting executable: \"{command}\"": "Starting executable: \"{command}\"", "Starts with mongodb:// or mongodb+srv://": "Starts with mongodb:// or mongodb+srv://", "subscription": "subscription", + "Subscription ID: {0}": "Subscription ID: {0}", + "Successfully configured subscription filtering. Selected {0} subscription(s)": "Successfully configured subscription filtering. Selected {0} subscription(s)", + "Successfully configured tenant filtering. Selected {0} tenant(s)": "Successfully configured tenant filtering. Selected {0} tenant(s)", "Successfully created resource group \"{0}\".": "Successfully created resource group \"{0}\".", "Successfully created storage account \"{0}\".": "Successfully created storage account \"{0}\".", "Successfully created user assigned identity \"{0}\".": "Successfully created user assigned identity \"{0}\".", + "Suggest a Feature": "Suggest a Feature", "Sure!": "Sure!", "Switch to the new \"Connections View\"…": "Switch to the new \"Connections View\"…", "Table View": "Table View", "Tag can only contain alphanumeric characters, underscores, periods, and hyphens.": "Tag can only contain alphanumeric characters, underscores, periods, and hyphens.", "Tag cannot be empty.": "Tag cannot be empty.", "Tag cannot be longer than 256 characters.": "Tag cannot be longer than 256 characters.", + "Tenant ID cannot be empty": "Tenant ID cannot be empty", + "Tenant ID: {0}": "Tenant ID: {0}", + "Tenant Name: {0}": "Tenant Name: {0}", "The \"{databaseId}\" database has been deleted.": "The \"{databaseId}\" database has been deleted.", "The \"{name}\" database has been created.": "The \"{name}\" database has been created.", "The \"{newCollectionName}\" collection has been created.": "The \"{newCollectionName}\" collection has been created.", + "The account management flow has completed.\n\nPlease try Service Discovery again to see your available subscriptions.": "The account management flow has completed.\n\nPlease try Service Discovery again to see your available subscriptions.", + "The account management flow has completed.\n\nPlease try the connection flow again to see your available tenants.": "The account management flow has completed.\n\nPlease try the connection flow again to see your available tenants.", + "The account management flow has completed.\n\nPlease try updating the credentials again to see your available tenants.": "The account management flow has completed.\n\nPlease try updating the credentials again to see your available tenants.", "The collection \"{0}\" already exists in the database \"{1}\".": "The collection \"{0}\" already exists in the database \"{1}\".", "The collection \"{collectionId}\" has been deleted.": "The collection \"{collectionId}\" has been deleted.", "The connection string has been copied to the clipboard": "The connection string has been copied to the clipboard", @@ -445,6 +507,7 @@ "Unexpected status code: {0}": "Unexpected status code: {0}", "Unknown error": "Unknown error", "Unknown Error": "Unknown Error", + "Unknown tenant": "Unknown tenant", "Unrecognized node type encountered. Could not parse {constructorCall} as part of {functionCall}": "Unrecognized node type encountered. Could not parse {constructorCall} as part of {functionCall}", "Unrecognized node type encountered. We could not parse {text}": "Unrecognized node type encountered. We could not parse {text}", "Unrecognized token. Token text: {text}": "Unrecognized token. Token text: {text}", @@ -463,7 +526,6 @@ "Upload": "Upload", "URL handling aborted. Connection was unsuccessful or the specified database/collection does not exist.": "URL handling aborted. Connection was unsuccessful or the specified database/collection does not exist.", "Use anyway": "Use anyway", - "User is not signed in to Azure.": "User is not signed in to Azure.", "Username and Password": "Username and Password", "Username cannot be empty": "Username cannot be empty", "Username for {resource}": "Username for {resource}", @@ -472,10 +534,12 @@ "Validate": "Validate", "View in Marketplace": "View in Marketplace", "View selected document": "View selected document", + "Viewing Azure account information for: {0}": "Viewing Azure account information for: {0}", "Waiting for Azure sign-in...": "Waiting for Azure sign-in...", "WARNING: Cannot create resource group \"{0}\" because the selected subscription is a concierge subscription. Using resource group \"{1}\" instead.": "WARNING: Cannot create resource group \"{0}\" because the selected subscription is a concierge subscription. Using resource group \"{1}\" instead.", "WARNING: Provider \"{0}\" does not support location \"{1}\". Using \"{2}\" instead.": "WARNING: Provider \"{0}\" does not support location \"{1}\". Using \"{2}\" instead.", "WARNING: Resource does not support extended location \"{0}\". Using \"{1}\" instead.": "WARNING: Resource does not support extended location \"{0}\". Using \"{1}\" instead.", + "What's New": "What's New", "Where to save the exported documents?": "Where to save the exported documents?", "with Popover": "with Popover", "Working…": "Working…", @@ -483,11 +547,11 @@ "Write error: {0}": "Write error: {0}", "Yes": "Yes", "Yes, continue": "Yes, continue", + "Yes, Manage Accounts": "Yes, Manage Accounts", "Yes, open Collection View": "Yes, open Collection View", "Yes, open connection": "Yes, open connection", "Yes, save my credentials": "Yes, save my credentials", "You are not signed in to an Azure account. Please sign in.": "You are not signed in to an Azure account. Please sign in.", - "You are not signed in to Azure. Sign in and retry.": "You are not signed in to Azure. Sign in and retry.", "You are not signed in to the MongoDB Cluster. Please sign in (by expanding the node \"{0}\") and try again.": "You are not signed in to the MongoDB Cluster. Please sign in (by expanding the node \"{0}\") and try again.", "You can connect to a different DocumentDB by:": "You can connect to a different DocumentDB by:", "You clicked a link that wants to open a DocumentDB connection in VS Code.": "You clicked a link that wants to open a DocumentDB connection in VS Code.", diff --git a/package-lock.json b/package-lock.json index 31e62cf9d..07b58bce8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "vscode-documentdb", - "version": "0.4.1", + "version": "0.5.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "vscode-documentdb", - "version": "0.4.1", + "version": "0.5.0", "license": "SEE LICENSE IN LICENSE.md", "dependencies": { "@azure/arm-compute": "^22.4.0", @@ -18258,9 +18258,9 @@ } }, "node_modules/tar-fs": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.3.tgz", - "integrity": "sha512-090nwYJDmlhwFwEW3QQl+vaNnxsO2yVsd45eTKRBzSzu+hlb1w2K9inVq5b0ngXuLVqQ4ApvsUHHnu/zQNkWAg==", + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.4.tgz", + "integrity": "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==", "dev": true, "license": "MIT", "optional": true, diff --git a/package.json b/package.json index d04ceb85b..812a698b1 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "vscode-documentdb", - "version": "0.4.1", + "version": "0.5.0", "aiKey": "0c6ae279ed8443289764825290e4f9e2-1a736e7c-1324-4338-be46-fc2a58ae4d14-7255", "publisher": "ms-azuretools", "displayName": "DocumentDB for VS Code", @@ -211,7 +211,7 @@ "icon": "$(plug)" }, { - "id": "documentDBHelp", + "id": "helpAndFeedbackView", "name": "Help and Feedback", "visibility": "collapsed", "icon": "$(question)" @@ -327,6 +327,13 @@ "title": "Save To DocumentDB Connections", "icon": "$(save)" }, + { + "//": "[DiscoveryView] Content Provider: Manage Credentials", + "category": "DocumentDB", + "command": "vscode-documentdb.command.discoveryView.manageCredentials", + "title": "Manage Credentials…", + "icon": "$(key)" + }, { "//": "[DiscoveryView] Filter Provider Content", "category": "DocumentDB", @@ -537,6 +544,11 @@ "when": "view == connectionsView && viewItem =~ /\\btreeitem_documentdbcluster\\b/i", "group": "0@5" }, + { + "command": "vscode-documentdb.command.discoveryView.addConnectionToConnectionsView", + "when": "view == discoveryView && viewItem =~ /\\btreeitem_documentdbcluster\\b/i", + "group": "0@1" + }, { "command": "vscode-documentdb.command.discoveryView.removeRegistry", "when": "view == discoveryView && viewItem =~ /\\brootItem\\b/i", @@ -548,29 +560,39 @@ "group": "0@1" }, { - "command": "vscode-documentdb.command.discoveryView.addConnectionToConnectionsView", - "when": "view == discoveryView && viewItem =~ /\\btreeitem_documentdbcluster\\b/i", - "group": "inline" + "command": "vscode-documentdb.command.discoveryView.manageCredentials", + "when": "view == discoveryView && viewItem =~ /\\benableManageCredentialsCommand\\b/i", + "group": "inline@1" }, { - "command": "vscode-documentdb.command.discoveryView.learnMoreAboutProvider", - "when": "view == discoveryView && viewItem =~ /\\benableLearnMoreCommand\\b/i", - "group": "1@3" + "command": "vscode-documentdb.command.discoveryView.manageCredentials", + "when": "view == discoveryView && viewItem =~ /\\benableManageCredentialsCommand\\b/i", + "group": "1@2" }, { - "command": "vscode-documentdb.command.discoveryView.learnMoreAboutProvider", - "when": "view == discoveryView && viewItem =~ /\\benableLearnMoreCommand\\b/i", + "command": "vscode-documentdb.command.discoveryView.addConnectionToConnectionsView", + "when": "view == discoveryView && viewItem =~ /\\btreeitem_documentdbcluster\\b/i", "group": "inline" }, { "command": "vscode-documentdb.command.discoveryView.filterProviderContent", "when": "view == discoveryView && viewItem =~ /\\benableFilterCommand\\b/i", - "group": "1@2" + "group": "1@3" }, { "command": "vscode-documentdb.command.discoveryView.filterProviderContent", "when": "view == discoveryView && viewItem =~ /\\benableFilterCommand\\b/i", - "group": "inline" + "group": "inline@2" + }, + { + "command": "vscode-documentdb.command.discoveryView.learnMoreAboutProvider", + "when": "view == discoveryView && viewItem =~ /\\benableLearnMoreCommand\\b/i", + "group": "1@4" + }, + { + "command": "vscode-documentdb.command.discoveryView.learnMoreAboutProvider", + "when": "view == discoveryView && viewItem =~ /\\benableLearnMoreCommand\\b/i", + "group": "inline@3" }, { "command": "vscode-documentdb.command.azureResourcesView.addConnectionToConnectionsView", @@ -716,6 +738,10 @@ "command": "vscode-documentdb.command.discoveryView.addConnectionToConnectionsView", "when": "never" }, + { + "command": "vscode-documentdb.command.discoveryView.manageCredentials", + "when": "never" + }, { "command": "vscode-documentdb.command.discoveryView.filterProviderContent", "when": "never" diff --git a/src/commands/addConnectionFromRegistry/addConnectionFromRegistry.ts b/src/commands/addConnectionFromRegistry/addConnectionFromRegistry.ts index 4ac5dd347..8ae66db20 100644 --- a/src/commands/addConnectionFromRegistry/addConnectionFromRegistry.ts +++ b/src/commands/addConnectionFromRegistry/addConnectionFromRegistry.ts @@ -66,7 +66,7 @@ export async function addConnectionFromRegistry(context: IActionContext, node: C } const parsedCS = new DocumentDBConnectionString(credentials.connectionString); - const username = credentials.connectionUser || parsedCS.username; + const username = credentials.nativeAuthConfig?.connectionUser || parsedCS.username; parsedCS.username = ''; const joinedHosts = [...parsedCS.hosts].sort().join(','); @@ -77,7 +77,9 @@ export async function addConnectionFromRegistry(context: IActionContext, node: C const existingDuplicateConnection = existingConnections.find((existingConnection) => { const existingCS = new DocumentDBConnectionString(existingConnection.secrets.connectionString); const existingHostsJoined = [...existingCS.hosts].sort().join(','); - return existingConnection.secrets.userName === username && existingHostsJoined === joinedHosts; + // Use nativeAuthConfig for comparison + const existingUsername = existingConnection.secrets.nativeAuthConfig?.connectionUser; + return existingUsername === username && existingHostsJoined === joinedHosts; }); if (existingDuplicateConnection) { @@ -145,8 +147,8 @@ export async function addConnectionFromRegistry(context: IActionContext, node: C properties: { api: API.DocumentDB, availableAuthMethods: credentials.availableAuthMethods }, secrets: { connectionString: parsedCS.toString(), - userName: credentials.connectionUser, - password: credentials.connectionPassword, + nativeAuthConfig: credentials.nativeAuthConfig, + entraIdAuthConfig: credentials.entraIdAuthConfig, }, }; diff --git a/src/commands/chooseDataMigrationExtension/chooseDataMigrationExtension.ts b/src/commands/chooseDataMigrationExtension/chooseDataMigrationExtension.ts index 350102d26..469ebecf7 100644 --- a/src/commands/chooseDataMigrationExtension/chooseDataMigrationExtension.ts +++ b/src/commands/chooseDataMigrationExtension/chooseDataMigrationExtension.ts @@ -105,8 +105,8 @@ export async function chooseDataMigrationExtension(context: IActionContext, node } const parsedCS = new DocumentDBConnectionString(credentials.connectionString); - parsedCS.username = credentials?.connectionUser ?? ''; - parsedCS.password = credentials?.connectionPassword ?? ''; + parsedCS.username = CredentialCache.getConnectionUser(node.cluster.id) ?? ''; + parsedCS.password = CredentialCache.getConnectionPassword(node.cluster.id) ?? ''; const options = { connectionString: parsedCS.toString(), @@ -122,33 +122,48 @@ export async function chooseDataMigrationExtension(context: IActionContext, node // No actions available, execute default action await selectedProvider.executeAction(options); } else { - // Extend actions with Learn More option if provider has a learn more URL - const extendedActions: (QuickPickItem & { - id: string; - learnMoreUrl?: string; - requiresAuthentication?: boolean; - })[] = [...availableActions]; - - const learnMoreUrl = selectedProvider.getLearnMoreUrl?.(); - - if (learnMoreUrl) { - extendedActions.push( - { id: 'separator', label: '', kind: QuickPickItemKind.Separator }, - { - id: 'learnMore', - label: l10n.t('Learn more…'), - detail: l10n.t('Learn more about {0}.', selectedProvider.label), - learnMoreUrl, - alwaysShow: true, - }, - ); - } + // Create async function to provide better loading UX and debugging experience + const getActionQuickPickItems = async (): Promise< + (QuickPickItem & { + id: string; + learnMoreUrl?: string; + requiresAuthentication?: boolean; + })[] + > => { + // Get available actions from the provider + const actions = await selectedProvider.getAvailableActions(options); + + // Extend actions with Learn More option if provider has a learn more URL + const extendedActions: (QuickPickItem & { + id: string; + learnMoreUrl?: string; + requiresAuthentication?: boolean; + })[] = [...actions]; + + const learnMoreUrl = selectedProvider.getLearnMoreUrl?.(); + + if (learnMoreUrl) { + extendedActions.push( + { id: 'separator', label: '', kind: QuickPickItemKind.Separator }, + { + id: 'learnMore', + label: l10n.t('Learn more…'), + detail: l10n.t('Learn more about {0}.', selectedProvider.label), + learnMoreUrl, + alwaysShow: true, + }, + ); + } + + return extendedActions; + }; // Show action picker to user - const selectedAction = await context.ui.showQuickPick(extendedActions, { + const selectedAction = await context.ui.showQuickPick(getActionQuickPickItems(), { placeHolder: l10n.t('Choose the migration action…'), stepName: 'selectMigrationAction', suppressPersistence: true, + loadingPlaceHolder: l10n.t('Loading migration actions…'), }); if (selectedAction.id === 'learnMore') { diff --git a/src/commands/copyConnectionString/copyConnectionString.ts b/src/commands/copyConnectionString/copyConnectionString.ts index f5b0da958..747c65e9f 100644 --- a/src/commands/copyConnectionString/copyConnectionString.ts +++ b/src/commands/copyConnectionString/copyConnectionString.ts @@ -30,7 +30,7 @@ export async function copyConnectionString(context: IActionContext, node: Cluste } const parsedConnectionString = new DocumentDBConnectionString(credentials.connectionString); - parsedConnectionString.username = credentials.connectionUser ?? ''; + parsedConnectionString.username = credentials.nativeAuthConfig?.connectionUser ?? ''; if (credentials.selectedAuthMethod === AuthMethodId.MicrosoftEntraID) { parsedConnectionString.searchParams.set('authMechanism', 'MONGODB-OIDC'); diff --git a/src/commands/filterProviderContent/filterProviderContent.ts b/src/commands/discoveryService.filterProviderContent/filterProviderContent.ts similarity index 96% rename from src/commands/filterProviderContent/filterProviderContent.ts rename to src/commands/discoveryService.filterProviderContent/filterProviderContent.ts index 6473edc24..fcef42282 100644 --- a/src/commands/filterProviderContent/filterProviderContent.ts +++ b/src/commands/discoveryService.filterProviderContent/filterProviderContent.ts @@ -33,6 +33,7 @@ export async function filterProviderContent(context: IActionContext, node: TreeE } const providerId = idSections[1]; + context.telemetry.properties.discoveryProviderId = providerId; const provider = DiscoveryService.getProvider(providerId); if (!provider?.configureTreeItemFilter) { diff --git a/src/commands/discoveryService.manageCredentials/manageCredentials.ts b/src/commands/discoveryService.manageCredentials/manageCredentials.ts new file mode 100644 index 000000000..7de9b86b3 --- /dev/null +++ b/src/commands/discoveryService.manageCredentials/manageCredentials.ts @@ -0,0 +1,59 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { type IActionContext } from '@microsoft/vscode-azext-utils'; +import { l10n } from 'vscode'; +import { Views } from '../../documentdb/Views'; +import { ext } from '../../extensionVariables'; +import { DiscoveryService } from '../../services/discoveryServices'; +import { type TreeElement } from '../../tree/TreeElement'; + +export async function manageCredentials(context: IActionContext, node: TreeElement): Promise { + if (!node) { + throw new Error(l10n.t('No node selected.')); + } + /** + * We can extract the provider id from the node instead of hardcoding it + * by accessing the node.id and looking from the start for the id in the following format + * + * node.id = '${Views.DiscoveryView}//potential/elements/thisNodesId' + * + * first, we'll verify that the id is in the format expected, if not, we'll return with an error + */ + + const idSections = node.id.split('/'); + const isValidFormat = + idSections.length >= 2 && idSections[0] === String(Views.DiscoveryView) && idSections[1].length > 0; + + if (!isValidFormat) { + context.telemetry.properties.result = 'Failed'; + context.telemetry.properties.errorReason = 'invalidNodeIdFormat'; + ext.outputChannel.error('Internal error: Node id is not in the expected format.'); + return; + } + + const providerId = idSections[1]; + context.telemetry.properties.discoveryProviderId = providerId; + const provider = DiscoveryService.getProvider(providerId); + + if (!provider?.configureCredentials) { + context.telemetry.properties.result = 'Failed'; + context.telemetry.properties.errorReason = 'noConfigureCredentialsFunction'; + ext.outputChannel.error(`No management function provided by the provider with the id "${providerId}".`); + return; + } + + try { + // Call the filter function provided by the provider + await provider.configureCredentials(context, node as TreeElement); + + // Refresh the discovery branch data provider to show the updated list + ext.discoveryBranchDataProvider.refresh(node as TreeElement); + } catch (error) { + context.telemetry.properties.result = 'Failed'; + context.telemetry.properties.errorReason = 'configureCredentialsThrew'; + throw error; + } +} diff --git a/src/commands/helpAndFeedback.openUrl/openUrl.ts b/src/commands/helpAndFeedback.openUrl/openUrl.ts new file mode 100644 index 000000000..0d7f734bd --- /dev/null +++ b/src/commands/helpAndFeedback.openUrl/openUrl.ts @@ -0,0 +1,22 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { type IActionContext } from '@microsoft/vscode-azext-utils'; +import { openUrl } from '../../utils/openUrl'; + +/** + * Opens a URL from the Help and Feedback view with telemetry tracking. + * + * @param context - Action context for telemetry + * @param url - The URL to open + */ +export async function openHelpAndFeedbackUrl(context: IActionContext, url: string): Promise { + // Log the URL to telemetry + context.telemetry.properties.url = url; + context.telemetry.properties.source = 'helpAndFeedbackView'; + + // Open the URL + await openUrl(url); +} diff --git a/src/commands/launchShell/launchShell.ts b/src/commands/launchShell/launchShell.ts index bfb4ea8e1..7975e2466 100644 --- a/src/commands/launchShell/launchShell.ts +++ b/src/commands/launchShell/launchShell.ts @@ -9,6 +9,7 @@ import * as vscode from 'vscode'; import { isWindows } from '../../constants'; import { AuthMethodId } from '../../documentdb/auth/AuthMethod'; import { ClustersClient } from '../../documentdb/ClustersClient'; +import { CredentialCache } from '../../documentdb/CredentialCache'; import { maskSensitiveValuesInTelemetry } from '../../documentdb/utils/connectionStringHelpers'; import { DocumentDBConnectionString } from '../../documentdb/utils/DocumentDBConnectionString'; import { ext } from '../../extensionVariables'; @@ -44,8 +45,8 @@ export async function launchShell( const clusterCredentials = activeClient.getCredentials(); if (clusterCredentials) { connectionString = clusterCredentials.connectionString; - username = clusterCredentials.connectionUser; - password = clusterCredentials.connectionPassword; + username = CredentialCache.getConnectionUser(node.cluster.id); + password = CredentialCache.getConnectionPassword(node.cluster.id); authMechanism = clusterCredentials.authMechanism; } } else { @@ -68,8 +69,9 @@ export async function launchShell( if (selectedAuthMethod === AuthMethodId.NativeAuth || (nativeAuthIsAvailable && !selectedAuthMethod)) { connectionString = discoveredClusterCredentials.connectionString; - username = discoveredClusterCredentials.connectionUser; - password = discoveredClusterCredentials.connectionPassword; + // Use nativeAuthConfig for credential access + username = discoveredClusterCredentials.nativeAuthConfig?.connectionUser; + password = discoveredClusterCredentials.nativeAuthConfig?.connectionPassword; authMechanism = AuthMethodId.NativeAuth; } else { // Only SCRAM-SHA-256 (username/password) authentication is supported here. diff --git a/src/commands/newConnection/ExecuteStep.ts b/src/commands/newConnection/ExecuteStep.ts index 91d1ef0b5..66d4ae13a 100644 --- a/src/commands/newConnection/ExecuteStep.ts +++ b/src/commands/newConnection/ExecuteStep.ts @@ -6,6 +6,7 @@ import { AzureWizardExecuteStep } from '@microsoft/vscode-azext-utils'; import * as l10n from '@vscode/l10n'; import * as vscode from 'vscode'; +import { AuthMethodId } from '../../documentdb/auth/AuthMethod'; import { DocumentDBConnectionString } from '../../documentdb/utils/DocumentDBConnectionString'; import { API } from '../../DocumentDBExperiences'; import { ext } from '../../extensionVariables'; @@ -36,8 +37,10 @@ export class ExecuteStep extends AzureWizardExecuteStep { const existingCS = new DocumentDBConnectionString(existingConnection.secrets.connectionString); const existingHostsJoined = [...existingCS.hosts].sort().join(','); + // Use nativeAuthConfig for comparison + const existingUsername = existingConnection.secrets.nativeAuthConfig?.connectionUser; - return ( - existingConnection.secrets.userName === newUsername && existingHostsJoined === newJoinedHosts - ); + return existingUsername === newUsername && existingHostsJoined === newJoinedHosts; }); if (existingDuplicateConnection) { @@ -129,7 +132,18 @@ export class ExecuteStep extends AzureWizardExecuteStep { @@ -64,6 +65,7 @@ export class PromptConnectionModeStep extends AzureWizardPromptStep this.validateInput(context, password), }); context.valuesToMask.push(password); - context.password = password; + // Update both structured config and legacy field + context.nativeAuthConfig = { + connectionUser: context.nativeAuthConfig?.connectionUser ?? '', + connectionPassword: password, + }; } public shouldPrompt(context: NewConnectionWizardContext): boolean { @@ -41,7 +45,7 @@ export class PromptPasswordStep extends AzureWizardPromptStep { + public async prompt(context: NewConnectionWizardContext): Promise { + // Create async function to provide better loading UX and debugging experience + const tenantItemsPromise = async (): Promise => { + // Load available tenants from Azure subscription provider + const tenants = await this.getAvailableTenants(context); + context.telemetry.measurements.availableTenantsCount = tenants.length; + + // Create quick pick items + const tenantItems: TenantQuickPickItem[] = [ + { + label: l10n.t('Manually enter a custom tenant ID'), + iconPath: new vscode.ThemeIcon('edit'), + isCustomOption: true, + alwaysShow: true, + }, + { + label: l10n.t('Sign in to other Azure accounts to access more tenants'), + iconPath: new vscode.ThemeIcon('key'), + alwaysShow: true, + isSignInOption: true, + }, + { label: '', kind: vscode.QuickPickItemKind.Separator }, + ]; + + // Add available tenants to the list, grouped by account + tenants.forEach((tenant) => { + const item: TenantQuickPickItem & { group?: string } = { + label: tenant.displayName ?? tenant.tenantId ?? '', + detail: tenant.tenantId, + description: tenant.defaultDomain, + group: tenant.account.label, + iconPath: new vscode.ThemeIcon('organization'), + tenant, + }; + tenantItems.push(item); + }); + + return tenantItems; + }; + + const selectedItem = await context.ui.showQuickPick(tenantItemsPromise(), { + stepName: 'selectTenant', + placeHolder: l10n.t('Select a tenant for Microsoft Entra ID authentication'), + suppressPersistence: true, + loadingPlaceHolder: l10n.t('Loading Tenants…'), + enableGrouping: true, + matchOnDescription: true, + }); + + if (selectedItem.isSignInOption) { + // Handle sign in to other Azure accounts + await this.handleSignInToOtherAccounts(context); + await this.showRetryInstructions(); + + // Exit wizard - user needs to restart the connection flow + throw new UserCancelledError('Account management completed'); + } else if (selectedItem.isCustomOption) { + // Show input box for custom tenant ID + const customTenantId = await context.ui.showInputBox({ + prompt: l10n.t('Enter the tenant ID (GUID)'), + placeHolder: l10n.t('e.g., 12345678-1234-1234-1234-123456789012 or 12345678123412341234123456789012'), + validateInput: (input) => this.validateTenantId(input), + }); + + // Normalize tenant ID - add dashes if missing + const normalizedTenantId = this.normalizeTenantId(customTenantId.trim()); + + // Set entraIdAuthConfig with the normalized tenant ID + context.entraIdAuthConfig = { + ...context.entraIdAuthConfig, + tenantId: normalizedTenantId, + }; + } else { + const tenant = nonNullValue(selectedItem.tenant, 'selectedItem.tenant', 'PromptTenantStep.ts'); + + // Set entraIdAuthConfig with the selected tenant ID + context.entraIdAuthConfig = { + ...context.entraIdAuthConfig, + tenantId: tenant.tenantId, + }; + } + + // Add telemetry - track selection method + if (selectedItem.isSignInOption) { + context.telemetry.properties.tenantSelectionMethod = 'signInTriggered'; + } else if (selectedItem.isCustomOption) { + context.telemetry.properties.tenantSelectionMethod = 'custom'; + } else { + context.telemetry.properties.tenantSelectionMethod = 'fromList'; + } + } + + public shouldPrompt(context: NewConnectionWizardContext): boolean { + // Only show this step if Microsoft Entra ID authentication is selected + return context.selectedAuthenticationMethod === AuthMethodId.MicrosoftEntraID; + } + + private async getAvailableTenants(_context: NewConnectionWizardContext): Promise { + try { + // Create a new Azure subscription provider to get tenants + const subscriptionProvider = new VSCodeAzureSubscriptionProvider(); + const tenants = await subscriptionProvider.getTenants(); + + return tenants.sort((a: AzureTenant, b: AzureTenant) => { + // Sort by display name if available, otherwise by tenant ID + const aName = a.displayName || a.tenantId || ''; + const bName = b.displayName || b.tenantId || ''; + return aName.localeCompare(bName); + }); + } catch { + // If we can't load tenants, just return empty array + // User can still use custom tenant ID option + return []; + } + } + + private validateTenantId(input: string): string | undefined { + if (!input || input.trim().length === 0) { + return l10n.t('Tenant ID cannot be empty'); + } + + const trimmedInput = input.trim(); + + // Validation for GUID format - with or without dashes + const guidWithDashesRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + const guidWithoutDashesRegex = /^[0-9a-f]{32}$/i; + + if (!guidWithDashesRegex.test(trimmedInput) && !guidWithoutDashesRegex.test(trimmedInput)) { + return l10n.t( + 'Please enter a valid tenant ID in GUID format (e.g., 12345678-1234-1234-1234-123456789012 or 12345678123412341234123456789012)', + ); + } + + return undefined; + } + + private normalizeTenantId(tenantId: string): string { + // If tenant ID already has dashes, return as-is + if (tenantId.includes('-')) { + return tenantId; + } + + // If it's a 32-character hex string without dashes, add them + if (/^[0-9a-f]{32}$/i.test(tenantId)) { + return [ + tenantId.slice(0, 8), + tenantId.slice(8, 12), + tenantId.slice(12, 16), + tenantId.slice(16, 20), + tenantId.slice(20, 32), + ].join('-'); + } + + // Return as-is if it doesn't match expected pattern + return tenantId; + } + + private async handleSignInToOtherAccounts(context: NewConnectionWizardContext): Promise { + // Add telemetry for credential configuration activation + context.telemetry.properties.credentialConfigActivated = 'true'; + context.telemetry.properties.nodeProvided = 'false'; + + // Create a new Azure subscription provider to trigger sign-in + const subscriptionProvider = new VSCodeAzureSubscriptionProvider(); + + // Call the credentials management function directly + const { configureAzureCredentials } = await import('../../plugins/api-shared/azure/credentialsManagement'); + await configureAzureCredentials( + context, + subscriptionProvider as AzureSubscriptionProviderWithFilters, + undefined, + ); + } + + private async showRetryInstructions(): Promise { + await vscode.window.showInformationMessage( + l10n.t('Account Management Completed'), + { + modal: true, + detail: l10n.t( + 'The account management flow has completed.\n\nPlease try the connection flow again to see your available tenants.', + ), + }, + l10n.t('OK'), + ); + } +} diff --git a/src/commands/newConnection/PromptUsernameStep.ts b/src/commands/newConnection/PromptUsernameStep.ts index bd34a254d..139090ee9 100644 --- a/src/commands/newConnection/PromptUsernameStep.ts +++ b/src/commands/newConnection/PromptUsernameStep.ts @@ -18,7 +18,7 @@ export class PromptUsernameStep extends AzureWizardPromptStep this.validateInput(context, username), // eslint-disable-next-line @typescript-eslint/require-await asyncValidationTask: async (username?: string) => { @@ -30,7 +30,11 @@ export class PromptUsernameStep extends AzureWizardPromptStep { const passwordTemp = await context.ui.showInputBox({ prompt: l10n.t('Please enter the password for the user "{username}"', { - username: context.username ?? '', + username: context.nativeAuthConfig?.connectionUser ?? '', }), - value: context.password, + value: context.nativeAuthConfig?.connectionPassword, password: true, ignoreFocusOut: true, }); - context.password = passwordTemp.trim(); - context.valuesToMask.push(context.password); + const trimmedPassword = passwordTemp.trim(); + + // Update structured config + context.nativeAuthConfig = { + connectionUser: context.nativeAuthConfig?.connectionUser ?? '', + connectionPassword: trimmedPassword, + }; + context.valuesToMask.push(trimmedPassword); } public shouldPrompt(context: UpdateCredentialsWizardContext): boolean { diff --git a/src/commands/updateCredentials/PromptTenantStep.ts b/src/commands/updateCredentials/PromptTenantStep.ts new file mode 100644 index 000000000..f532d7dcc --- /dev/null +++ b/src/commands/updateCredentials/PromptTenantStep.ts @@ -0,0 +1,208 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { VSCodeAzureSubscriptionProvider, type AzureTenant } from '@microsoft/vscode-azext-azureauth'; +import { AzureWizardPromptStep, UserCancelledError } from '@microsoft/vscode-azext-utils'; +import * as l10n from '@vscode/l10n'; +import * as vscode from 'vscode'; +import { AuthMethodId } from '../../documentdb/auth/AuthMethod'; +import { type AzureSubscriptionProviderWithFilters } from '../../plugins/api-shared/azure/AzureSubscriptionProviderWithFilters'; +import { nonNullValue } from '../../utils/nonNull'; +import { type UpdateCredentialsWizardContext } from './UpdateCredentialsWizardContext'; + +interface TenantQuickPickItem extends vscode.QuickPickItem { + tenant?: AzureTenant; + isCustomOption?: boolean; + isSignInOption?: boolean; +} + +export class PromptTenantStep extends AzureWizardPromptStep { + public async prompt(context: UpdateCredentialsWizardContext): Promise { + // Create async function to provide better loading UX and debugging experience + const tenantItemsPromise = async (): Promise => { + // Load available tenants from Azure subscription provider + const tenants = await this.getAvailableTenants(context); + context.telemetry.measurements.availableTenantsCount = tenants.length; + + // Create quick pick items + const tenantItems: TenantQuickPickItem[] = [ + { + label: l10n.t('Manually enter a custom tenant ID'), + iconPath: new vscode.ThemeIcon('edit'), + isCustomOption: true, + alwaysShow: true, + }, + { + label: l10n.t('Sign in to other Azure accounts to access more tenants'), + iconPath: new vscode.ThemeIcon('key'), + alwaysShow: true, + isSignInOption: true, + }, + { label: '', kind: vscode.QuickPickItemKind.Separator }, + ]; + + // Add available tenants to the list, grouped by account + tenants.forEach((tenant) => { + const item: TenantQuickPickItem & { group?: string } = { + label: tenant.displayName ?? tenant.tenantId ?? '', + detail: tenant.tenantId, + description: tenant.defaultDomain, + group: tenant.account.label, + iconPath: new vscode.ThemeIcon('organization'), + tenant, + }; + tenantItems.push(item); + }); + + return tenantItems; + }; + + const selectedItem = await context.ui.showQuickPick(tenantItemsPromise(), { + stepName: 'selectTenant', + placeHolder: l10n.t('Select a tenant for Microsoft Entra ID authentication'), + suppressPersistence: true, + loadingPlaceHolder: l10n.t('Loading Tenants…'), + enableGrouping: true, + matchOnDescription: true, + }); + + if (selectedItem.isSignInOption) { + // Handle sign in to other Azure accounts + await this.handleSignInToOtherAccounts(context); + await this.showRetryInstructions(); + + // Exit wizard - user needs to restart the credentials update flow + throw new UserCancelledError('Account management completed'); + } else if (selectedItem.isCustomOption) { + // Show input box for custom tenant ID + const customTenantId = await context.ui.showInputBox({ + prompt: l10n.t('Enter the tenant ID (GUID)'), + placeHolder: l10n.t('e.g., 12345678-1234-1234-1234-123456789012 or 12345678123412341234123456789012'), + validateInput: (input) => this.validateTenantId(input), + }); + + // Normalize tenant ID - add dashes if missing + const normalizedTenantId = this.normalizeTenantId(customTenantId.trim()); + + // Set entraIdAuthConfig with the normalized tenant ID + context.entraIdAuthConfig = { + ...context.entraIdAuthConfig, + tenantId: normalizedTenantId, + }; + } else { + const tenant = nonNullValue(selectedItem.tenant, 'selectedItem.tenant', 'PromptTenantStep.ts'); + + // Set entraIdAuthConfig with the selected tenant ID + context.entraIdAuthConfig = { + ...context.entraIdAuthConfig, + tenantId: tenant.tenantId, + }; + } + + // Add telemetry - track selection method + if (selectedItem.isSignInOption) { + context.telemetry.properties.tenantSelectionMethod = 'signInTriggered'; + } else if (selectedItem.isCustomOption) { + context.telemetry.properties.tenantSelectionMethod = 'custom'; + } else { + context.telemetry.properties.tenantSelectionMethod = 'fromList'; + } + } + + public shouldPrompt(context: UpdateCredentialsWizardContext): boolean { + // Only show this step if Microsoft Entra ID authentication is selected + return context.selectedAuthenticationMethod === AuthMethodId.MicrosoftEntraID; + } + + private async getAvailableTenants(_context: UpdateCredentialsWizardContext): Promise { + try { + // Create a new Azure subscription provider to get tenants + const subscriptionProvider = new VSCodeAzureSubscriptionProvider(); + const tenants = await subscriptionProvider.getTenants(); + + return tenants.sort((a: AzureTenant, b: AzureTenant) => { + // Sort by display name if available, otherwise by tenant ID + const aName = a.displayName || a.tenantId || ''; + const bName = b.displayName || b.tenantId || ''; + return aName.localeCompare(bName); + }); + } catch { + // If we can't load tenants, just return empty array + // User can still use custom tenant ID option + return []; + } + } + + private validateTenantId(input: string): string | undefined { + if (!input || input.trim().length === 0) { + return l10n.t('Tenant ID cannot be empty'); + } + + const trimmedInput = input.trim(); + + // Validation for GUID format - with or without dashes + const guidWithDashesRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + const guidWithoutDashesRegex = /^[0-9a-f]{32}$/i; + + if (!guidWithDashesRegex.test(trimmedInput) && !guidWithoutDashesRegex.test(trimmedInput)) { + return l10n.t( + 'Please enter a valid tenant ID in GUID format (e.g., 12345678-1234-1234-1234-123456789012 or 12345678123412341234123456789012)', + ); + } + + return undefined; + } + + private normalizeTenantId(tenantId: string): string { + // If tenant ID already has dashes, return as-is + if (tenantId.includes('-')) { + return tenantId; + } + + // If it's a 32-character hex string without dashes, add them + if (/^[0-9a-f]{32}$/i.test(tenantId)) { + return [ + tenantId.slice(0, 8), + tenantId.slice(8, 12), + tenantId.slice(12, 16), + tenantId.slice(16, 20), + tenantId.slice(20, 32), + ].join('-'); + } + + // Return as-is if it doesn't match expected pattern + return tenantId; + } + + private async handleSignInToOtherAccounts(context: UpdateCredentialsWizardContext): Promise { + // Add telemetry for credential configuration activation + context.telemetry.properties.credentialConfigActivated = 'true'; + context.telemetry.properties.nodeProvided = 'false'; + + // Create a new Azure subscription provider to trigger sign-in + const subscriptionProvider = new VSCodeAzureSubscriptionProvider(); + + // Call the credentials management function directly + const { configureAzureCredentials } = await import('../../plugins/api-shared/azure/credentialsManagement'); + await configureAzureCredentials( + context, + subscriptionProvider as AzureSubscriptionProviderWithFilters, + undefined, + ); + } + + private async showRetryInstructions(): Promise { + await vscode.window.showInformationMessage( + l10n.t('Account Management Completed'), + { + modal: true, + detail: l10n.t( + 'The account management flow has completed.\n\nPlease try updating the credentials again to see your available tenants.', + ), + }, + l10n.t('OK'), + ); + } +} diff --git a/src/commands/updateCredentials/PromptUserNameStep.ts b/src/commands/updateCredentials/PromptUserNameStep.ts index 5a02b5b5f..182f321a3 100644 --- a/src/commands/updateCredentials/PromptUserNameStep.ts +++ b/src/commands/updateCredentials/PromptUserNameStep.ts @@ -13,12 +13,18 @@ export class PromptUserNameStep extends AzureWizardPromptStep { const username = await context.ui.showInputBox({ prompt: l10n.t('Please enter the username'), - value: context.username, + value: context.nativeAuthConfig?.connectionUser ?? '', ignoreFocusOut: true, }); - context.username = username.trim(); - context.valuesToMask.push(context.username, username); + const trimmedUsername = username.trim(); + + // Update structured config + context.nativeAuthConfig = { + connectionUser: trimmedUsername, + connectionPassword: context.nativeAuthConfig?.connectionPassword, + }; + context.valuesToMask.push(trimmedUsername, username); } public shouldPrompt(context: UpdateCredentialsWizardContext): boolean { diff --git a/src/commands/updateCredentials/UpdateCredentialsWizardContext.ts b/src/commands/updateCredentials/UpdateCredentialsWizardContext.ts index ca6d98541..66589d8ad 100644 --- a/src/commands/updateCredentials/UpdateCredentialsWizardContext.ts +++ b/src/commands/updateCredentials/UpdateCredentialsWizardContext.ts @@ -4,6 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import { type IActionContext } from '@microsoft/vscode-azext-utils'; +import { type EntraIdAuthConfig, type NativeAuthConfig } from '../../documentdb/auth/AuthConfig'; import { type AuthMethodId } from '../../documentdb/auth/AuthMethod'; export interface UpdateCredentialsWizardContext extends IActionContext { @@ -13,8 +14,9 @@ export interface UpdateCredentialsWizardContext extends IActionContext { availableAuthenticationMethods: AuthMethodId[]; - // user input - username?: string; - password?: string; + // structured authentication configurations + nativeAuthConfig?: NativeAuthConfig; + entraIdAuthConfig?: EntraIdAuthConfig; + selectedAuthenticationMethod?: AuthMethodId; } diff --git a/src/commands/updateCredentials/updateCredentials.ts b/src/commands/updateCredentials/updateCredentials.ts index c38e2613b..42c281515 100644 --- a/src/commands/updateCredentials/updateCredentials.ts +++ b/src/commands/updateCredentials/updateCredentials.ts @@ -17,6 +17,7 @@ import { refreshView } from '../refreshView/refreshView'; import { PromptAuthMethodStep } from '../updateCredentials/PromptAuthMethodStep'; import { ExecuteStep } from './ExecuteStep'; import { PromptPasswordStep } from './PromptPasswordStep'; +import { PromptTenantStep } from './PromptTenantStep'; import { PromptUserNameStep } from './PromptUserNameStep'; import { type UpdateCredentialsWizardContext } from './UpdateCredentialsWizardContext'; @@ -52,8 +53,8 @@ export async function updateCredentials(context: IActionContext, node: DocumentD const wizardContext: UpdateCredentialsWizardContext = { ...context, - username: connectionCredentials?.secrets.userName, - password: connectionCredentials?.secrets.password, + nativeAuthConfig: connectionCredentials?.secrets.nativeAuthConfig, + entraIdAuthConfig: connectionCredentials?.secrets.entraIdAuthConfig, availableAuthenticationMethods: authMethodsFromString(supportedAuthMethods), selectedAuthenticationMethod: authMethodFromString(connectionCredentials?.properties.selectedAuthMethod), isEmulator: Boolean(node.cluster.emulatorConfiguration?.isEmulator), @@ -62,7 +63,12 @@ export async function updateCredentials(context: IActionContext, node: DocumentD const wizard = new AzureWizard(wizardContext, { title: l10n.t('Update cluster credentials'), - promptSteps: [new PromptAuthMethodStep(), new PromptUserNameStep(), new PromptPasswordStep()], + promptSteps: [ + new PromptAuthMethodStep(), + new PromptTenantStep(), + new PromptUserNameStep(), + new PromptPasswordStep(), + ], executeSteps: [new ExecuteStep()], showLoadingPrompt: true, }); diff --git a/src/documentdb/ClustersClient.ts b/src/documentdb/ClustersClient.ts index 41ba1ea66..a173f5481 100644 --- a/src/documentdb/ClustersClient.ts +++ b/src/documentdb/ClustersClient.ts @@ -34,7 +34,7 @@ import { type AuthHandler } from './auth/AuthHandler'; import { AuthMethodId } from './auth/AuthMethod'; import { MicrosoftEntraIDAuthHandler } from './auth/MicrosoftEntraIDAuthHandler'; import { NativeAuthHandler } from './auth/NativeAuthHandler'; -import { CredentialCache, type ClustersCredentials } from './CredentialCache'; +import { CredentialCache, type CachedClusterCredentials } from './CredentialCache'; import { getHostsFromConnectionString, hasAzureDomain } from './utils/connectionStringHelpers'; import { getClusterMetadata, type ClusterMetadata } from './utils/getClusterMetadata'; import { toFilterQueryObj } from './utils/toFilterQuery'; @@ -220,25 +220,25 @@ export class ClustersClient { } getUserName() { - return CredentialCache.getCredentials(this.credentialId)?.connectionUser; + return CredentialCache.getConnectionUser(this.credentialId); } /** - * @deprecated Use getCredentials() which returns a ClusterCredentials object instead. + * @deprecated Use getCredentials() which returns a CachedClusterCredentials object instead. */ getConnectionString(): string | undefined { return this.getCredentials()?.connectionString; } /** - * @deprecated Use getCredentials() which returns a ClusterCredentials object instead. + * @deprecated Use getCredentials() which returns a CachedClusterCredentials object instead. */ getConnectionStringWithPassword(): string | undefined { return CredentialCache.getConnectionStringWithPassword(this.credentialId); } - public getCredentials(): ClustersCredentials | undefined { - return CredentialCache.getCredentials(this.credentialId) as ClustersCredentials | undefined; + public getCredentials(): CachedClusterCredentials | undefined { + return CredentialCache.getCredentials(this.credentialId) as CachedClusterCredentials | undefined; } async listDatabases(): Promise { diff --git a/src/documentdb/ClustersExtension.ts b/src/documentdb/ClustersExtension.ts index 248b7a2e6..bfa06a545 100644 --- a/src/documentdb/ClustersExtension.ts +++ b/src/documentdb/ClustersExtension.ts @@ -27,8 +27,10 @@ import { createAzureDatabase } from '../commands/createDatabase/createDatabase'; import { createMongoDocument } from '../commands/createDocument/createDocument'; import { deleteCollection } from '../commands/deleteCollection/deleteCollection'; import { deleteAzureDatabase } from '../commands/deleteDatabase/deleteDatabase'; +import { filterProviderContent } from '../commands/discoveryService.filterProviderContent/filterProviderContent'; +import { manageCredentials } from '../commands/discoveryService.manageCredentials/manageCredentials'; import { exportEntireCollection, exportQueryResults } from '../commands/exportDocuments/exportDocuments'; -import { filterProviderContent } from '../commands/filterProviderContent/filterProviderContent'; +import { openHelpAndFeedbackUrl } from '../commands/helpAndFeedback.openUrl/openUrl'; import { importDocuments } from '../commands/importDocuments/importDocuments'; import { launchShell } from '../commands/launchShell/launchShell'; import { learnMoreAboutServiceProvider } from '../commands/learnMoreAboutServiceProvider/learnMoreAboutServiceProvider'; @@ -57,6 +59,7 @@ import { ClustersWorkspaceBranchDataProvider } from '../tree/azure-workspace-vie import { DocumentDbWorkspaceResourceProvider } from '../tree/azure-workspace-view/DocumentDbWorkspaceResourceProvider'; import { ConnectionsBranchDataProvider } from '../tree/connections-view/ConnectionsBranchDataProvider'; import { DiscoveryBranchDataProvider } from '../tree/discovery-view/DiscoveryBranchDataProvider'; +import { HelpAndFeedbackBranchDataProvider } from '../tree/help-and-feedback-view/HelpAndFeedbackBranchDataProvider'; import { registerCommandWithModalErrors, registerCommandWithTreeNodeUnwrappingAndModalErrors, @@ -100,6 +103,16 @@ export class ClustersExtension implements vscode.Disposable { ext.context.subscriptions.push(treeView); } + registerHelpAndFeedbackTree(_activateContext: IActionContext): void { + ext.helpAndFeedbackBranchDataProvider = new HelpAndFeedbackBranchDataProvider(); + + const treeView = vscode.window.createTreeView(Views.HelpAndFeedbackView, { + treeDataProvider: ext.helpAndFeedbackBranchDataProvider, + }); + + ext.context.subscriptions.push(treeView); + } + async registerAzureResourcesIntegration(activateContext: IActionContext): Promise { // Dynamic registration so this file compiles when the enum members aren't present // This is how we detect whether the update to Azure Resources has been deployed @@ -150,6 +163,7 @@ export class ClustersExtension implements vscode.Disposable { this.registerDiscoveryServices(activateContext); this.registerConnectionsTree(activateContext); this.registerDiscoveryTree(activateContext); + this.registerHelpAndFeedbackTree(activateContext); //// General Commands: @@ -205,6 +219,11 @@ export class ClustersExtension implements vscode.Disposable { filterProviderContent, ); + registerCommandWithTreeNodeUnwrapping( + 'vscode-documentdb.command.discoveryView.manageCredentials', + manageCredentials, + ); + registerCommandWithTreeNodeUnwrapping( 'vscode-documentdb.command.discoveryView.learnMoreAboutProvider', learnMoreAboutServiceProvider, @@ -255,6 +274,8 @@ export class ClustersExtension implements vscode.Disposable { registerCommand('vscode-documentdb.command.internal.documentView.open', openDocumentView); + registerCommand('vscode-documentdb.command.internal.helpAndFeedback.openUrl', openHelpAndFeedbackUrl); + registerCommandWithTreeNodeUnwrapping('vscode-documentdb.command.internal.retry', retryAuthentication); registerCommandWithTreeNodeUnwrapping('vscode-documentdb.command.internal.revealView', revealView); diff --git a/src/documentdb/CredentialCache.ts b/src/documentdb/CredentialCache.ts index 5a865ca24..6d9abb587 100644 --- a/src/documentdb/CredentialCache.ts +++ b/src/documentdb/CredentialCache.ts @@ -3,27 +3,36 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +import { type ConnectionItem } from '../services/connectionStorageService'; import { CaseInsensitiveMap } from '../utils/CaseInsensitiveMap'; import { type EmulatorConfiguration } from '../utils/emulatorConfiguration'; -import { type AuthMethodId } from './auth/AuthMethod'; +import { type EntraIdAuthConfig, type NativeAuthConfig } from './auth/AuthConfig'; +import { AuthMethodId, type AuthMethodId as AuthMethodIdType } from './auth/AuthMethod'; import { addAuthenticationDataToConnectionString } from './utils/connectionStringHelpers'; -export interface ClustersCredentials { +export interface CachedClusterCredentials { mongoClusterId: string; connectionStringWithPassword?: string; connectionString: string; - connectionUser: string; - connectionPassword?: string; - authMechanism?: AuthMethodId; + authMechanism?: AuthMethodIdType; // Optional, as it's only relevant for local workspace connetions emulatorConfiguration?: EmulatorConfiguration; + + // Authentication method specific configurations + nativeAuthConfig?: NativeAuthConfig; + entraIdConfig?: EntraIdAuthConfig; } +/** + * @deprecated Use CachedClusterCredentials instead. This alias is provided for backward compatibility. + */ +export type ClustersCredentials = CachedClusterCredentials; + export class CredentialCache { // the id of the cluster === the tree item id -> cluster credentials // Some SDKs for azure differ the case on some resources ("DocumentDb" vs "DocumentDB") - private static _store: CaseInsensitiveMap = new CaseInsensitiveMap(); + private static _store: CaseInsensitiveMap = new CaseInsensitiveMap(); public static getConnectionStringWithPassword(mongoClusterId: string): string { return CredentialCache._store.get(mongoClusterId)?.connectionStringWithPassword as string; @@ -37,7 +46,31 @@ export class CredentialCache { return CredentialCache._store.get(mongoClusterId)?.emulatorConfiguration; } - public static getCredentials(mongoClusterId: string): ClustersCredentials | undefined { + public static getEntraIdConfig(mongoClusterId: string): EntraIdAuthConfig | undefined { + return CredentialCache._store.get(mongoClusterId)?.entraIdConfig; + } + + public static getNativeAuthConfig(mongoClusterId: string): NativeAuthConfig | undefined { + return CredentialCache._store.get(mongoClusterId)?.nativeAuthConfig; + } + + /** + * Gets the connection user for native authentication. + * Returns undefined for non-native authentication methods like Entra ID. + */ + public static getConnectionUser(mongoClusterId: string): string | undefined { + return CredentialCache._store.get(mongoClusterId)?.nativeAuthConfig?.connectionUser; + } + + /** + * Gets the connection password for native authentication. + * Returns undefined for non-native authentication methods like Entra ID. + */ + public static getConnectionPassword(mongoClusterId: string): string | undefined { + return CredentialCache._store.get(mongoClusterId)?.nativeAuthConfig?.connectionPassword; + } + + public static getCredentials(mongoClusterId: string): CachedClusterCredentials | undefined { return CredentialCache._store.get(mongoClusterId); } @@ -73,11 +106,14 @@ export class CredentialCache { password, ); - const credentials: ClustersCredentials = { + const credentials: CachedClusterCredentials = { mongoClusterId: mongoClusterId, connectionStringWithPassword: connectionStringWithPassword, connectionString: connectionString, - connectionUser: username, + nativeAuthConfig: { + connectionUser: username, + connectionPassword: password, + }, emulatorConfiguration: emulatorConfiguration, }; @@ -94,33 +130,95 @@ export class CredentialCache { * @param mongoClusterId - The credential id. It's supposed to be the same as the tree item id of the mongo cluster item to simplify the lookup. * @param authMethod - The authentication method/mechanism to be used (e.g. SCRAM, X509, Azure/Entra flows). * @param connectionString - The connection string to which optional credentials will be added. - * @param username - The username to be used for authentication (optional for some auth methods). - * @param password - The password to be used for authentication (optional for some auth methods). + * @param nativeAuthConfig - The native authentication configuration (optional, for username/password auth). * @param emulatorConfiguration - The emulator configuration object (optional, only relevant for local workspace connections). + * @param entraIdConfig - The Entra ID configuration object (optional, only relevant for Microsoft Entra ID authentication). */ public static setAuthCredentials( mongoClusterId: string, - authMethod: AuthMethodId, + authMethod: AuthMethodIdType, connectionString: string, - username: string = '', - password: string = '', + nativeAuthConfig?: NativeAuthConfig, emulatorConfiguration?: EmulatorConfiguration, + entraIdConfig?: EntraIdAuthConfig, ): void { + const username = nativeAuthConfig?.connectionUser ?? ''; + const password = nativeAuthConfig?.connectionPassword ?? ''; + const connectionStringWithPassword = addAuthenticationDataToConnectionString( connectionString, username, password, ); - const credentials: ClustersCredentials = { + const credentials: CachedClusterCredentials = { mongoClusterId: mongoClusterId, connectionStringWithPassword: connectionStringWithPassword, connectionString: connectionString, - connectionUser: username, emulatorConfiguration: emulatorConfiguration, authMechanism: authMethod, + entraIdConfig: entraIdConfig, + nativeAuthConfig: nativeAuthConfig, }; CredentialCache._store.set(mongoClusterId, credentials); } + + /** + * Bridge method to convert ConnectionItem's structured auth secrets into the runtime cache format. + * This method handles the conversion between persistent storage (ConnectionItem) and memory cache (CachedClusterCredentials). + * + * The conversion handles: + * - Determining auth method from available configurations + * - Converting central auth configs to local cache format + * - Maintaining backward compatibility with legacy username/password + * + * @param connectionItem - The persistent connection item with structured auth secrets + * @param authMethod - Optional explicit auth method; if not provided, will be inferred from available configs + * @param emulatorConfiguration - Optional emulator configuration for local connections + */ + public static setFromConnectionItem( + connectionItem: ConnectionItem, + authMethod?: AuthMethodIdType, + emulatorConfiguration?: EmulatorConfiguration, + ): void { + const { secrets } = connectionItem; + + // Determine auth method if not explicitly provided + let selectedAuthMethod = authMethod; + if (!selectedAuthMethod) { + if (secrets.entraIdAuthConfig) { + selectedAuthMethod = AuthMethodId.MicrosoftEntraID; + } else if (secrets.nativeAuthConfig) { + selectedAuthMethod = AuthMethodId.NativeAuth; + } else { + // Use the selected method from properties or first available method + selectedAuthMethod = + (connectionItem.properties.selectedAuthMethod as AuthMethodIdType) ?? + (connectionItem.properties.availableAuthMethods[0] as AuthMethodIdType) ?? + AuthMethodId.NativeAuth; + } + } + + // Convert central auth configs to local cache format + let cacheEntraIdConfig: EntraIdAuthConfig | undefined; + if (secrets.entraIdAuthConfig) { + // Preserve all optional fields for backward compatibility + cacheEntraIdConfig = { ...secrets.entraIdAuthConfig }; + } + + // Use structured configurations + const username = secrets.nativeAuthConfig?.connectionUser ?? ''; + const password = secrets.nativeAuthConfig?.connectionPassword ?? ''; + + // Use the existing setAuthCredentials method to ensure consistent behavior + CredentialCache.setAuthCredentials( + connectionItem.id, + selectedAuthMethod, + secrets.connectionString, + username || password ? { connectionUser: username, connectionPassword: password } : undefined, + emulatorConfiguration, + cacheEntraIdConfig, + ); + } } diff --git a/src/documentdb/Views.ts b/src/documentdb/Views.ts index 7e95735ec..d5f331908 100644 --- a/src/documentdb/Views.ts +++ b/src/documentdb/Views.ts @@ -8,6 +8,7 @@ export enum Views { DiscoveryView = 'discoveryView', // do not change this value AzureResourcesView = 'azureResourcesView', AzureWorkspaceView = 'azureWorkspaceView', + HelpAndFeedbackView = 'helpAndFeedbackView', // do not change this value /** * Note to future maintainers: do not modify these string constants. diff --git a/src/documentdb/auth/AuthConfig.ts b/src/documentdb/auth/AuthConfig.ts new file mode 100644 index 000000000..d7efebb5a --- /dev/null +++ b/src/documentdb/auth/AuthConfig.ts @@ -0,0 +1,51 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +/** + * Configuration for native MongoDB authentication using username/password. + * This represents the traditional authentication method where credentials + * are directly provided for database connection. + */ +export interface NativeAuthConfig { + /** The username for database authentication */ + readonly connectionUser: string; + + /** The password for database authentication */ + readonly connectionPassword?: string; +} + +/** + * Configuration for Entra ID (Azure Active Directory) authentication. + * Supports both explicit tenant specification and tenant discovery scenarios. + */ +export interface EntraIdAuthConfig { + /** + * The Azure Active Directory tenant ID. + * When provided, authentication will target this specific tenant. + * When omitted, Azure SDK will attempt tenant discovery based on the user context. + * This flexibility supports both single-tenant and multi-tenant scenarios. + */ + readonly tenantId?: string; + /** + * The Azure subscription ID associated with the authentication context. + * This is typically required when performing operations that are scoped to a specific Azure subscription, + * such as resource management or billing. While `tenantId` identifies the Azure Active Directory tenant, + * `subscriptionId` specifies the particular subscription within that tenant. + * This field is optional and may not be needed for all authentication scenarios. + */ + readonly subscriptionId?: string; + + /** + * Additional Entra ID specific configuration can be added here as needed. + * Examples: clientId, scope, authority, etc. + */ +} + +/** + * Union type representing all supported authentication configurations. + * This type can be extended with additional auth methods in the future + * (e.g., certificate-based auth, OAuth, etc.) without breaking existing code. + */ +export type AuthConfig = NativeAuthConfig | EntraIdAuthConfig; diff --git a/src/documentdb/auth/MicrosoftEntraIDAuthHandler.ts b/src/documentdb/auth/MicrosoftEntraIDAuthHandler.ts index 27b9ae6f7..f52c25f4e 100644 --- a/src/documentdb/auth/MicrosoftEntraIDAuthHandler.ts +++ b/src/documentdb/auth/MicrosoftEntraIDAuthHandler.ts @@ -7,7 +7,7 @@ import { getSessionFromVSCode } from '@microsoft/vscode-azext-azureauth/out/src/getSessionFromVSCode'; import * as l10n from '@vscode/l10n'; import { type MongoClientOptions, type OIDCCallbackParams, type OIDCResponse } from 'mongodb'; -import { type ClustersCredentials } from '../CredentialCache'; +import { type CachedClusterCredentials } from '../CredentialCache'; import { DocumentDBConnectionString } from '../utils/DocumentDBConnectionString'; import { type AuthHandler, type AuthHandlerResponse } from './AuthHandler'; @@ -15,13 +15,13 @@ import { type AuthHandler, type AuthHandlerResponse } from './AuthHandler'; * Handler for Microsoft Entra ID authentication via OIDC */ export class MicrosoftEntraIDAuthHandler implements AuthHandler { - constructor(private readonly clusterCredentials: ClustersCredentials) {} + constructor(private readonly clusterCredentials: CachedClusterCredentials) {} public async configureAuth(): Promise { // Get Microsoft Entra ID token const session = await getSessionFromVSCode( ['https://ossrdbms-aad.database.windows.net/.default'], - undefined, // currently, we don't see any requirements for support of scoping by tenantIds + this.clusterCredentials.entraIdConfig?.tenantId, { createIfNone: true, }, diff --git a/src/documentdb/auth/NativeAuthHandler.ts b/src/documentdb/auth/NativeAuthHandler.ts index c9ae7d457..ddab5e92a 100644 --- a/src/documentdb/auth/NativeAuthHandler.ts +++ b/src/documentdb/auth/NativeAuthHandler.ts @@ -5,14 +5,14 @@ import { type MongoClientOptions } from 'mongodb'; import { nonNullValue } from '../../utils/nonNull'; -import { type ClustersCredentials } from '../CredentialCache'; +import { type CachedClusterCredentials } from '../CredentialCache'; import { type AuthHandler, type AuthHandlerResponse } from './AuthHandler'; /** * Handler for native MongoDB authentication using username and password */ export class NativeAuthHandler implements AuthHandler { - constructor(private readonly clusterCredentials: ClustersCredentials) {} + constructor(private readonly clusterCredentials: CachedClusterCredentials) {} public configureAuth(): Promise { const options: MongoClientOptions = {}; diff --git a/src/documentdb/wizards/authenticate/AuthenticateWizardContext.ts b/src/documentdb/wizards/authenticate/AuthenticateWizardContext.ts index fff9e836d..bceecaf2e 100644 --- a/src/documentdb/wizards/authenticate/AuthenticateWizardContext.ts +++ b/src/documentdb/wizards/authenticate/AuthenticateWizardContext.ts @@ -5,6 +5,7 @@ import { type IActionContext } from '@microsoft/vscode-azext-utils'; +import { type EntraIdAuthConfig, type NativeAuthConfig } from '../../auth/AuthConfig'; import { type AuthMethodId } from '../../auth/AuthMethod'; export interface AuthenticateWizardContext extends IActionContext { @@ -20,6 +21,10 @@ export interface AuthenticateWizardContext extends IActionContext { /** These values will be populated by the wizard. */ + // structured authentication configurations + nativeAuthConfig?: NativeAuthConfig; + entraIdAuthConfig?: EntraIdAuthConfig; + /** States whether the username was set during the wizard flow. */ isUserNameUpdated?: boolean; selectedUserName?: string; diff --git a/src/documentdb/wizards/authenticate/ProvidePasswordStep.ts b/src/documentdb/wizards/authenticate/ProvidePasswordStep.ts index 366b82291..b5e405fa6 100644 --- a/src/documentdb/wizards/authenticate/ProvidePasswordStep.ts +++ b/src/documentdb/wizards/authenticate/ProvidePasswordStep.ts @@ -13,19 +13,26 @@ export class ProvidePasswordStep extends AzureWizardPromptStep { + private getTenantAndSubscriptionFilters(): string[] { // Try the Azure Resource Groups config first const config = vscode.workspace.getConfiguration('azureResourceGroups'); - let fullSubscriptionIds = config.get('selectedSubscriptions', []); + let fullSubscriptionIds = config.get('selectedSubscriptions'); - // If nothing found there, try our fallback storage - if (fullSubscriptionIds.length === 0) { + // If no configuration found (undefined), try our fallback storage + if (fullSubscriptionIds === undefined) { fullSubscriptionIds = ext.context.globalState.get('azure-discovery.selectedSubscriptions', []); } else { - // Sync to our fallback storage if primary storage had data + // Sync to our fallback storage if primary storage had data (even if empty array) void ext.context.globalState.update('azure-discovery.selectedSubscriptions', fullSubscriptionIds); } return fullSubscriptionIds; } /** - * Override the getTenantFilters method to provide custom tenant filtering - * Uses the same logic as in the original implementation but with fallback storage support + * Gets subscriptions from the Azure subscription provider. + * Note: Callers must explicitly call getTenantFilteredSubscriptions() if tenant filtering is needed. + * + * @param filter Whether to apply subscription filtering or a custom filter + * @returns List of subscriptions from the base provider (without tenant filtering) */ - protected override async getTenantFilters(): Promise { - const fullSubscriptionIds = await this.getTenantAndSubscriptionFilters(); - // Extract the tenant IDs from the full IDs (tenantId/subscriptionId) - return fullSubscriptionIds.map((id) => id.split('/')[0]); + public override async getSubscriptions(filter?: boolean | GetSubscriptionsFilter): Promise { + return await super.getSubscriptions(filter); } /** @@ -45,8 +51,8 @@ export class AzureSubscriptionProviderWithFilters extends VSCodeAzureSubscriptio * Uses the same logic as in the original implementation but with fallback storage support */ protected override async getSubscriptionFilters(): Promise { - const fullSubscriptionIds = await this.getTenantAndSubscriptionFilters(); + const fullSubscriptionIds = this.getTenantAndSubscriptionFilters(); // Extract the subscription IDs from the full IDs (tenantId/subscriptionId) - return fullSubscriptionIds.map((id) => id.split('/')[1]); + return Promise.resolve(fullSubscriptionIds.map((id) => id.split('/')[1])); } } diff --git a/src/plugins/api-shared/azure/askToConfigureCredentials.ts b/src/plugins/api-shared/azure/askToConfigureCredentials.ts new file mode 100644 index 000000000..ad23fb4e1 --- /dev/null +++ b/src/plugins/api-shared/azure/askToConfigureCredentials.ts @@ -0,0 +1,31 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as l10n from '@vscode/l10n'; +import { window } from 'vscode'; + +/** + * Shows a modal dialog asking the user if they want to configure/manage their Azure credentials. + * Used when no Azure subscriptions are found or when user is not signed in. + * + * @returns Promise that resolves to 'configure' if user wants to manage accounts, 'cancel' otherwise + */ +export async function askToConfigureCredentials(): Promise<'configure' | 'cancel'> { + const configure = l10n.t('Yes, Manage Accounts'); + + const result = await window.showInformationMessage( + l10n.t('No Azure Subscriptions Found'), + { + modal: true, + detail: l10n.t( + 'To connect to Azure resources, you need to sign in to Azure accounts.\n\n' + + 'Would you like to manage your Azure accounts now?', + ), + }, + { title: configure }, + ); + + return result?.title === configure ? 'configure' : 'cancel'; +} diff --git a/src/plugins/api-shared/azure/credentialsManagement/AccountActionsStep.ts b/src/plugins/api-shared/azure/credentialsManagement/AccountActionsStep.ts new file mode 100644 index 000000000..d78ab4622 --- /dev/null +++ b/src/plugins/api-shared/azure/credentialsManagement/AccountActionsStep.ts @@ -0,0 +1,67 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { AzureWizardPromptStep, GoBackError, UserCancelledError } from '@microsoft/vscode-azext-utils'; +import * as l10n from '@vscode/l10n'; +import * as vscode from 'vscode'; +import { nonNullValue } from '../../../../utils/nonNull'; +import { type CredentialsManagementWizardContext } from './CredentialsManagementWizardContext'; + +interface AccountActionQuickPickItem extends vscode.QuickPickItem { + action?: 'back' | 'exit'; +} + +export class AccountActionsStep extends AzureWizardPromptStep { + public async prompt(context: CredentialsManagementWizardContext): Promise { + const selectedAccount = nonNullValue( + context.selectedAccount, + 'context.selectedAccount', + 'AccountActionsStep.ts', + ); + + // Create action items for the selected account + const actionItems: AccountActionQuickPickItem[] = [ + { + label: l10n.t('Back to account selection'), + detail: l10n.t('Return to the account list'), + iconPath: new vscode.ThemeIcon('arrow-left'), + action: 'back', + }, + { label: '', kind: vscode.QuickPickItemKind.Separator }, + { + label: l10n.t('Exit'), + detail: l10n.t('Close the account management wizard'), + iconPath: new vscode.ThemeIcon('close'), + action: 'exit', + }, + ]; + + const selectedAction = await context.ui.showQuickPick(actionItems, { + stepName: 'accountActions', + placeHolder: l10n.t('{0} is currently being used for Azure service discovery', selectedAccount.label), + suppressPersistence: true, + }); + + // Handle the selected action + if (selectedAction.action === 'back') { + // Clear the selected account to go back to selection + context.selectedAccount = undefined; + context.telemetry.properties.accountAction = 'back'; + + // Use GoBackError to navigate back to the previous step + throw new GoBackError(); + } else if (selectedAction.action === 'exit') { + context.telemetry.properties.accountAction = 'exit'; + + // User chose to exit - throw UserCancelledError to gracefully exit wizard + throw new UserCancelledError('exitAccountManagement'); + } + } + + public shouldPrompt(context: CredentialsManagementWizardContext): boolean { + // Only show this step if we have a selected account + return !!context.selectedAccount; + } +} diff --git a/src/plugins/api-shared/azure/credentialsManagement/CredentialsManagementWizardContext.ts b/src/plugins/api-shared/azure/credentialsManagement/CredentialsManagementWizardContext.ts new file mode 100644 index 000000000..514ff68ba --- /dev/null +++ b/src/plugins/api-shared/azure/credentialsManagement/CredentialsManagementWizardContext.ts @@ -0,0 +1,19 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { type IActionContext } from '@microsoft/vscode-azext-utils'; +import type * as vscode from 'vscode'; +import { type AzureSubscriptionProviderWithFilters } from '../AzureSubscriptionProviderWithFilters'; + +export interface CredentialsManagementWizardContext extends IActionContext { + // Required context + azureSubscriptionProvider: AzureSubscriptionProviderWithFilters; + + // Selected account information + selectedAccount?: vscode.AuthenticationSessionAccountInformation; + + // Available options + availableAccounts?: vscode.AuthenticationSessionAccountInformation[]; +} diff --git a/src/plugins/api-shared/azure/credentialsManagement/ExecuteStep.ts b/src/plugins/api-shared/azure/credentialsManagement/ExecuteStep.ts new file mode 100644 index 000000000..b28d1d4d4 --- /dev/null +++ b/src/plugins/api-shared/azure/credentialsManagement/ExecuteStep.ts @@ -0,0 +1,34 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { AzureWizardExecuteStep } from '@microsoft/vscode-azext-utils'; +import * as l10n from '@vscode/l10n'; +import { ext } from '../../../../extensionVariables'; +import { nonNullValue } from '../../../../utils/nonNull'; +import { type CredentialsManagementWizardContext } from './CredentialsManagementWizardContext'; + +export class ExecuteStep extends AzureWizardExecuteStep { + public priority: number = 100; + + // eslint-disable-next-line @typescript-eslint/require-await + public async execute(context: CredentialsManagementWizardContext): Promise { + const executeStartTime = Date.now(); + const selectedAccount = nonNullValue(context.selectedAccount, 'context.selectedAccount', 'ExecuteStep.ts'); + + ext.outputChannel.appendLine(l10n.t('Viewing Azure account information for: {0}', selectedAccount.label)); + + // Add telemetry for execution + context.telemetry.properties.filteringActionType = 'accountManagement'; + + ext.outputChannel.appendLine(l10n.t('Azure account management wizard completed.')); + + // Add completion telemetry + context.telemetry.measurements.executionTimeMs = Date.now() - executeStartTime; + } + + public shouldExecute(context: CredentialsManagementWizardContext): boolean { + return !!context.selectedAccount; + } +} diff --git a/src/plugins/api-shared/azure/credentialsManagement/SelectAccountStep.ts b/src/plugins/api-shared/azure/credentialsManagement/SelectAccountStep.ts new file mode 100644 index 000000000..8f7134a0a --- /dev/null +++ b/src/plugins/api-shared/azure/credentialsManagement/SelectAccountStep.ts @@ -0,0 +1,148 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { AzureWizardPromptStep, UserCancelledError } from '@microsoft/vscode-azext-utils'; +import * as l10n from '@vscode/l10n'; +import * as vscode from 'vscode'; +import { ext } from '../../../../extensionVariables'; +import { nonNullValue } from '../../../../utils/nonNull'; +import { type CredentialsManagementWizardContext } from './CredentialsManagementWizardContext'; + +interface AccountQuickPickItem extends vscode.QuickPickItem { + account?: vscode.AuthenticationSessionAccountInformation; + isSignInOption?: boolean; + isLearnMoreOption?: boolean; + isExitOption?: boolean; +} + +export class SelectAccountStep extends AzureWizardPromptStep { + public async prompt(context: CredentialsManagementWizardContext): Promise { + // Create async function to provide better loading UX and debugging experience + const getAccountQuickPickItems = async (): Promise => { + const loadStartTime = Date.now(); + + const accounts = await this.getAvailableAccounts(context); + context.availableAccounts = accounts; + + // Add telemetry for account availability + context.telemetry.measurements.initialAccountCount = accounts.length; + context.telemetry.measurements.accountsLoadingTimeMs = Date.now() - loadStartTime; + + const accountItems: AccountQuickPickItem[] = accounts.map((account) => ({ + label: account.label, + iconPath: new vscode.ThemeIcon('account'), + account, + })); + + // Handle empty accounts case + if (accountItems.length === 0) { + context.telemetry.properties.noAccountsAvailable = 'true'; + return [ + { + label: l10n.t('Sign in to Azure to continue…'), + detail: l10n.t('DocumentDB for VS Code is not signed in to Azure'), + iconPath: new vscode.ThemeIcon('sign-in'), + isSignInOption: true, + }, + { label: '', kind: vscode.QuickPickItemKind.Separator }, + { + label: l10n.t('Exit without making changes'), + iconPath: new vscode.ThemeIcon('close'), + isExitOption: true, + }, + ]; + } + + // Show signed-in accounts + option to add more + return [ + ...accountItems, + { label: '', kind: vscode.QuickPickItemKind.Separator }, + { + label: l10n.t('Sign in with a different account…'), + iconPath: new vscode.ThemeIcon('sign-in'), + isSignInOption: true, + }, + { + label: l10n.t('Exit without making changes'), + iconPath: new vscode.ThemeIcon('close'), + isExitOption: true, + }, + ]; + }; + + const selectedItem = await context.ui.showQuickPick(getAccountQuickPickItems(), { + stepName: 'selectAccount', + placeHolder: l10n.t('Azure accounts used for service discovery'), + matchOnDescription: true, + suppressPersistence: true, + loadingPlaceHolder: l10n.t('Loading Azure Accounts Used for Service Discovery…'), + }); + + // Add telemetry for account selection method + if (selectedItem.isSignInOption) { + context.telemetry.properties.accountSelectionMethod = 'signIn'; + + await this.handleSignIn(context); + + // After successful sign-in, exit the wizard gracefully + // No need to restart - the account has been added successfully + throw new UserCancelledError('accountAddedSuccessfully'); + } else if (selectedItem.isExitOption) { + context.telemetry.properties.accountSelectionMethod = 'exit'; + + // User chose to exit - throw UserCancelledError to gracefully exit wizard + throw new UserCancelledError('exitAccountManagement'); + } else { + context.telemetry.properties.accountSelectionMethod = 'existingAccount'; + } + + context.selectedAccount = nonNullValue(selectedItem.account, 'selectedItem.account', 'SelectAccountStep.ts'); + } + + public shouldPrompt(context: CredentialsManagementWizardContext): boolean { + return !context.selectedAccount; + } + + private async getAvailableAccounts( + context: CredentialsManagementWizardContext, + ): Promise { + try { + // Get all tenants which include the accounts + const tenants = await context.azureSubscriptionProvider.getTenants(); + + // Extract unique accounts from tenants + const accounts = tenants.map((tenant) => tenant.account); + const uniqueAccounts = accounts.filter( + (account, index, self) => index === self.findIndex((a) => a.id === account.id), + ); + + return uniqueAccounts.sort((a, b) => a.label.localeCompare(b.label)); + } catch (error) { + ext.outputChannel.appendLine( + l10n.t( + 'Failed to retrieve Azure accounts: {0}', + error instanceof Error ? error.message : String(error), + ), + ); + return []; + } + } + + private async handleSignIn(context: CredentialsManagementWizardContext): Promise { + try { + ext.outputChannel.appendLine(l10n.t('Starting Azure sign-in process…')); + const success = await context.azureSubscriptionProvider.signIn(); + if (success) { + ext.outputChannel.appendLine(l10n.t('Azure sign-in completed successfully')); + } else { + ext.outputChannel.appendLine(l10n.t('Azure sign-in was cancelled or failed')); + } + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + ext.outputChannel.appendLine(l10n.t('Azure sign-in failed: {0}', errorMessage)); + throw error; + } + } +} diff --git a/src/plugins/api-shared/azure/credentialsManagement/configureAzureCredentials.ts b/src/plugins/api-shared/azure/credentialsManagement/configureAzureCredentials.ts new file mode 100644 index 000000000..6202eff29 --- /dev/null +++ b/src/plugins/api-shared/azure/credentialsManagement/configureAzureCredentials.ts @@ -0,0 +1,119 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { + AzureWizard, + callWithTelemetryAndErrorHandling, + UserCancelledError, + type IActionContext, +} from '@microsoft/vscode-azext-utils'; +import * as l10n from '@vscode/l10n'; +import { ext } from '../../../../extensionVariables'; +import { isTreeElementWithContextValue } from '../../../../tree/TreeElementWithContextValue'; +import { type AzureSubscriptionProviderWithFilters } from '../AzureSubscriptionProviderWithFilters'; +import { AccountActionsStep } from './AccountActionsStep'; +import { type CredentialsManagementWizardContext } from './CredentialsManagementWizardContext'; +import { ExecuteStep } from './ExecuteStep'; +import { SelectAccountStep } from './SelectAccountStep'; + +/** + * Internal implementation of Azure account management. + */ +async function configureAzureCredentialsInternal( + context: IActionContext, + azureSubscriptionProvider: AzureSubscriptionProviderWithFilters, +): Promise { + const startTime = Date.now(); + context.telemetry.properties.credentialsManagementAction = 'configure'; + + try { + ext.outputChannel.appendLine(l10n.t('Starting Azure account management wizard')); + + // Create wizard context + const wizardContext: CredentialsManagementWizardContext = { + ...context, + selectedAccount: undefined, + azureSubscriptionProvider, + }; + + // Create and configure the wizard + const wizard = new AzureWizard(wizardContext, { + title: l10n.t('Manage Azure Accounts'), + promptSteps: [new SelectAccountStep(), new AccountActionsStep()], + executeSteps: [new ExecuteStep()], + }); + + // Execute the wizard + await wizard.prompt(); + await wizard.execute(); + + // Success telemetry + context.telemetry.measurements.credentialsManagementDurationMs = Date.now() - startTime; + context.telemetry.properties.credentialsManagementResult = 'Succeeded'; + } catch (error) { + context.telemetry.measurements.credentialsManagementDurationMs = Date.now() - startTime; + + if (error instanceof UserCancelledError) { + if (error.message === 'accountAddedSuccessfully') { + // Account was successfully added + context.telemetry.properties.credentialsManagementResult = 'Succeeded'; + context.telemetry.properties.accountAdded = 'true'; + ext.outputChannel.appendLine(l10n.t('Azure account added successfully.')); + return; + } else { + // User cancelled + context.telemetry.properties.credentialsManagementResult = 'Canceled'; + ext.outputChannel.appendLine(l10n.t('Azure account management was cancelled by user.')); + return; + } + } + + // Any other error - don't retry, just throw + context.telemetry.properties.credentialsManagementResult = 'Failed'; + context.telemetry.properties.credentialsManagementError = error instanceof Error ? error.name : 'UnknownError'; + const errorMessage = error instanceof Error ? error.message : String(error); + ext.outputChannel.appendLine(l10n.t('Azure account management failed: {0}', errorMessage)); + throw error; + } +} + +/** + * Configures Azure credentials by allowing the user to select accounts and tenants + * for filtering Azure discovery results. + * + * @param context - The action context + * @param azureSubscriptionProvider - The Azure subscription provider with filtering capabilities + * @param node - Optional tree node from which the account management was initiated + */ +export async function configureAzureCredentials( + context: IActionContext, + azureSubscriptionProvider: AzureSubscriptionProviderWithFilters, + node?: unknown, +): Promise { + return await callWithTelemetryAndErrorHandling( + 'serviceDiscovery.configureAzureCredentials', + async (telemetryContext: IActionContext) => { + // Track node context information + telemetryContext.telemetry.properties.nodeProvided = node ? 'true' : 'false'; + if (node && isTreeElementWithContextValue(node)) { + telemetryContext.telemetry.properties.nodeContextValue = node.contextValue; + } + + // Pass through other telemetry properties from the calling context + if (context.telemetry.properties.discoveryProviderId) { + telemetryContext.telemetry.properties.discoveryProviderId = + context.telemetry.properties.discoveryProviderId; + } + + await configureAzureCredentialsInternal(telemetryContext, azureSubscriptionProvider); + + // Copy the credentials management result to the outer context so providers can access it + if (telemetryContext.telemetry.properties.credentialsManagementResult) { + context.telemetry.properties.credentialsManagementResult = + telemetryContext.telemetry.properties.credentialsManagementResult; + } + }, + ); +} diff --git a/src/plugins/api-shared/azure/credentialsManagement/index.ts b/src/plugins/api-shared/azure/credentialsManagement/index.ts new file mode 100644 index 000000000..862b190e2 --- /dev/null +++ b/src/plugins/api-shared/azure/credentialsManagement/index.ts @@ -0,0 +1,10 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +export { AccountActionsStep } from './AccountActionsStep'; +export { configureAzureCredentials } from './configureAzureCredentials'; +export type { CredentialsManagementWizardContext } from './CredentialsManagementWizardContext'; +export { ExecuteStep } from './ExecuteStep'; +export { SelectAccountStep } from './SelectAccountStep'; diff --git a/src/plugins/api-shared/azure/subscriptionFiltering.ts b/src/plugins/api-shared/azure/subscriptionFiltering.ts deleted file mode 100644 index 29c79fba1..000000000 --- a/src/plugins/api-shared/azure/subscriptionFiltering.ts +++ /dev/null @@ -1,167 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import { type AzureSubscription, type VSCodeAzureSubscriptionProvider } from '@microsoft/vscode-azext-azureauth'; -import { type IActionContext, type IAzureQuickPickItem } from '@microsoft/vscode-azext-utils'; -import * as vscode from 'vscode'; -import { l10n } from 'vscode'; -import { ext } from '../../../extensionVariables'; - -/** - * Subscription filtering functionality is provided by the `VSCodeAzureSubscriptionProvider` - * from the `vscode-azuretools` library: - * https://github.com/tnaum-ms/vscode-azuretools/blob/main/auth/src/VSCodeAzureSubscriptionProvider.ts - * - * Although the provider supports filtering subscriptions internally, it does not include built-in - * UI or configuration logic to manage these filters directly. - * - * Instead, the `vscode-azuretools` library relies on filter settings stored by another extension, - * `vscode-azureresourcegroups`. Specifically, it uses a hardcoded configuration key: - * - Configuration section: 'azureResourceGroups' - * - Configuration property: 'subscriptions' - * - * To avoid introducing a direct dependency on the `vscode-azureresourcegroups` extension, - * we replicate the filter logic here by accessing the same configuration keys. This approach - * ensures consistency and compatibility, as users of Azure Service Discovery are likely also - * using the Azure Resource Groups extension. - */ - -/** - * Returns the currently selected subscription IDs from the shared configuration. - * The ID of the tenant is being excluced from the ID. - * The IDs are stored in the format 'tenantId/subscriptionId'. - * For example: 'tenantId/subscriptionId'. - * The function returns an array of subscription IDs without the tenant ID. - * For example: 'subscriptionId'. - * - * @returns An array of selected subscription IDs. - */ -export function getSelectedSubscriptionIds(): string[] { - // Try the Azure Resource Groups config first - const config = vscode.workspace.getConfiguration('azureResourceGroups'); - const fullSubscriptionIds = config.get('selectedSubscriptions', []); - - // If nothing found there, try our fallback storage - if (fullSubscriptionIds.length === 0) { - const fallbackIds = ext.context.globalState.get('azure-discovery.selectedSubscriptions', []); - return fallbackIds.map((id) => id.split('/')[1]); - } - - // Sync to our fallback storage if primary storage had data - // This ensures we maintain a copy if Azure Resources extension is later removed - void ext.context.globalState.update('azure-discovery.selectedSubscriptions', fullSubscriptionIds); - - return fullSubscriptionIds.map((id) => id.split('/')[1]); -} - -/** - * Updates the selected subscription IDs in the shared configuration. - * These have to contain the full subscription ID, which is a combination of the tenant ID and subscription ID. - * For example: 'tenantId/subscriptionId'. - */ -export async function setSelectedSubscriptionIds(subscriptionIds: string[]): Promise { - try { - const config = vscode.workspace.getConfiguration('azureResourceGroups'); - await config.update('selectedSubscriptions', subscriptionIds, vscode.ConfigurationTarget.Global); - } catch (error) { - // Log the error if the Azure Resource Groups config update fails - console.error('Unable to update Azure Resource Groups configuration, using fallback storage.', error); - } finally { - // Always update our fallback storage regardless of primary storage success - await ext.context.globalState.update('azure-discovery.selectedSubscriptions', subscriptionIds); - } -} - -/** - * Identifies subscriptions with duplicate names. - */ -export function getDuplicateSubscriptions(subscriptions: AzureSubscription[]): AzureSubscription[] { - const names = new Map(); - const duplicates: AzureSubscription[] = []; - - for (const subscription of subscriptions) { - const count = (names.get(subscription.name) || 0) + 1; - names.set(subscription.name, count); - if (count > 1) { - duplicates.push(subscription); - } - } - - return subscriptions.filter((s) => names.get(s.name)! > 1); -} - -/** - * Configures the Azure subscription filter. - */ -export async function configureAzureSubscriptionFilter( - context: IActionContext, - azureSubscriptionProvider: VSCodeAzureSubscriptionProvider, -): Promise { - /** - * Ensure the user is signed in to Azure - */ - if (!(await azureSubscriptionProvider.isSignedIn())) { - const signIn: vscode.MessageItem = { title: l10n.t('Sign In') }; - void vscode.window - .showInformationMessage(l10n.t('You are not signed in to Azure. Sign in and retry.'), signIn) - .then(async (input) => { - if (input === signIn) { - await azureSubscriptionProvider.signIn(); - ext.discoveryBranchDataProvider.refresh(); - } - }); - - // return so that the signIn flow can be completed before continuing - return; - } - - const selectedSubscriptionIds = getSelectedSubscriptionIds(); - - // it's an async function so that the wizard when shown can show the 'loading' state - const subscriptionQuickPickItems: () => Promise[]> = async () => { - const allSubscriptions = await azureSubscriptionProvider.getSubscriptions(false); // Get all unfiltered subscriptions - const duplicates = getDuplicateSubscriptions(allSubscriptions); - - return allSubscriptions - .map( - (subscription) => - >{ - label: duplicates.includes(subscription) - ? subscription.name + ` (${subscription.account?.label})` - : subscription.name, - description: subscription.subscriptionId, - data: subscription, - group: subscription.account.label, - iconPath: vscode.Uri.joinPath( - ext.context.extensionUri, - 'resources', - 'from_node_modules', - '@microsoft', - 'vscode-azext-azureutils', - 'resources', - 'azureSubscription.svg', - ), - }, - ) - .sort((a, b) => a.label.localeCompare(b.label)); - }; - - const picks = await context.ui.showQuickPick(subscriptionQuickPickItems(), { - canPickMany: true, - placeHolder: l10n.t('Select Subscriptions'), - isPickSelected: (pick) => { - return ( - selectedSubscriptionIds.length === 0 || - selectedSubscriptionIds.includes((pick as IAzureQuickPickItem).data.subscriptionId) - ); - }, - }); - - if (picks) { - // Update the setting with the new selection - const newSelectedIds = picks.map((pick) => `${pick.data.tenantId}/${pick.data.subscriptionId}`); - await setSelectedSubscriptionIds(newSelectedIds); - } -} diff --git a/src/plugins/api-shared/azure/subscriptionFiltering/ExecuteStep.ts b/src/plugins/api-shared/azure/subscriptionFiltering/ExecuteStep.ts new file mode 100644 index 000000000..c6779959e --- /dev/null +++ b/src/plugins/api-shared/azure/subscriptionFiltering/ExecuteStep.ts @@ -0,0 +1,127 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { AzureWizardExecuteStep } from '@microsoft/vscode-azext-utils'; +import * as l10n from '@vscode/l10n'; +import { ext } from '../../../../extensionVariables'; +import { type FilteringWizardContext } from './FilteringWizardContext'; +import { + addUnselectedTenant, + removeUnselectedTenant, + setSelectedSubscriptionIds, +} from './subscriptionFilteringHelpers'; + +export class ExecuteStep extends AzureWizardExecuteStep { + public priority: number = 100; + + public async execute(context: FilteringWizardContext): Promise { + const executeStartTime = Date.now(); + + ext.outputChannel.appendLine(l10n.t('Applying Azure discovery filters…')); + + // Apply tenant filtering if tenants were selected + if (context.selectedTenants && context.availableTenants && context.availableTenants.length > 0) { + await this.applyTenantFiltering(context); + } + + await this.applySubscriptionFiltering(context); + + ext.outputChannel.appendLine(l10n.t('Refreshing Azure discovery tree…')); + ext.discoveryBranchDataProvider.refresh(); + + ext.outputChannel.appendLine(l10n.t('Azure discovery filters applied successfully.')); + + // Add completion telemetry + context.telemetry.measurements.filteringExecutionTimeMs = Date.now() - executeStartTime; + context.telemetry.properties.filteringExecutionResult = 'Succeeded'; + } + + private async applyTenantFiltering(context: FilteringWizardContext): Promise { + const selectedTenants = context.selectedTenants || []; + const allTenants = context.availableTenants || []; + + ext.outputChannel.appendLine(l10n.t('Configuring tenant filtering…')); + + // Get all unique account IDs from subscriptions to apply tenant filtering per account + const accountIds = new Set(); + if (context.allSubscriptions) { + for (const subscription of context.allSubscriptions) { + if (subscription.account?.id) { + accountIds.add(subscription.account.id); + } + } + } + + const selectedTenantIds = new Set(selectedTenants.map((tenant) => tenant.tenantId || '')); + + // Add telemetry for tenant filtering + context.telemetry.measurements.tenantFilteringCount = allTenants.length; + context.telemetry.measurements.selectedFinalTenantsCount = selectedTenants.length; + context.telemetry.properties.filteringActionType = 'tenantFiltering'; + + // Apply tenant filtering for each account + for (const accountId of accountIds) { + // Process each tenant - add to unselected if not selected, remove from unselected if selected + for (const tenant of allTenants) { + const tenantId = tenant.tenantId || ''; + if (selectedTenantIds.has(tenantId)) { + // Tenant is selected, so remove it from unselected list (make it available) + await removeUnselectedTenant(tenantId, accountId); + } else { + // Tenant is not selected, so add it to unselected list (filter it out) + await addUnselectedTenant(tenantId, accountId); + } + } + } + + ext.outputChannel.appendLine( + l10n.t('Successfully configured tenant filtering. Selected {0} tenant(s)', selectedTenants.length), + ); + + if (selectedTenants.length > 0) { + const tenantNames = selectedTenants.map( + (tenant) => tenant.displayName || tenant.tenantId || l10n.t('Unknown tenant'), + ); + ext.outputChannel.appendLine(l10n.t('Selected tenants: {0}', tenantNames.join(', '))); + } else { + ext.outputChannel.appendLine( + l10n.t('No tenants selected. Azure discovery will be filtered to exclude all tenant results.'), + ); + } + } + + private async applySubscriptionFiltering(context: FilteringWizardContext): Promise { + const selectedSubscriptions = context.selectedSubscriptions || []; + + ext.outputChannel.appendLine(l10n.t('Configuring subscription filtering…')); + + // Convert subscriptions to the format expected by setSelectedSubscriptionIds + const selectedIds = selectedSubscriptions.map( + (subscription) => `${subscription.tenantId}/${subscription.subscriptionId}`, + ); + + // Store the selected subscription IDs + await setSelectedSubscriptionIds(selectedIds); + + ext.outputChannel.appendLine( + l10n.t( + 'Successfully configured subscription filtering. Selected {0} subscription(s)', + selectedSubscriptions.length, + ), + ); + + if (selectedSubscriptions.length > 0) { + const subscriptionNames = selectedSubscriptions.map( + (subscription) => subscription.name || subscription.subscriptionId, + ); + ext.outputChannel.appendLine(l10n.t('Selected subscriptions: {0}', subscriptionNames.join(', '))); + } + } + + public shouldExecute(context: FilteringWizardContext): boolean { + // Execute if we have either tenant or subscription filtering to apply + return !!(context.selectedTenants || context.selectedSubscriptions); + } +} diff --git a/src/plugins/api-shared/azure/subscriptionFiltering/FilterSubscriptionSubStep.ts b/src/plugins/api-shared/azure/subscriptionFiltering/FilterSubscriptionSubStep.ts new file mode 100644 index 000000000..3d8d889de --- /dev/null +++ b/src/plugins/api-shared/azure/subscriptionFiltering/FilterSubscriptionSubStep.ts @@ -0,0 +1,113 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { type AzureSubscription } from '@microsoft/vscode-azext-azureauth'; +import { AzureWizardPromptStep, type IAzureQuickPickItem } from '@microsoft/vscode-azext-utils'; +import * as l10n from '@vscode/l10n'; +import * as vscode from 'vscode'; +import { ext } from '../../../../extensionVariables'; +import { type FilteringWizardContext } from './FilteringWizardContext'; +import { getDuplicateSubscriptions, getSelectedSubscriptionIds } from './subscriptionFilteringHelpers'; + +export class FilterSubscriptionSubStep extends AzureWizardPromptStep { + public async prompt(context: FilteringWizardContext): Promise { + const selectedSubscriptionIds = getSelectedSubscriptionIds(); + const allSubscriptions = context.allSubscriptions || []; + let availableSubscriptions: AzureSubscription[]; + + // Only filter subscriptions by selected tenants in multi-tenant scenarios + if (context.telemetry.properties.filteringFlow === 'multiTenant') { + const selectedTenants = context.selectedTenants || []; + const selectedTenantIds = new Set(selectedTenants.map((tenant) => tenant.tenantId)); + + availableSubscriptions = allSubscriptions.filter((subscription) => { + // If no tenants selected, show all subscriptions + if (selectedTenantIds.size === 0) { + return true; + } + // Otherwise, only show subscriptions from selected tenants + return selectedTenantIds.has(subscription.tenantId); + }); + } else { + // Single tenant scenario: show all subscriptions without tenant filtering + availableSubscriptions = allSubscriptions; + } + + // Add telemetry for subscription filtering + context.telemetry.measurements.subscriptionsAfterTenantFiltering = availableSubscriptions.length; + + if (availableSubscriptions.length === 0) { + void vscode.window.showWarningMessage( + l10n.t( + 'No subscriptions found for the selected tenants. Please adjust your tenant selection or check your Azure permissions.', + ), + ); + return; + } + + // Build tenant display name lookup from preloaded tenant data + const tenantDisplayNames = new Map(); + const availableTenants = context.availableTenants || []; + for (const tenant of availableTenants) { + if (tenant.tenantId && tenant.displayName) { + tenantDisplayNames.set(tenant.tenantId, tenant.displayName); + } + } + + // Use duplicate detection logic + const duplicates = getDuplicateSubscriptions(availableSubscriptions); + + // Create subscription quick pick items (data is preloaded, no async needed) + const subscriptionItems: IAzureQuickPickItem[] = availableSubscriptions + .map((subscription) => { + const tenantName = tenantDisplayNames.get(subscription.tenantId); + + // Build description with tenant information + const description = tenantName + ? `${subscription.subscriptionId} (${tenantName})` + : subscription.subscriptionId; + + return >{ + label: duplicates.includes(subscription) + ? subscription.name + ` (${subscription.account?.label})` + : subscription.name, + description, + data: subscription, + group: subscription.account.label, + iconPath: vscode.Uri.joinPath( + ext.context.extensionUri, + 'resources', + 'from_node_modules', + '@microsoft', + 'vscode-azext-azureutils', + 'resources', + 'azureSubscription.svg', + ), + }; + }) + .sort((a, b) => a.label.localeCompare(b.label)); + + const selectedItems = await context.ui.showQuickPick(subscriptionItems, { + stepName: 'filterSubscriptions', + canPickMany: true, + placeHolder: l10n.t('Select subscriptions to include in service discovery'), + isPickSelected: (item: IAzureQuickPickItem) => + selectedSubscriptionIds.includes(item.data.subscriptionId), + }); + + const selectedSubscriptions = selectedItems.map((item) => item.data); + + // Add telemetry for subscription selection + context.telemetry.measurements.subscriptionsSelected = selectedItems.length; + context.telemetry.measurements.subscriptionsUnselected = subscriptionItems.length - selectedItems.length; + + // Store the selected subscriptions in context for the execute step + context.selectedSubscriptions = selectedSubscriptions; + } + + public shouldPrompt(): boolean { + return true; + } +} diff --git a/src/plugins/api-shared/azure/subscriptionFiltering/FilterTenantSubStep.ts b/src/plugins/api-shared/azure/subscriptionFiltering/FilterTenantSubStep.ts new file mode 100644 index 000000000..ccf644cce --- /dev/null +++ b/src/plugins/api-shared/azure/subscriptionFiltering/FilterTenantSubStep.ts @@ -0,0 +1,89 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { type AzureTenant } from '@microsoft/vscode-azext-azureauth'; +import { AzureWizardPromptStep } from '@microsoft/vscode-azext-utils'; +import * as l10n from '@vscode/l10n'; +import * as vscode from 'vscode'; +import { nonNullValue } from '../../../../utils/nonNull'; +import { type FilteringWizardContext } from './FilteringWizardContext'; + +interface TenantQuickPickItem extends vscode.QuickPickItem { + tenant?: AzureTenant; +} + +export class FilterTenantSubStep extends AzureWizardPromptStep { + public async prompt(context: FilteringWizardContext): Promise { + const tenants = context.availableTenants || []; + + // Add telemetry for tenant filtering + context.telemetry.measurements.availableTenantsForFilteringCount = tenants.length; + + if (tenants.length === 0) { + void vscode.window.showWarningMessage( + l10n.t('No tenants found. Please try signing in again or check your Azure permissions.'), + ); + return; + } + + // Create quick pick items for tenants (data is preloaded, no async needed) + const tenantItems: TenantQuickPickItem[] = tenants.map((tenant) => { + return { + label: tenant.displayName ?? tenant.tenantId ?? '', + detail: tenant.tenantId, + description: tenant.defaultDomain, + group: tenant.account.label, + iconPath: new vscode.ThemeIcon('organization'), + tenant, + }; + }); + + const selectedItems = await context.ui.showQuickPick(tenantItems, { + stepName: 'filterTenants', + placeHolder: l10n.t('Select tenants to include in subscription discovery'), + canPickMany: true, + enableGrouping: true, + matchOnDescription: true, + suppressPersistence: true, + loadingPlaceHolder: l10n.t('Loading Tenant Filter Options…'), + isPickSelected: (pick) => { + const tenantPick = pick as TenantQuickPickItem; + + if (!tenantPick.tenant?.tenantId) { + return true; // Default to selected if no tenant ID + } + + // Use the preinitialized selectedTenants from context (handles both initial and going back scenarios) + if (context.selectedTenants && context.selectedTenants.length > 0) { + return context.selectedTenants.some( + (selectedTenant) => selectedTenant.tenantId === tenantPick.tenant?.tenantId, + ); + } + + // Fallback to true if no selectedTenants (shouldn't happen with proper initialization) + return true; + }, + }); + + // Extract selected tenants + context.selectedTenants = selectedItems.map((item) => + nonNullValue(item.tenant, 'item.tenant', 'FilterTenantSubStep.ts'), + ); + + // Add telemetry for tenant selection + const totalTenants = context.availableTenants?.length ?? 0; + context.telemetry.measurements.selectedTenantsForFilteringCount = selectedItems.length; + context.telemetry.measurements.unselectedTenantsForFilteringCount = totalTenants - selectedItems.length; + context.telemetry.properties.allTenantsSelectedForFiltering = ( + selectedItems.length === totalTenants + ).toString(); + context.telemetry.properties.noTenantsSelectedForFiltering = (selectedItems.length === 0).toString(); + } + + public shouldPrompt(): boolean { + // The decision has been made in the init step when the subwizard was constructed + return true; + } +} diff --git a/src/plugins/api-shared/azure/subscriptionFiltering/FilteringWizardContext.ts b/src/plugins/api-shared/azure/subscriptionFiltering/FilteringWizardContext.ts new file mode 100644 index 000000000..c3df6810f --- /dev/null +++ b/src/plugins/api-shared/azure/subscriptionFiltering/FilteringWizardContext.ts @@ -0,0 +1,23 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { + type AzureSubscription, + type AzureTenant, + type VSCodeAzureSubscriptionProvider, +} from '@microsoft/vscode-azext-azureauth'; +import { type IActionContext } from '@microsoft/vscode-azext-utils'; + +export interface FilteringWizardContext extends IActionContext { + azureSubscriptionProvider: VSCodeAzureSubscriptionProvider; + + // Initialized data + availableTenants?: AzureTenant[]; + allSubscriptions?: AzureSubscription[]; + + // User selections + selectedTenants?: AzureTenant[]; + selectedSubscriptions?: AzureSubscription[]; +} diff --git a/src/plugins/api-shared/azure/subscriptionFiltering/InitializeFilteringStep.ts b/src/plugins/api-shared/azure/subscriptionFiltering/InitializeFilteringStep.ts new file mode 100644 index 000000000..f52330545 --- /dev/null +++ b/src/plugins/api-shared/azure/subscriptionFiltering/InitializeFilteringStep.ts @@ -0,0 +1,175 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { type AzureTenant, type VSCodeAzureSubscriptionProvider } from '@microsoft/vscode-azext-azureauth'; +import { + AzureWizardPromptStep, + type IActionContext, + type IWizardOptions, + UserCancelledError, +} from '@microsoft/vscode-azext-utils'; +import { type AzureSubscription } from '@microsoft/vscode-azureresources-api'; +import * as l10n from '@vscode/l10n'; +import { type QuickPickItem } from 'vscode'; +import { askToConfigureCredentials } from '../askToConfigureCredentials'; +import { type AzureSubscriptionProviderWithFilters } from '../AzureSubscriptionProviderWithFilters'; +import { type FilteringWizardContext } from './FilteringWizardContext'; +import { FilterSubscriptionSubStep } from './FilterSubscriptionSubStep'; +import { FilterTenantSubStep } from './FilterTenantSubStep'; +import { getTenantFilteredSubscriptions, isTenantFilteredOut } from './subscriptionFilteringHelpers'; + +/** + * Custom error to signal that initialization has completed and wizard should proceed to subwizard + */ +class InitializationCompleteError extends Error { + constructor(message: string = 'Filtering initialization completed successfully') { + super(message); + this.name = 'InitializationCompleteError'; + } +} + +/** + * Initialize filtering data and determine the appropriate subwizard flow based on tenant count + */ +export class InitializeFilteringStep extends AzureWizardPromptStep { + public async prompt(context: FilteringWizardContext): Promise { + try { + // Use QuickPick with loading state for unified UX + await context.ui.showQuickPick(this.initializeFilteringData(context), { + loadingPlaceHolder: l10n.t('Loading Tenants and Subscription Data…'), + suppressPersistence: true, + }); + } catch (error) { + if (error instanceof InitializationCompleteError) { + // Initialization completed - this is expected behavior + // The exception signals that initialization is done and we should proceed to subwizard + // Note: This was the only way to make the quick pick terminate. We're using it + // to maintain a UX-unified behavior to control the visibility of the tenant-selection step. + // Wizard steps support "shouldPrompt" function and that'd be the preferred path, however + // while "shouldPrompt" is processed, no UI is being shown. This is a bad UX. + return; // Proceed to getSubWizard + } + // Re-throw any other errors + throw error; + } + } + + private async initializeFilteringData(context: FilteringWizardContext): Promise { + const azureSubscriptionProvider = context.azureSubscriptionProvider; + + const tenantLoadStartTime = Date.now(); + context.availableTenants = await azureSubscriptionProvider.getTenants(); + context.availableTenants = context.availableTenants.sort((a, b) => { + // Sort by display name if available, otherwise by tenant ID + const aName = a.displayName || a.tenantId || ''; + const bName = b.displayName || b.tenantId || ''; + return aName.localeCompare(bName); + }); + + context.telemetry.measurements.tenantLoadTimeMs = Date.now() - tenantLoadStartTime; + context.telemetry.measurements.tenantsCount = context.availableTenants.length; + + const subscriptionLoadStartTime = Date.now(); + context.allSubscriptions = await azureSubscriptionProvider.getSubscriptions(false); + context.telemetry.measurements.subscriptionLoadTimeMs = Date.now() - subscriptionLoadStartTime; + context.telemetry.measurements.allSubscriptionsCount = context.allSubscriptions.length; + + // Check if there are any tenant-filtered subscriptions available + const filteredSubscriptions = getTenantFilteredSubscriptions(context.allSubscriptions); + if (!filteredSubscriptions || filteredSubscriptions.length === 0) { + // Show modal dialog for empty state + const configureResult = await askToConfigureCredentials(); + if (configureResult === 'configure') { + await this.configureCredentialsFromWizard(context, azureSubscriptionProvider); + + throw new UserCancelledError('User chose to configure Azure credentials'); + } + // User chose not to configure - also cancel the wizard since there's nothing to filter + throw new UserCancelledError('No subscriptions available for filtering'); + } + + // Initialize selectedTenants based on current filtering state (only if not already set from going back) + if (!context.selectedTenants) { + context.selectedTenants = this.getSelectedTenantsFromSettings(context.availableTenants); + context.telemetry.measurements.initialSelectedTenantCount = context.selectedTenants.length; + } + + // Determine the flow based on tenant count, but let's look at the actual subscriptions, + // so that in case of a tenant without subscriptions, we don't bother the user with these. + const uniqueTenants = this.getUniqueTenants(context.allSubscriptions); + context.telemetry.properties.tenantCountFromSubscriptions = uniqueTenants.length.toString(); + + if (uniqueTenants.length > 1) { + context.telemetry.properties.filteringFlow = 'multiTenant'; + } else { + context.telemetry.properties.filteringFlow = 'singleTenant'; + } + + // Throw exception to signal initialization completion and auto-proceed to subwizard + throw new InitializationCompleteError('Tenant and subscription initialization completed'); + } + + private getUniqueTenants(subscriptions: AzureSubscription[]): string[] { + const tenantIds = new Set(); + for (const subscription of subscriptions) { + if (subscription.tenantId) { + tenantIds.add(subscription.tenantId); + } + } + return Array.from(tenantIds); + } + + private getSelectedTenantsFromSettings(availableTenants: AzureTenant[]): AzureTenant[] { + // Initialize selectedTenants based on current filtering state + // Include tenants that are NOT filtered out (i.e., currently selected) + return availableTenants.filter((tenant) => { + if (tenant.tenantId && tenant.account?.id) { + // Tenant is selected if it's NOT filtered out + return !isTenantFilteredOut(tenant.tenantId, tenant.account.id); + } + // Default to selected if no tenant ID or account ID + return true; + }); + } + + // eslint-disable-next-line @typescript-eslint/require-await + public async getSubWizard(context: FilteringWizardContext): Promise> { + if (context.telemetry.properties.filteringFlow === 'multiTenant') { + // Multi-tenant: show both tenant and subscription filtering + return { + title: l10n.t('Configure Tenant & Subscription Filters'), + promptSteps: [new FilterTenantSubStep(), new FilterSubscriptionSubStep()], + }; + } else { + // Single tenant: skip directly to subscription filtering + return { + title: l10n.t('Configure Subscription Filter'), + promptSteps: [new FilterSubscriptionSubStep()], + }; + } + } + + private async configureCredentialsFromWizard( + context: IActionContext, + subscriptionProvider: VSCodeAzureSubscriptionProvider, + ): Promise { + // Add telemetry for credential configuration activation + context.telemetry.properties.credentialConfigActivated = 'true'; + context.telemetry.properties.nodeProvided = 'false'; + + // Call the credentials management function directly using the subscription provider from context + // The subscription provider in the wizard context is actually AzureSubscriptionProviderWithFilters + const { configureAzureCredentials } = await import('../credentialsManagement'); + await configureAzureCredentials( + context, + subscriptionProvider as AzureSubscriptionProviderWithFilters, + undefined, + ); + } + + public shouldPrompt(): boolean { + return true; + } +} diff --git a/src/plugins/api-shared/azure/subscriptionFiltering/configureAzureSubscriptionFilter.ts b/src/plugins/api-shared/azure/subscriptionFiltering/configureAzureSubscriptionFilter.ts new file mode 100644 index 000000000..f9938ab16 --- /dev/null +++ b/src/plugins/api-shared/azure/subscriptionFiltering/configureAzureSubscriptionFilter.ts @@ -0,0 +1,86 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { type VSCodeAzureSubscriptionProvider } from '@microsoft/vscode-azext-azureauth'; +import { + AzureWizard, + type AzureWizardExecuteStep, + type AzureWizardPromptStep, + type IActionContext, +} from '@microsoft/vscode-azext-utils'; +import * as l10n from '@vscode/l10n'; +import { ExecuteStep } from './ExecuteStep'; +import { type FilteringWizardContext } from './FilteringWizardContext'; +import { InitializeFilteringStep } from './InitializeFilteringStep'; + +/** + * Options for extending the subscription filtering wizard with additional steps + */ +export interface SubscriptionFilteringOptions { + /** Additional prompt steps to include after the standard filtering steps */ + additionalPromptSteps?: AzureWizardPromptStep[]; + /** Additional execute steps to include after the standard execute steps */ + additionalExecuteSteps?: AzureWizardExecuteStep[]; + /** Function to extend the wizard context with additional properties */ + contextExtender?: (context: FilteringWizardContext) => TContext; + /** Custom title for the wizard */ + title?: string; +} + +/** + * Configures the Azure subscription filter using the wizard pattern. + */ +export async function configureAzureSubscriptionFilter< + TContext extends FilteringWizardContext = FilteringWizardContext, +>( + context: IActionContext, + azureSubscriptionProvider: VSCodeAzureSubscriptionProvider, + options?: SubscriptionFilteringOptions, +): Promise { + context.telemetry.properties.subscriptionFiltering = 'configureAzureSubscriptionFilter'; + + // Create base wizard context + const baseWizardContext: FilteringWizardContext = { + ...context, + azureSubscriptionProvider, + }; + + // Extend context if extender is provided + const wizardContext = options?.contextExtender + ? options.contextExtender(baseWizardContext) + : (baseWizardContext as TContext); + + // Build prompt steps + const promptSteps: AzureWizardPromptStep[] = [ + new InitializeFilteringStep() as AzureWizardPromptStep, + ]; + if (options?.additionalPromptSteps) { + promptSteps.push(...options.additionalPromptSteps); + } + + // Build execute steps + const executeSteps: AzureWizardExecuteStep[] = [new ExecuteStep() as AzureWizardExecuteStep]; + if (options?.additionalExecuteSteps) { + executeSteps.push(...options.additionalExecuteSteps); + } + + // Create and run wizard + const wizard = new AzureWizard(wizardContext, { + title: options?.title || l10n.t('Configure Azure Discovery Filters'), + promptSteps, + executeSteps, + }); + + try { + await wizard.prompt(); + await wizard.execute(); + context.telemetry.properties.subscriptionFilteringResult = 'Succeeded'; + } catch (error) { + context.telemetry.properties.subscriptionFilteringResult = 'Failed'; + context.telemetry.properties.subscriptionFilteringError = + error instanceof Error ? error.message : String(error); + throw error; + } +} diff --git a/src/plugins/api-shared/azure/subscriptionFiltering/index.ts b/src/plugins/api-shared/azure/subscriptionFiltering/index.ts new file mode 100644 index 000000000..36664d6eb --- /dev/null +++ b/src/plugins/api-shared/azure/subscriptionFiltering/index.ts @@ -0,0 +1,12 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +export { configureAzureSubscriptionFilter } from './configureAzureSubscriptionFilter'; +export * from './ExecuteStep'; +export * from './FilteringWizardContext'; +export * from './FilterSubscriptionSubStep'; +export * from './FilterTenantSubStep'; +export * from './InitializeFilteringStep'; +export * from './subscriptionFilteringHelpers'; diff --git a/src/plugins/api-shared/azure/subscriptionFiltering/subscriptionFilteringHelpers.ts b/src/plugins/api-shared/azure/subscriptionFiltering/subscriptionFilteringHelpers.ts new file mode 100644 index 000000000..05470d8b2 --- /dev/null +++ b/src/plugins/api-shared/azure/subscriptionFiltering/subscriptionFilteringHelpers.ts @@ -0,0 +1,182 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { type AzureSubscription } from '@microsoft/vscode-azext-azureauth'; +import * as vscode from 'vscode'; +import { ext } from '../../../../extensionVariables'; + +/** + * Subscription filtering functionality is provided by the `VSCodeAzureSubscriptionProvider` + * from the `vscode-azuretools` library: + * https://github.com/microsoft/vscode-azuretools/blob/main/auth/src/VSCodeAzureSubscriptionProvider.ts + * + * Although the provider supports filtering subscriptions internally, it does not include built-in + * UI or configuration logic to manage these filters directly. + * + * Instead, the `vscode-azuretools` library relies on filter settings stored by another extension, + * `vscode-azureresourcegroups`. Specifically, it uses a hardcoded configuration key: + * - Configuration section: 'azureResourceGroups' + * - Configuration property: 'subscriptions' + * + * To avoid introducing a direct dependency on the `vscode-azureresourcegroups` extension, + * we replicate the filter logic here by accessing the same configuration keys. This approach + * ensures consistency and compatibility, as users of Azure Service Discovery are likely also + * using the Azure Resource Groups extension. + */ + +/** + * Returns the currently selected subscription IDs from the shared configuration. + * The ID of the tenant is being excluded from the ID. + * The IDs are stored in the format 'tenantId/subscriptionId'. + * For example: 'tenantId/subscriptionId'. + * The function returns an array of subscription IDs without the tenant ID. + * For example: 'subscriptionId'. + * + * @returns An array of selected subscription IDs. + */ +export function getSelectedSubscriptionIds(): string[] { + // Try the Azure Resource Groups config first (primary storage) + const azureResourcesConfig = vscode.workspace.getConfiguration('azureResourceGroups'); + const primarySubscriptionIds = azureResourcesConfig.get('selectedSubscriptions'); + + // If no configuration found (undefined), try our fallback storage + if (primarySubscriptionIds === undefined) { + const fallbackSubscriptionIds = ext.context.globalState.get( + 'azure-discovery.selectedSubscriptions', + [], + ); + return fallbackSubscriptionIds.map((id) => id.split('/')[1]); + } + + // Sync from primary storage to fallback storage (even if empty array) + // This ensures we maintain a backup copy in case the Azure Resources extension goes down later + void ext.context.globalState.update('azure-discovery.selectedSubscriptions', primarySubscriptionIds); + + return primarySubscriptionIds.map((id) => id.split('/')[1]); +} + +/** + * Updates the selected subscription IDs in the shared configuration. + * These have to contain the full subscription ID, which is a combination of the tenant ID and subscription ID. + * For example: 'tenantId/subscriptionId'. + */ +export async function setSelectedSubscriptionIds(subscriptionIds: string[]): Promise { + try { + const azureResourcesConfig = vscode.workspace.getConfiguration('azureResourceGroups'); + await azureResourcesConfig.update('selectedSubscriptions', subscriptionIds, vscode.ConfigurationTarget.Global); + } catch (error) { + // Log the error if the primary storage (Azure Resource Groups config) update fails + console.error('Unable to update Azure Resource Groups configuration, using fallback storage.', error); + } finally { + // Always update our fallback storage regardless of primary storage success + await ext.context.globalState.update('azure-discovery.selectedSubscriptions', subscriptionIds); + } +} + +/** + * Checks if a tenant is filtered out based on stored tenant filters. + * + * Note: The Azure Resource Groups extension stores unselected tenants in their own + * extension's globalState using context.globalState.get('unselectedTenants'). + * Since each extension has its own isolated globalState, we cannot access their data. + * We replicate their behavior using our own storage so that if Azure Resource Groups + * ever exposes their unselected tenants list publicly, we can set up synchronization. + * + * @param tenantId The tenant ID to check + * @param accountId The account ID associated with the tenant + * @returns True if the tenant is filtered out (unchecked), false otherwise + */ +export function isTenantFilteredOut(tenantId: string, accountId: string): boolean { + const unselectedTenants = ext.context.globalState.get('azure-discovery.unselectedTenants', []); + return unselectedTenants.includes(`${tenantId}/${accountId}`); +} + +/** + * Filters subscriptions based on tenant selection settings. + * Returns only subscriptions from selected tenants. + * + * @param subscriptions All subscriptions returned from the API + * @returns Filtered subscriptions from selected tenants only + */ +export function getTenantFilteredSubscriptions(subscriptions: AzureSubscription[]): AzureSubscription[] { + const filteredSubscriptions = subscriptions.filter( + (subscription) => !isTenantFilteredOut(subscription.tenantId, subscription.account.id), + ); + + // If filtering would result in an empty list, return all subscriptions as a fallback + return filteredSubscriptions.length > 0 ? filteredSubscriptions : subscriptions; +} + +/** + * Adds a tenant to the unselected tenants list. + * This will filter out the tenant from discovery. + * + * Note: We use our own extension's globalState for tenant filtering since the Azure Resource Groups + * extension's unselected tenants list is not publicly accessible (stored in their own globalState). + * This replicates their behavior so that if they ever expose their data, we can sync with it. + * + * @param tenantId The tenant ID to add to unselected list + * @param accountId The account ID associated with the tenant + */ +export async function addUnselectedTenant(tenantId: string, accountId: string): Promise { + const tenantKey = `${tenantId}/${accountId}`; + const currentUnselectedTenants = ext.context.globalState.get('azure-discovery.unselectedTenants', []); + + // Add if not already present + if (!currentUnselectedTenants.includes(tenantKey)) { + const updatedUnselectedTenants = [...currentUnselectedTenants, tenantKey]; + await ext.context.globalState.update('azure-discovery.unselectedTenants', updatedUnselectedTenants); + } +} + +/** + * Removes a tenant from the unselected tenants list. + * This will make the tenant available for discovery. + * + * Note: We use our own extension's globalState for tenant filtering since the Azure Resource Groups + * extension's unselected tenants list is not publicly accessible (stored in their own globalState). + * This replicates their behavior so that if they ever expose their data, we can sync with it. + * + * @param tenantId The tenant ID to remove from unselected list + * @param accountId The account ID associated with the tenant + */ +export async function removeUnselectedTenant(tenantId: string, accountId: string): Promise { + const tenantKey = `${tenantId}/${accountId}`; + const currentUnselectedTenants = ext.context.globalState.get('azure-discovery.unselectedTenants', []); + + // Remove if present + const updatedUnselectedTenants = currentUnselectedTenants.filter((tenant) => tenant !== tenantKey); + await ext.context.globalState.update('azure-discovery.unselectedTenants', updatedUnselectedTenants); +} + +/** + * Clears all tenant filtering configuration. + * This will make all tenants available for discovery. + * + * Note: We use our own extension's globalState for tenant filtering since the Azure Resource Groups + * extension's unselected tenants list is not publicly accessible (stored in their own globalState). + * This replicates their behavior so that if they ever expose their data, we can sync with it. + */ +export async function clearTenantFiltering(): Promise { + await ext.context.globalState.update('azure-discovery.unselectedTenants', []); +} + +/** + * Identifies subscriptions with duplicate names. + */ +export function getDuplicateSubscriptions(subscriptions: AzureSubscription[]): AzureSubscription[] { + const names = new Map(); + const duplicates: AzureSubscription[] = []; + + for (const subscription of subscriptions) { + const count = (names.get(subscription.name) || 0) + 1; + names.set(subscription.name, count); + if (count > 1) { + duplicates.push(subscription); + } + } + + return subscriptions.filter((s) => names.get(s.name)! > 1); +} diff --git a/src/plugins/api-shared/azure/wizard/SelectSubscriptionStep.ts b/src/plugins/api-shared/azure/wizard/SelectSubscriptionStep.ts index 54bbe9a9d..60f259e00 100644 --- a/src/plugins/api-shared/azure/wizard/SelectSubscriptionStep.ts +++ b/src/plugins/api-shared/azure/wizard/SelectSubscriptionStep.ts @@ -3,12 +3,15 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { VSCodeAzureSubscriptionProvider } from '@microsoft/vscode-azext-azureauth'; +import { VSCodeAzureSubscriptionProvider, type AzureSubscription } from '@microsoft/vscode-azext-azureauth'; import { AzureWizardPromptStep, UserCancelledError } from '@microsoft/vscode-azext-utils'; import * as l10n from '@vscode/l10n'; -import { Uri, window, type MessageItem, type QuickPickItem } from 'vscode'; +import { QuickPickItemKind, ThemeIcon, Uri, window, type QuickPickItem } from 'vscode'; import { type NewConnectionWizardContext } from '../../../../commands/newConnection/NewConnectionWizardContext'; import { ext } from '../../../../extensionVariables'; +import { askToConfigureCredentials } from '../askToConfigureCredentials'; +import { type AzureSubscriptionProviderWithFilters } from '../AzureSubscriptionProviderWithFilters'; +import { getDuplicateSubscriptions } from '../subscriptionFiltering/subscriptionFilteringHelpers'; import { AzureContextProperties } from './AzureContextProperties'; export class SelectSubscriptionStep extends AzureWizardPromptStep { @@ -37,45 +40,103 @@ export class SelectSubscriptionStep extends AzureWizardPromptStep { - if (input === signIn) { - void subscriptionProvider.signIn(); + // Store subscriptions outside the async function so we can access them later + let subscriptions!: Awaited; + + // Create async function to provide better loading UX and debugging experience + const getSubscriptionQuickPickItems = async (): Promise<(QuickPickItem & { id: string })[]> => { + // Note: No tenant filtering here, because this flow should allow the user to access everything with no filtering. + subscriptions = await subscriptionProvider.getSubscriptions(false); + + // This information is extracted to improve the UX, that's why there are fallbacks to 'undefined' + // Note to future maintainers: we used to run getSubscriptions and getTenants "in parallel", however + // this lead to incorrect responses from getSubscriptions. We didn't investigate + const tenantPromise = subscriptionProvider.getTenants().catch(() => undefined); + const timeoutPromise = new Promise((resolve) => setTimeout(() => resolve(undefined), 5000)); + const knownTenants = await Promise.race([tenantPromise, timeoutPromise]); + + // Build tenant display name lookup for better UX + const tenantDisplayNames = new Map(); + + if (knownTenants) { + for (const tenant of knownTenants) { + if (tenant.tenantId && tenant.displayName) { + tenantDisplayNames.set(tenant.tenantId, tenant.displayName); } - }); + } + } - throw new UserCancelledError(l10n.t('User is not signed in to Azure.')); - } + // Check for empty state first + if (subscriptions.length === 0) { + // Show modal dialog for empty state + const configureResult = await askToConfigureCredentials(); + if (configureResult === 'configure') { + await this.configureCredentialsFromWizard(context, subscriptionProvider); + await this.showRetryInstructions(); + } + // Both paths abort the wizard + throw new UserCancelledError('No subscriptions available'); + } - const subscriptions = await subscriptionProvider.getSubscriptions(false); + // Use duplicate detection logic from subscriptionFiltering + const duplicates = getDuplicateSubscriptions(subscriptions); - const promptItems: (QuickPickItem & { id: string })[] = subscriptions - .map((subscription) => ({ - id: subscription.subscriptionId, - label: subscription.name, - description: subscription.subscriptionId, - iconPath: this.iconPath, + const subscriptionItems = subscriptions + .map((subscription) => { + const tenantName = tenantDisplayNames.get(subscription.tenantId); - alwaysShow: true, - })) - // Sort alphabetically - .sort((a, b) => a.label.localeCompare(b.label)); + // Handle duplicate subscription names by adding account label + const label = duplicates.includes(subscription) + ? `${subscription.name} (${subscription.account?.label})` + : subscription.name; - const selectedItem = await context.ui.showQuickPick([...promptItems], { + // Build description with tenant information + const description = tenantName + ? `${subscription.subscriptionId} (${tenantName})` + : subscription.subscriptionId; + + return { + id: subscription.subscriptionId, + label, + description, + iconPath: this.iconPath, + alwaysShow: true, + }; + }) + .sort((a, b) => a.label.localeCompare(b.label)); + + // Add edit entry at the top + return [ + { + id: 'editAccountsAndTenants', + label: l10n.t('Sign in to other Azure accounts to access more subscriptions'), + iconPath: new ThemeIcon('key'), + alwaysShow: true, + }, + { id: 'separator', label: '', kind: QuickPickItemKind.Separator }, + ...subscriptionItems, + ]; + }; + + const selectedItem = await context.ui.showQuickPick(getSubscriptionQuickPickItems(), { stepName: 'selectSubscription', - placeHolder: l10n.t('Choose a subscription…'), - loadingPlaceHolder: l10n.t('Loading subscriptions…'), - enableGrouping: true, + placeHolder: l10n.t('Choose a Subscription…'), + loadingPlaceHolder: l10n.t('Loading Subscriptions…'), + enableGrouping: false, matchOnDescription: true, suppressPersistence: true, }); + // Handle edit accounts selection + if (selectedItem.id === 'editAccountsAndTenants') { + await this.configureCredentialsFromWizard(context, subscriptionProvider); + await this.showRetryInstructions(); + + // Exit wizard - user needs to restart service discovery + throw new UserCancelledError('Account management completed'); + } + + // Use the subscriptions we already loaded (no second API call needed) context.properties[AzureContextProperties.SelectedSubscription] = subscriptions.find( (subscription) => subscription.subscriptionId === selectedItem.id, ); @@ -84,4 +145,35 @@ export class SelectSubscriptionStep extends AzureWizardPromptStep { + // Add telemetry for credential configuration activation + context.telemetry.properties.credentialConfigActivated = 'true'; + context.telemetry.properties.nodeProvided = 'false'; + + // Call the credentials management function directly using the subscription provider from context + // The subscription provider in the wizard context is actually AzureSubscriptionProviderWithFilters + const { configureAzureCredentials } = await import('../credentialsManagement'); + await configureAzureCredentials( + context, + subscriptionProvider as AzureSubscriptionProviderWithFilters, + undefined, + ); + } + + private async showRetryInstructions(): Promise { + await window.showInformationMessage( + l10n.t('Account Management Completed'), + { + modal: true, + detail: l10n.t( + 'The account management flow has completed.\n\nPlease try Service Discovery again to see your available subscriptions.', + ), + }, + l10n.t('OK'), + ); + } } diff --git a/src/plugins/service-azure-mongo-ru/AzureMongoRUDiscoveryProvider.ts b/src/plugins/service-azure-mongo-ru/AzureMongoRUDiscoveryProvider.ts index 34211b5bd..f785cd7a8 100644 --- a/src/plugins/service-azure-mongo-ru/AzureMongoRUDiscoveryProvider.ts +++ b/src/plugins/service-azure-mongo-ru/AzureMongoRUDiscoveryProvider.ts @@ -10,9 +10,9 @@ import { ext } from '../../extensionVariables'; import { type DiscoveryProvider } from '../../services/discoveryServices'; import { type TreeElement } from '../../tree/TreeElement'; import { AzureSubscriptionProviderWithFilters } from '../api-shared/azure/AzureSubscriptionProviderWithFilters'; -import { configureAzureSubscriptionFilter } from '../api-shared/azure/subscriptionFiltering'; +import { configureAzureSubscriptionFilter } from '../api-shared/azure/subscriptionFiltering/configureAzureSubscriptionFilter'; import { AzureContextProperties } from '../api-shared/azure/wizard/AzureContextProperties'; -import { SelectSubscriptionStep } from '../service-azure-vm/discovery-wizard/SelectSubscriptionStep'; +import { SelectSubscriptionStep } from '../api-shared/azure/wizard/SelectSubscriptionStep'; import { AzureMongoRUServiceRootItem } from './discovery-tree/AzureMongoRUServiceRootItem'; import { AzureMongoRUExecuteStep } from './discovery-wizard/AzureMongoRUExecuteStep'; import { SelectRUClusterStep } from './discovery-wizard/SelectRUClusterStep'; @@ -58,4 +58,25 @@ export class AzureMongoRUDiscoveryProvider extends Disposable implements Discove ext.discoveryBranchDataProvider.refresh(node); } } + + async configureCredentials(context: IActionContext, node?: TreeElement): Promise { + // Add telemetry for credential configuration activation + context.telemetry.properties.credentialConfigActivated = 'true'; + context.telemetry.properties.discoveryProviderId = this.id; + context.telemetry.properties.nodeProvided = node ? 'true' : 'false'; + + if (!node || node instanceof AzureMongoRUServiceRootItem) { + // Use the new Azure credentials configuration wizard + const { configureAzureCredentials } = await import('../api-shared/azure/credentialsManagement'); + await configureAzureCredentials(context, this.azureSubscriptionProvider, node); + + if (node) { + // Tree context: refresh specific node + ext.discoveryBranchDataProvider.refresh(node); + } else { + // Wizard context: refresh entire discovery tree + ext.discoveryBranchDataProvider.refresh(); + } + } + } } diff --git a/src/plugins/service-azure-mongo-ru/discovery-tree/AzureMongoRUServiceRootItem.ts b/src/plugins/service-azure-mongo-ru/discovery-tree/AzureMongoRUServiceRootItem.ts index 5a60b20cf..78b0352e6 100644 --- a/src/plugins/service-azure-mongo-ru/discovery-tree/AzureMongoRUServiceRootItem.ts +++ b/src/plugins/service-azure-mongo-ru/discovery-tree/AzureMongoRUServiceRootItem.ts @@ -3,10 +3,9 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { type VSCodeAzureSubscriptionProvider } from '@microsoft/vscode-azext-azureauth'; +import { type AzureTenant, type VSCodeAzureSubscriptionProvider } from '@microsoft/vscode-azext-azureauth'; import * as l10n from '@vscode/l10n'; import * as vscode from 'vscode'; -import { ext } from '../../../extensionVariables'; import { createGenericElementWithContext } from '../../../tree/api/createGenericElementWithContext'; import { type ExtTreeElementBase, type TreeElement } from '../../../tree/TreeElement'; import { @@ -14,13 +13,16 @@ import { type TreeElementWithContextValue, } from '../../../tree/TreeElementWithContextValue'; import { type TreeElementWithRetryChildren } from '../../../tree/TreeElementWithRetryChildren'; +import { askToConfigureCredentials } from '../../api-shared/azure/askToConfigureCredentials'; +import { getTenantFilteredSubscriptions } from '../../api-shared/azure/subscriptionFiltering/subscriptionFilteringHelpers'; import { AzureMongoRUSubscriptionItem } from './AzureMongoRUSubscriptionItem'; export class AzureMongoRUServiceRootItem implements TreeElement, TreeElementWithContextValue, TreeElementWithRetryChildren { public readonly id: string; - public contextValue: string = 'enableRefreshCommand;enableFilterCommand;enableLearnMoreCommand;azureMongoRUService'; + public contextValue: string = + 'enableRefreshCommand;enableManageCredentialsCommand;enableFilterCommand;enableLearnMoreCommand;azureMongoRUService'; constructor( private readonly azureSubscriptionProvider: VSCodeAzureSubscriptionProvider, @@ -30,19 +32,17 @@ export class AzureMongoRUServiceRootItem } async getChildren(): Promise { - /** - * This is an important step to ensure that the user is signed in to Azure before listing subscriptions. - */ - if (!(await this.azureSubscriptionProvider.isSignedIn())) { - const signIn: vscode.MessageItem = { title: l10n.t('Sign In') }; - void vscode.window - .showInformationMessage(l10n.t('You are not signed in to Azure. Sign in and retry.'), signIn) - .then(async (input) => { - if (input === signIn) { - await this.azureSubscriptionProvider.signIn(); - ext.discoveryBranchDataProvider.refresh(); - } - }); + const allSubscriptions = await this.azureSubscriptionProvider.getSubscriptions(true); + const subscriptions = getTenantFilteredSubscriptions(allSubscriptions); + + if (!subscriptions || subscriptions.length === 0) { + // Show modal dialog for empty state + const configureResult = await askToConfigureCredentials(); + if (configureResult === 'configure') { + // Note to future maintainers: 'void' is important here so that the return below returns the error node. + // Otherwise, the /retry node might be duplicated as we're inside of tree node with a loading state (the node items are being swapped etc.) + void vscode.commands.executeCommand('vscode-documentdb.command.discoveryView.manageCredentials', this); + } return [ createGenericElementWithContext({ @@ -56,9 +56,21 @@ export class AzureMongoRUServiceRootItem ]; } - const subscriptions = await this.azureSubscriptionProvider.getSubscriptions(true); - if (!subscriptions || subscriptions.length === 0) { - return []; + // This information is extracted to improve the UX, that's why there are fallbacks to 'undefined' + // Note to future maintainers: we used to run getSubscriptions and getTenants "in parallel", however + // this lead to incorrect responses from getSubscriptions. We didn't investigate + const tenantPromise = this.azureSubscriptionProvider.getTenants().catch(() => undefined); + const timeoutPromise = new Promise((resolve) => setTimeout(() => resolve(undefined), 5000)); + const knownTenants = await Promise.race([tenantPromise, timeoutPromise]); + + // Build tenant lookup for better performance + const tenantMap = new Map(); + if (knownTenants) { + for (const tenant of knownTenants) { + if (tenant.tenantId) { + tenantMap.set(tenant.tenantId, tenant); + } + } } return ( @@ -71,6 +83,7 @@ export class AzureMongoRUServiceRootItem subscription: sub, subscriptionName: sub.name, subscriptionId: sub.subscriptionId, + tenant: tenantMap.get(sub.tenantId), }); }) ); diff --git a/src/plugins/service-azure-mongo-ru/discovery-tree/AzureMongoRUSubscriptionItem.ts b/src/plugins/service-azure-mongo-ru/discovery-tree/AzureMongoRUSubscriptionItem.ts index a6cdd22db..a2268772a 100644 --- a/src/plugins/service-azure-mongo-ru/discovery-tree/AzureMongoRUSubscriptionItem.ts +++ b/src/plugins/service-azure-mongo-ru/discovery-tree/AzureMongoRUSubscriptionItem.ts @@ -3,6 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +import { type AzureTenant } from '@microsoft/vscode-azext-azureauth'; import { getResourceGroupFromId, uiUtils } from '@microsoft/vscode-azext-azureutils'; import { callWithTelemetryAndErrorHandling, type IActionContext } from '@microsoft/vscode-azext-utils'; import { type AzureSubscription } from '@microsoft/vscode-azureresources-api'; @@ -20,6 +21,7 @@ export interface AzureSubscriptionModel { subscriptionName: string; subscription: AzureSubscription; subscriptionId: string; + tenant?: AzureTenant; } export class AzureMongoRUSubscriptionItem implements TreeElement, TreeElementWithContextValue { @@ -37,12 +39,17 @@ export class AzureMongoRUSubscriptionItem implements TreeElement, TreeElementWit return await callWithTelemetryAndErrorHandling( 'azure-mongo-ru-discovery.getChildren', async (context: IActionContext) => { + const startTime = Date.now(); context.telemetry.properties.discoveryProvider = 'azure-mongo-ru-discovery'; const managementClient = await createCosmosDBManagementClient(context, this.subscription.subscription); const allAccounts = await uiUtils.listAllIterator(managementClient.databaseAccounts.list()); const accounts = allAccounts.filter((account) => account.kind === 'MongoDB'); + // Add enhanced telemetry for discovery + context.telemetry.measurements.discoveryResourcesCount = accounts.length; + context.telemetry.measurements.discoveryLoadTimeMs = Date.now() - startTime; + return accounts .sort((a, b) => (a.name || '').localeCompare(b.name || '')) .map((account) => { @@ -61,11 +68,25 @@ export class AzureMongoRUSubscriptionItem implements TreeElement, TreeElementWit } public getTreeItem(): vscode.TreeItem { + const tooltipParts: string[] = [vscode.l10n.t('Subscription ID: {0}', this.subscription.subscriptionId), '']; + + const tenantName = this.subscription.tenant?.displayName; + if (tenantName) { + tooltipParts.push(vscode.l10n.t('Tenant Name: {0}', tenantName)); + } + + const tenantId = this.subscription.subscription.tenantId; + if (tenantId) { + tooltipParts.push(vscode.l10n.t('Tenant ID: {0}', tenantId)); + } + + const tooltip: string = tooltipParts.join('\n'); + return { id: this.id, contextValue: this.contextValue, label: this.subscription.subscriptionName, - tooltip: `Subscription ID: ${this.subscription.subscriptionId}`, + tooltip, iconPath: vscode.Uri.joinPath( ext.context.extensionUri, 'resources', diff --git a/src/plugins/service-azure-mongo-ru/discovery-tree/documentdb/MongoRUResourceItem.ts b/src/plugins/service-azure-mongo-ru/discovery-tree/documentdb/MongoRUResourceItem.ts index 828a7047a..9ef5e5c97 100644 --- a/src/plugins/service-azure-mongo-ru/discovery-tree/documentdb/MongoRUResourceItem.ts +++ b/src/plugins/service-azure-mongo-ru/discovery-tree/documentdb/MongoRUResourceItem.ts @@ -11,7 +11,7 @@ import { ClustersClient } from '../../../../documentdb/ClustersClient'; import { CredentialCache } from '../../../../documentdb/CredentialCache'; import { Views } from '../../../../documentdb/Views'; import { ext } from '../../../../extensionVariables'; -import { ClusterItemBase, type ClusterCredentials } from '../../../../tree/documentdb/ClusterItemBase'; +import { ClusterItemBase, type EphemeralClusterCredentials } from '../../../../tree/documentdb/ClusterItemBase'; import { type ClusterModel } from '../../../../tree/documentdb/ClusterModel'; import { extractCredentialsFromRUAccount } from '../../utils/ruClusterHelpers'; @@ -34,10 +34,11 @@ export class MongoRUResourceItem extends ClusterItemBase { super(cluster); } - public async getCredentials(): Promise { + public async getCredentials(): Promise { return callWithTelemetryAndErrorHandling('getCredentials', async (context: IActionContext) => { context.telemetry.properties.view = Views.DiscoveryView; context.telemetry.properties.discoveryProvider = 'azure-mongo-ru-discovery'; + context.telemetry.properties.resourceType = 'mongoRU'; const credentials = await extractCredentialsFromRUAccount( context, @@ -56,8 +57,11 @@ export class MongoRUResourceItem extends ClusterItemBase { */ protected async authenticateAndConnect(): Promise { const result = await callWithTelemetryAndErrorHandling('connect', async (context: IActionContext) => { + const connectionStartTime = Date.now(); context.telemetry.properties.view = Views.DiscoveryView; context.telemetry.properties.discoveryProvider = 'azure-mongo-ru-discovery'; + context.telemetry.properties.connectionInitiatedFrom = 'discoveryView'; + context.telemetry.properties.resourceType = 'mongoRU'; ext.outputChannel.appendLine( l10n.t('Attempting to authenticate with "{cluster}"…', { @@ -81,8 +85,7 @@ export class MongoRUResourceItem extends ClusterItemBase { this.id, credentials.selectedAuthMethod ?? credentials.availableAuthMethods[0], credentials.connectionString, - credentials.connectionUser, - credentials.connectionPassword, + credentials.nativeAuthConfig, ); // Connect using the cached credentials @@ -94,8 +97,17 @@ export class MongoRUResourceItem extends ClusterItemBase { }), ); + // Add success telemetry + context.telemetry.measurements.connectionEstablishmentTimeMs = Date.now() - connectionStartTime; + context.telemetry.properties.connectionResult = 'success'; + return clustersClient; } catch (error) { + // Add error telemetry + context.telemetry.measurements.connectionEstablishmentTimeMs = Date.now() - connectionStartTime; + context.telemetry.properties.connectionResult = 'failed'; + context.telemetry.properties.connectionErrorType = error instanceof Error ? error.name : 'UnknownError'; + ext.outputChannel.appendLine(l10n.t('Error: {error}', { error: (error as Error).message })); void vscode.window.showErrorMessage( diff --git a/src/plugins/service-azure-mongo-ru/discovery-wizard/AzureMongoRUExecuteStep.ts b/src/plugins/service-azure-mongo-ru/discovery-wizard/AzureMongoRUExecuteStep.ts index 63c654ec6..13870172d 100644 --- a/src/plugins/service-azure-mongo-ru/discovery-wizard/AzureMongoRUExecuteStep.ts +++ b/src/plugins/service-azure-mongo-ru/discovery-wizard/AzureMongoRUExecuteStep.ts @@ -36,8 +36,7 @@ export class AzureMongoRUExecuteStep extends AzureWizardExecuteStep => { + const managementClient = await createCosmosDBManagementClient( + context, + context.properties[AzureContextProperties.SelectedSubscription] as unknown as AzureSubscription, + ); - const allAccounts = await uiUtils.listAllIterator(managementClient.databaseAccounts.list()); - const accounts = allAccounts.filter((account) => account.kind === 'MongoDB'); + const allAccounts = await uiUtils.listAllIterator(managementClient.databaseAccounts.list()); + const accounts = allAccounts.filter((account) => account.kind === 'MongoDB'); + + const promptItems: (QuickPickItem & { id: string })[] = accounts + .filter((account) => account.name) // Filter out accounts without a name + .map((account) => ({ + id: account.id!, + label: account.name!, + description: account.id, + iconPath: this.iconPath, - const promptItems: (QuickPickItem & { id: string })[] = accounts - .filter((account) => account.name) // Filter out accounts without a name - .map((account) => ({ - id: account.id!, - label: account.name!, - description: account.id, - iconPath: this.iconPath, + alwaysShow: true, + })) + .sort((a, b) => a.label.localeCompare(b.label)); - alwaysShow: true, - })) - .sort((a, b) => a.label.localeCompare(b.label)); + return promptItems; + }; - const selectedItem = await context.ui.showQuickPick([...promptItems], { + const selectedItem = await context.ui.showQuickPick(getRUClusterQuickPickItems(), { stepName: 'selectRUCluster', placeHolder: l10n.t('Choose a RU cluster…'), - loadingPlaceHolder: l10n.t('Loading RU clusters…'), + loadingPlaceHolder: l10n.t('Loading Clusters…'), enableGrouping: true, matchOnDescription: true, suppressPersistence: true, }); + // Get accounts again to find the selected one (likely cached by Azure SDK) + const managementClient = await createCosmosDBManagementClient( + context, + context.properties[AzureContextProperties.SelectedSubscription] as unknown as AzureSubscription, + ); + const allAccounts = await uiUtils.listAllIterator(managementClient.databaseAccounts.list()); + const accounts = allAccounts.filter((account) => account.kind === 'MongoDB'); + context.properties[AzureContextProperties.SelectedCluster] = accounts.find( (account) => account.id === selectedItem.id, ); diff --git a/src/plugins/service-azure-mongo-ru/utils/ruClusterHelpers.ts b/src/plugins/service-azure-mongo-ru/utils/ruClusterHelpers.ts index 695f0d22d..104f51fe1 100644 --- a/src/plugins/service-azure-mongo-ru/utils/ruClusterHelpers.ts +++ b/src/plugins/service-azure-mongo-ru/utils/ruClusterHelpers.ts @@ -9,7 +9,7 @@ import * as l10n from '@vscode/l10n'; import { AuthMethodId } from '../../../documentdb/auth/AuthMethod'; import { maskSensitiveValuesInTelemetry } from '../../../documentdb/utils/connectionStringHelpers'; import { DocumentDBConnectionString } from '../../../documentdb/utils/DocumentDBConnectionString'; -import { type ClusterCredentials } from '../../../tree/documentdb/ClusterItemBase'; +import { type EphemeralClusterCredentials } from '../../../tree/documentdb/ClusterItemBase'; import { createCosmosDBManagementClient } from '../../../utils/azureClients'; /** @@ -20,7 +20,7 @@ export async function extractCredentialsFromRUAccount( subscription: AzureSubscription, resourceGroup: string, accountName: string, -): Promise { +): Promise { if (!resourceGroup || !accountName) { throw new Error(l10n.t('Account information is incomplete.')); } @@ -83,13 +83,19 @@ export async function extractCredentialsFromRUAccount( // it here anyway. parsedCS.searchParams.delete('appName'); - const clusterCredentials: ClusterCredentials = { + const clusterCredentials: EphemeralClusterCredentials = { connectionString: parsedCS.toString(), - connectionUser: username, - connectionPassword: password, availableAuthMethods: [AuthMethodId.NativeAuth], selectedAuthMethod: AuthMethodId.NativeAuth, + // Auth configs + nativeAuthConfig: { + connectionUser: username, + connectionPassword: password, + }, }; + // Add telemetry properties from subscription + context.telemetry.properties.isCustomCloud = subscription.isCustomCloud.toString(); + return clusterCredentials; } diff --git a/src/plugins/service-azure-mongo-vcore/AzureDiscoveryProvider.ts b/src/plugins/service-azure-mongo-vcore/AzureDiscoveryProvider.ts index a58633d62..11b1e312b 100644 --- a/src/plugins/service-azure-mongo-vcore/AzureDiscoveryProvider.ts +++ b/src/plugins/service-azure-mongo-vcore/AzureDiscoveryProvider.ts @@ -10,9 +10,9 @@ import { ext } from '../../extensionVariables'; import { type DiscoveryProvider } from '../../services/discoveryServices'; import { type TreeElement } from '../../tree/TreeElement'; import { AzureSubscriptionProviderWithFilters } from '../api-shared/azure/AzureSubscriptionProviderWithFilters'; -import { configureAzureSubscriptionFilter } from '../api-shared/azure/subscriptionFiltering'; +import { configureAzureSubscriptionFilter } from '../api-shared/azure/subscriptionFiltering/configureAzureSubscriptionFilter'; import { AzureContextProperties } from '../api-shared/azure/wizard/AzureContextProperties'; -import { SelectSubscriptionStep } from '../service-azure-vm/discovery-wizard/SelectSubscriptionStep'; +import { SelectSubscriptionStep } from '../api-shared/azure/wizard/SelectSubscriptionStep'; import { AzureServiceRootItem } from './discovery-tree/AzureServiceRootItem'; import { AzureExecuteStep } from './discovery-wizard/AzureExecuteStep'; import { SelectClusterStep } from './discovery-wizard/SelectClusterStep'; @@ -64,4 +64,25 @@ export class AzureDiscoveryProvider extends Disposable implements DiscoveryProvi ext.discoveryBranchDataProvider.refresh(node); } } + + async configureCredentials(context: IActionContext, node?: TreeElement): Promise { + // Add telemetry for credential configuration activation + context.telemetry.properties.credentialConfigActivated = 'true'; + context.telemetry.properties.discoveryProviderId = this.id; + context.telemetry.properties.nodeProvided = node ? 'true' : 'false'; + + if (!node || node instanceof AzureServiceRootItem) { + // Use the new Azure credentials configuration wizard + const { configureAzureCredentials } = await import('../api-shared/azure/credentialsManagement'); + await configureAzureCredentials(context, this.azureSubscriptionProvider, node); + + if (node) { + // Tree context: refresh specific node + ext.discoveryBranchDataProvider.refresh(node); + } else { + // Wizard context: refresh entire discovery tree + ext.discoveryBranchDataProvider.refresh(); + } + } + } } diff --git a/src/plugins/service-azure-mongo-vcore/discovery-tree/AzureServiceRootItem.ts b/src/plugins/service-azure-mongo-vcore/discovery-tree/AzureServiceRootItem.ts index c150280cd..366d1e33d 100644 --- a/src/plugins/service-azure-mongo-vcore/discovery-tree/AzureServiceRootItem.ts +++ b/src/plugins/service-azure-mongo-vcore/discovery-tree/AzureServiceRootItem.ts @@ -3,10 +3,9 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { type VSCodeAzureSubscriptionProvider } from '@microsoft/vscode-azext-azureauth'; +import { type AzureTenant, type VSCodeAzureSubscriptionProvider } from '@microsoft/vscode-azext-azureauth'; import * as l10n from '@vscode/l10n'; import * as vscode from 'vscode'; -import { ext } from '../../../extensionVariables'; import { createGenericElementWithContext } from '../../../tree/api/createGenericElementWithContext'; import { type ExtTreeElementBase, type TreeElement } from '../../../tree/TreeElement'; import { @@ -14,12 +13,14 @@ import { type TreeElementWithContextValue, } from '../../../tree/TreeElementWithContextValue'; import { type TreeElementWithRetryChildren } from '../../../tree/TreeElementWithRetryChildren'; +import { askToConfigureCredentials } from '../../api-shared/azure/askToConfigureCredentials'; +import { getTenantFilteredSubscriptions } from '../../api-shared/azure/subscriptionFiltering/subscriptionFilteringHelpers'; import { AzureSubscriptionItem } from './AzureSubscriptionItem'; export class AzureServiceRootItem implements TreeElement, TreeElementWithContextValue, TreeElementWithRetryChildren { public readonly id: string; public contextValue: string = - 'enableRefreshCommand;enableFilterCommand;enableLearnMoreCommand;discoveryAzureServiceRootItem'; + 'enableRefreshCommand;enableManageCredentialsCommand;enableFilterCommand;enableLearnMoreCommand;discoveryAzureServiceRootItem'; constructor( private readonly azureSubscriptionProvider: VSCodeAzureSubscriptionProvider, @@ -29,19 +30,17 @@ export class AzureServiceRootItem implements TreeElement, TreeElementWithContext } async getChildren(): Promise { - /** - * This is an important step to ensure that the user is signed in to Azure before listing subscriptions. - */ - if (!(await this.azureSubscriptionProvider.isSignedIn())) { - const signIn: vscode.MessageItem = { title: l10n.t('Sign In') }; - void vscode.window - .showInformationMessage(l10n.t('You are not signed in to Azure. Sign in and retry.'), signIn) - .then(async (input) => { - if (input === signIn) { - await this.azureSubscriptionProvider.signIn(); - ext.discoveryBranchDataProvider.refresh(); - } - }); + const allSubscriptions = await this.azureSubscriptionProvider.getSubscriptions(true); + const subscriptions = getTenantFilteredSubscriptions(allSubscriptions); + + if (!subscriptions || subscriptions.length === 0) { + // Show modal dialog for empty state + const configureResult = await askToConfigureCredentials(); + if (configureResult === 'configure') { + // Note to future maintainers: 'void' is important here so that the return below returns the error node. + // Otherwise, the /retry node might be duplicated as we're inside of tree node with a loading state (the node items are being swapped etc.) + void vscode.commands.executeCommand('vscode-documentdb.command.discoveryView.manageCredentials', this); + } return [ createGenericElementWithContext({ @@ -55,9 +54,21 @@ export class AzureServiceRootItem implements TreeElement, TreeElementWithContext ]; } - const subscriptions = await this.azureSubscriptionProvider.getSubscriptions(true); - if (!subscriptions || subscriptions.length === 0) { - return []; + // This information is extracted to improve the UX, that's why there are fallbacks to 'undefined' + // Note to future maintainers: we used to run getSubscriptions and getTenants "in parallel", however + // this lead to incorrect responses from getSubscriptions. We didn't investigate + const tenantPromise = this.azureSubscriptionProvider.getTenants().catch(() => undefined); + const timeoutPromise = new Promise((resolve) => setTimeout(() => resolve(undefined), 5000)); + const knownTenants = await Promise.race([tenantPromise, timeoutPromise]); + + // Build tenant lookup for better performance + const tenantMap = new Map(); + if (knownTenants) { + for (const tenant of knownTenants) { + if (tenant.tenantId) { + tenantMap.set(tenant.tenantId, tenant); + } + } } return ( @@ -70,6 +81,7 @@ export class AzureServiceRootItem implements TreeElement, TreeElementWithContext subscription: sub, subscriptionName: sub.name, subscriptionId: sub.subscriptionId, + tenant: tenantMap.get(sub.tenantId), }); }) ); diff --git a/src/plugins/service-azure-mongo-vcore/discovery-tree/AzureSubscriptionItem.ts b/src/plugins/service-azure-mongo-vcore/discovery-tree/AzureSubscriptionItem.ts index e6687fc4b..1a0bc27d7 100644 --- a/src/plugins/service-azure-mongo-vcore/discovery-tree/AzureSubscriptionItem.ts +++ b/src/plugins/service-azure-mongo-vcore/discovery-tree/AzureSubscriptionItem.ts @@ -3,6 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +import { type AzureTenant } from '@microsoft/vscode-azext-azureauth'; import { getResourceGroupFromId, uiUtils } from '@microsoft/vscode-azext-azureutils'; import { callWithTelemetryAndErrorHandling, type IActionContext } from '@microsoft/vscode-azext-utils'; import { type AzureSubscription } from '@microsoft/vscode-azureresources-api'; @@ -20,6 +21,7 @@ export interface AzureSubscriptionModel { subscriptionName: string; subscription: AzureSubscription; subscriptionId: string; + tenant?: AzureTenant; } export class AzureSubscriptionItem implements TreeElement, TreeElementWithContextValue { @@ -61,11 +63,25 @@ export class AzureSubscriptionItem implements TreeElement, TreeElementWithContex } public getTreeItem(): vscode.TreeItem { + const tooltipParts: string[] = [vscode.l10n.t('Subscription ID: {0}', this.subscription.subscriptionId), '']; + + const tenantName = this.subscription.tenant?.displayName; + if (tenantName) { + tooltipParts.push(vscode.l10n.t('Tenant Name: {0}', tenantName)); + } + + const tenantId = this.subscription.subscription.tenantId; + if (tenantId) { + tooltipParts.push(vscode.l10n.t('Tenant ID: {0}', tenantId)); + } + + const tooltip: string = tooltipParts.join('\n'); + return { id: this.id, contextValue: this.contextValue, label: this.subscription.subscriptionName, - tooltip: `Subscription ID: ${this.subscription.subscriptionId}`, + tooltip, iconPath: vscode.Uri.joinPath( ext.context.extensionUri, 'resources', diff --git a/src/plugins/service-azure-mongo-vcore/discovery-tree/documentdb/DocumentDBResourceItem.ts b/src/plugins/service-azure-mongo-vcore/discovery-tree/documentdb/DocumentDBResourceItem.ts index 8f33604b5..f99943d27 100644 --- a/src/plugins/service-azure-mongo-vcore/discovery-tree/documentdb/DocumentDBResourceItem.ts +++ b/src/plugins/service-azure-mongo-vcore/discovery-tree/documentdb/DocumentDBResourceItem.ts @@ -22,7 +22,7 @@ import { ChooseAuthMethodStep } from '../../../../documentdb/wizards/authenticat import { ProvidePasswordStep } from '../../../../documentdb/wizards/authenticate/ProvidePasswordStep'; import { ProvideUserNameStep } from '../../../../documentdb/wizards/authenticate/ProvideUsernameStep'; import { ext } from '../../../../extensionVariables'; -import { ClusterItemBase, type ClusterCredentials } from '../../../../tree/documentdb/ClusterItemBase'; +import { ClusterItemBase, type EphemeralClusterCredentials } from '../../../../tree/documentdb/ClusterItemBase'; import { type ClusterModel } from '../../../../tree/documentdb/ClusterModel'; import { nonNullValue } from '../../../../utils/nonNull'; import { extractCredentialsFromCluster, getClusterInformationFromAzure } from '../../utils/clusterHelpers'; @@ -46,10 +46,11 @@ export class DocumentDBResourceItem extends ClusterItemBase { super(cluster); } - public async getCredentials(): Promise { + public async getCredentials(): Promise { return callWithTelemetryAndErrorHandling('getCredentials', async (context: IActionContext) => { context.telemetry.properties.view = Views.DiscoveryView; context.telemetry.properties.discoveryProvider = 'azure-mongo-vcore-discovery'; + context.telemetry.properties.resourceType = 'mongoVCore'; // Retrieve and validate cluster information (throws if invalid) const clusterInformation = await getClusterInformationFromAzure( @@ -59,7 +60,7 @@ export class DocumentDBResourceItem extends ClusterItemBase { this.cluster.name, ); - return extractCredentialsFromCluster(context, clusterInformation); + return extractCredentialsFromCluster(context, clusterInformation, this.subscription); }); } @@ -82,8 +83,11 @@ export class DocumentDBResourceItem extends ClusterItemBase { */ protected async authenticateAndConnect(): Promise { const result = await callWithTelemetryAndErrorHandling('connect', async (context: IActionContext) => { + const connectionStartTime = Date.now(); context.telemetry.properties.view = Views.DiscoveryView; context.telemetry.properties.discoveryProvider = 'azure-mongo-vcore-discovery'; + context.telemetry.properties.connectionInitiatedFrom = 'discoveryView'; + context.telemetry.properties.resourceType = 'mongoVCore'; ext.outputChannel.appendLine( l10n.t('Attempting to authenticate with "{cluster}"…', { @@ -93,12 +97,12 @@ export class DocumentDBResourceItem extends ClusterItemBase { // Get and validate cluster information const clusterInformation = await this.getClusterInformation(context); - const credentials = extractCredentialsFromCluster(context, clusterInformation); + const credentials = extractCredentialsFromCluster(context, clusterInformation, this.subscription); // Prepare wizard context const wizardContext: AuthenticateWizardContext = { ...context, - adminUserName: credentials.connectionUser, + adminUserName: credentials.nativeAuthConfig?.connectionUser, resourceName: this.cluster.name, availableAuthMethods: credentials.availableAuthMethods, }; @@ -122,8 +126,14 @@ export class DocumentDBResourceItem extends ClusterItemBase { 'DocumentDBResourceItem.ts', ), nonNullValue(credentials.connectionString, 'credentials.connectionString', 'DocumentDBResourceItem.ts'), - wizardContext.selectedUserName, - wizardContext.password, + wizardContext.selectedUserName || wizardContext.password + ? { + connectionUser: wizardContext.selectedUserName ?? '', + connectionPassword: wizardContext.password, + } + : undefined, + undefined, + credentials.entraIdAuthConfig, ); switch (wizardContext.selectedAuthMethod) { @@ -147,8 +157,17 @@ export class DocumentDBResourceItem extends ClusterItemBase { }), ); + // Add success telemetry + context.telemetry.measurements.connectionEstablishmentTimeMs = Date.now() - connectionStartTime; + context.telemetry.properties.connectionResult = 'success'; + return clustersClient; } catch (error) { + // Add error telemetry + context.telemetry.measurements.connectionEstablishmentTimeMs = Date.now() - connectionStartTime; + context.telemetry.properties.connectionResult = 'failed'; + context.telemetry.properties.connectionErrorType = error instanceof Error ? error.name : 'UnknownError'; + ext.outputChannel.appendLine(l10n.t('Error: {error}', { error: (error as Error).message })); void vscode.window.showErrorMessage( @@ -182,7 +201,7 @@ export class DocumentDBResourceItem extends ClusterItemBase { private async promptForCredentials(wizardContext: AuthenticateWizardContext): Promise { const wizard = new AzureWizard(wizardContext, { promptSteps: [new ChooseAuthMethodStep(), new ProvideUserNameStep(), new ProvidePasswordStep()], - title: l10n.t('Authenticate to connect with your DocumentDB cluster'), + title: l10n.t('Authenticate to Connect with Your DocumentDB Cluster'), showLoadingPrompt: true, }); @@ -190,6 +209,8 @@ export class DocumentDBResourceItem extends ClusterItemBase { await callWithTelemetryAndErrorHandling('connect.promptForCredentials', async (context: IActionContext) => { context.telemetry.properties.view = Views.DiscoveryView; context.telemetry.properties.discoveryProvider = 'azure-mongo-vcore-discovery'; + context.telemetry.properties.credentialsRequired = 'true'; + context.telemetry.properties.credentialPromptReason = 'firstTime'; context.errorHandling.rethrow = true; context.errorHandling.suppressDisplay = false; diff --git a/src/plugins/service-azure-mongo-vcore/discovery-wizard/AzureExecuteStep.ts b/src/plugins/service-azure-mongo-vcore/discovery-wizard/AzureExecuteStep.ts index 798df5ca4..7fc93cb1c 100644 --- a/src/plugins/service-azure-mongo-vcore/discovery-wizard/AzureExecuteStep.ts +++ b/src/plugins/service-azure-mongo-vcore/discovery-wizard/AzureExecuteStep.ts @@ -36,11 +36,11 @@ export class AzureExecuteStep extends AzureWizardExecuteStep => { + const client = await createResourceManagementClient( + context, + context.properties[AzureContextProperties.SelectedSubscription] as unknown as AzureSubscription, + ); - const accounts = await uiUtils.listAllIterator( - client.resources.list({ filter: "resourceType eq 'Microsoft.DocumentDB/mongoClusters'" }), - ); + const accounts = await uiUtils.listAllIterator( + client.resources.list({ filter: "resourceType eq 'Microsoft.DocumentDB/mongoClusters'" }), + ); + + const promptItems: (QuickPickItem & { id: string })[] = accounts + .filter((account) => account.name) // Filter out accounts without a name + .map((account) => ({ + id: account.id!, + label: account.name!, + description: account.id, + iconPath: this.iconPath, - const promptItems: (QuickPickItem & { id: string })[] = accounts - .filter((account) => account.name) // Filter out accounts without a name - .map((account) => ({ - id: account.id!, - label: account.name!, - description: account.id, - iconPath: this.iconPath, + alwaysShow: true, + })) + .sort((a, b) => a.label.localeCompare(b.label)); - alwaysShow: true, - })) - .sort((a, b) => a.label.localeCompare(b.label)); + return promptItems; + }; - const selectedItem = await context.ui.showQuickPick([...promptItems], { + const selectedItem = await context.ui.showQuickPick(getClusterQuickPickItems(), { stepName: 'selectCluster', placeHolder: l10n.t('Choose a cluster…'), - loadingPlaceHolder: l10n.t('Loading clusters…'), + loadingPlaceHolder: l10n.t('Loading Clusters…'), enableGrouping: true, matchOnDescription: true, suppressPersistence: true, }); + // Get accounts again to find the selected one (likely cached by Azure SDK) + const client = await createResourceManagementClient( + context, + context.properties[AzureContextProperties.SelectedSubscription] as unknown as AzureSubscription, + ); + const accounts = await uiUtils.listAllIterator( + client.resources.list({ filter: "resourceType eq 'Microsoft.DocumentDB/mongoClusters'" }), + ); + context.properties[AzureContextProperties.SelectedCluster] = accounts.find( (account) => account.id === selectedItem.id, ); diff --git a/src/plugins/service-azure-mongo-vcore/utils/clusterHelpers.ts b/src/plugins/service-azure-mongo-vcore/utils/clusterHelpers.ts index 237b351ef..8f6d04786 100644 --- a/src/plugins/service-azure-mongo-vcore/utils/clusterHelpers.ts +++ b/src/plugins/service-azure-mongo-vcore/utils/clusterHelpers.ts @@ -7,9 +7,9 @@ import { type MongoCluster } from '@azure/arm-mongocluster'; import { type IActionContext } from '@microsoft/vscode-azext-utils'; import { type AzureSubscription } from '@microsoft/vscode-azureresources-api'; import { l10n } from 'vscode'; -import { isSupportedAuthMethod } from '../../../documentdb/auth/AuthMethod'; +import { AuthMethodId, isSupportedAuthMethod } from '../../../documentdb/auth/AuthMethod'; import { DocumentDBConnectionString } from '../../../documentdb/utils/DocumentDBConnectionString'; -import { type ClusterCredentials } from '../../../tree/documentdb/ClusterItemBase'; +import { type EphemeralClusterCredentials } from '../../../tree/documentdb/ClusterItemBase'; import { createMongoClustersManagementClient } from '../../../utils/azureClients'; /** @@ -70,7 +70,8 @@ export async function getClusterInformationFromAzure( export function extractCredentialsFromCluster( context: IActionContext, clusterInformation: MongoCluster, -): ClusterCredentials { + subscription: AzureSubscription, +): EphemeralClusterCredentials { // Ensure connection string and admin username are masked if (clusterInformation.properties?.connectionString) { context.valuesToMask.push(clusterInformation.properties.connectionString); @@ -85,10 +86,16 @@ export function extractCredentialsFromCluster( parsedCS.password = ''; // Prepare credentials object. - const credentials: ClusterCredentials = { + const credentials: EphemeralClusterCredentials = { connectionString: parsedCS.toString(), - connectionUser: clusterInformation.properties?.administrator?.userName, availableAuthMethods: [], + // Auth configs - populate native auth if we have username + nativeAuthConfig: clusterInformation.properties?.administrator?.userName + ? { + connectionUser: clusterInformation.properties.administrator.userName, + connectionPassword: undefined, // Password will be collected during authentication + } + : undefined, }; const allowedModes = clusterInformation.properties?.authConfig?.allowedModes ?? []; @@ -100,5 +107,15 @@ export function extractCredentialsFromCluster( const unknownMethodIds = allowedModes.filter((methodId) => !isSupportedAuthMethod(methodId)); context.telemetry.properties.unknownAuthMethods = unknownMethodIds.join(','); + if (credentials.availableAuthMethods.includes(AuthMethodId.MicrosoftEntraID)) { + credentials.entraIdAuthConfig = { + tenantId: subscription.tenantId, + subscriptionId: subscription.subscriptionId, + }; + } + + // Add telemetry properties from subscription + context.telemetry.properties.isCustomCloud = subscription.isCustomCloud.toString(); + return credentials; } diff --git a/src/plugins/service-azure-vm/AzureVMDiscoveryProvider.ts b/src/plugins/service-azure-vm/AzureVMDiscoveryProvider.ts index d9b1a3f6a..43e50c2c7 100644 --- a/src/plugins/service-azure-vm/AzureVMDiscoveryProvider.ts +++ b/src/plugins/service-azure-vm/AzureVMDiscoveryProvider.ts @@ -10,11 +10,11 @@ import { ext } from '../../extensionVariables'; import { type DiscoveryProvider } from '../../services/discoveryServices'; import { type TreeElement } from '../../tree/TreeElement'; import { AzureSubscriptionProviderWithFilters } from '../api-shared/azure/AzureSubscriptionProviderWithFilters'; +import { SelectSubscriptionStep } from '../api-shared/azure/wizard/SelectSubscriptionStep'; import { AzureServiceRootItem } from './discovery-tree/AzureServiceRootItem'; import { configureVmFilter } from './discovery-tree/configureVmFilterWizard'; import { AzureVMExecuteStep } from './discovery-wizard/AzureVMExecuteStep'; import { SelectPortStep } from './discovery-wizard/SelectPortStep'; -import { SelectSubscriptionStep } from './discovery-wizard/SelectSubscriptionStep'; import { SelectTagStep } from './discovery-wizard/SelectTagStep'; import { SelectVMStep } from './discovery-wizard/SelectVMStep'; @@ -70,4 +70,25 @@ export class AzureVMDiscoveryProvider extends Disposable implements DiscoveryPro ext.discoveryBranchDataProvider.refresh(node); } } + + async configureCredentials(context: IActionContext, node?: TreeElement): Promise { + // Add telemetry for credential configuration activation + context.telemetry.properties.credentialConfigActivated = 'true'; + context.telemetry.properties.discoveryProviderId = this.id; + context.telemetry.properties.nodeProvided = node ? 'true' : 'false'; + + if (!node || node instanceof AzureServiceRootItem) { + // Use the new Azure credentials configuration wizard + const { configureAzureCredentials } = await import('../api-shared/azure/credentialsManagement'); + await configureAzureCredentials(context, this.azureSubscriptionProvider, node); + + if (node) { + // Tree context: refresh specific node + ext.discoveryBranchDataProvider.refresh(node); + } else { + // Wizard context: refresh entire discovery tree + ext.discoveryBranchDataProvider.refresh(); + } + } + } } diff --git a/src/plugins/service-azure-vm/discovery-tree/AzureServiceRootItem.ts b/src/plugins/service-azure-vm/discovery-tree/AzureServiceRootItem.ts index d4bb0eb26..c70f7398d 100644 --- a/src/plugins/service-azure-vm/discovery-tree/AzureServiceRootItem.ts +++ b/src/plugins/service-azure-vm/discovery-tree/AzureServiceRootItem.ts @@ -3,10 +3,9 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { type VSCodeAzureSubscriptionProvider } from '@microsoft/vscode-azext-azureauth'; +import { type AzureTenant, type VSCodeAzureSubscriptionProvider } from '@microsoft/vscode-azext-azureauth'; import * as l10n from '@vscode/l10n'; import * as vscode from 'vscode'; -import { ext } from '../../../extensionVariables'; import { createGenericElementWithContext } from '../../../tree/api/createGenericElementWithContext'; import { type ExtTreeElementBase, type TreeElement } from '../../../tree/TreeElement'; import { @@ -14,12 +13,14 @@ import { type TreeElementWithContextValue, } from '../../../tree/TreeElementWithContextValue'; import { type TreeElementWithRetryChildren } from '../../../tree/TreeElementWithRetryChildren'; +import { askToConfigureCredentials } from '../../api-shared/azure/askToConfigureCredentials'; +import { getTenantFilteredSubscriptions } from '../../api-shared/azure/subscriptionFiltering/subscriptionFilteringHelpers'; import { AzureSubscriptionItem } from './AzureSubscriptionItem'; export class AzureServiceRootItem implements TreeElement, TreeElementWithContextValue, TreeElementWithRetryChildren { public readonly id: string; public contextValue: string = - 'enableRefreshCommand;enableFilterCommand;enableLearnMoreCommand;discoveryAzureVMRootItem'; + 'enableRefreshCommand;enableManageCredentialsCommand;enableFilterCommand;enableLearnMoreCommand;discoveryAzureVMRootItem'; constructor( private readonly azureSubscriptionProvider: VSCodeAzureSubscriptionProvider, @@ -29,19 +30,17 @@ export class AzureServiceRootItem implements TreeElement, TreeElementWithContext } async getChildren(): Promise { - /** - * This is an important step to ensure that the user is signed in to Azure before listing subscriptions. - */ - if (!(await this.azureSubscriptionProvider.isSignedIn())) { - const signIn: vscode.MessageItem = { title: l10n.t('Sign In') }; - void vscode.window - .showInformationMessage(l10n.t('You are not signed in to Azure. Sign in and retry.'), signIn) - .then(async (input) => { - if (input === signIn) { - await this.azureSubscriptionProvider.signIn(); - ext.discoveryBranchDataProvider.refresh(); - } - }); + const allSubscriptions = await this.azureSubscriptionProvider.getSubscriptions(true); + const subscriptions = getTenantFilteredSubscriptions(allSubscriptions); + + if (!subscriptions || subscriptions.length === 0) { + // Show modal dialog for empty state + const configureResult = await askToConfigureCredentials(); + if (configureResult === 'configure') { + // Note to future maintainers: 'void' is important here so that the return below returns the error node. + // Otherwise, the /retry node might be duplicated as we're inside of tree node with a loading state (the node items are being swapped etc.) + void vscode.commands.executeCommand('vscode-documentdb.command.discoveryView.manageCredentials', this); + } return [ createGenericElementWithContext({ @@ -55,9 +54,21 @@ export class AzureServiceRootItem implements TreeElement, TreeElementWithContext ]; } - const subscriptions = await this.azureSubscriptionProvider.getSubscriptions(true); - if (!subscriptions || subscriptions.length === 0) { - return []; + // This information is extracted to improve the UX, that's why there are fallbacks to 'undefined' + // Note to future maintainers: we used to run getSubscriptions and getTenants "in parallel", however + // this lead to incorrect responses from getSubscriptions. We didn't investigate + const tenantPromise = this.azureSubscriptionProvider.getTenants().catch(() => undefined); + const timeoutPromise = new Promise((resolve) => setTimeout(() => resolve(undefined), 5000)); + const knownTenants = await Promise.race([tenantPromise, timeoutPromise]); + + // Build tenant lookup for better performance + const tenantMap = new Map(); + if (knownTenants) { + for (const tenant of knownTenants) { + if (tenant.tenantId) { + tenantMap.set(tenant.tenantId, tenant); + } + } } return ( @@ -70,6 +81,7 @@ export class AzureServiceRootItem implements TreeElement, TreeElementWithContext subscription: sub, subscriptionName: sub.name, subscriptionId: sub.subscriptionId, + tenant: tenantMap.get(sub.tenantId), }); }) ); diff --git a/src/plugins/service-azure-vm/discovery-tree/AzureSubscriptionItem.ts b/src/plugins/service-azure-vm/discovery-tree/AzureSubscriptionItem.ts index 96c1f67c6..57a037572 100644 --- a/src/plugins/service-azure-vm/discovery-tree/AzureSubscriptionItem.ts +++ b/src/plugins/service-azure-vm/discovery-tree/AzureSubscriptionItem.ts @@ -3,6 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +import { type AzureTenant } from '@microsoft/vscode-azext-azureauth'; import { getResourceGroupFromId, uiUtils } from '@microsoft/vscode-azext-azureutils'; import { callWithTelemetryAndErrorHandling, type IActionContext } from '@microsoft/vscode-azext-utils'; import { type AzureSubscription } from '@microsoft/vscode-azureresources-api'; @@ -20,6 +21,7 @@ export interface AzureSubscriptionModel { subscriptionName: string; subscription: AzureSubscription; subscriptionId: string; + tenant?: AzureTenant; } export class AzureSubscriptionItem implements TreeElement, TreeElementWithContextValue { @@ -48,7 +50,6 @@ export class AzureSubscriptionItem implements TreeElement, TreeElementWithContex const vmItems: AzureVMResourceItem[] = []; for (const vm of vms) { - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access if (vm.tags && vm.tags[tagName] !== undefined && vm.id && vm.name) { let publicIpAddress: string | undefined; let fqdn: string | undefined; @@ -118,11 +119,25 @@ export class AzureSubscriptionItem implements TreeElement, TreeElementWithContex } public getTreeItem(): vscode.TreeItem { + const tooltipParts: string[] = [vscode.l10n.t('Subscription ID: {0}', this.subscription.subscriptionId), '']; + + const tenantName = this.subscription.tenant?.displayName; + if (tenantName) { + tooltipParts.push(vscode.l10n.t('Tenant Name: {0}', tenantName)); + } + + const tenantId = this.subscription.subscription.tenantId; + if (tenantId) { + tooltipParts.push(vscode.l10n.t('Tenant ID: {0}', tenantId)); + } + + const tooltip: string = tooltipParts.join('\n'); + return { id: this.id, contextValue: this.contextValue, label: this.subscription.subscriptionName, - tooltip: `Subscription ID: ${this.subscription.subscriptionId}`, + tooltip, iconPath: vscode.Uri.joinPath( ext.context.extensionUri, 'resources', diff --git a/src/plugins/service-azure-vm/discovery-tree/VmFilteringWizardContext.ts b/src/plugins/service-azure-vm/discovery-tree/VmFilteringWizardContext.ts new file mode 100644 index 000000000..096a2a8e3 --- /dev/null +++ b/src/plugins/service-azure-vm/discovery-tree/VmFilteringWizardContext.ts @@ -0,0 +1,14 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { type FilteringWizardContext } from '../../api-shared/azure/subscriptionFiltering/FilteringWizardContext'; + +/** + * Extended wizard context for VM-specific filtering that includes tag filtering + */ +export interface VmFilteringWizardContext extends FilteringWizardContext { + /** The Azure VM tag to filter by */ + vmTag?: string; +} diff --git a/src/plugins/service-azure-vm/discovery-tree/VmTagFilterStep.ts b/src/plugins/service-azure-vm/discovery-tree/VmTagFilterStep.ts new file mode 100644 index 000000000..fb143104a --- /dev/null +++ b/src/plugins/service-azure-vm/discovery-tree/VmTagFilterStep.ts @@ -0,0 +1,51 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { AzureWizardPromptStep } from '@microsoft/vscode-azext-utils'; +import * as l10n from '@vscode/l10n'; +import { ext } from '../../../extensionVariables'; +import { type VmFilteringWizardContext } from './VmFilteringWizardContext'; + +/** + * Wizard step for configuring Azure VM tag filtering + */ +export class VmTagFilterStep extends AzureWizardPromptStep { + public async prompt(context: VmFilteringWizardContext): Promise { + const defaultTag = ext.context.globalState.get('azure-vm-discovery.tag', 'DocumentDB'); + + const result = await context.ui.showInputBox({ + prompt: l10n.t('Enter the Azure VM tag to filter by'), + value: defaultTag, + placeHolder: l10n.t('e.g., DocumentDB, Environment, Project'), + validateInput: (value: string) => { + if (!value) { + return l10n.t('Tag cannot be empty.'); + } + if (!/^[a-zA-Z0-9_.-]+$/.test(value)) { + return l10n.t('Tag can only contain alphanumeric characters, underscores, periods, and hyphens.'); + } + if (value.length > 256) { + return l10n.t('Tag cannot be longer than 256 characters.'); + } + return undefined; + }, + }); + + if (result !== undefined) { + // Input box returns undefined if cancelled + await ext.context.globalState.update('azure-vm-discovery.tag', result); + context.vmTag = result; + context.telemetry.properties.tagConfigured = 'true'; + context.telemetry.properties.tagValue = result; + } else { + context.telemetry.properties.tagConfigured = 'cancelled'; + // Do not change existing tag if cancelled + } + } + + public shouldPrompt(_context: VmFilteringWizardContext): boolean { + return true; // Always show this step + } +} diff --git a/src/plugins/service-azure-vm/discovery-tree/configureVmFilterWizard.ts b/src/plugins/service-azure-vm/discovery-tree/configureVmFilterWizard.ts index d9697fbb5..834f39f7b 100644 --- a/src/plugins/service-azure-vm/discovery-tree/configureVmFilterWizard.ts +++ b/src/plugins/service-azure-vm/discovery-tree/configureVmFilterWizard.ts @@ -3,169 +3,33 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { type AzureSubscription, type VSCodeAzureSubscriptionProvider } from '@microsoft/vscode-azext-azureauth'; -import { - AzureWizard, - AzureWizardPromptStep, - UserCancelledError, - type IActionContext, - type IAzureQuickPickItem, -} from '@microsoft/vscode-azext-utils'; +import { type VSCodeAzureSubscriptionProvider } from '@microsoft/vscode-azext-azureauth'; +import { type IActionContext } from '@microsoft/vscode-azext-utils'; import * as l10n from '@vscode/l10n'; -import * as vscode from 'vscode'; -import { ext } from '../../../extensionVariables'; import { - getDuplicateSubscriptions, - getSelectedSubscriptionIds, - setSelectedSubscriptionIds, -} from '../../api-shared/azure/subscriptionFiltering'; - -export interface ConfigureVmFilterWizardContext extends IActionContext { - azureSubscriptionProvider: VSCodeAzureSubscriptionProvider; -} - -class SubscriptionFilterStep extends AzureWizardPromptStep { - public async prompt(context: ConfigureVmFilterWizardContext): Promise { - const azureSubscriptionProvider = context.azureSubscriptionProvider; - - if (!(await azureSubscriptionProvider.isSignedIn())) { - const signIn: vscode.MessageItem = { title: l10n.t('Sign In') }; - void vscode.window - .showInformationMessage(l10n.t('You are not signed in to Azure. Sign in and retry.'), signIn) - .then(async (input) => { - if (input === signIn) { - await azureSubscriptionProvider.signIn(); - ext.discoveryBranchDataProvider.refresh(); - } - }); - - throw new UserCancelledError(l10n.t('User is not signed in to Azure.')); - } - - const selectedSubscriptionIds = getSelectedSubscriptionIds(); - - const subscriptionQuickPickItemsProvider: () => Promise< - IAzureQuickPickItem[] - > = async () => { - const allSubscriptions = await azureSubscriptionProvider.getSubscriptions(false); // Get all unfiltered subscriptions - const duplicates = getDuplicateSubscriptions(allSubscriptions); - - return allSubscriptions - .map( - (subscription) => - >{ - label: duplicates.includes(subscription) - ? subscription.name + ` (${subscription.account?.label})` - : subscription.name, - description: subscription.subscriptionId, - data: subscription, - group: subscription.account.label, - iconPath: vscode.Uri.joinPath( - ext.context.extensionUri, - 'resources', - 'from_node_modules', - '@microsoft', - 'vscode-azext-azureutils', - 'resources', - 'azureSubscription.svg', - ), - }, - ) - .sort((a, b) => a.label.localeCompare(b.label)); - }; - - const picks = await context.ui.showQuickPick(subscriptionQuickPickItemsProvider(), { - canPickMany: true, - placeHolder: l10n.t('Select Subscriptions to Display'), - isPickSelected: (pick) => { - return ( - selectedSubscriptionIds.length === 0 || - selectedSubscriptionIds.includes( - (pick as IAzureQuickPickItem).data.subscriptionId, - ) - ); - }, - suppressPersistence: true, // Recommended for multi-step wizards - }); - - if (picks !== undefined) { - // User made a choice (could be an empty array if they deselected all) - const newSelectedIds = picks.map((pick) => `${pick.data.tenantId}/${pick.data.subscriptionId}`); - await setSelectedSubscriptionIds(newSelectedIds); - context.telemetry.properties.subscriptionsConfigured = 'true'; - context.telemetry.properties.subscriptionCount = String(newSelectedIds.length); - } else { - // User cancelled the quick pick (e.g., pressed Esc) - context.telemetry.properties.subscriptionsConfigured = 'cancelled'; - // Do not change existing selection if cancelled - } - } - - public shouldPrompt(_context: ConfigureVmFilterWizardContext): boolean { - return true; // Always show this step - } -} - -class TagFilterStep extends AzureWizardPromptStep { - public async prompt(context: ConfigureVmFilterWizardContext): Promise { - const defaultTag = ext.context.globalState.get('azure-vm-discovery.tag', 'DocumentDB'); - - const result = await context.ui.showInputBox({ - prompt: l10n.t('Enter the Azure VM tag to filter by'), - value: defaultTag, - placeHolder: l10n.t('e.g., DocumentDB, Environment, Project'), - validateInput: (value: string) => { - if (!value) { - return l10n.t('Tag cannot be empty.'); - } - if (!/^[a-zA-Z0-9_.-]+$/.test(value)) { - return l10n.t('Tag can only contain alphanumeric characters, underscores, periods, and hyphens.'); - } - if (value.length > 256) { - return l10n.t('Tag cannot be longer than 256 characters.'); - } - return undefined; - }, - }); - - if (result !== undefined) { - // Input box returns undefined if cancelled - await ext.context.globalState.update('azure-vm-discovery.tag', result); - context.telemetry.properties.tagConfigured = 'true'; - context.telemetry.properties.tagValue = result; - } else { - context.telemetry.properties.tagConfigured = 'cancelled'; - // Do not change existing tag if cancelled - } - } - - public shouldPrompt(_context: ConfigureVmFilterWizardContext): boolean { - return true; // Always show this step - } -} - + configureAzureSubscriptionFilter, + type SubscriptionFilteringOptions, +} from '../../api-shared/azure/subscriptionFiltering/configureAzureSubscriptionFilter'; +import { type VmFilteringWizardContext } from './VmFilteringWizardContext'; +import { VmTagFilterStep } from './VmTagFilterStep'; + +/** + * Configures the Azure VM discovery filters, including both subscription/tenant filtering and VM-specific tag filtering + */ export async function configureVmFilter( baseContext: IActionContext, azureSubscriptionProvider: VSCodeAzureSubscriptionProvider, ): Promise { - const wizardContext: ConfigureVmFilterWizardContext = { - ...baseContext, - azureSubscriptionProvider: azureSubscriptionProvider, - telemetry: { - // Ensure telemetry object and its properties are initialized - properties: { ...(baseContext.telemetry?.properties || {}) }, - measurements: { ...(baseContext.telemetry?.measurements || {}) }, - suppressIfSuccessful: baseContext.telemetry?.suppressIfSuccessful || false, - suppressAll: baseContext.telemetry?.suppressAll || false, - }, - }; - - const wizard = new AzureWizard(wizardContext, { + const options: SubscriptionFilteringOptions = { title: l10n.t('Configure Azure VM Discovery Filters'), - promptSteps: [new SubscriptionFilterStep(), new TagFilterStep()], - executeSteps: [], // Configuration happens in prompt steps, no separate execution steps - }); + additionalPromptSteps: [new VmTagFilterStep()], + contextExtender: (context) => + ({ + ...context, + // Initialize VM-specific properties + vmTag: undefined, + }) as VmFilteringWizardContext, + }; - await wizard.prompt(); - // Data is saved by the prompt steps themselves. + await configureAzureSubscriptionFilter(baseContext, azureSubscriptionProvider, options); } diff --git a/src/plugins/service-azure-vm/discovery-tree/vm/AzureVMResourceItem.ts b/src/plugins/service-azure-vm/discovery-tree/vm/AzureVMResourceItem.ts index 30ffd04d9..e388c6c0f 100644 --- a/src/plugins/service-azure-vm/discovery-tree/vm/AzureVMResourceItem.ts +++ b/src/plugins/service-azure-vm/discovery-tree/vm/AzureVMResourceItem.ts @@ -22,7 +22,7 @@ import { type AuthenticateWizardContext } from '../../../../documentdb/wizards/a import { ProvidePasswordStep } from '../../../../documentdb/wizards/authenticate/ProvidePasswordStep'; import { ProvideUserNameStep } from '../../../../documentdb/wizards/authenticate/ProvideUsernameStep'; import { ext } from '../../../../extensionVariables'; -import { ClusterItemBase, type ClusterCredentials } from '../../../../tree/documentdb/ClusterItemBase'; +import { ClusterItemBase, type EphemeralClusterCredentials } from '../../../../tree/documentdb/ClusterItemBase'; import { type ClusterModel } from '../../../../tree/documentdb/ClusterModel'; import { nonNullProp, nonNullValue } from '../../../../utils/nonNull'; @@ -65,7 +65,7 @@ export class AzureVMResourceItem extends ClusterItemBase { this.tooltipOverride = new vscode.MarkdownString(tooltipParts.join('\n\n')); } - public async getCredentials(): Promise { + public async getCredentials(): Promise { return callWithTelemetryAndErrorHandling('connect', async (context: IActionContext) => { context.telemetry.properties.discoveryProvider = 'azure-vm-discovery'; context.telemetry.properties.view = Views.DiscoveryView; @@ -255,7 +255,7 @@ export class AzureVMResourceItem extends ClusterItemBase { private async promptForCredentials(wizardContext: AuthenticateWizardContext): Promise { const wizard = new AzureWizard(wizardContext, { promptSteps: [new ProvideUserNameStep(), new ProvidePasswordStep()], - title: l10n.t('Authenticate to connect with your MongoDB cluster'), + title: l10n.t('Authenticate to Connect with Your DocumentDB Cluster'), showLoadingPrompt: true, }); diff --git a/src/plugins/service-azure-vm/discovery-wizard/SelectSubscriptionStep.ts b/src/plugins/service-azure-vm/discovery-wizard/SelectSubscriptionStep.ts deleted file mode 100644 index 96eacec1a..000000000 --- a/src/plugins/service-azure-vm/discovery-wizard/SelectSubscriptionStep.ts +++ /dev/null @@ -1,87 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import { VSCodeAzureSubscriptionProvider } from '@microsoft/vscode-azext-azureauth'; -import { AzureWizardPromptStep, UserCancelledError } from '@microsoft/vscode-azext-utils'; -import * as l10n from '@vscode/l10n'; -import { Uri, window, type MessageItem, type QuickPickItem } from 'vscode'; -import { type NewConnectionWizardContext } from '../../../commands/newConnection/NewConnectionWizardContext'; -import { ext } from '../../../extensionVariables'; -import { AzureContextProperties } from '../../api-shared/azure/wizard/AzureContextProperties'; - -export class SelectSubscriptionStep extends AzureWizardPromptStep { - iconPath = Uri.joinPath( - ext.context.extensionUri, - 'resources', - 'from_node_modules', - '@microsoft', - 'vscode-azext-azureutils', - 'resources', - 'azureSubscription.svg', - ); - - public async prompt(context: NewConnectionWizardContext): Promise { - if ( - context.properties[AzureContextProperties.AzureSubscriptionProvider] === undefined || - !( - context.properties[AzureContextProperties.AzureSubscriptionProvider] instanceof - VSCodeAzureSubscriptionProvider - ) - ) { - throw new Error('ServiceDiscoveryProvider is not set or is not of the correct type.'); - } - - const subscriptionProvider = context.properties[ - AzureContextProperties.AzureSubscriptionProvider - ] as VSCodeAzureSubscriptionProvider; - - /** - * This is an important step to ensure that the user is signed in to Azure before listing subscriptions. - */ - if (!(await subscriptionProvider.isSignedIn())) { - const signIn: MessageItem = { title: l10n.t('Sign In') }; - void window - .showInformationMessage(l10n.t('You are not signed in to Azure. Sign in and retry.'), signIn) - .then((input) => { - if (input === signIn) { - void subscriptionProvider.signIn(); - } - }); - - throw new UserCancelledError(l10n.t('User is not signed in to Azure.')); - } - - const subscriptions = await subscriptionProvider.getSubscriptions(false); - - const promptItems: (QuickPickItem & { id: string })[] = subscriptions - .map((subscription) => ({ - id: subscription.subscriptionId, - label: subscription.name, - description: subscription.subscriptionId, - iconPath: this.iconPath, - - alwaysShow: true, - })) - // Sort alphabetically - .sort((a, b) => a.label.localeCompare(b.label)); - - const selectedItem = await context.ui.showQuickPick([...promptItems], { - stepName: 'selectSubscription', - placeHolder: l10n.t('Choose a subscription…'), - loadingPlaceHolder: l10n.t('Loading subscriptions…'), - enableGrouping: true, - matchOnDescription: true, - suppressPersistence: true, - }); - - context.properties[AzureContextProperties.SelectedSubscription] = subscriptions.find( - (subscription) => subscription.subscriptionId === selectedItem.id, - ); - } - - public shouldPrompt(): boolean { - return true; - } -} diff --git a/src/plugins/service-azure-vm/discovery-wizard/SelectVMStep.ts b/src/plugins/service-azure-vm/discovery-wizard/SelectVMStep.ts index cd69a8761..f18d2a7d4 100644 --- a/src/plugins/service-azure-vm/discovery-wizard/SelectVMStep.ts +++ b/src/plugins/service-azure-vm/discovery-wizard/SelectVMStep.ts @@ -47,90 +47,94 @@ export class SelectVMStep extends AzureWizardPromptStep[] = []; - - for (const vm of allVms) { - if (vm.tags && vm.tags[tagName] !== undefined) { - let publicIpAddress: string | undefined; - let fqdn: string | undefined; - - if (vm.networkProfile?.networkInterfaces) { - for (const nicRef of vm.networkProfile.networkInterfaces) { - if (nicRef.id) { - const nicName = nicRef.id.substring(nicRef.id.lastIndexOf('/') + 1); - const rgName = getResourceGroupFromId(nicRef.id); - const nic = await networkClient.networkInterfaces.get(rgName, nicName); - if (nic.ipConfigurations) { - for (const ipConfig of nic.ipConfigurations) { - if (ipConfig.publicIPAddress?.id) { - const pipName = ipConfig.publicIPAddress.id.substring( - ipConfig.publicIPAddress.id.lastIndexOf('/') + 1, - ); - const pipRg = getResourceGroupFromId(ipConfig.publicIPAddress.id); - const publicIp = await networkClient.publicIPAddresses.get(pipRg, pipName); - if (publicIp.ipAddress) { - publicIpAddress = publicIp.ipAddress; + // Create async function to provide better loading UX and debugging experience + const getVMQuickPickItems = async (): Promise< + IAzureQuickPickItem[] + > => { + // Using ComputeManagementClient to list VMs + const allVms = await uiUtils.listAllIterator(computeClient.virtualMachines.listAll()); + + const taggedVms: IAzureQuickPickItem[] = []; + + for (const vm of allVms) { + if (vm.tags && vm.tags[tagName] !== undefined) { + let publicIpAddress: string | undefined; + let fqdn: string | undefined; + + if (vm.networkProfile?.networkInterfaces) { + for (const nicRef of vm.networkProfile.networkInterfaces) { + if (nicRef.id) { + const nicName = nicRef.id.substring(nicRef.id.lastIndexOf('/') + 1); + const rgName = getResourceGroupFromId(nicRef.id); + const nic = await networkClient.networkInterfaces.get(rgName, nicName); + if (nic.ipConfigurations) { + for (const ipConfig of nic.ipConfigurations) { + if (ipConfig.publicIPAddress?.id) { + const pipName = ipConfig.publicIPAddress.id.substring( + ipConfig.publicIPAddress.id.lastIndexOf('/') + 1, + ); + const pipRg = getResourceGroupFromId(ipConfig.publicIPAddress.id); + const publicIp = await networkClient.publicIPAddresses.get(pipRg, pipName); + if (publicIp.ipAddress) { + publicIpAddress = publicIp.ipAddress; + } + if (publicIp.dnsSettings?.fqdn) { + fqdn = publicIp.dnsSettings.fqdn; + } + // Stop if we found a public IP for this VM + if (publicIpAddress) break; } - if (publicIp.dnsSettings?.fqdn) { - fqdn = publicIp.dnsSettings.fqdn; - } - // Stop if we found a public IP for this VM - if (publicIpAddress) break; } } } + if (publicIpAddress) break; // Stop checking NICs if IP found } - if (publicIpAddress) break; // Stop checking NICs if IP found } - } - const label = vm.name!; - let description = ''; - let detail = `VM Size: ${vm.hardwareProfile?.vmSize}`; // Add VM Size to detail + const label = vm.name!; + let description = ''; + let detail = `VM Size: ${vm.hardwareProfile?.vmSize}`; // Add VM Size to detail - if (publicIpAddress || fqdn) { - description = fqdn ? fqdn : publicIpAddress!; - detail += fqdn ? ` (IP: ${publicIpAddress || 'N/A'})` : ''; - } else { - description = l10n.t('No public connectivity'); - detail += l10n.t(', No public IP or FQDN found.'); - } + if (publicIpAddress || fqdn) { + description = fqdn ? fqdn : publicIpAddress!; + detail += fqdn ? ` (IP: ${publicIpAddress || 'N/A'})` : ''; + } else { + description = l10n.t('No public connectivity'); + detail += l10n.t(', No public IP or FQDN found.'); + } - taggedVms.push({ - label, - description, - detail, - data: { ...vm, publicIpAddress, fqdn }, - iconPath: this.iconPath, - alwaysShow: true, - }); + taggedVms.push({ + label, + description, + detail, + data: { ...vm, publicIpAddress, fqdn }, + iconPath: this.iconPath, + alwaysShow: true, + }); + } } - } - if (taggedVms.length === 0) { - context.errorHandling.suppressReportIssue = true; // No need to report an issue if no VMs are found - throw new Error( - l10n.t(`No Azure VMs found with tag "{tagName}" in subscription "{subscriptionName}".`, { - tagName, - subscriptionName: subscription.name, - }), - ); - } + if (taggedVms.length === 0) { + context.errorHandling.suppressReportIssue = true; // No need to report an issue if no VMs are found + throw new Error( + l10n.t(`No Azure VMs found with tag "{tagName}" in subscription "{subscriptionName}".`, { + tagName, + subscriptionName: subscription.name, + }), + ); + } - const selectedVMItem = await context.ui.showQuickPick( - taggedVms.sort((a, b) => a.label.localeCompare(b.label)), - { - stepName: 'selectVM', - placeHolder: l10n.t('Choose a Virtual Machine…'), - loadingPlaceHolder: l10n.t('Loading Virtual Machines…'), - enableGrouping: true, - matchOnDescription: true, - suppressPersistence: true, - }, - ); + return taggedVms.sort((a, b) => a.label.localeCompare(b.label)); + }; + + const selectedVMItem = await context.ui.showQuickPick(getVMQuickPickItems(), { + stepName: 'selectVM', + placeHolder: l10n.t('Choose a Virtual Machine…'), + loadingPlaceHolder: l10n.t('Loading Virtual Machines…'), + enableGrouping: true, + matchOnDescription: true, + suppressPersistence: true, + }); context.properties[AzureVMContextProperties.SelectedVM] = selectedVMItem.data; } diff --git a/src/services/connectionStorageService.ts b/src/services/connectionStorageService.ts index 96a87a2a8..ff9101af5 100644 --- a/src/services/connectionStorageService.ts +++ b/src/services/connectionStorageService.ts @@ -6,6 +6,7 @@ import { apiUtils, callWithTelemetryAndErrorHandling, type IActionContext } from '@microsoft/vscode-azext-utils'; import * as l10n from '@vscode/l10n'; import * as vscode from 'vscode'; +import { type EntraIdAuthConfig, type NativeAuthConfig } from '../documentdb/auth/AuthConfig'; import { AuthMethodId } from '../documentdb/auth/AuthMethod'; import { DocumentDBConnectionString } from '../documentdb/utils/DocumentDBConnectionString'; import { API } from '../DocumentDBExperiences'; @@ -62,19 +63,28 @@ export interface ConnectionItem { secrets: { /** assume that the connection string doesn't contain the username and password */ connectionString: string; - userName?: string; - password?: string; + + // Structured authentication configurations + nativeAuthConfig?: NativeAuthConfig; + entraIdAuthConfig?: EntraIdAuthConfig; }; } /** * StorageService offers secrets storage as a string[] so we need to ensure * we keep using correct indexes when accessing secrets. + * + * Auth config fields are stored individually as flat string values to avoid + * nested object serialization issues with VS Code SecretStorage. */ const enum SecretIndex { ConnectionString = 0, - UserName = 1, - Password = 2, + // Native auth config fields (consolidated from legacy UserName/Password) + NativeAuthConnectionUser = 1, + NativeAuthConnectionPassword = 2, + // Entra ID auth config fields + EntraIdTenantId = 3, + EntraIdSubscriptionId = 4, } /** @@ -156,11 +166,24 @@ export class ConnectionStorageService { const secretsArray: string[] = []; if (item.secrets) { secretsArray[SecretIndex.ConnectionString] = item.secrets.connectionString; - if (item.secrets.userName) { - secretsArray[SecretIndex.UserName] = item.secrets.userName; + + // Store nativeAuthConfig fields individually + if (item.secrets.nativeAuthConfig) { + secretsArray[SecretIndex.NativeAuthConnectionUser] = item.secrets.nativeAuthConfig.connectionUser; + if (item.secrets.nativeAuthConfig.connectionPassword) { + secretsArray[SecretIndex.NativeAuthConnectionPassword] = + item.secrets.nativeAuthConfig.connectionPassword; + } } - if (item.secrets.password) { - secretsArray[SecretIndex.Password] = item.secrets.password; + + // Store Entra ID auth config fields individually + if (item.secrets.entraIdAuthConfig) { + if (item.secrets.entraIdAuthConfig.tenantId) { + secretsArray[SecretIndex.EntraIdTenantId] = item.secrets.entraIdAuthConfig.tenantId; + } + if (item.secrets.entraIdAuthConfig.subscriptionId) { + secretsArray[SecretIndex.EntraIdSubscriptionId] = item.secrets.entraIdAuthConfig.subscriptionId; + } } } @@ -179,10 +202,35 @@ export class ConnectionStorageService { } const secretsArray = item.secrets ?? []; + + // Reconstruct native auth config from individual fields + let nativeAuthConfig: NativeAuthConfig | undefined; + const nativeAuthUser = secretsArray[SecretIndex.NativeAuthConnectionUser]; + const nativeAuthPassword = secretsArray[SecretIndex.NativeAuthConnectionPassword]; + + if (nativeAuthUser) { + nativeAuthConfig = { + connectionUser: nativeAuthUser, + connectionPassword: nativeAuthPassword, + }; + } + + // Reconstruct Entra ID auth config from individual fields + let entraIdAuthConfig: EntraIdAuthConfig | undefined; + const entraIdTenantId = secretsArray[SecretIndex.EntraIdTenantId]; + const entraIdSubscriptionId = secretsArray[SecretIndex.EntraIdSubscriptionId]; + + if (entraIdTenantId || entraIdSubscriptionId) { + entraIdAuthConfig = { + tenantId: entraIdTenantId, + subscriptionId: entraIdSubscriptionId, + }; + } + const secrets = { connectionString: secretsArray[SecretIndex.ConnectionString] ?? '', - password: secretsArray[SecretIndex.Password], - userName: secretsArray[SecretIndex.UserName], + nativeAuthConfig: nativeAuthConfig, + entraIdAuthConfig: entraIdAuthConfig, }; return { @@ -227,8 +275,13 @@ export class ConnectionStorageService { }, secrets: { connectionString: parsedCS.toString(), - userName: username, - password: password, + // Structured auth configuration populated from the same data + nativeAuthConfig: username + ? { + connectionUser: username, + connectionPassword: password, + } + : undefined, }, }; } diff --git a/src/services/discoveryServices.ts b/src/services/discoveryServices.ts index 0716fb0f1..6d82e6f1d 100644 --- a/src/services/discoveryServices.ts +++ b/src/services/discoveryServices.ts @@ -55,6 +55,15 @@ export interface DiscoveryProvider extends ProviderDescription { getLearnMoreUrl?(): string | undefined; configureTreeItemFilter?(context: IActionContext, node: TreeElement): Promise; + + /** + * Configures credentials for the discovery provider. + * + * @param context - The action context + * @param node - Optional tree node. When provided, refreshes the specific node. + * When undefined, refreshes the entire discovery tree (wizard context). + */ + configureCredentials?(context: IActionContext, node?: TreeElement): Promise; } /** diff --git a/src/tree/azure-resources-view/documentdb/VCoreResourceItem.ts b/src/tree/azure-resources-view/documentdb/VCoreResourceItem.ts index 6e61c6117..0f381fb78 100644 --- a/src/tree/azure-resources-view/documentdb/VCoreResourceItem.ts +++ b/src/tree/azure-resources-view/documentdb/VCoreResourceItem.ts @@ -27,7 +27,7 @@ import { getClusterInformationFromAzure, } from '../../../plugins/service-azure-mongo-vcore/utils/clusterHelpers'; import { nonNullValue } from '../../../utils/nonNull'; -import { ClusterItemBase, type ClusterCredentials } from '../../documentdb/ClusterItemBase'; +import { ClusterItemBase, type EphemeralClusterCredentials } from '../../documentdb/ClusterItemBase'; import { type ClusterModel } from '../../documentdb/ClusterModel'; export class VCoreResourceItem extends ClusterItemBase { @@ -49,7 +49,7 @@ export class VCoreResourceItem extends ClusterItemBase { super(cluster); } - public async getCredentials(): Promise { + public async getCredentials(): Promise { return callWithTelemetryAndErrorHandling('getCredentials', async (context: IActionContext) => { context.telemetry.properties.view = Views.AzureResourcesView; context.telemetry.properties.branch = 'documentdb'; @@ -62,7 +62,7 @@ export class VCoreResourceItem extends ClusterItemBase { this.cluster.name, ); - return extractCredentialsFromCluster(context, clusterInformation); + return extractCredentialsFromCluster(context, clusterInformation, this.subscription); }); } @@ -96,12 +96,12 @@ export class VCoreResourceItem extends ClusterItemBase { // Get and validate cluster information const clusterInformation = await this.getClusterInformation(context); - const credentials = extractCredentialsFromCluster(context, clusterInformation); + const credentials = extractCredentialsFromCluster(context, clusterInformation, this.subscription); // Prepare wizard context const wizardContext: AuthenticateWizardContext = { ...context, - adminUserName: credentials.connectionUser, + adminUserName: credentials.nativeAuthConfig?.connectionUser, resourceName: this.cluster.name, availableAuthMethods: credentials.availableAuthMethods, }; @@ -117,6 +117,16 @@ export class VCoreResourceItem extends ClusterItemBase { } // Cache credentials and attempt connection + const nativeAuthConfig = + wizardContext.selectedUserName || wizardContext.password + ? { + connectionUser: + wizardContext.nativeAuthConfig?.connectionUser ?? wizardContext.selectedUserName ?? '', + connectionPassword: + wizardContext.nativeAuthConfig?.connectionPassword ?? wizardContext.password ?? '', + } + : wizardContext.nativeAuthConfig; + CredentialCache.setAuthCredentials( this.id, nonNullValue( @@ -125,8 +135,9 @@ export class VCoreResourceItem extends ClusterItemBase { 'VCoreResourceItem.ts', ), nonNullValue(credentials.connectionString, 'credentials.connectionString', 'VCoreResourceItem.ts'), - wizardContext.selectedUserName, - wizardContext.password, + nativeAuthConfig, + undefined, + credentials.entraIdAuthConfig, ); switch (wizardContext.selectedAuthMethod) { diff --git a/src/tree/azure-resources-view/mongo-ru/RUCoreResourceItem.ts b/src/tree/azure-resources-view/mongo-ru/RUCoreResourceItem.ts index 6800de03a..df387ece9 100644 --- a/src/tree/azure-resources-view/mongo-ru/RUCoreResourceItem.ts +++ b/src/tree/azure-resources-view/mongo-ru/RUCoreResourceItem.ts @@ -16,7 +16,7 @@ import { Views } from '../../../documentdb/Views'; import { ext } from '../../../extensionVariables'; import { createCosmosDBManagementClient } from '../../../utils/azureClients'; import { nonNullValue } from '../../../utils/nonNull'; -import { ClusterItemBase, type ClusterCredentials } from '../../documentdb/ClusterItemBase'; +import { ClusterItemBase, type EphemeralClusterCredentials } from '../../documentdb/ClusterItemBase'; import { type ClusterModel } from '../../documentdb/ClusterModel'; export class RUResourceItem extends ClusterItemBase { @@ -38,7 +38,7 @@ export class RUResourceItem extends ClusterItemBase { super(cluster); } - public async getCredentials(): Promise { + public async getCredentials(): Promise { return callWithTelemetryAndErrorHandling('getCredentials', async (context: IActionContext) => { context.telemetry.properties.view = Views.AzureResourcesView; context.telemetry.properties.branch = 'ru'; @@ -84,13 +84,12 @@ export class RUResourceItem extends ClusterItemBase { this.id, credentials.selectedAuthMethod!, nonNullValue(credentials.connectionString, 'credentials.connectionString', 'RUCoreResourceItem.ts'), - credentials.connectionUser, - credentials.connectionPassword, + credentials.nativeAuthConfig, ); ext.outputChannel.append( l10n.t('Connecting to the cluster as "{username}"…', { - username: credentials.connectionUser ?? '', + username: credentials.nativeAuthConfig?.connectionUser ?? '', }), ); @@ -134,7 +133,7 @@ export class RUResourceItem extends ClusterItemBase { subscription: AzureSubscription, resourceGroup: string, clusterName: string, - ): Promise { + ): Promise { // subscription comes from different azure packages in callers; cast here intentionally // eslint-disable-next-line @typescript-eslint/no-unsafe-argument, @typescript-eslint/no-explicit-any const managementClient = await createCosmosDBManagementClient(context, subscription as any); @@ -197,12 +196,15 @@ export class RUResourceItem extends ClusterItemBase { // it here anyway. parsedCS.searchParams.delete('appName'); - const clusterCredentials: ClusterCredentials = { + const clusterCredentials: EphemeralClusterCredentials = { connectionString: parsedCS.toString(), - connectionUser: username, - connectionPassword: password, availableAuthMethods: [AuthMethodId.NativeAuth], selectedAuthMethod: AuthMethodId.NativeAuth, + // Auth configs + nativeAuthConfig: { + connectionUser: username, + connectionPassword: password, + }, }; return clusterCredentials; diff --git a/src/tree/connections-view/DocumentDBClusterItem.ts b/src/tree/connections-view/DocumentDBClusterItem.ts index cda50be24..b44d92a13 100644 --- a/src/tree/connections-view/DocumentDBClusterItem.ts +++ b/src/tree/connections-view/DocumentDBClusterItem.ts @@ -25,7 +25,7 @@ import { ProvideUserNameStep } from '../../documentdb/wizards/authenticate/Provi import { SaveCredentialsStep } from '../../documentdb/wizards/authenticate/SaveCredentialsStep'; import { ext } from '../../extensionVariables'; import { ConnectionStorageService, ConnectionType } from '../../services/connectionStorageService'; -import { ClusterItemBase, type ClusterCredentials } from '../documentdb/ClusterItemBase'; +import { ClusterItemBase, type EphemeralClusterCredentials } from '../documentdb/ClusterItemBase'; import { type ClusterModelWithStorage } from '../documentdb/ClusterModel'; import { type TreeElementWithStorageId } from '../TreeElementWithStorageId'; @@ -41,7 +41,7 @@ export class DocumentDBClusterItem extends ClusterItemBase implements TreeElemen return this.cluster.storageId; } - public async getCredentials(): Promise { + public async getCredentials(): Promise { const connectionType = this.cluster.emulatorConfiguration?.isEmulator ? ConnectionType.Emulators : ConnectionType.Clusters; @@ -53,10 +53,16 @@ export class DocumentDBClusterItem extends ClusterItemBase implements TreeElemen return { connectionString: connectionCredentials.secrets.connectionString, - connectionUser: connectionCredentials.secrets.userName, - connectionPassword: connectionCredentials.secrets.password, availableAuthMethods: authMethodsFromString(connectionCredentials?.properties.availableAuthMethods), selectedAuthMethod: authMethodFromString(connectionCredentials?.properties.selectedAuthMethod), + + // Structured auth configurations + nativeAuthConfig: connectionCredentials.secrets.nativeAuthConfig, + entraIdAuthConfig: connectionCredentials.secrets.entraIdAuthConfig + ? { + tenantId: connectionCredentials.secrets.entraIdAuthConfig.tenantId, + } + : undefined, }; } @@ -87,8 +93,9 @@ export class DocumentDBClusterItem extends ClusterItemBase implements TreeElemen const connectionString = new DocumentDBConnectionString(connectionCredentials.secrets.connectionString); - let username: string | undefined = connectionCredentials.secrets.userName; - let password: string | undefined = connectionCredentials.secrets.password; + // Use nativeAuthConfig for credentials + let username: string | undefined = connectionCredentials.secrets.nativeAuthConfig?.connectionUser; + let password: string | undefined = connectionCredentials.secrets.nativeAuthConfig?.connectionPassword; let authMethod: AuthMethodId | undefined = authMethodFromString( connectionCredentials.properties.selectedAuthMethod, ); @@ -153,8 +160,14 @@ export class DocumentDBClusterItem extends ClusterItemBase implements TreeElemen connection.properties.selectedAuthMethod = authMethod; connection.secrets = { connectionString: connectionString.toString(), - userName: username, - password: password, + // Populate nativeAuthConfig configuration + nativeAuthConfig: + authMethod === AuthMethodId.NativeAuth && (username || password) + ? { + connectionUser: username ?? '', + connectionPassword: password ?? '', + } + : undefined, }; try { await ConnectionStorageService.save(connectionType, connection, true); @@ -194,9 +207,14 @@ export class DocumentDBClusterItem extends ClusterItemBase implements TreeElemen this.id, authMethod, connectionString.toString(), - username, - password, + username && password + ? { + connectionUser: username, + connectionPassword: password, + } + : undefined, this.cluster.emulatorConfiguration, // workspace items can potentially be connecting to an emulator, so we always pass it + connectionCredentials.secrets.entraIdAuthConfig, ); let clustersClient: ClustersClient; diff --git a/src/tree/documentdb/ClusterItemBase.ts b/src/tree/documentdb/ClusterItemBase.ts index d92baa62d..61c9de917 100644 --- a/src/tree/documentdb/ClusterItemBase.ts +++ b/src/tree/documentdb/ClusterItemBase.ts @@ -9,6 +9,7 @@ import * as vscode from 'vscode'; import { type Experience } from '../../DocumentDBExperiences'; import { ClustersClient, type DatabaseItemModel } from '../../documentdb/ClustersClient'; import { CredentialCache } from '../../documentdb/CredentialCache'; +import { type EntraIdAuthConfig, type NativeAuthConfig } from '../../documentdb/auth/AuthConfig'; import { type AuthMethodId } from '../../documentdb/auth/AuthMethod'; import { ext } from '../../extensionVariables'; import { regionToDisplayName } from '../../utils/regionToDisplayName'; @@ -21,25 +22,31 @@ import { type ClusterModel } from './ClusterModel'; import { DatabaseItem } from './DatabaseItem'; /** - * Full connection details for a DocumentDB cluster used at runtime. + * Full connection details for a DocumentDB cluster used at runtime during service discovery. * - * This type intentionally contains concrete credentials - * because some service-discovery flows provide ephemeral credentials from an - * external service rather than from stored connections. + * This type intentionally contains concrete credentials because some service-discovery + * flows provide ephemeral credentials from an external service rather than from stored connections. * * TODO: Maintainer notes: * - This type is a temporary bridge for service-discovery scenarios. The preferred * long-term approach is an optional discovery API that returns connection info * on demand so we avoid keeping credentials in memory longer than necessary. */ -export type ClusterCredentials = { +export type EphemeralClusterCredentials = { connectionString: string; - connectionUser?: string; - connectionPassword?: string; availableAuthMethods: AuthMethodId[]; selectedAuthMethod?: AuthMethodId; // some providers can pre-select a method + + // Authentication method specific configurations + nativeAuthConfig?: NativeAuthConfig; + entraIdAuthConfig?: EntraIdAuthConfig; }; +/** + * @deprecated Use EphemeralClusterCredentials instead. This alias is provided for backward compatibility. + */ +export type ClusterCredentials = EphemeralClusterCredentials; + // This info will be available at every level in the tree for immediate access export abstract class ClusterItemBase implements TreeElement, TreeElementWithExperience, TreeElementWithContextValue, TreeElementWithRetryChildren @@ -89,9 +96,9 @@ export abstract class ClusterItemBase * Must be implemented by subclasses. * This is relevant for service discovery scenarios * - * @returns A promise that resolves to the credentials if successful; otherwise, undefined. + * @returns A promise that resolves to the EphemeralClusterCredentials if successful; otherwise, undefined. */ - public abstract getCredentials(): Promise; + public abstract getCredentials(): Promise; /** * Authenticates and connects to the cluster to list all available databases. diff --git a/src/tree/help-and-feedback-view/HelpAndFeedbackBranchDataProvider.ts b/src/tree/help-and-feedback-view/HelpAndFeedbackBranchDataProvider.ts new file mode 100644 index 000000000..081e4f634 --- /dev/null +++ b/src/tree/help-and-feedback-view/HelpAndFeedbackBranchDataProvider.ts @@ -0,0 +1,144 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { + callWithTelemetryAndErrorHandling, + createGenericElement, + type IActionContext, +} from '@microsoft/vscode-azext-utils'; +import * as vscode from 'vscode'; +import { Views } from '../../documentdb/Views'; +import { BaseExtendedTreeDataProvider } from '../BaseExtendedTreeDataProvider'; +import { type TreeElement } from '../TreeElement'; +import { isTreeElementWithContextValue } from '../TreeElementWithContextValue'; + +/** + * Tree data provider for the Help and Feedback view. + * + * This provider displays a static list of helpful links including: + * - What's New (changelog) + * - Extension Documentation + * - DocumentDB Documentation + * - Suggest a Feature (GitHub issue template) + * - Report a Bug (GitHub issue template) + * - Create Free Azure DocumentDB Cluster + * + * All items are leaf nodes (no children) that open external links when clicked. + * The links are opened using the 'vscode-documentdb.command.internal.helpAndFeedback.openUrl' command, + * which provides telemetry tracking for each URL opened. + */ +export class HelpAndFeedbackBranchDataProvider extends BaseExtendedTreeDataProvider { + async getChildren(element?: TreeElement): Promise { + return callWithTelemetryAndErrorHandling('getChildren', async (context: IActionContext) => { + context.telemetry.properties.view = Views.HelpAndFeedbackView; + + if (!element) { + context.telemetry.properties.parentNodeContext = 'root'; + + // Clear cache for root-level items + this.clearParentCache(); + + const rootItems = this.getRootItems(); + + // Process root items + if (rootItems) { + for (const item of rootItems) { + if (isTreeElementWithContextValue(item)) { + this.appendContextValues(item, Views.HelpAndFeedbackView); + } + + // Register root items in cache + this.registerNodeInCache(item); + } + } + + return rootItems; + } + + // No children for leaf nodes + context.telemetry.properties.parentNodeContext = (await element.getTreeItem()).contextValue; + return undefined; + }); + } + + /** + * Helper function to get the root items of the help and feedback tree. + * These are static link items with no children. + */ + private getRootItems(): TreeElement[] | null | undefined { + const parentId = Views.HelpAndFeedbackView; + + const rootItems: TreeElement[] = [ + createGenericElement({ + contextValue: 'helpItem', + id: `${parentId}/whats-new`, + label: vscode.l10n.t("What's New"), + iconPath: new vscode.ThemeIcon('megaphone'), + commandId: 'vscode-documentdb.command.internal.helpAndFeedback.openUrl', + commandArgs: ['https://github.com/microsoft/vscode-documentdb/discussions/categories/announcements'], + }) as TreeElement, + + createGenericElement({ + contextValue: 'helpItem', + id: `${parentId}/changelog`, + label: vscode.l10n.t('Changelog'), + iconPath: new vscode.ThemeIcon('history'), + commandId: 'vscode-documentdb.command.internal.helpAndFeedback.openUrl', + commandArgs: ['https://github.com/microsoft/vscode-documentdb/blob/main/CHANGELOG.md'], + }) as TreeElement, + + createGenericElement({ + contextValue: 'helpItem', + id: `${parentId}/extension-docs`, + label: vscode.l10n.t('Extension Documentation'), + iconPath: new vscode.ThemeIcon('book'), + commandId: 'vscode-documentdb.command.internal.helpAndFeedback.openUrl', + commandArgs: ['https://microsoft.github.io/vscode-documentdb/'], + }) as TreeElement, + + createGenericElement({ + contextValue: 'helpItem', + id: `${parentId}/documentdb-docs`, + label: vscode.l10n.t('DocumentDB Documentation'), + iconPath: new vscode.ThemeIcon('library'), + commandId: 'vscode-documentdb.command.internal.helpAndFeedback.openUrl', + commandArgs: ['https://documentdb.io'], + }) as TreeElement, + + createGenericElement({ + contextValue: 'feedbackItem', + id: `${parentId}/suggest-feature`, + label: vscode.l10n.t('Suggest a Feature'), + iconPath: new vscode.ThemeIcon('lightbulb'), + commandId: 'vscode-documentdb.command.internal.helpAndFeedback.openUrl', + commandArgs: [ + 'https://github.com/microsoft/vscode-documentdb/issues/new?assignees=&labels=user%20feedback', + ], + }) as TreeElement, + + createGenericElement({ + contextValue: 'feedbackItem', + id: `${parentId}/report-bug`, + label: vscode.l10n.t('Report a Bug'), + iconPath: new vscode.ThemeIcon('bug'), + commandId: 'vscode-documentdb.command.internal.helpAndFeedback.openUrl', + commandArgs: [ + 'https://github.com/microsoft/vscode-documentdb/issues/new?assignees=&labels=user%20feedback,bug', + ], + }) as TreeElement, + + createGenericElement({ + contextValue: 'actionItem', + id: `${parentId}/create-free-cluster`, + label: vscode.l10n.t('Create Free Azure DocumentDB Cluster'), + iconPath: new vscode.ThemeIcon('add'), + commandId: 'vscode-documentdb.command.internal.helpAndFeedback.openUrl', + commandArgs: ['https://aka.ms/tryvcore'], + }) as TreeElement, + ]; + + return rootItems; + } +}