diff --git a/extensions/mssql/config.json b/extensions/mssql/config.json index 5e97475ed8..78ddc1a24b 100644 --- a/extensions/mssql/config.json +++ b/extensions/mssql/config.json @@ -1,6 +1,6 @@ { "downloadUrl": "https://github.com/Microsoft/sqltoolsservice/releases/download/{#version#}/microsoft.sqltools.servicelayer-{#fileName#}", - "version": "4.5.0.22", + "version": "4.5.0.24", "downloadFileNames": { "Windows_86": "win-x86-net7.0.zip", "Windows_64": "win-x64-net7.0.zip", diff --git a/extensions/mssql/package.json b/extensions/mssql/package.json index 10493ab8e1..398304b647 100644 --- a/extensions/mssql/package.json +++ b/extensions/mssql/package.json @@ -67,6 +67,26 @@ "category": "MSSQL", "title": "%title.designTable%" }, + { + "command": "mssql.newLogin", + "category": "MSSQL", + "title": "%title.newLogin%" + }, + { + "command": "mssql.newUser", + "category": "MSSQL", + "title": "%title.newUser%" + }, + { + "command": "mssql.objectProperties", + "category": "MSSQL", + "title": "%title.objectProperties%" + }, + { + "command": "mssql.deleteObject", + "category": "MSSQL", + "title": "%title.deleteObject%" + }, { "command": "mssql.enableGroupBySchema", "category": "MSSQL", @@ -221,12 +241,12 @@ ], "enumDescriptions": [ "%mssql.executionPlan.expensiveOperationMetric.off%", - "%mssql.executionPlan.expensiveOperationMetric.actualElapsedTime%", - "%mssql.executionPlan.expensiveOperationMetric.actualElapsedCpuTime%", - "%mssql.executionPlan.cost%", - "%mssql.executionPlan.subtreeCost%", - "%mssql.executionPlan.actualNumberOfRowsForAllExecutions%", - "%mssql.executionPlan.numberOfRowsRead%" + "%mssql.executionPlan.expensiveOperationMetric.actualElapsedTime%", + "%mssql.executionPlan.expensiveOperationMetric.actualElapsedCpuTime%", + "%mssql.executionPlan.cost%", + "%mssql.executionPlan.subtreeCost%", + "%mssql.executionPlan.actualNumberOfRowsForAllExecutions%", + "%mssql.executionPlan.numberOfRowsRead%" ] }, "mssql.query.rowCount": { @@ -391,6 +411,34 @@ { "command": "mssql.designTable", "when": "false" + }, + { + "command": "mssql.newServerRole", + "when": "false" + }, + { + "command": "mssql.newLogin", + "when": "false" + }, + { + "command": "mssql.newDatabaseRole", + "when": "false" + }, + { + "command": "mssql.newUser", + "when": "false" + }, + { + "command": "mssql.newApplicationRole", + "when": "false" + }, + { + "command": "mssql.objectProperties", + "when": "false" + }, + { + "command": "mssql.deleteObject", + "when": "false" } ], "objectExplorer/item/context": [ @@ -404,6 +452,26 @@ "when": "connectionProvider == MSSQL && nodeType == Folder && objectType == Tables", "group": "0_query@1" }, + { + "command": "mssql.newLogin", + "when": "connectionProvider == MSSQL && nodeType == Folder && objectType == ServerLevelLogins && config.workbench.enablePreviewFeatures", + "group": "0_query@1" + }, + { + "command": "mssql.newUser", + "when": "connectionProvider == MSSQL && nodeType == Folder && objectType == Users && config.workbench.enablePreviewFeatures", + "group": "0_query@1" + }, + { + "command": "mssql.objectProperties", + "when": "connectionProvider == MSSQL && nodeType =~ /^(ServerLevelLogin|User)$/ && config.workbench.enablePreviewFeatures", + "group": "0_query@1" + }, + { + "command": "mssql.deleteObject", + "when": "connectionProvider == MSSQL && nodeType =~ /^(ServerLevelLogin|User)$/ && config.workbench.enablePreviewFeatures", + "group": "0_query@2" + }, { "command": "mssql.enableGroupBySchema", "when": "connectionProvider == MSSQL && nodeType && nodeType =~ /^(Server|Database)$/ && !config.mssql.objectExplorer.groupBySchema" @@ -424,6 +492,26 @@ "when": "connectionProvider == MSSQL && nodeType == Folder && objectType == Tables", "group": "connection@1" }, + { + "command": "mssql.newLogin", + "when": "connectionProvider == MSSQL && nodeType == Folder && objectType == ServerLevelLogins && config.workbench.enablePreviewFeatures", + "group": "connection@1" + }, + { + "command": "mssql.newUser", + "when": "connectionProvider == MSSQL && nodeType == Folder && objectType == Users && config.workbench.enablePreviewFeatures", + "group": "connection@1" + }, + { + "command": "mssql.objectProperties", + "when": "connectionProvider == MSSQL && nodeType =~ /^(ServerLevelLogin|User)$/ && config.workbench.enablePreviewFeatures", + "group": "connection@1" + }, + { + "command": "mssql.deleteObject", + "when": "connectionProvider == MSSQL && nodeType =~ /^(ServerLevelLogin|User)$/ && config.workbench.enablePreviewFeatures", + "group": "connection@2" + }, { "command": "mssql.enableGroupBySchema", "when": "connectionProvider == MSSQL && nodeType && nodeType =~ /^(Server|Database)$/ && !config.mssql.objectExplorer.groupBySchema" @@ -901,7 +989,9 @@ "showOnConnectionDialog": true, "onSelectionChange": [ { - "values": ["strict"], + "values": [ + "strict" + ], "dependentOptionActions": [ { "optionName": "trustServerCertificate", diff --git a/extensions/mssql/package.nls.json b/extensions/mssql/package.nls.json index 8ad3a8f4ef..4f9d019962 100644 --- a/extensions/mssql/package.nls.json +++ b/extensions/mssql/package.nls.json @@ -179,8 +179,14 @@ "title.designTable": "Design", "mssql.parallelMessageProcessing" : "[Experimental] Whether the requests to the SQL Tools Service should be handled in parallel. This is introduced to discover the issues there might be when handling all requests in parallel. The default value is false. Relaunch of ADS is required when the value is changed.", "mssql.tableDesigner.preloadDatabaseModel": "Whether to preload the database model when the database node in the object explorer is expanded. When enabled, the loading time of table designer can be reduced. Note: You might see higher than normal memory usage if you need to expand a lot of database nodes.", - "mssql.objectExplorer.groupBySchema": "When enabled, the database objects in Object Explorer will be categorized by schema.", "mssql.objectExplorer.enableGroupBySchema":"Enable Group By Schema", - "mssql.objectExplorer.disableGroupBySchema":"Disable Group By Schema" + "mssql.objectExplorer.disableGroupBySchema":"Disable Group By Schema", + "title.newServerRole": "New Server Role", + "title.newLogin": "New Login", + "title.newDatabaseRole": "New Database Role", + "title.newApplicationRole": "New Application Role", + "title.newUser": "New User", + "title.objectProperties": "Properties", + "title.deleteObject": "Delete" } diff --git a/extensions/mssql/src/constants.ts b/extensions/mssql/src/constants.ts index 52c19a6a01..2b57ccfa09 100644 --- a/extensions/mssql/src/constants.ts +++ b/extensions/mssql/src/constants.ts @@ -23,6 +23,7 @@ export const objectExplorerPrefix: string = 'objectexplorer://'; export const SqlAssessmentService = 'sqlAssessmentService'; export const NotebookConvertService = 'notebookConvertService'; export const AzureBlobService = 'azureBlobService'; +export const ObjectManagementService = 'objectManagementService'; // CONFIGURATION VALUES ////////////////////////////////////////////////////////// export const configObjectExplorerGroupBySchemaFlagName = 'mssql.objectExplorer.groupBySchema'; diff --git a/extensions/mssql/src/contracts.ts b/extensions/mssql/src/contracts.ts index 6848a1e25c..8cfea6953a 100644 --- a/extensions/mssql/src/contracts.ts +++ b/extensions/mssql/src/contracts.ts @@ -1191,3 +1191,100 @@ export namespace ExecutionPlanComparisonRequest { } // ------------------------------- < Execution Plan > ------------------------------------ + +// ------------------------------- < Object Management > ------------------------------------ +export interface InitializeLoginViewRequestParams { + connectionUri: string; + contextId: string; + isNewObject: boolean; + name: string | undefined; +} + +export namespace InitializeLoginViewRequest { + export const type = new RequestType('objectManagement/initializeLoginView'); +} + +export interface CreateLoginRequestParams { + contextId: string; + login: mssql.ObjectManagement.Login; +} + +export namespace CreateLoginRequest { + export const type = new RequestType('objectManagement/createLogin'); +} + +export interface UpdateLoginRequestParams { + contextId: string; + login: mssql.ObjectManagement.Login; +} + +export namespace UpdateLoginRequest { + export const type = new RequestType('objectManagement/updateLogin'); +} + +export interface DeleteLoginRequestParams { + connectionUri: string; + name: string; +} + +export namespace DeleteLoginRequest { + export const type = new RequestType('objectManagement/deleteLogin'); +} + +export interface DisposeLoginViewRequestParams { + contextId: string; +} + +export namespace DisposeLoginViewRequest { + export const type = new RequestType('objectManagement/disposeLoginView'); +} + +export interface InitializeUserViewRequestParams { + connectionUri: string; + contextId: string; + isNewObject: boolean; + database: string; + name: string | undefined; +} + +export namespace InitializeUserViewRequest { + export const type = new RequestType('objectManagement/initializeUserView'); +} + +export interface CreateUserRequestParams { + contextId: string; + user: mssql.ObjectManagement.User; +} + +export namespace CreateUserRequest { + export const type = new RequestType('objectManagement/createUser'); +} + +export interface UpdateUserRequestParams { + contextId: string; + user: mssql.ObjectManagement.User; +} + +export namespace UpdateUserRequest { + export const type = new RequestType('objectManagement/updateUser'); +} + +export interface DeleteUserRequestParams { + connectionUri: string; + database: string; + name: string; +} + +export namespace DeleteUserRequest { + export const type = new RequestType('objectManagement/deleteUser'); +} + +export interface DisposeUserViewRequestParams { + contextId: string; +} + +export namespace DisposeUserViewRequest { + export const type = new RequestType('objectManagement/disposeUserView'); +} + +// ------------------------------- < Object Management > ------------------------------------ diff --git a/extensions/mssql/src/main.ts b/extensions/mssql/src/main.ts index b62229f792..1dc101c905 100644 --- a/extensions/mssql/src/main.ts +++ b/extensions/mssql/src/main.ts @@ -22,6 +22,7 @@ import { IconPathHelper } from './iconHelper'; import * as nls from 'vscode-nls'; import { INotebookConvertService } from './notebookConvert/notebookConvertService'; import { registerTableDesignerCommands } from './tableDesigner/tableDesigner'; +import { registerObjectManagementCommands } from './objectManagement/commands'; import { TelemetryActions, TelemetryReporter, TelemetryViews } from './telemetry'; const localize = nls.loadMessageBundle(); @@ -111,6 +112,7 @@ export async function activate(context: vscode.ExtensionContext): Promise; getAssessmentItems(ownerUri: string, targetType: azdata.sqlAssessment.SqlAssessmentTargetType): Promise; @@ -438,4 +436,385 @@ declare module 'mssql' { */ createSas(connectionUri: string, blobContainerUri: string, blobStorageKey: string, storageAccountName: string, expirationDate: string): Promise; } + + // Object Management - Begin. + export namespace ObjectManagement { + /** + * Base interface for all the objects. + */ + export interface SqlObject { + /** + * Name of the object. + */ + name: string; + } + + /** + * Base interface for the object view information + */ + export interface ObjectViewInfo { + /** + * The object information + */ + objectInfo: T; + } + + /** + * Server level login. + */ + export interface Login extends SqlObject { + /** + * Authentication type. + */ + authenticationType: AuthenticationType; + /** + * Password for the login. + * Only applicable when the authentication type is 'Sql'. + */ + password: string | undefined; + /** + * Old password of the login. + * Only applicable when the authentication type is 'Sql'. + * The old password is required when updating the login's own password and it doesn't have the 'ALTER ANY LOGIN' permission. + */ + oldPassword: string | undefined; + /** + * Whether the password complexity policy is enforced. + * Only applicable when the authentication type is 'Sql'. + */ + enforcePasswordPolicy: boolean | undefined; + /** + * Whether the password expiration policy is enforced. + * Only applicable when the authentication type is 'Sql'. + */ + enforcePasswordExpiration: boolean | undefined; + /** + * Whether SQL Server should prompt for an updated password when the next the login is used. + * Only applicable when the authentication type is 'Sql'. + */ + mustChangePassword: boolean | undefined; + /** + * Whether the login is locked out due to password policy violation. + * Only applicable when the authentication type is 'Sql'. + */ + isLockedOut: boolean; + /** + * The default database for the login. + */ + defaultDatabase: string; + /** + * The default language for the login. + */ + defaultLanguage: string; + /** + * The server roles of the login. + */ + serverRoles: string[]; + /** + * The database users the login is mapped to. + */ + userMapping: ServerLoginUserInfo[]; + /** + * Whether the login is enabled. + */ + isEnabled: boolean; + /** + * Whether the connect permission is granted to the login. + */ + connectPermission: boolean; + } + + /** + * The authentication types. + */ + export enum AuthenticationType { + Windows = 'Windows', + Sql = 'Sql', + AzureActiveDirectory = 'AAD' + } + + /** + * The user mapping information for login. + */ + export interface ServerLoginUserInfo { + /** + * Target database name. + */ + database: string; + /** + * User name. + */ + user: string; + /** + * Default schema of the user. + */ + defaultSchema: string; + /** + * Databases roles of the user. + */ + databaseRoles: string[]; + } + + /** + * The information required to render the login view. + */ + export interface LoginViewInfo extends ObjectViewInfo { + /** + * Whether Windows Authentication is supported. + */ + supportWindowsAuthentication: boolean; + /** + * Whether Azure Active Directory Authentication is supported. + */ + supportAADAuthentication: boolean; + /** + * Whether SQL Authentication is supported. + */ + supportSQLAuthentication: boolean; + /** + * Whether the locked out state can be changed. + */ + canEditLockedOutState: boolean; + /** + * Name of the databases in the server. + */ + databases: string[]; + /** + * Available languages in the server. + */ + languages: string[]; + /** + * All server roles in the server. + */ + serverRoles: string[]; + /** + * Whether advanced password options are supported. + * Advanced password options: check policy, check expiration, must change, unlock. + * Notes: 2 options to control the advanced options because Analytics Platform supports advanced options but does not support advanced options. + */ + supportAdvancedPasswordOptions: boolean; + /** + * Whether advanced options are supported. + * Advanced options: default database, default language and connect permission. + */ + supportAdvancedOptions: boolean; + } + + /** + * The permission information a principal has on a securable. + */ + export interface Permission { + /** + * Name of the permission. + */ + name: string; + /** + * Whether the permission is granted or denied. + */ + grant: boolean; + /** + * Whether the pincipal can grant this permission to other principals. + * The value will be ignored if the grant property is set to false. + */ + withGrant: boolean; + } + + /** + * The permissions a principal has over a securable. + */ + export interface SecurablePermissions { + /** + * The securable. + */ + securable: SqlObject; + /** + * The Permissions. + */ + permissions: Permission[]; + } + + /** + * Extend property for objects. + */ + export interface ExtendedProperty { + /** + * Name of the property. + */ + name: string; + /** + * Value of the property. + */ + value: string; + } + + /** + * User types. + */ + export enum UserType { + /** + * User with a server level login. + */ + WithLogin = 'WithLogin', + /** + * User based on a Windows user/group that has no login, but can connect to the Database Engine through membership in a Windows group. + */ + WithWindowsGroupLogin = 'WithWindowsGroupLogin', + /** + * Contained user, authentication is done within the database. + */ + Contained = 'Contained', + /** + * User that cannot authenticate. + */ + NoConnectAccess = 'NoConnectAccess' + } + + /** + * Database user. + */ + export interface User extends SqlObject { + /** + * Type of the user. + */ + type: UserType; + /** + * Default schema of the user. + */ + defaultSchema: string | undefined; + /** + * Schemas owned by the user. + */ + ownedSchemas: string[] | undefined; + /** + * Database roles that the user belongs to. + */ + databaseRoles: string[] | undefined; + /** + * The name of the server login associated with the user. + * Only applicable when the user type is 'WithLogin'. + */ + loginName: string | undefined; + /** + * The default language of the user. + * Only applicable when the user type is 'Contained'. + */ + defaultLanguage: string | undefined; + /** + * Authentication type. + * Only applicable when user type is 'Contained'. + */ + authenticationType: AuthenticationType | undefined; + /** + * Password of the user. + * Only applicable when the user type is 'Contained' and the authentication type is 'Sql'. + */ + password: string | undefined; + } + + /** + * The information required to render the user view. + */ + export interface UserViewInfo extends ObjectViewInfo { + /** + * Whether contained user is supported. + */ + supportContainedUser: boolean; + /** + * Whether Windows authentication is supported. + */ + supportWindowsAuthentication: boolean; + /** + * Whether Azure Active Directory authentication is supported. + */ + supportAADAuthentication: boolean; + /** + * Whether SQL Authentication is supported. + */ + supportSQLAuthentication: boolean; + /** + * All languages supported by the database. + */ + languages: string[]; + /** + * All schemas in the database. + */ + schemas: string[]; + /** + * Name of all the logins in the server. + */ + logins: string[]; + /** + * Name of all the database roles. + */ + databaseRoles: string[]; + } + } + + export interface IObjectManagementService { + /** + * Initialize the login view and return the information to render the view. + * @param connectionUri The original connection's URI. + * @param contextId The context id of the view, generated by the extension and will be used in subsequent create/update/dispose operations. + * @param isNewObject Whether the view is for creating a new login object. + * @param name Name of the login. Only applicable when isNewObject is false. + */ + initializeLoginView(connectionUri: string, contextId: string, isNewObject: boolean, name: string | undefined): Thenable; + /** + * Create a login. + * @param contextId The login view's context id. + * @param login The login information. + */ + createLogin(contextId: string, login: ObjectManagement.Login): Thenable; + /** + * Update a login. + * @param contextId The login view's context id. + * @param login The login information. + */ + updateLogin(contextId: string, login: ObjectManagement.Login): Thenable; + /** + * Delete a login. + * @param connectionUri The URI of the server connection. + * @param name Name of the login. + */ + deleteLogin(connectionUri: string, name: string): Thenable; + /** + * Dispose the login view. + * @param contextId The id of the view. + */ + disposeLoginView(contextId: string): Thenable; + /** + * Initialize the user view and return the information to render the view. + * @param connectionUri The original connection's URI. + * @param database Name of the database. + * @param contextId The id of the view, generated by the extension and will be used in subsequent create/update/dispose operations. + * @param isNewObject Whether the view is for creating a new user object. + * @param name Name of the user. Only applicable when isNewObject is false. + */ + initializeUserView(connectionUri: string, database: string, contextId: string, isNewObject: boolean, name: string | undefined): Thenable; + /** + * Create a user. + * @param contextId Id of the view. + * @param user The user information. + */ + createUser(contextId: string, user: ObjectManagement.User): Thenable; + /** + * Create a login. + * @param contextId Id of the view. + * @param user The user information. + */ + updateUser(contextId: string, user: ObjectManagement.User): Thenable; + /** + * Create a login. + * @param connectionUri The URI of the server connection. + * @param database Name of the database. + * @param name Name of the user. + */ + deleteUser(connectionUri: string, database: string, name: string): Thenable; + /** + * Dispose the user view. + * @param contextId The id of the view. + */ + disposeUserView(contextId: string): Thenable; + } + // Object Management - End. } diff --git a/extensions/mssql/src/objectManagement/commands.ts b/extensions/mssql/src/objectManagement/commands.ts new file mode 100644 index 0000000000..1f74b25eb6 --- /dev/null +++ b/extensions/mssql/src/objectManagement/commands.ts @@ -0,0 +1,153 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the Source EULA. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { AppContext } from '../appContext'; +import * as azdata from 'azdata'; +import * as vscode from 'vscode'; +import { LoginDialog } from './ui/loginDialog'; +import { TestObjectManagementService } from './objectManagementService'; +import { getErrorMessage } from '../utils'; +import { NodeType, TelemetryActions, TelemetryViews } from './constants'; +import * as localizedConstants from './localizedConstants'; +import { UserDialog } from './ui/userDialog'; +import { IObjectManagementService } from 'mssql'; +import * as constants from '../constants'; +import { getNodeTypeDisplayName, refreshParentNode } from './utils'; +import { TelemetryReporter } from '../telemetry'; + +export function registerObjectManagementCommands(appContext: AppContext) { + // Notes: Change the second parameter to false to use the actual object management service. + const service = getObjectManagementService(appContext, false); + appContext.extensionContext.subscriptions.push(vscode.commands.registerCommand('mssql.newLogin', async (context: azdata.ObjectExplorerContext) => { + await handleNewLoginDialogCommand(context, service); + })); + appContext.extensionContext.subscriptions.push(vscode.commands.registerCommand('mssql.newUser', async (context: azdata.ObjectExplorerContext) => { + await handleNewUserDialogCommand(context, service); + })); + appContext.extensionContext.subscriptions.push(vscode.commands.registerCommand('mssql.objectProperties', async (context: azdata.ObjectExplorerContext) => { + await handleObjectPropertiesDialogCommand(context, service); + })); + appContext.extensionContext.subscriptions.push(vscode.commands.registerCommand('mssql.deleteObject', async (context: azdata.ObjectExplorerContext) => { + await handleDeleteObjectCommand(context, service); + })); +} + +function getObjectManagementService(appContext: AppContext, useTestService: boolean): IObjectManagementService { + if (useTestService) { + return new TestObjectManagementService(); + } else { + return appContext.getService(constants.ObjectManagementService); + } +} + +async function handleNewLoginDialogCommand(context: azdata.ObjectExplorerContext, service: IObjectManagementService): Promise { + try { + const connectionUri = await azdata.connection.getUriForConnection(context.connectionProfile.id); + const dialog = new LoginDialog(service, connectionUri, true, undefined, context); + await dialog.open(); + } + catch (err) { + TelemetryReporter.createErrorEvent(TelemetryViews.ObjectManagement, TelemetryActions.OpenNewObjectDialog).withAdditionalProperties({ + objectType: NodeType.Login + }).send(); + await vscode.window.showErrorMessage(localizedConstants.OpenNewObjectDialogError(localizedConstants.LoginTypeDisplayName, getErrorMessage(err))); + } +} + +async function handleNewUserDialogCommand(context: azdata.ObjectExplorerContext, service: IObjectManagementService): Promise { + try { + const connectionUri = await azdata.connection.getUriForConnection(context.connectionProfile.id); + const dialog = new UserDialog(service, connectionUri, context.connectionProfile.databaseName, true, undefined, context); + await dialog.open(); + } + catch (err) { + TelemetryReporter.createErrorEvent(TelemetryViews.ObjectManagement, TelemetryActions.OpenNewObjectDialog).withAdditionalProperties({ + objectType: NodeType.User + }).send(); + await vscode.window.showErrorMessage(localizedConstants.OpenNewObjectDialogError(localizedConstants.UserTypeDisplayName, getErrorMessage(err))); + } +} + +async function handleObjectPropertiesDialogCommand(context: azdata.ObjectExplorerContext, service: IObjectManagementService): Promise { + const nodeTypeDisplayName = getNodeTypeDisplayName(context.nodeInfo.nodeType); + try { + const connectionUri = await azdata.connection.getUriForConnection(context.connectionProfile.id); + let dialog; + switch (context.nodeInfo.nodeType) { + case NodeType.Login: + dialog = new LoginDialog(service, connectionUri, false, context.nodeInfo.label); + break; + case NodeType.User: + dialog = new UserDialog(service, connectionUri, context.connectionProfile.databaseName, false, context.nodeInfo.label); + break; + default: + break; + } + if (dialog) { + await dialog.open(); + } + } + catch (err) { + TelemetryReporter.createErrorEvent(TelemetryViews.ObjectManagement, TelemetryActions.OpenPropertiesDialog).withAdditionalProperties({ + objectType: context.nodeInfo.nodeType + }).send(); + await vscode.window.showErrorMessage(localizedConstants.OpenObjectPropertiesDialogError(nodeTypeDisplayName, context.nodeInfo.label, getErrorMessage(err))); + } +} + +async function handleDeleteObjectCommand(context: azdata.ObjectExplorerContext, service: IObjectManagementService): Promise { + let additionalConfirmationMessage: string; + switch (context.nodeInfo.nodeType) { + case NodeType.Login: + additionalConfirmationMessage = localizedConstants.DeleteLoginConfirmationText; + break; + default: + break; + } + const nodeTypeDisplayName = getNodeTypeDisplayName(context.nodeInfo.nodeType); + let confirmMessage = localizedConstants.DeleteObjectConfirmationText(nodeTypeDisplayName, context.nodeInfo.label); + if (additionalConfirmationMessage) { + confirmMessage = `${additionalConfirmationMessage} ${confirmMessage}`; + } + const confirmResult = await vscode.window.showWarningMessage(confirmMessage, { modal: true }, localizedConstants.YesText); + if (confirmResult !== localizedConstants.YesText) { + return; + } + azdata.tasks.startBackgroundOperation({ + displayName: localizedConstants.DeleteObjectOperationDisplayName(nodeTypeDisplayName, context.nodeInfo.label), + description: '', + isCancelable: false, + operation: async (operation) => { + try { + const startTime = Date.now(); + const connectionUri = await azdata.connection.getUriForConnection(context.connectionProfile.id); + switch (context.nodeInfo.nodeType) { + case NodeType.Login: + await service.deleteLogin(connectionUri, context.nodeInfo.label); + break; + case NodeType.User: + await service.deleteUser(connectionUri, context.connectionProfile.databaseName, context.nodeInfo.label); + break; + default: + return; + } + TelemetryReporter.sendTelemetryEvent(TelemetryActions.DeleteObject, { + objectType: context.nodeInfo.nodeType + }, { + ellapsedTime: Date.now() - startTime + }); + } + catch (err) { + operation.updateStatus(azdata.TaskStatus.Failed, localizedConstants.DeleteObjectError(nodeTypeDisplayName, context.nodeInfo.label, getErrorMessage(err))); + TelemetryReporter.createErrorEvent(TelemetryViews.ObjectManagement, TelemetryActions.DeleteObject).withAdditionalProperties({ + objectType: context.nodeInfo.nodeType + }).send(); + return; + } + await refreshParentNode(context); + operation.updateStatus(azdata.TaskStatus.Succeeded); + } + }); +} diff --git a/extensions/mssql/src/objectManagement/constants.ts b/extensions/mssql/src/objectManagement/constants.ts new file mode 100644 index 0000000000..7b0fcf5134 --- /dev/null +++ b/extensions/mssql/src/objectManagement/constants.ts @@ -0,0 +1,62 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the Source EULA. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +/** + * The object types in object explorer's node context. + */ +export enum NodeType { + Login = 'ServerLevelLogin', + User = 'User' +} + +export const PublicServerRoleName = 'public'; + +/** + * User types. + */ +export enum UserType { + /** + * User with a server level login. + */ + WithLogin = 'WithLogin', + /** + * User based on a Windows user/group that has no login, but can connect to the Database Engine through membership in a Windows group. + */ + WithWindowsGroupLogin = 'WithWindowsGroupLogin', + /** + * Contained user, authentication is done within the database. + */ + Contained = 'Contained', + /** + * User that cannot authenticate. + */ + NoConnectAccess = 'NoConnectAccess' +} + +/** + * The authentication types. + */ +export enum AuthenticationType { + Windows = 'Windows', + Sql = 'Sql', + AzureActiveDirectory = 'AAD' +} + +export const CreateUserDocUrl = 'https://learn.microsoft.com/en-us/sql/t-sql/statements/create-user-transact-sql'; +export const AlterUserDocUrl = 'https://learn.microsoft.com/en-us/sql/t-sql/statements/alter-user-transact-sql'; +export const CreateLoginDocUrl = 'https://learn.microsoft.com/en-us/sql/t-sql/statements/create-login-transact-sql'; +export const AlterLoginDocUrl = 'https://learn.microsoft.com/en-us/sql/t-sql/statements/alter-login-transact-sql'; + +export enum TelemetryActions { + CreateObject = 'CreateObject', + UpdateObject = 'UpdateObject', + DeleteObject = 'DeleteObject', + OpenNewObjectDialog = 'OpenNewObjectDialog', + OpenPropertiesDialog = 'OpenPropertiesDialog' +} + +export enum TelemetryViews { + ObjectManagement = 'ObjectManagement' +} diff --git a/extensions/mssql/src/objectManagement/localizedConstants.ts b/extensions/mssql/src/objectManagement/localizedConstants.ts new file mode 100644 index 0000000000..eaa3b156fe --- /dev/null +++ b/extensions/mssql/src/objectManagement/localizedConstants.ts @@ -0,0 +1,132 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the Source EULA. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as nls from 'vscode-nls'; +const localize = nls.loadMessageBundle(); + +// Object Types +export const LoginTypeDisplayName: string = localize('objectManagement.LoginTypeDisplayName', "login"); +export const UserTypeDisplayName: string = localize('objectManagement.UserDisplayName', "user"); +export const LoginTypeDisplayNameInTitle: string = localize('objectManagement.LoginTypeDisplayNameInTitle', "Login"); +export const UserTypeDisplayNameInTitle: string = localize('objectManagement.UserTypeDisplayNameInTitle', "User"); + +// Shared Strings +export const HelpText: string = localize('objectManagement.helpText', "Help"); +export const YesText: string = localize('objectManagement.yesText', "Yes"); +export const OkText: string = localize('objectManagement.OkText', "OK"); +export const LoadingDialogText: string = localize('objectManagement.loadingDialog', "Loading dialog...") + +export function RefreshObjectExplorerError(error: string): string { + return localize({ + key: 'objectManagement.refreshOEError', + comment: ['{0}: error message.'] + }, "An error occurred while while refreshing the object explorer. {0}", error); +} + +export function DeleteObjectConfirmationText(objectType: string, objectName: string): string { + return localize({ + key: 'objectManagement.deleteObjectConfirmation', + comment: ['{0} object type, {1}: object name.'] + }, "Are you sure you want to delete the {0}: {1}?", objectType, objectName); +} + +export function CreateObjectOperationDisplayName(objectType: string): string { + return localize({ + key: 'objectManagement.createObjectOperationName', + comment: ['{0} object type'] + }, "Create {0}", objectType); +} + +export function UpdateObjectOperationDisplayName(objectType: string, objectName: string): string { + return localize({ + key: 'objectManagement.updateObjectOperationName', + comment: ['{0} object type, {1}: object name.'] + }, "Update {0} '{1}'", objectType, objectName); +} + +export function DeleteObjectOperationDisplayName(objectType: string, objectName: string): string { + return localize({ + key: 'objectManagement.deleteObjectOperationName', + comment: ['{0} object type, {1}: object name.'] + }, "Delete {0} '{1}'", objectType, objectName); +} + +export function DeleteObjectError(objectType: string, objectName: string, error: string): string { + return localize({ + key: 'objectManagement.deleteObjectError', + comment: ['{0} object type, {1}: object name, {2}: error message.'] + }, "An error occurred while deleting the {0}: {1}. {2}", objectType, objectName, error); +} + +export function OpenObjectPropertiesDialogError(objectType: string, objectName: string, error: string): string { + return localize({ + key: 'objectManagement.openObjectPropertiesDialogError', + comment: ['{0} object type, {1}: object name, {2}: error message.'] + }, "An error occurred while opening the properties dialog for {0}: {1}. {2}", objectType, objectName, error); +} + +export function OpenNewObjectDialogError(objectType: string, error: string): string { + return localize({ + key: 'objectManagement.openNewObjectDialogError', + comment: ['{0} object type, {1}: error message.'] + }, "An error occurred while opening the new {0} dialog. {1}", objectType, error); +} + +export function NewObjectDialogTitle(objectType: string): string { + return localize({ + key: 'objectManagement.newObjectDialogTitle', + comment: ['{0} object type.'] + }, '{0} - New (Preview)', objectType); +} + +export function ObjectPropertiesDialogTitle(objectType: string, objectName: string): string { + return localize({ + key: 'objectManagement.objectPropertiesDialogTitle', + comment: ['{0} object type, {1}: object name.'] + }, '{0} - {1} (Preview)', objectType, objectName); +} + +export const NameText = localize('objectManagement.nameLabel', "Name"); +export const SelectedText = localize('objectManagement.selectedLabel', "Selected"); +export const GeneralSectionHeader = localize('objectManagement.generalSectionHeader', "General"); +export const AdvancedSectionHeader = localize('objectManagement.advancedSectionHeader', "Advanced"); +export const PasswordText = localize('objectManagement.passwordLabel', "Password"); +export const ConfirmPasswordText = localize('objectManagement.confirmPasswordLabel', "Confirm password"); +export const EnabledText = localize('objectManagement.enabledLabel', "Enabled"); +export const NameCannotBeEmptyError = localize('objectManagement.nameCannotBeEmptyError', "Name cannot be empty."); +export const PasswordCannotBeEmptyError = localize('objectManagement.passwordCannotBeEmptyError', "Password cannot be empty."); +export const PasswordsNotMatchError = localize('objectManagement.passwordsNotMatchError', "Password must match the confirm password."); +export const InvalidPasswordError = localize('objectManagement.invalidPasswordError', "Password doesn't meet the complexity requirement. For more information: https://docs.microsoft.com/sql/relational-databases/security/password-policy"); + +// Login +export const BlankPasswordConfirmationText: string = localize('objectManagement.blankPasswordConfirmation', "Creating a login with a blank password is a security risk. Are you sure you want to continue?"); +export const DeleteLoginConfirmationText: string = localize('objectManagement.deleteLoginConfirmation', "Deleting server logins does not delete the database users associated with the logins. To complete the process, delete the users in each database. It may be necessary to first transfer the ownership of schemas to new users."); +export const SQLAuthenticationSectionHeader = localize('objectManagement.login.sqlAuthSectionHeader', "SQL Authentication"); +export const ServerRoleSectionHeader = localize('objectManagement.login.serverRoleSectionHeader', "Server Roles"); +export const AuthTypeText = localize('objectManagement.login.authenticateType', "Authentication"); +export const SpecifyOldPasswordText = localize('objectManagement.login.specifyOldPasswordLabel', "Specify old password"); +export const OldPasswordText = localize('objectManagement.login.oldPasswordLabel', "Old password"); +export const EnforcePasswordPolicyText = localize('objectManagement.login.enforcePasswordPolicyLabel', "Enforce password policy"); +export const EnforcePasswordExpirationText = localize('objectManagement.login.enforcePasswordExpirationLabel', "Enforce password expiration"); +export const MustChangePasswordText = localize('objectManagement.login.mustChangePasswordLabel', "User must change password at next login"); +export const DefaultDatabaseText = localize('objectManagement.login.defaultDatabaseLabel', "Default database"); +export const DefaultLanguageText = localize('objectManagement.login.defaultLanguageLabel', "Default language"); +export const PermissionToConnectText = localize('objectManagement.login.permissionToConnectLabel', "Permission to connect to database engine"); +export const LoginLockedOutText = localize('objectManagement.login.lockedOutLabel', "Login is locked out"); +export const WindowsAuthenticationTypeDisplayText = localize('objectManagement.login.windowsAuthenticationType', "Windows Authentication"); +export const SQLAuthenticationTypeDisplayText = localize('objectManagement.login.sqlAuthenticationType', "SQL Authentication"); +export const AADAuthenticationTypeDisplayText = localize('objectManagement.login.aadAuthenticationType', "Azure Active Directory Authentication"); +export const OldPasswordCannotBeEmptyError = localize('objectManagement.login.oldPasswordCannotBeEmptyError', "Old password cannot be empty."); + +// User +export const UserTypeText = localize('objectManagement.user.type', "Type"); +export const UserWithLoginText = localize('objectManagement.user.userWithLogin', "User with login"); +export const UserWithWindowsGroupLoginText = localize('objectManagement.user.userWithGroupLogin', "User with Windows group login"); +export const ContainedUserText = localize('objectManagement.user.containedUser', "Contained user"); +export const UserWithNoConnectAccess = localize('objectManagement.user.userWithNoConnectAccess', "User with no connect access"); +export const DefaultSchemaText = localize('objectManagement.user.defaultSchemaLabel', "Default schema"); +export const LoginText = localize('objectManagement.user.loginLabel', "Login"); +export const OwnedSchemaSectionHeader = localize('objectManagement.user.ownedSchemasLabel', "Owned Schemas"); +export const MembershipSectionHeader = localize('objectManagement.user.membershipLabel', "Membership"); diff --git a/extensions/mssql/src/objectManagement/objectManagementService.ts b/extensions/mssql/src/objectManagement/objectManagementService.ts new file mode 100644 index 0000000000..619cb2ed4c --- /dev/null +++ b/extensions/mssql/src/objectManagement/objectManagementService.ts @@ -0,0 +1,303 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the Source EULA. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { ISqlOpsFeature, SqlOpsDataClient } from 'dataprotocol-client'; +import { ObjectManagement, IObjectManagementService } from 'mssql'; +import { ClientCapabilities } from 'vscode-languageclient'; +import { AppContext } from '../appContext'; +import * as Utils from '../utils'; +import * as constants from '../constants'; +import * as contracts from '../contracts'; +import { AuthenticationType, UserType } from './constants'; + +export class ObjectManagementService implements IObjectManagementService { + public static asFeature(context: AppContext): ISqlOpsFeature { + return class extends ObjectManagementService { + constructor(client: SqlOpsDataClient) { + super(context, client); + } + + fillClientCapabilities(capabilities: ClientCapabilities): void { + Utils.ensure(capabilities, 'objectManagement')!.objectManagement = true; + } + + initialize(): void { + } + }; + } + + private constructor(context: AppContext, protected readonly client: SqlOpsDataClient) { + context.registerService(constants.ObjectManagementService, this); + } + initializeLoginView(connectionUri: string, contextId: string, isNewObject: boolean, name: string | undefined): Thenable { + const params: contracts.InitializeLoginViewRequestParams = { connectionUri, contextId, isNewObject, name }; + return this.client.sendRequest(contracts.InitializeLoginViewRequest.type, params).then( + r => { + return r; + }, + e => { + this.client.logFailedRequest(contracts.InitializeLoginViewRequest.type, e); + return Promise.reject(new Error(e.message)); + } + ); + } + createLogin(contextId: string, login: ObjectManagement.Login): Thenable { + const params: contracts.CreateLoginRequestParams = { contextId, login }; + return this.client.sendRequest(contracts.CreateLoginRequest.type, params).then( + r => { }, + e => { + this.client.logFailedRequest(contracts.CreateLoginRequest.type, e); + return Promise.reject(new Error(e.message)); + } + ); + } + updateLogin(contextId: string, login: ObjectManagement.Login): Thenable { + const params: contracts.UpdateLoginRequestParams = { contextId, login }; + return this.client.sendRequest(contracts.UpdateLoginRequest.type, params).then( + r => { }, + e => { + this.client.logFailedRequest(contracts.UpdateLoginRequest.type, e); + return Promise.reject(new Error(e.message)); + } + ); + } + deleteLogin(connectionUri: string, name: string): Thenable { + const params: contracts.DeleteLoginRequestParams = { connectionUri, name }; + return this.client.sendRequest(contracts.DeleteLoginRequest.type, params).then( + r => { }, + e => { + this.client.logFailedRequest(contracts.DeleteLoginRequest.type, e); + return Promise.reject(new Error(e.message)); + } + ); + } + disposeLoginView(contextId: string): Thenable { + const params: contracts.DisposeLoginViewRequestParams = { contextId }; + return this.client.sendRequest(contracts.DisposeLoginViewRequest.type, params).then( + r => { }, + e => { + this.client.logFailedRequest(contracts.DisposeLoginViewRequest.type, e); + return Promise.reject(new Error(e.message)); + } + ); + } + + initializeUserView(connectionUri: string, database: string, contextId: string, isNewObject: boolean, name: string | undefined): Thenable { + const params: contracts.InitializeUserViewRequestParams = { connectionUri, database, contextId, isNewObject, name }; + return this.client.sendRequest(contracts.InitializeUserViewRequest.type, params).then( + r => { + return r; + }, + e => { + this.client.logFailedRequest(contracts.InitializeUserViewRequest.type, e); + return Promise.reject(new Error(e.message)); + } + ); + } + createUser(contextId: string, user: ObjectManagement.User): Thenable { + const params: contracts.CreateUserRequestParams = { contextId, user }; + return this.client.sendRequest(contracts.CreateUserRequest.type, params).then( + r => { }, + e => { + this.client.logFailedRequest(contracts.CreateUserRequest.type, e); + return Promise.reject(new Error(e.message)); + } + ); + } + updateUser(contextId: string, user: ObjectManagement.User): Thenable { + const params: contracts.UpdateUserRequestParams = { contextId, user }; + return this.client.sendRequest(contracts.UpdateUserRequest.type, params).then( + r => { }, + e => { + this.client.logFailedRequest(contracts.UpdateLoginRequest.type, e); + return Promise.reject(new Error(e.message)); + } + ); + } + deleteUser(connectionUri: string, database: string, name: string): Thenable { + const params: contracts.DeleteUserRequestParams = { connectionUri, database, name }; + return this.client.sendRequest(contracts.DeleteUserRequest.type, params).then( + r => { }, + e => { + this.client.logFailedRequest(contracts.DeleteUserRequest.type, e); + return Promise.reject(new Error(e.message)); + } + ); + } + disposeUserView(contextId: string): Thenable { + const params: contracts.DisposeUserViewRequestParams = { contextId }; + return this.client.sendRequest(contracts.DisposeUserViewRequest.type, params).then( + r => { }, + e => { + this.client.logFailedRequest(contracts.DisposeUserViewRequest.type, e); + return Promise.reject(new Error(e.message)); + } + ); + } +} + +export class TestObjectManagementService implements IObjectManagementService { + initializeLoginView(connectionUri: string, contextId: string, isNewObject: boolean, name: string | undefined): Promise { + return new Promise((resolve, reject) => { + setTimeout(() => { + const serverRoles = ['sysadmin', 'public', 'bulkadmin', 'dbcreator', 'diskadmin', 'processadmin', 'securityadmin', 'serveradmin']; + const languages = ['', 'English']; + const databases = ['master', 'db1', 'db2']; + let login: ObjectManagement.LoginViewInfo; + if (isNewObject) { + login = { + objectInfo: { + name: '', + authenticationType: AuthenticationType.Sql, + enforcePasswordPolicy: true, + enforcePasswordExpiration: true, + mustChangePassword: true, + defaultDatabase: 'master', + defaultLanguage: '', + serverRoles: ['public', 'bulkadmin'], + connectPermission: true, + isEnabled: true, + isLockedOut: false + }, + supportAADAuthentication: true, + supportSQLAuthentication: true, + supportWindowsAuthentication: true, + supportAdvancedOptions: true, + supportAdvancedPasswordOptions: true, + canEditLockedOutState: false, + languages: languages, + databases: databases, + serverRoles: serverRoles + }; + } else { + login = { + objectInfo: { + name: name, + authenticationType: AuthenticationType.Sql, + enforcePasswordPolicy: true, + enforcePasswordExpiration: true, + mustChangePassword: true, + defaultDatabase: 'master', + defaultLanguage: '', + serverRoles: ['public'], + connectPermission: true, + isEnabled: true, + isLockedOut: false, + password: '******************' + }, + supportAADAuthentication: true, + supportSQLAuthentication: true, + supportWindowsAuthentication: true, + supportAdvancedOptions: true, + supportAdvancedPasswordOptions: true, + canEditLockedOutState: false, + languages: languages, + databases: databases, + serverRoles: serverRoles + }; + } + resolve(login); + }, 3000); + }); + } + async createLogin(contextId: string, login: ObjectManagement.Login): Promise { + return new Promise((resolve, reject) => { + setTimeout(() => { + resolve(); + }, 3000); + }); + } + async updateLogin(contextId: string, login: ObjectManagement.Login): Promise { + return new Promise((resolve, reject) => { + setTimeout(() => { + resolve(); + }, 3000); + }); + } + async deleteLogin(connectionUri: string, name: string): Promise { + return new Promise((resolve, reject) => { + setTimeout(() => { + resolve(); + }, 3000); + }); + } + async disposeLoginView(contextId: string): Promise { + } + async initializeUserView(connectionUri: string, database: string, contextId: string, isNewObject: boolean, name: string): Promise { + return new Promise((resolve, reject) => { + setTimeout(() => { + let viewInfo: ObjectManagement.UserViewInfo; + const languages = ['', 'English']; + const schemas = ['dbo', 'sys', 'alanren']; + const logins = ['sa', 'alanren', 'alanren@microsoft.com']; + const databaseRoles = ['dbmanager', 'loginmanager', 'bulkadmin', 'sysadmin', 'tablemanager', 'viewmanager']; + + if (isNewObject) { + viewInfo = { + objectInfo: { + name: '', + type: UserType.WithLogin, + defaultSchema: 'dbo', + defaultLanguage: '', + authenticationType: AuthenticationType.Sql, + loginName: 'sa', + ownedSchemas: [], + databaseRoles: [] + }, + languages: languages, + schemas: schemas, + logins: logins, + databaseRoles: databaseRoles, + supportContainedUser: true, + supportAADAuthentication: true, + supportSQLAuthentication: true, + supportWindowsAuthentication: true + }; + } else { + viewInfo = { + objectInfo: { + name: name, + type: UserType.WithLogin, + defaultSchema: 'dbo', + defaultLanguage: '', + loginName: 'sa', + authenticationType: AuthenticationType.Sql, + ownedSchemas: ['dbo'], + databaseRoles: ['dbmanager', 'bulkadmin'] + }, + languages: languages, + schemas: schemas, + logins: logins, + databaseRoles: databaseRoles, + supportContainedUser: true, + supportAADAuthentication: true, + supportSQLAuthentication: true, + supportWindowsAuthentication: true + }; + } + resolve(viewInfo); + }, 3000); + }); + } + async createUser(contextId: string, user: ObjectManagement.User): Promise { + return this.delayAndResolve(); + } + async updateUser(contextId: string, login: ObjectManagement.User): Promise { + return this.delayAndResolve(); + } + async deleteUser(connectionUri: string, database: string, name: string): Promise { + return this.delayAndResolve(); + } + async disposeUserView(contextId: string): Promise { + } + + private delayAndResolve(): Promise { + return new Promise((resolve, reject) => { + setTimeout(() => { + resolve(); + }, 3000); + }); + } +} diff --git a/extensions/mssql/src/objectManagement/ui/loginDialog.ts b/extensions/mssql/src/objectManagement/ui/loginDialog.ts new file mode 100644 index 0000000000..84bb95db84 --- /dev/null +++ b/extensions/mssql/src/objectManagement/ui/loginDialog.ts @@ -0,0 +1,299 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { DefaultInputWidth, ObjectManagementDialogBase } from './objectManagementDialogBase'; +import { IObjectManagementService, ObjectManagement } from 'mssql'; +import * as localizedConstants from '../localizedConstants'; +import { AlterLoginDocUrl, AuthenticationType, CreateLoginDocUrl, NodeType, PublicServerRoleName } from '../constants'; +import { getAuthenticationTypeByDisplayName, getAuthenticationTypeDisplayName, isValidSQLPassword } from '../utils'; + +export class LoginDialog extends ObjectManagementDialogBase { + private formContainer: azdata.DivContainer; + private generalSection: azdata.GroupContainer; + private sqlAuthSection: azdata.GroupContainer; + private serverRoleSection: azdata.GroupContainer; + private advancedSection: azdata.GroupContainer; + private nameInput: azdata.InputBoxComponent; + private authTypeDropdown: azdata.DropDownComponent; + private passwordInput: azdata.InputBoxComponent; + private confirmPasswordInput: azdata.InputBoxComponent; + private specifyOldPasswordCheckbox: azdata.CheckBoxComponent; + private oldPasswordInput: azdata.InputBoxComponent; + private enforcePasswordPolicyCheckbox: azdata.CheckBoxComponent; + private enforcePasswordExpirationCheckbox: azdata.CheckBoxComponent; + private mustChangePasswordCheckbox: azdata.CheckBoxComponent; + private defaultDatabaseDropdown: azdata.DropDownComponent; + private defaultLanguageDropdown: azdata.DropDownComponent; + private serverRoleTable: azdata.TableComponent; + private connectPermissionCheckbox: azdata.CheckBoxComponent; + private enabledCheckbox: azdata.CheckBoxComponent; + private lockedOutCheckbox: azdata.CheckBoxComponent; + + constructor(objectManagementService: IObjectManagementService, connectionUri: string, isNewObject: boolean, name?: string, objectExplorerContext?: azdata.ObjectExplorerContext) { + super(NodeType.Login, isNewObject ? CreateLoginDocUrl : AlterLoginDocUrl, objectManagementService, connectionUri, isNewObject, name, objectExplorerContext); + } + + protected override async onConfirmation(): Promise { + // Empty password is only allowed when advanced password options are supported and the password policy check is off. + // To match the SSMS behavior, a warning is shown to the user. + if (this.viewInfo.supportAdvancedPasswordOptions + && this.objectInfo.authenticationType === AuthenticationType.Sql + && !this.objectInfo.password + && !this.objectInfo.enforcePasswordPolicy) { + const result = await vscode.window.showWarningMessage(localizedConstants.BlankPasswordConfirmationText, { modal: true }, localizedConstants.YesText); + return result === localizedConstants.YesText; + } + return true; + } + + protected async validateInput(): Promise { + const errors: string[] = []; + if (!this.objectInfo.name) { + errors.push(localizedConstants.NameCannotBeEmptyError); + } + if (this.objectInfo.authenticationType === AuthenticationType.Sql) { + if (!this.objectInfo.password && !(this.viewInfo.supportAdvancedPasswordOptions && !this.objectInfo.enforcePasswordPolicy)) { + errors.push(localizedConstants.PasswordCannotBeEmptyError); + } + + if (this.objectInfo.password && (this.objectInfo.enforcePasswordPolicy || !this.viewInfo.supportAdvancedPasswordOptions) + && !isValidSQLPassword(this.objectInfo.password, this.objectInfo.name) + && (this.isNewObject || this.objectInfo.password !== this.originalObjectInfo.password)) { + errors.push(localizedConstants.InvalidPasswordError); + } + + if (this.objectInfo.password !== this.confirmPasswordInput.value) { + errors.push(localizedConstants.PasswordsNotMatchError); + } + + if (this.specifyOldPasswordCheckbox?.checked && !this.objectInfo.oldPassword) { + errors.push(localizedConstants.OldPasswordCannotBeEmptyError); + } + } + return errors; + } + + protected async onComplete(): Promise { + if (this.isNewObject) { + await this.objectManagementService.createLogin(this.contextId, this.objectInfo); + } else { + await this.objectManagementService.updateLogin(this.contextId, this.objectInfo); + } + } + + protected async onDispose(): Promise { + await this.objectManagementService.disposeLoginView(this.contextId); + } + + protected async initializeData(): Promise { + const viewInfo = await this.objectManagementService.initializeLoginView(this.connectionUri, this.contextId, this.isNewObject, this.objectName); + viewInfo.objectInfo.password = viewInfo.objectInfo.password ?? ''; + return viewInfo; + } + + protected async initializeUI(): Promise { + this.dialogObject.registerContent(async view => { + const sections: azdata.Component[] = []; + this.initializeGeneralSection(view); + sections.push(this.generalSection); + + if (this.isNewObject || this.objectInfo.authenticationType === 'Sql') { + this.initializeSqlAuthSection(view); + sections.push(this.sqlAuthSection); + } + + this.initializeServerRolesSection(view); + sections.push(this.serverRoleSection); + + if (this.viewInfo.supportAdvancedOptions) { + this.initializeAdvancedSection(view); + sections.push(this.advancedSection); + } + + this.formContainer = this.createFormContainer(view, sections); + return view.initializeModel(this.formContainer) + }); + } + + private initializeGeneralSection(view: azdata.ModelView): void { + this.nameInput = view.modelBuilder.inputBox().withProps({ + ariaLabel: localizedConstants.NameText, + enabled: this.isNewObject, + value: this.objectInfo.name, + width: DefaultInputWidth + }).component(); + this.nameInput.onTextChanged(async () => { + this.objectInfo.name = this.nameInput.value; + this.onObjectValueChange(); + await this.runValidation(false); + }); + + const nameContainer = this.createLabelInputContainer(view, localizedConstants.NameText, this.nameInput); + const authTypes = []; + if (this.viewInfo.supportWindowsAuthentication) { + authTypes.push(localizedConstants.WindowsAuthenticationTypeDisplayText); + } + if (this.viewInfo.supportSQLAuthentication) { + authTypes.push(localizedConstants.SQLAuthenticationTypeDisplayText); + } + if (this.viewInfo.supportAADAuthentication) { + authTypes.push(localizedConstants.AADAuthenticationTypeDisplayText); + } + this.authTypeDropdown = view.modelBuilder.dropDown().withProps({ + ariaLabel: localizedConstants.AuthTypeText, + values: authTypes, + value: getAuthenticationTypeDisplayName(this.objectInfo.authenticationType), + width: DefaultInputWidth, + enabled: this.isNewObject + }).component(); + this.authTypeDropdown.onValueChanged(async () => { + this.objectInfo.authenticationType = getAuthenticationTypeByDisplayName(this.authTypeDropdown.value); + this.setViewByAuthenticationType(); + this.onObjectValueChange(); + await this.runValidation(false); + }); + const authTypeContainer = this.createLabelInputContainer(view, localizedConstants.AuthTypeText, this.authTypeDropdown); + + this.enabledCheckbox = this.createCheckbox(view, localizedConstants.EnabledText, this.objectInfo.isEnabled); + this.enabledCheckbox.onChanged(() => { + this.objectInfo.isEnabled = this.enabledCheckbox.checked; + this.onObjectValueChange(); + }); + this.generalSection = this.createGroup(view, localizedConstants.GeneralSectionHeader, [nameContainer, authTypeContainer, this.enabledCheckbox], false); + } + + private initializeSqlAuthSection(view: azdata.ModelView): void { + const items: azdata.Component[] = []; + this.passwordInput = this.createPasswordInputBox(view, localizedConstants.PasswordText, this.objectInfo.password ?? ''); + const passwordRow = this.createLabelInputContainer(view, localizedConstants.PasswordText, this.passwordInput); + this.confirmPasswordInput = this.createPasswordInputBox(view, localizedConstants.ConfirmPasswordText, this.objectInfo.password ?? ''); + this.passwordInput.onTextChanged(async () => { + this.objectInfo.password = this.passwordInput.value; + this.onObjectValueChange(); + await this.runValidation(false); + }); + this.confirmPasswordInput.onTextChanged(async () => { + await this.runValidation(false); + }); + const confirmPasswordRow = this.createLabelInputContainer(view, localizedConstants.ConfirmPasswordText, this.confirmPasswordInput); + items.push(passwordRow, confirmPasswordRow); + + if (!this.isNewObject) { + this.specifyOldPasswordCheckbox = this.createCheckbox(view, localizedConstants.SpecifyOldPasswordText); + this.oldPasswordInput = this.createPasswordInputBox(view, localizedConstants.OldPasswordText, '', false); + const oldPasswordRow = this.createLabelInputContainer(view, localizedConstants.OldPasswordText, this.oldPasswordInput); + this.specifyOldPasswordCheckbox.onChanged(async () => { + this.oldPasswordInput.enabled = this.specifyOldPasswordCheckbox.checked; + this.objectInfo.oldPassword = ''; + if (!this.specifyOldPasswordCheckbox.checked) { + this.oldPasswordInput.value = ''; + } + this.onObjectValueChange(); + await this.runValidation(false); + }); + this.oldPasswordInput.onTextChanged(async () => { + this.objectInfo.oldPassword = this.oldPasswordInput.value; + this.onObjectValueChange(); + await this.runValidation(false); + }); + items.push(this.specifyOldPasswordCheckbox, oldPasswordRow); + } + + if (this.viewInfo.supportAdvancedPasswordOptions) { + this.enforcePasswordPolicyCheckbox = this.createCheckbox(view, localizedConstants.EnforcePasswordPolicyText, this.objectInfo.enforcePasswordPolicy); + this.enforcePasswordExpirationCheckbox = this.createCheckbox(view, localizedConstants.EnforcePasswordExpirationText, this.objectInfo.enforcePasswordPolicy); + this.mustChangePasswordCheckbox = this.createCheckbox(view, localizedConstants.MustChangePasswordText, this.objectInfo.mustChangePassword); + this.enforcePasswordPolicyCheckbox.onChanged(async () => { + const enforcePolicy = this.enforcePasswordPolicyCheckbox.checked; + this.objectInfo.enforcePasswordPolicy = enforcePolicy; + this.enforcePasswordExpirationCheckbox.enabled = enforcePolicy; + this.mustChangePasswordCheckbox.enabled = enforcePolicy; + this.enforcePasswordExpirationCheckbox.checked = enforcePolicy; + this.mustChangePasswordCheckbox.checked = enforcePolicy; + this.onObjectValueChange(); + await this.runValidation(false); + }); + this.enforcePasswordExpirationCheckbox.onChanged(() => { + const enforceExpiration = this.enforcePasswordExpirationCheckbox.checked; + this.objectInfo.enforcePasswordExpiration = enforceExpiration; + this.mustChangePasswordCheckbox.enabled = enforceExpiration; + this.mustChangePasswordCheckbox.checked = enforceExpiration; + this.onObjectValueChange(); + }); + this.mustChangePasswordCheckbox.onChanged(() => { + this.objectInfo.mustChangePassword = this.mustChangePasswordCheckbox.checked; + this.onObjectValueChange(); + }); + items.push(this.enforcePasswordPolicyCheckbox, this.enforcePasswordExpirationCheckbox, this.mustChangePasswordCheckbox); + if (!this.isNewObject) { + this.lockedOutCheckbox = this.createCheckbox(view, localizedConstants.LoginLockedOutText, this.objectInfo.isLockedOut, this.viewInfo.canEditLockedOutState); + items.push(this.lockedOutCheckbox); + this.lockedOutCheckbox.onChanged(() => { + this.objectInfo.isLockedOut = this.lockedOutCheckbox.checked; + this.onObjectValueChange(); + }); + } + } + + this.sqlAuthSection = this.createGroup(view, localizedConstants.SQLAuthenticationSectionHeader, items); + } + + private initializeAdvancedSection(view: azdata.ModelView): void { + const items: azdata.Component[] = []; + if (this.viewInfo.supportAdvancedOptions) { + this.defaultDatabaseDropdown = view.modelBuilder.dropDown().withProps({ + ariaLabel: localizedConstants.DefaultDatabaseText, + values: this.viewInfo.databases, + value: this.objectInfo.defaultDatabase, + width: DefaultInputWidth + }).component(); + const defaultDatabaseContainer = this.createLabelInputContainer(view, localizedConstants.DefaultDatabaseText, this.defaultDatabaseDropdown); + this.defaultDatabaseDropdown.onValueChanged(() => { + this.objectInfo.defaultDatabase = this.defaultDatabaseDropdown.value; + this.onObjectValueChange(); + }); + + this.defaultLanguageDropdown = view.modelBuilder.dropDown().withProps({ + ariaLabel: localizedConstants.DefaultLanguageText, + values: this.viewInfo.languages, + value: this.objectInfo.defaultLanguage, + width: DefaultInputWidth + }).component(); + const defaultLanguageContainer = this.createLabelInputContainer(view, localizedConstants.DefaultLanguageText, this.defaultLanguageDropdown); + this.defaultLanguageDropdown.onValueChanged(() => { + this.objectInfo.defaultLanguage = this.defaultLanguageDropdown.value; + this.onObjectValueChange(); + }); + + this.connectPermissionCheckbox = this.createCheckbox(view, localizedConstants.PermissionToConnectText, this.objectInfo.connectPermission); + this.connectPermissionCheckbox.onChanged(() => { + this.objectInfo.connectPermission = this.connectPermissionCheckbox.checked; + this.onObjectValueChange(); + }); + items.push(defaultDatabaseContainer, defaultLanguageContainer, this.connectPermissionCheckbox); + } + + this.advancedSection = this.createGroup(view, localizedConstants.AdvancedSectionHeader, items); + } + + private initializeServerRolesSection(view: azdata.ModelView): void { + const serverRolesData = this.viewInfo.serverRoles.map(name => { + const isRoleSelected = this.objectInfo.serverRoles.indexOf(name) !== -1; + const isRoleSelectionEnabled = name !== PublicServerRoleName; + return [{ enabled: isRoleSelectionEnabled, checked: isRoleSelected }, name]; + }); + this.serverRoleTable = this.createTableList(view, localizedConstants.ServerRoleSectionHeader, this.viewInfo.serverRoles, this.objectInfo.serverRoles, serverRolesData); + this.serverRoleSection = this.createGroup(view, localizedConstants.ServerRoleSectionHeader, [this.serverRoleTable]); + } + + private setViewByAuthenticationType(): void { + if (this.authTypeDropdown.value === localizedConstants.SQLAuthenticationTypeDisplayText) { + this.addItem(this.formContainer, this.sqlAuthSection, 1); + } else if (this.authTypeDropdown.value !== localizedConstants.SQLAuthenticationTypeDisplayText) { + this.removeItem(this.formContainer, this.sqlAuthSection); + } + } +} diff --git a/extensions/mssql/src/objectManagement/ui/objectManagementDialogBase.ts b/extensions/mssql/src/objectManagement/ui/objectManagementDialogBase.ts new file mode 100644 index 0000000000..3c0a90aaba --- /dev/null +++ b/extensions/mssql/src/objectManagement/ui/objectManagementDialogBase.ts @@ -0,0 +1,260 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the Source EULA. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +// TODO: +// 1. include server properties and other properties in the telemetry. + +import * as azdata from 'azdata'; +import { IObjectManagementService, ObjectManagement } from 'mssql'; +import * as vscode from 'vscode'; +import { EOL } from 'os'; +import { generateUuid } from 'vscode-languageclient/lib/utils/uuid'; +import { getErrorMessage } from '../../utils'; +import { NodeType, TelemetryActions, TelemetryViews } from '../constants'; +import { + CreateObjectOperationDisplayName, HelpText, LoadingDialogText, + NameText, + NewObjectDialogTitle, ObjectPropertiesDialogTitle, OkText, SelectedText, UpdateObjectOperationDisplayName +} from '../localizedConstants'; +import { deepClone, getNodeTypeDisplayName, refreshNode } from '../utils'; +import { TelemetryReporter } from '../../telemetry'; + +export const DefaultLabelWidth = 150; +export const DefaultInputWidth = 300; +export const DefaultTableWidth = DefaultInputWidth + DefaultLabelWidth; +export const DefaultTableMaxHeight = 400; +export const DefaultTableMinRowCount = 2; +export const TableRowHeight = 25; +export const TableColumnHeaderHeight = 30; + +export function getTableHeight(rowCount: number, minRowCount: number = DefaultTableMinRowCount, maxHeight: number = DefaultTableMaxHeight): number { + return Math.min(Math.max(rowCount, minRowCount) * TableRowHeight + TableColumnHeaderHeight, maxHeight); +} + +function getDialogName(type: NodeType, isNewObject: boolean): string { + return isNewObject ? `New${type}` : `${type}Properties` +} + +export abstract class ObjectManagementDialogBase> { + protected readonly disposables: vscode.Disposable[] = []; + protected readonly dialogObject: azdata.window.Dialog; + protected readonly contextId: string; + private _viewInfo: ViewInfoType; + private _originalObjectInfo: ObjectInfoType; + + constructor(private readonly objectType: NodeType, + docUrl: string, + protected readonly objectManagementService: IObjectManagementService, + protected readonly connectionUri: string, + protected isNewObject: boolean, + protected readonly objectName: string | undefined = undefined, + protected readonly objectExplorerContext?: azdata.ObjectExplorerContext, + dialogWidth: azdata.window.DialogWidth = 'narrow') { + const objectTypeDisplayName = getNodeTypeDisplayName(objectType, true); + const dialogTitle = isNewObject ? NewObjectDialogTitle(objectTypeDisplayName) : ObjectPropertiesDialogTitle(objectTypeDisplayName, objectName); + this.dialogObject = azdata.window.createModelViewDialog(dialogTitle, getDialogName(objectType, isNewObject), dialogWidth); + this.dialogObject.okButton.label = OkText; + this.disposables.push(this.dialogObject.onClosed(async () => { await this.dispose(); })); + const helpButton = azdata.window.createButton(HelpText, 'left'); + this.disposables.push(helpButton.onClick(async () => { + await vscode.commands.executeCommand('vscode.open', vscode.Uri.parse(docUrl)); + })); + this.dialogObject.customButtons = [helpButton]; + this.contextId = generateUuid(); + this.dialogObject.registerCloseValidator(async (): Promise => { + const confirmed = await this.onConfirmation(); + if (!confirmed) { + return false; + } + return await this.runValidation(); + }); + } + + protected abstract initializeData(): Promise; + protected abstract initializeUI(): Promise; + protected abstract onComplete(): Promise; + protected abstract onDispose(): Promise; + protected abstract validateInput(): Promise; + + protected onObjectValueChange(): void { + this.dialogObject.okButton.enabled = JSON.stringify(this.objectInfo) !== JSON.stringify(this._originalObjectInfo); + } + + protected async onConfirmation(): Promise { + return true; + } + + protected get viewInfo(): ViewInfoType { + return this._viewInfo; + } + + protected get objectInfo(): ObjectInfoType { + return this._viewInfo?.objectInfo; + } + + protected get originalObjectInfo(): ObjectInfoType { + return this._originalObjectInfo; + } + + public async open(): Promise { + await vscode.window.withProgress({ + location: vscode.ProgressLocation.Notification, + title: LoadingDialogText + }, async () => { + try { + this._viewInfo = await this.initializeData(); + this._originalObjectInfo = deepClone(this.objectInfo); + await this.initializeUI(); + const typeDisplayName = getNodeTypeDisplayName(this.objectType); + this.dialogObject.registerOperation({ + displayName: this.isNewObject ? CreateObjectOperationDisplayName(typeDisplayName) + : UpdateObjectOperationDisplayName(typeDisplayName, this.objectName), + description: '', + isCancelable: false, + operation: async (operation: azdata.BackgroundOperation): Promise => { + const actionName = this.isNewObject ? TelemetryActions.CreateObject : TelemetryActions.UpdateObject; + try { + if (JSON.stringify(this.objectInfo) !== JSON.stringify(this._originalObjectInfo)) { + const startTime = Date.now(); + await this.onComplete(); + if (this.isNewObject && this.objectExplorerContext) { + await refreshNode(this.objectExplorerContext); + } + + TelemetryReporter.sendTelemetryEvent(actionName, { + objectType: this.objectType + }, { + ellapsedTime: Date.now() - startTime + }); + operation.updateStatus(azdata.TaskStatus.Succeeded); + } + } + catch (err) { + operation.updateStatus(azdata.TaskStatus.Failed, getErrorMessage(err)); + TelemetryReporter.createErrorEvent(TelemetryViews.ObjectManagement, actionName).withAdditionalProperties({ + objectType: this.objectType + }).send(); + } + } + }); + azdata.window.openDialog(this.dialogObject); + } catch (err) { + const actionName = this.isNewObject ? TelemetryActions.OpenNewObjectDialog : TelemetryActions.OpenPropertiesDialog; + TelemetryReporter.createErrorEvent(TelemetryViews.ObjectManagement, actionName).withAdditionalProperties({ + objectType: this.objectType + }).send(); + void vscode.window.showErrorMessage(getErrorMessage(err)); + } + }); + } + + private async dispose(): Promise { + await this.onDispose(); + this.disposables.forEach(disposable => disposable.dispose()); + } + + protected async runValidation(showErrorMessage: boolean = true): Promise { + const errors = await this.validateInput(); + if (errors.length > 0 && (this.dialogObject.message || showErrorMessage)) { + this.dialogObject.message = { + text: errors.join(EOL), + level: azdata.window.MessageLevel.Error + }; + } else { + this.dialogObject.message = undefined; + } + return errors.length === 0; + } + + protected createLabelInputContainer(view: azdata.ModelView, label: string, input: azdata.InputBoxComponent | azdata.DropDownComponent): azdata.FlexContainer { + const labelComponent = view.modelBuilder.text().withProps({ width: DefaultLabelWidth, value: label, requiredIndicator: input.required }).component(); + const row = view.modelBuilder.flexContainer().withLayout({ flexFlow: 'horizontal', flexWrap: 'nowrap', alignItems: 'center' }).withItems([labelComponent, input]).component(); + return row; + } + + protected createCheckbox(view: azdata.ModelView, label: string, checked: boolean = false, enabled: boolean = true): azdata.CheckBoxComponent { + return view.modelBuilder.checkBox().withProps({ + label: label, + checked: checked, + enabled: enabled + }).component(); + } + + protected createPasswordInputBox(view: azdata.ModelView, ariaLabel: string, value: string = '', enabled: boolean = true, width: number = DefaultInputWidth): azdata.InputBoxComponent { + return this.createInputBox(view, ariaLabel, value, enabled, 'password', width); + } + + protected createInputBox(view: azdata.ModelView, ariaLabel: string, value: string = '', enabled: boolean = true, type: azdata.InputBoxInputType = 'text', width: number = DefaultInputWidth): azdata.InputBoxComponent { + return view.modelBuilder.inputBox().withProps({ inputType: type, enabled: enabled, ariaLabel: ariaLabel, value: value, width: width }).component(); + } + + protected createGroup(view: azdata.ModelView, header: string, items: azdata.Component[], collapsible: boolean = true, collapsed: boolean = false): azdata.GroupContainer { + return view.modelBuilder.groupContainer().withLayout({ + header: header, + collapsed: false, + collapsible: collapsible + }).withProps({ collapsed: collapsed }).withItems(items).component(); + } + + protected createFormContainer(view: azdata.ModelView, items: azdata.Component[]): azdata.DivContainer { + return view.modelBuilder.divContainer().withLayout({ width: 'calc(100% - 20px)', height: 'calc(100% - 20px)' }).withProps({ + CSSStyles: { 'padding': '10px' } + }).withItems(items, { CSSStyles: { 'margin-block-end': '10px' } }).component(); + } + + protected createTableList(view: azdata.ModelView, ariaLabel: string, listValues: string[], selectedValues: string[], data?: any[][]): azdata.TableComponent { + let tableData = data; + if (tableData === undefined) { + tableData = listValues.map(name => { + const isSelected = selectedValues.indexOf(name) !== -1; + return [isSelected, name]; + }); + } + const table = view.modelBuilder.table().withProps( + { + ariaLabel: ariaLabel, + data: tableData, + columns: [ + { + value: SelectedText, + type: azdata.ColumnType.checkBox, + options: { actionOnCheckbox: azdata.ActionOnCellCheckboxCheck.customAction } + }, { + value: NameText, + } + ], + width: DefaultTableWidth, + height: getTableHeight(tableData.length) + } + ).component(); + table.onCellAction((arg: azdata.ICheckboxCellActionEventArgs) => { + const name = listValues[arg.row]; + const idx = selectedValues.indexOf(name); + if (arg.checked && idx === -1) { + selectedValues.push(name); + } else if (!arg.checked && idx !== -1) { + selectedValues.splice(idx, 1) + } + this.onObjectValueChange(); + }); + return table; + } + + protected removeItem(container: azdata.DivContainer | azdata.FlexContainer, item: azdata.Component): void { + if (container.items.indexOf(item) !== -1) { + container.removeItem(item); + } + } + + protected addItem(container: azdata.DivContainer | azdata.FlexContainer, item: azdata.Component, index?: number): void { + if (container.items.indexOf(item) === -1) { + if (index === undefined) { + container.addItem(item); + } else { + container.insertItem(item, index); + } + } + } +} diff --git a/extensions/mssql/src/objectManagement/ui/userDialog.ts b/extensions/mssql/src/objectManagement/ui/userDialog.ts new file mode 100644 index 0000000000..b57e0d316f --- /dev/null +++ b/extensions/mssql/src/objectManagement/ui/userDialog.ts @@ -0,0 +1,248 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { DefaultInputWidth, ObjectManagementDialogBase } from './objectManagementDialogBase'; +import { IObjectManagementService, ObjectManagement } from 'mssql'; +import * as localizedConstants from '../localizedConstants'; +import { AlterUserDocUrl, AuthenticationType, CreateUserDocUrl, NodeType, UserType } from '../constants'; +import { getAuthenticationTypeByDisplayName, getAuthenticationTypeDisplayName, getUserTypeByDisplayName, getUserTypeDisplayName, isValidSQLPassword } from '../utils'; + +export class UserDialog extends ObjectManagementDialogBase { + private formContainer: azdata.DivContainer; + private generalSection: azdata.GroupContainer; + private ownedSchemaSection: azdata.GroupContainer; + private membershipSection: azdata.GroupContainer; + private advancedSection: azdata.GroupContainer; + private nameInput: azdata.InputBoxComponent; + private typeDropdown: azdata.DropDownComponent; + private typeContainer: azdata.FlexContainer; + private authTypeDropdown: azdata.DropDownComponent; + private authTypeContainer: azdata.FlexContainer; + private loginDropdown: azdata.DropDownComponent; + private loginContainer: azdata.FlexContainer; + private passwordInput: azdata.InputBoxComponent; + private passwordContainer: azdata.FlexContainer; + private confirmPasswordInput: azdata.InputBoxComponent; + private confirmPasswordContainer: azdata.FlexContainer; + private defaultSchemaDropdown: azdata.DropDownComponent; + private defaultSchemaContainer: azdata.FlexContainer; + private defaultLanguageDropdown: azdata.DropDownComponent; + private ownedSchemaTable: azdata.TableComponent; + private membershipTable: azdata.TableComponent; + + constructor(objectManagementService: IObjectManagementService, connectionUri: string, private readonly database: string, isNewObject: boolean, name?: string, objectExplorerContext?: azdata.ObjectExplorerContext) { + super(NodeType.User, isNewObject ? CreateUserDocUrl : AlterUserDocUrl, objectManagementService, connectionUri, isNewObject, name, objectExplorerContext); + } + + protected async initializeData(): Promise { + const viewInfo = await this.objectManagementService.initializeUserView(this.connectionUri, this.database, this.contextId, this.isNewObject, this.objectName); + viewInfo.objectInfo.password = viewInfo.objectInfo.password ?? ''; + return viewInfo; + } + + protected async validateInput(): Promise { + const errors: string[] = []; + if (!this.objectInfo.name) { + errors.push(localizedConstants.NameCannotBeEmptyError); + } + if (this.objectInfo.type === UserType.Contained && this.objectInfo.authenticationType === AuthenticationType.Sql) { + if (!this.objectInfo.password) { + errors.push(localizedConstants.PasswordCannotBeEmptyError); + } + if (this.objectInfo.password !== this.confirmPasswordInput.value) { + errors.push(localizedConstants.PasswordsNotMatchError); + } + if (!isValidSQLPassword(this.objectInfo.password, this.objectInfo.name) + && (this.isNewObject || this.objectInfo.password !== this.originalObjectInfo.password)) { + errors.push(localizedConstants.InvalidPasswordError); + } + } + return errors; + } + + protected async onComplete(): Promise { + if (this.isNewObject) { + await this.objectManagementService.createUser(this.contextId, this.objectInfo); + } else { + await this.objectManagementService.updateUser(this.contextId, this.objectInfo); + } + } + + protected async onDispose(): Promise { + await this.objectManagementService.disposeUserView(this.contextId); + } + + protected async initializeUI(): Promise { + this.dialogObject.registerContent(async view => { + const sections: azdata.Component[] = []; + this.initializeGeneralSection(view); + this.initializeOwnedSchemaSection(view); + this.initializeMembershipSection(view); + this.initializeAdvancedSection(view); + sections.push(this.generalSection, this.ownedSchemaSection, this.membershipSection, this.advancedSection); + this.formContainer = this.createFormContainer(view, sections); + setTimeout(() => { + this.setViewByUserType(); + }, 100); + return view.initializeModel(this.formContainer) + }); + } + + private initializeGeneralSection(view: azdata.ModelView): void { + this.nameInput = view.modelBuilder.inputBox().withProps({ + ariaLabel: localizedConstants.NameText, + enabled: this.isNewObject, + value: this.objectInfo.name, + width: DefaultInputWidth + }).component(); + this.nameInput.onTextChanged(async () => { + this.objectInfo.name = this.nameInput.value; + this.onObjectValueChange(); + await this.runValidation(false); + }); + const nameContainer = this.createLabelInputContainer(view, localizedConstants.NameText, this.nameInput); + + this.defaultSchemaDropdown = view.modelBuilder.dropDown().withProps({ + ariaLabel: localizedConstants.DefaultSchemaText, + values: this.viewInfo.schemas, + value: this.objectInfo.defaultSchema, + width: DefaultInputWidth + }).component(); + this.defaultSchemaContainer = this.createLabelInputContainer(view, localizedConstants.DefaultSchemaText, this.defaultSchemaDropdown); + this.defaultSchemaDropdown.onValueChanged(() => { + this.objectInfo.defaultSchema = this.defaultSchemaDropdown.value; + this.onObjectValueChange(); + }); + + this.typeDropdown = view.modelBuilder.dropDown().withProps({ + ariaLabel: localizedConstants.UserTypeText, + values: [localizedConstants.UserWithLoginText, localizedConstants.UserWithWindowsGroupLoginText, localizedConstants.ContainedUserText, localizedConstants.UserWithNoConnectAccess], + value: getUserTypeDisplayName(this.objectInfo.type), + width: DefaultInputWidth, + enabled: this.isNewObject + }).component(); + this.typeDropdown.onValueChanged(async () => { + this.objectInfo.type = getUserTypeByDisplayName(this.typeDropdown.value); + this.onObjectValueChange(); + this.setViewByUserType(); + await this.runValidation(false); + }); + this.typeContainer = this.createLabelInputContainer(view, localizedConstants.UserTypeText, this.typeDropdown); + + this.loginDropdown = view.modelBuilder.dropDown().withProps({ + ariaLabel: localizedConstants.LoginText, + values: this.viewInfo.logins, + value: this.objectInfo.loginName, + width: DefaultInputWidth, + enabled: this.isNewObject + }).component(); + this.loginDropdown.onValueChanged(() => { + this.objectInfo.loginName = this.loginDropdown.value; + this.onObjectValueChange(); + }); + this.loginContainer = this.createLabelInputContainer(view, localizedConstants.LoginText, this.loginDropdown); + + const authTypes = []; + if (this.viewInfo.supportWindowsAuthentication) { + authTypes.push(localizedConstants.WindowsAuthenticationTypeDisplayText); + } + if (this.viewInfo.supportSQLAuthentication) { + authTypes.push(localizedConstants.SQLAuthenticationTypeDisplayText); + } + if (this.viewInfo.supportAADAuthentication) { + authTypes.push(localizedConstants.AADAuthenticationTypeDisplayText); + } + this.authTypeDropdown = view.modelBuilder.dropDown().withProps({ + ariaLabel: localizedConstants.AuthTypeText, + values: authTypes, + value: getAuthenticationTypeDisplayName(this.objectInfo.authenticationType), + width: DefaultInputWidth, + enabled: this.isNewObject + }).component(); + this.authTypeContainer = this.createLabelInputContainer(view, localizedConstants.AuthTypeText, this.authTypeDropdown); + this.authTypeDropdown.onValueChanged(async () => { + this.objectInfo.authenticationType = getAuthenticationTypeByDisplayName(this.authTypeDropdown.value); + this.onObjectValueChange(); + this.setViewByAuthenticationType(); + await this.runValidation(false); + }); + + this.passwordInput = this.createPasswordInputBox(view, localizedConstants.PasswordText, this.objectInfo.password ?? ''); + this.passwordContainer = this.createLabelInputContainer(view, localizedConstants.PasswordText, this.passwordInput); + this.confirmPasswordInput = this.createPasswordInputBox(view, localizedConstants.ConfirmPasswordText, this.objectInfo.password ?? ''); + this.confirmPasswordContainer = this.createLabelInputContainer(view, localizedConstants.ConfirmPasswordText, this.confirmPasswordInput); + this.passwordInput.onTextChanged(async () => { + this.objectInfo.password = this.passwordInput.value; + this.onObjectValueChange(); + await this.runValidation(false); + }); + this.confirmPasswordInput.onTextChanged(async () => { + await this.runValidation(false); + }); + + this.generalSection = this.createGroup(view, localizedConstants.GeneralSectionHeader, [ + nameContainer, + this.defaultSchemaContainer, + this.typeContainer, + this.loginContainer, + this.authTypeContainer, + this.passwordContainer, + this.confirmPasswordContainer + ], false); + } + + private initializeOwnedSchemaSection(view: azdata.ModelView): void { + this.ownedSchemaTable = this.createTableList(view, localizedConstants.OwnedSchemaSectionHeader, this.viewInfo.schemas, this.objectInfo.ownedSchemas); + this.ownedSchemaSection = this.createGroup(view, localizedConstants.OwnedSchemaSectionHeader, [this.ownedSchemaTable]); + } + + private initializeMembershipSection(view: azdata.ModelView): void { + this.membershipTable = this.createTableList(view, localizedConstants.MembershipSectionHeader, this.viewInfo.databaseRoles, this.objectInfo.databaseRoles); + this.membershipSection = this.createGroup(view, localizedConstants.MembershipSectionHeader, [this.membershipTable]); + } + + private initializeAdvancedSection(view: azdata.ModelView): void { + this.defaultLanguageDropdown = view.modelBuilder.dropDown().withProps({ + ariaLabel: localizedConstants.DefaultLanguageText, + values: this.viewInfo.languages, + value: this.objectInfo.defaultLanguage, + width: DefaultInputWidth + }).component(); + this.defaultLanguageDropdown.onValueChanged(() => { + this.objectInfo.defaultLanguage = this.defaultLanguageDropdown.value; + this.onObjectValueChange(); + }); + const container = this.createLabelInputContainer(view, localizedConstants.DefaultLanguageText, this.defaultLanguageDropdown); + this.advancedSection = this.createGroup(view, localizedConstants.AdvancedSectionHeader, [container]); + } + + private setViewByUserType(): void { + if (this.typeDropdown.value === localizedConstants.UserWithLoginText) { + this.removeItem(this.generalSection, this.authTypeContainer); + this.removeItem(this.formContainer, this.advancedSection); + this.addItem(this.generalSection, this.loginContainer); + } else if (this.typeDropdown.value === localizedConstants.ContainedUserText) { + this.removeItem(this.generalSection, this.loginContainer); + this.addItem(this.generalSection, this.authTypeContainer); + this.addItem(this.formContainer, this.advancedSection); + } else { + this.removeItem(this.generalSection, this.loginContainer); + this.removeItem(this.generalSection, this.authTypeContainer); + this.removeItem(this.formContainer, this.advancedSection); + } + this.setViewByAuthenticationType(); + } + + private setViewByAuthenticationType(): void { + const showPassword = this.typeDropdown.value === localizedConstants.ContainedUserText && this.authTypeDropdown.value === localizedConstants.SQLAuthenticationTypeDisplayText; + if (showPassword) { + this.addItem(this.generalSection, this.passwordContainer); + this.addItem(this.generalSection, this.confirmPasswordContainer); + } else { + this.removeItem(this.generalSection, this.passwordContainer); + this.removeItem(this.generalSection, this.confirmPasswordContainer); + } + } +} diff --git a/extensions/mssql/src/objectManagement/utils.ts b/extensions/mssql/src/objectManagement/utils.ts new file mode 100644 index 0000000000..03dd14c348 --- /dev/null +++ b/extensions/mssql/src/objectManagement/utils.ts @@ -0,0 +1,122 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { getErrorMessage } from '../utils'; +import { AuthenticationType, NodeType, UserType } from './constants'; +import { AADAuthenticationTypeDisplayText, ContainedUserText, LoginTypeDisplayName, LoginTypeDisplayNameInTitle, RefreshObjectExplorerError, SQLAuthenticationTypeDisplayText, UserTypeDisplayName, UserTypeDisplayNameInTitle, UserWithLoginText, UserWithNoConnectAccess, UserWithWindowsGroupLoginText, WindowsAuthenticationTypeDisplayText } from './localizedConstants'; + +export function deepClone(obj: T): T { + if (!obj || typeof obj !== 'object') { + return obj; + } + if (obj instanceof RegExp) { + // See https://github.com/Microsoft/TypeScript/issues/10990 + return obj as any; + } + const result: any = Array.isArray(obj) ? [] : {}; + Object.keys(obj).forEach((key: string) => { + if ((obj)[key] && typeof (obj)[key] === 'object') { + result[key] = deepClone((obj)[key]); + } else { + result[key] = (obj)[key]; + } + }); + return result; +} + +export async function refreshParentNode(context: azdata.ObjectExplorerContext): Promise { + if (context) { + try { + const node = await azdata.objectexplorer.getNode(context.connectionProfile.id, context.nodeInfo.nodePath); + const parentNode = await node?.getParent(); + await parentNode?.refresh(); + } + catch (err) { + await vscode.window.showErrorMessage(RefreshObjectExplorerError(getErrorMessage(err))); + } + } +} + +export async function refreshNode(context: azdata.ObjectExplorerContext): Promise { + if (context) { + try { + const node = await azdata.objectexplorer.getNode(context.connectionProfile.id, context.nodeInfo.nodePath); + await node?.refresh(); + } + catch (err) { + await vscode.window.showErrorMessage(RefreshObjectExplorerError(getErrorMessage(err))); + } + } +} + +export function getNodeTypeDisplayName(type: string, inTitle: boolean = false): string { + switch (type) { + case NodeType.Login: + return inTitle ? LoginTypeDisplayNameInTitle : LoginTypeDisplayName; + case NodeType.User: + return inTitle ? UserTypeDisplayNameInTitle : UserTypeDisplayName; + default: + throw new Error(`Unkown node type: ${type}`); + } +} + +export function getAuthenticationTypeDisplayName(authType: AuthenticationType): string { + switch (authType) { + case AuthenticationType.Windows: + return WindowsAuthenticationTypeDisplayText; + case AuthenticationType.AzureActiveDirectory: + return AADAuthenticationTypeDisplayText; + default: + return SQLAuthenticationTypeDisplayText; + } +} + +export function getAuthenticationTypeByDisplayName(displayValue: string): AuthenticationType { + switch (displayValue) { + case WindowsAuthenticationTypeDisplayText: + return AuthenticationType.Windows; + case AADAuthenticationTypeDisplayText: + return AuthenticationType.AzureActiveDirectory; + default: + return AuthenticationType.Sql; + } +} +export function getUserTypeDisplayName(userType: UserType): string { + switch (userType) { + case UserType.WithLogin: + return UserWithLoginText; + case UserType.WithWindowsGroupLogin: + return UserWithWindowsGroupLoginText; + case UserType.Contained: + return ContainedUserText; + default: + return UserWithNoConnectAccess; + } +} + +export function getUserTypeByDisplayName(userTypeDisplayName: string): UserType { + switch (userTypeDisplayName) { + case UserWithLoginText: + return UserType.WithLogin; + case UserWithWindowsGroupLoginText: + return UserType.WithWindowsGroupLogin; + case ContainedUserText: + return UserType.Contained; + default: + return UserType.NoConnectAccess; + } +} + +// https://docs.microsoft.com/sql/relational-databases/security/password-policy +export function isValidSQLPassword(password: string, userName: string = 'sa'): boolean { + const containsUserName = password && userName !== undefined && password.toUpperCase().includes(userName.toUpperCase()); + const hasUpperCase = /[A-Z]/.test(password) ? 1 : 0; + const hasLowerCase = /[a-z]/.test(password) ? 1 : 0; + const hasNumbers = /\d/.test(password) ? 1 : 0; + const hasNonAlphas = /\W/.test(password) ? 1 : 0; + return !containsUserName && password.length >= 8 && password.length <= 128 && (hasUpperCase + hasLowerCase + hasNumbers + hasNonAlphas >= 3); +} diff --git a/extensions/mssql/src/sqlToolsServer.ts b/extensions/mssql/src/sqlToolsServer.ts index ca02cdffd5..9589dfe9a3 100644 --- a/extensions/mssql/src/sqlToolsServer.ts +++ b/extensions/mssql/src/sqlToolsServer.ts @@ -29,6 +29,7 @@ import { SqlCredentialService } from './credentialstore/sqlCredentialService'; import { AzureBlobService } from './azureBlob/azureBlobService'; import { ErrorDiagnosticsProvider } from './errorDiagnostics/errorDiagnosticsProvider'; import { SqlProjectsService } from './sqlProjects/sqlProjectsService'; +import { ObjectManagementService } from './objectManagement/objectManagementService'; const localize = nls.loadMessageBundle(); const outputChannel = vscode.window.createOutputChannel(Constants.serviceName); @@ -195,7 +196,8 @@ function getClientOptions(context: AppContext): ClientOptions { SqlCredentialService.asFeature(context), TableDesignerFeature, ExecutionPlanServiceFeature, - ErrorDiagnosticsProvider.asFeature(context) + ErrorDiagnosticsProvider.asFeature(context), + ObjectManagementService.asFeature(context) ], outputChannel: outputChannel, // Automatically reveal the output channel only in dev mode, so that the users are not impacted and issues can still be caught during development.