From 4cc9cbb0c5d9945a849fc9887511fc44aefb772e Mon Sep 17 00:00:00 2001 From: Charles Gagnon Date: Tue, 15 Sep 2020 16:00:27 -0700 Subject: [PATCH] Use custom dialog for prompting MIAA connection info (#12316) * Use custom dialog for prompting MIAA connection info * disable inputs * Update strings --- extensions/arc/src/common/utils.ts | 4 + extensions/arc/src/constants.ts | 2 + extensions/arc/src/localizedConstants.ts | 3 + extensions/arc/src/models/miaaModel.ts | 108 ++++++-------- .../src/ui/dialogs/connectControllerDialog.ts | 6 +- .../arc/src/ui/dialogs/connectSqlDialog.ts | 136 ++++++++++++++++++ 6 files changed, 190 insertions(+), 69 deletions(-) create mode 100644 extensions/arc/src/ui/dialogs/connectSqlDialog.ts diff --git a/extensions/arc/src/common/utils.ts b/extensions/arc/src/common/utils.ts index dc54765962..bce78eb93e 100644 --- a/extensions/arc/src/common/utils.ts +++ b/extensions/arc/src/common/utils.ts @@ -208,3 +208,7 @@ export function parseIpAndPort(address: string): { ip: string, port: string } { port: sections[1] }; } + +export function createCredentialId(controllerId: string, resourceType: string, instanceName: string): string { + return `${controllerId}::${resourceType}::${instanceName}`; +} diff --git a/extensions/arc/src/constants.ts b/extensions/arc/src/constants.ts index 974138eaea..8d232e4d8d 100644 --- a/extensions/arc/src/constants.ts +++ b/extensions/arc/src/constants.ts @@ -7,6 +7,8 @@ import * as vscode from 'vscode'; export const refreshActionId = 'arc.refresh'; +export const credentialNamespace = 'arcCredentials'; + export interface IconPath { dark: string; light: string; diff --git a/extensions/arc/src/localizedConstants.ts b/extensions/arc/src/localizedConstants.ts index 8494044e54..5c0ea1e8b7 100644 --- a/extensions/arc/src/localizedConstants.ts +++ b/extensions/arc/src/localizedConstants.ts @@ -73,8 +73,10 @@ export const indirect = localize('arc.indirect', "Indirect"); export const loading = localize('arc.loading', "Loading..."); export const refreshToEnterCredentials = localize('arc.refreshToEnterCredentials', "Refresh node to enter credentials"); export const connectToController = localize('arc.connectToController', "Connect to Existing Controller"); +export function connectToSql(name: string): string { return localize('arc.connectToSql', "Connect to SQL instance - Azure Arc ({0})", name); } export const passwordToController = localize('arc.passwordToController', "Provide Password to Controller"); export const controllerUrl = localize('arc.controllerUrl', "Controller URL"); +export const serverEndpoint = localize('arc.serverEndpoint', "Server Endpoint"); export const controllerName = localize('arc.controllerName', "Name"); export const defaultControllerName = localize('arc.defaultControllerName', "arc-dc"); export const username = localize('arc.username', "Username"); @@ -149,6 +151,7 @@ export function openDashboardFailed(error: any): string { return localize('arc.o export function resourceDeletionFailed(name: string, error: any): string { return localize('arc.resourceDeletionFailed', "Failed to delete resource {0}. {1}", name, getErrorMessage(error)); } export function databaseCreationFailed(name: string, error: any): string { return localize('arc.databaseCreationFailed', "Failed to create database {0}. {1}", name, getErrorMessage(error)); } export function connectToControllerFailed(url: string, error: any): string { return localize('arc.connectToControllerFailed', "Could not connect to controller {0}. {1}", url, getErrorMessage(error)); } +export function connectToSqlFailed(serverName: string, error: any): string { return localize('arc.connectToSqlFailed', "Could not connect to MIAA Instance {0}. {1}", serverName, getErrorMessage(error)); } export function fetchConfigFailed(name: string, error: any): string { return localize('arc.fetchConfigFailed', "An unexpected error occurred retrieving the config for '{0}'. {1}", name, getErrorMessage(error)); } export function fetchEndpointsFailed(name: string, error: any): string { return localize('arc.fetchEndpointsFailed', "An unexpected error occurred retrieving the endpoints for '{0}'. {1}", name, getErrorMessage(error)); } export function fetchRegistrationsFailed(name: string, error: any): string { return localize('arc.fetchRegistrationsFailed', "An unexpected error occurred retrieving the registrations for '{0}'. {1}", name, getErrorMessage(error)); } diff --git a/extensions/arc/src/models/miaaModel.ts b/extensions/arc/src/models/miaaModel.ts index f4383d0aef..616877e4cc 100644 --- a/extensions/arc/src/models/miaaModel.ts +++ b/extensions/arc/src/models/miaaModel.ts @@ -8,8 +8,10 @@ import * as azdata from 'azdata'; import * as azdataExt from 'azdata-ext'; import * as vscode from 'vscode'; import { Deferred } from '../common/promise'; -import { UserCancelledError } from '../common/utils'; +import { createCredentialId, parseIpAndPort, UserCancelledError } from '../common/utils'; +import { credentialNamespace } from '../constants'; import * as loc from '../localizedConstants'; +import { ConnectToSqlDialog } from '../ui/dialogs/connectSqlDialog'; import { AzureArcTreeDataProvider } from '../ui/tree/azureArcTreeDataProvider'; import { ControllerModel, Registration } from './controllerModel'; import { ResourceModel } from './resourceModel'; @@ -155,83 +157,55 @@ export class MiaaModel extends ResourceModel { if (this._connectionProfile) { return; } - let connection: azdata.connection.ConnectionProfile | azdata.connection.Connection | undefined; + const ipAndPort = parseIpAndPort(this.config?.status.externalEndpoint || ''); + let connectionProfile: azdata.IConnectionProfile | undefined = { + serverName: `${ipAndPort.ip},${ipAndPort.port}`, + databaseName: '', + authenticationType: 'SqlLogin', + providerName: 'MSSQL', + connectionName: '', + userName: 'sa', + password: '', + savePassword: true, + groupFullName: undefined, + saveProfile: true, + id: '', + groupId: undefined, + options: {} + }; + + // If we have the ID stored then try to retrieve the password from previous connections if (this.info.connectionId) { try { - const connections = await azdata.connection.getConnections(); - const existingConnection = connections.find(conn => conn.connectionId === this.info.connectionId); - if (existingConnection) { - const credentials = await azdata.connection.getCredentials(this.info.connectionId); - if (credentials) { - existingConnection.options['password'] = credentials.password; - connection = existingConnection; - } else { - // We need the password so prompt the user for it - const connectionProfile: azdata.IConnectionProfile = { - serverName: existingConnection.options['serverName'], - databaseName: existingConnection.options['databaseName'], - authenticationType: existingConnection.options['authenticationType'], - providerName: 'MSSQL', - connectionName: '', - userName: existingConnection.options['user'], - password: '', - savePassword: false, - groupFullName: undefined, - saveProfile: true, - id: '', - groupId: undefined, - options: existingConnection.options - }; - connection = await azdata.connection.openConnectionDialog(['MSSQL'], connectionProfile); + const credentialProvider = await azdata.credentials.getProvider(credentialNamespace); + const credentials = await credentialProvider.readCredential(createCredentialId(this._controllerModel.info.id, this.info.resourceType, this.info.name)); + if (credentials.password) { + // Try to connect to verify credentials are still valid + connectionProfile.password = credentials.password; + const result = await azdata.connection.connect(connectionProfile, false, false); + if (!result.connected) { + vscode.window.showErrorMessage(loc.connectToSqlFailed(connectionProfile.serverName, result.errorMessage)); + const connectToSqlDialog = new ConnectToSqlDialog(this._controllerModel, this); + connectToSqlDialog.showDialog(connectionProfile); + connectionProfile = await connectToSqlDialog.waitForClose(); } } } catch (err) { - // ignore - the connection may not necessarily exist anymore and in that case we'll just reprompt for a connection + console.warn(`Unexpected error fetching password for MIAA instance ${err}`); + // ignore - something happened fetching the password so just reprompt } } - if (!connection) { - // We need the password so prompt the user for it - const connectionProfile: azdata.IConnectionProfile = { - // TODO chgagnon fill in external IP and port - // serverName: (this.registration.externalIp && this.registration.externalPort) ? `${this.registration.externalIp},${this.registration.externalPort}` : '', - serverName: '', - databaseName: '', - authenticationType: 'SqlLogin', - providerName: 'MSSQL', - connectionName: '', - userName: 'sa', - password: '', - savePassword: true, - groupFullName: undefined, - saveProfile: true, - id: '', - groupId: undefined, - options: {} - }; - // Weren't able to load the existing connection so prompt user for new one - connection = await azdata.connection.openConnectionDialog(['MSSQL'], connectionProfile); + if (!connectionProfile?.password) { + // Need to prompt user for password since we don't have one stored + const connectToSqlDialog = new ConnectToSqlDialog(this._controllerModel, this); + connectToSqlDialog.showDialog(connectionProfile); + connectionProfile = await connectToSqlDialog.waitForClose(); } - if (connection) { - const profile = { - // The option name might be different here based on where it came from - serverName: connection.options['serverName'] || connection.options['server'], - databaseName: connection.options['databaseName'] || connection.options['database'], - authenticationType: connection.options['authenticationType'], - providerName: 'MSSQL', - connectionName: '', - userName: connection.options['user'], - password: connection.options['password'], - savePassword: false, - groupFullName: undefined, - saveProfile: true, - id: connection.connectionId, - groupId: undefined, - options: connection.options - }; - this.updateConnectionProfile(profile); + if (connectionProfile) { + this.updateConnectionProfile(connectionProfile); } else { throw new UserCancelledError(); } diff --git a/extensions/arc/src/ui/dialogs/connectControllerDialog.ts b/extensions/arc/src/ui/dialogs/connectControllerDialog.ts index 8fad5ee42f..2b65375b13 100644 --- a/extensions/arc/src/ui/dialogs/connectControllerDialog.ts +++ b/extensions/arc/src/ui/dialogs/connectControllerDialog.ts @@ -3,7 +3,7 @@ * Licensed under the Source EULA. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { ControllerInfo } from 'arc'; +import { ControllerInfo, ResourceInfo } from 'arc'; import * as azdata from 'azdata'; import * as azdataExt from 'azdata-ext'; import { v4 as uuid } from 'uuid'; @@ -74,6 +74,7 @@ abstract class ControllerDialogBase extends InitializingComponent { protected completionPromise = new Deferred(); protected id!: string; + protected resources: ResourceInfo[] = []; constructor(protected treeDataProvider: AzureArcTreeDataProvider, title: string) { super(); @@ -82,6 +83,7 @@ abstract class ControllerDialogBase extends InitializingComponent { public showDialog(controllerInfo?: ControllerInfo, password: string | undefined = undefined): azdata.window.Dialog { this.id = controllerInfo?.id ?? uuid(); + this.resources = controllerInfo?.resources ?? []; this.dialog.cancelButton.onClick(() => this.handleCancel()); this.dialog.registerContent(async (view) => { this.modelBuilder = view.modelBuilder; @@ -168,7 +170,7 @@ export class ConnectToControllerDialog extends ControllerDialogBase { name: this.nameInputBox.value ?? '', username: this.usernameInputBox.value, rememberPassword: this.rememberPwCheckBox.checked ?? false, - resources: [] + resources: this.resources }; const controllerModel = new ControllerModel(this.treeDataProvider, controllerInfo, this.passwordInputBox.value); try { diff --git a/extensions/arc/src/ui/dialogs/connectSqlDialog.ts b/extensions/arc/src/ui/dialogs/connectSqlDialog.ts new file mode 100644 index 0000000000..e2ab5c1579 --- /dev/null +++ b/extensions/arc/src/ui/dialogs/connectSqlDialog.ts @@ -0,0 +1,136 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the Source EULA. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as azdata from 'azdata'; +import * as vscode from 'vscode'; +import { Deferred } from '../../common/promise'; +import { createCredentialId } from '../../common/utils'; +import { credentialNamespace } from '../../constants'; +import * as loc from '../../localizedConstants'; +import { ControllerModel } from '../../models/controllerModel'; +import { MiaaModel } from '../../models/miaaModel'; +import { InitializingComponent } from '../components/initializingComponent'; + +export class ConnectToSqlDialog extends InitializingComponent { + private modelBuilder!: azdata.ModelBuilder; + + private serverNameInputBox!: azdata.InputBoxComponent; + private usernameInputBox!: azdata.InputBoxComponent; + private passwordInputBox!: azdata.InputBoxComponent; + private rememberPwCheckBox!: azdata.CheckBoxComponent; + + private _completionPromise = new Deferred(); + + constructor(private _controllerModel: ControllerModel, private _miaaModel: MiaaModel) { + super(); + } + + public showDialog(connectionProfile?: azdata.IConnectionProfile): azdata.window.Dialog { + const dialog = azdata.window.createModelViewDialog(loc.connectToSql(this._miaaModel.info.name)); + dialog.cancelButton.onClick(() => this.handleCancel()); + dialog.registerContent(async view => { + this.modelBuilder = view.modelBuilder; + + this.serverNameInputBox = this.modelBuilder.inputBox() + .withProperties({ + value: connectionProfile?.serverName, + enabled: false + }).component(); + this.usernameInputBox = this.modelBuilder.inputBox() + .withProperties({ + value: connectionProfile?.userName, + enabled: false + }).component(); + this.passwordInputBox = this.modelBuilder.inputBox() + .withProperties({ + inputType: 'password', + value: connectionProfile?.password + }) + .component(); + this.rememberPwCheckBox = this.modelBuilder.checkBox() + .withProperties({ + label: loc.rememberPassword, + checked: connectionProfile?.savePassword + }).component(); + + let formModel = this.modelBuilder.formContainer() + .withFormItems([{ + components: [ + { + component: this.serverNameInputBox, + title: loc.serverEndpoint, + required: true + }, { + component: this.usernameInputBox, + title: loc.username, + required: true + }, { + component: this.passwordInputBox, + title: loc.password, + required: true + }, { + component: this.rememberPwCheckBox, + title: '' + } + ], + title: '' + }]).withLayout({ width: '100%' }).component(); + await view.initializeModel(formModel); + this.serverNameInputBox.focus(); + this.initialized = true; + }); + + dialog.registerCloseValidator(async () => await this.validate()); + dialog.okButton.label = loc.connect; + dialog.cancelButton.label = loc.cancel; + azdata.window.openDialog(dialog); + return dialog; + } + + public async validate(): Promise { + if (!this.serverNameInputBox.value || !this.usernameInputBox.value || !this.passwordInputBox.value) { + return false; + } + const connectionProfile: azdata.IConnectionProfile = { + serverName: this.serverNameInputBox.value, + databaseName: '', + authenticationType: 'SqlLogin', + providerName: 'MSSQL', + connectionName: '', + userName: this.usernameInputBox.value, + password: this.passwordInputBox.value, + savePassword: !!this.rememberPwCheckBox.checked, + groupFullName: undefined, + saveProfile: true, + id: '', + groupId: undefined, + options: {} + }; + const result = await azdata.connection.connect(connectionProfile, false, false); + if (result.connected) { + connectionProfile.id = result.connectionId; + const credentialProvider = await azdata.credentials.getProvider(credentialNamespace); + if (connectionProfile.savePassword) { + await credentialProvider.saveCredential(createCredentialId(this._controllerModel.info.id, this._miaaModel.info.resourceType, this._miaaModel.info.name), connectionProfile.password); + } else { + await credentialProvider.deleteCredential(createCredentialId(this._controllerModel.info.id, this._miaaModel.info.resourceType, this._miaaModel.info.name)); + } + this._completionPromise.resolve(connectionProfile); + return true; + } + else { + vscode.window.showErrorMessage(loc.connectToSqlFailed(this.serverNameInputBox.value, result.errorMessage)); + return false; + } + } + + private handleCancel(): void { + this._completionPromise.resolve(undefined); + } + + public waitForClose(): Promise { + return this._completionPromise.promise; + } +}