diff --git a/webapp/packages/core-app/src/index.ts b/webapp/packages/core-app/src/index.ts index 2ceff99379..2ff7b6b860 100644 --- a/webapp/packages/core-app/src/index.ts +++ b/webapp/packages/core-app/src/index.ts @@ -6,6 +6,7 @@ export * from './TopNavBar/SettingsMenu/SettingsMenuService'; export * from './TopNavBar/ConnectionSchemaManager/ConnectionSchemaManagerService'; export * from './TopNavBar/TopNavBarService'; export * from './TopNavBar/AdministrationTopAppBarBootstrapService'; +export * from './TopNavBar/shared/topMenuStyles'; export * from './shared/NodesManager/ConnectionDialogsService'; export * from './shared/NodesManager/extensions/IObjectCatalogProvider'; export * from './shared/NodesManager/extensions/IObjectCatalogSetter'; diff --git a/webapp/packages/core-authentication/src/AuthProviderService.ts b/webapp/packages/core-authentication/src/AuthProviderService.ts index 3967ad02df..9fdb1d314a 100644 --- a/webapp/packages/core-authentication/src/AuthProviderService.ts +++ b/webapp/packages/core-authentication/src/AuthProviderService.ts @@ -39,6 +39,10 @@ export class AuthProviderService { return provider.get(); } + hashValue(value: string): string { + return md5(value).toUpperCase(); + } + async processCredentials(providerId: string, credentials: Record): Promise> { const provider = await this.authProvidersResource.load(providerId); @@ -49,9 +53,7 @@ export class AuthProviderService { const credentialsProcessed = { ...credentials }; for (const parameter of provider.credentialParameters) { if (parameter.encryption === 'hash' && parameter.id in credentialsProcessed) { - const md5Hash = md5(credentialsProcessed[parameter.id]) - .toUpperCase(); - credentialsProcessed[parameter.id] = md5Hash; + credentialsProcessed[parameter.id] = this.hashValue(credentialsProcessed[parameter.id]); } } @@ -59,7 +61,7 @@ export class AuthProviderService { } } -interface IAuthProviderContext{ +interface IAuthProviderContext { get: () => boolean; auth: () => void; } diff --git a/webapp/packages/core-authentication/src/UsersResource.ts b/webapp/packages/core-authentication/src/UsersResource.ts index 38d4094445..54fd396ba2 100644 --- a/webapp/packages/core-authentication/src/UsersResource.ts +++ b/webapp/packages/core-authentication/src/UsersResource.ts @@ -145,6 +145,13 @@ export class UsersResource extends CachedMapResource { + await this.graphQLService.sdk.authChangeLocalPassword({ + oldPassword: this.authProviderService.hashValue(oldPassword), + newPassword: this.authProviderService.hashValue(newPassword), + }); + } + async delete(key: ResourceKey): Promise { await ResourceKeyUtils.forEachAsync(key, async key => { if (this.isActiveUser(key)) { diff --git a/webapp/packages/core-sdk/src/queries/authentication/authChangeLocalPassword.gql b/webapp/packages/core-sdk/src/queries/authentication/authChangeLocalPassword.gql new file mode 100644 index 0000000000..f6ce279297 --- /dev/null +++ b/webapp/packages/core-sdk/src/queries/authentication/authChangeLocalPassword.gql @@ -0,0 +1,3 @@ +query authChangeLocalPassword($oldPassword: String!, $newPassword: String!) { + authChangeLocalPassword(oldPassword: $oldPassword, newPassword: $newPassword) +} \ No newline at end of file diff --git a/webapp/packages/core-sdk/src/sdk.ts b/webapp/packages/core-sdk/src/sdk.ts index 515256df4d..d55e29eca1 100644 --- a/webapp/packages/core-sdk/src/sdk.ts +++ b/webapp/packages/core-sdk/src/sdk.ts @@ -19,6 +19,7 @@ export interface Scalars { export interface Query { activeUser?: Maybe; allConnections: ConnectionInfo[]; + authChangeLocalPassword: Scalars['Boolean']; authLogin: UserAuthToken; authLogout?: Maybe; authModels: DatabaseAuthModel[]; @@ -76,6 +77,11 @@ export interface QueryAllConnectionsArgs { id?: Maybe; } +export interface QueryAuthChangeLocalPasswordArgs { + oldPassword: Scalars['String']; + newPassword: Scalars['String']; +} + export interface QueryAuthLoginArgs { provider: Scalars['ID']; credentials: Scalars['Object']; @@ -996,6 +1002,13 @@ export type AsyncTaskCancelMutationVariables = Exact<{ export interface AsyncTaskCancelMutation { result: Mutation['asyncTaskCancel'] } +export type AuthChangeLocalPasswordQueryVariables = Exact<{ + oldPassword: Scalars['String']; + newPassword: Scalars['String']; +}>; + +export type AuthChangeLocalPasswordQuery = Pick; + export type AuthLoginQueryVariables = Exact<{ provider: Scalars['ID']; credentials: Scalars['Object']; @@ -1856,6 +1869,11 @@ export const AsyncTaskCancelDocument = ` result: asyncTaskCancel(id: $taskId) } `; +export const AuthChangeLocalPasswordDocument = ` + query authChangeLocalPassword($oldPassword: String!, $newPassword: String!) { + authChangeLocalPassword(oldPassword: $oldPassword, newPassword: $newPassword) +} + `; export const AuthLoginDocument = ` query authLogin($provider: ID!, $credentials: Object!, $linkUser: Boolean, $customIncludeOriginDetails: Boolean!) { authToken: authLogin( @@ -2640,6 +2658,9 @@ export function getSdk(client: GraphQLClient, withWrapper: SdkFunctionWrapper = asyncTaskCancel(variables: AsyncTaskCancelMutationVariables): Promise { return withWrapper(() => client.request(AsyncTaskCancelDocument, variables)); }, + authChangeLocalPassword(variables: AuthChangeLocalPasswordQueryVariables): Promise { + return withWrapper(() => client.request(AuthChangeLocalPasswordDocument, variables)); + }, authLogin(variables: AuthLoginQueryVariables): Promise { return withWrapper(() => client.request(AuthLoginDocument, variables)); }, diff --git a/webapp/packages/plugin-authentication/src/PluginBootstrap.ts b/webapp/packages/plugin-authentication/src/PluginBootstrap.ts index b5ac144b90..c46cddbefc 100644 --- a/webapp/packages/plugin-authentication/src/PluginBootstrap.ts +++ b/webapp/packages/plugin-authentication/src/PluginBootstrap.ts @@ -16,7 +16,8 @@ import { ServerConfigurationService } from '@cloudbeaver/plugin-administration'; import { AuthenticationProviders } from './Administration/ServerConfiguration/AuthenticationProviders'; import { AuthenticationService } from './AuthenticationService'; import { AuthDialogService } from './Dialog/AuthDialogService'; -import { UserInfo } from './UserInfo'; +import { UserMenu } from './UserMenu/UserMenu'; +import { UserMenuService } from './UserMenu/UserMenuService'; @injectable() export class PluginBootstrap extends Bootstrap { @@ -28,6 +29,7 @@ export class PluginBootstrap extends Bootstrap { private settingsMenuService: SettingsMenuService, private topNavService: TopNavService, private administrationTopAppBarService: AdministrationTopAppBarService, + private userMenuService: UserMenuService, private readonly serverConfigurationService: ServerConfigurationService ) { super(); @@ -55,9 +57,9 @@ export class PluginBootstrap extends Bootstrap { onClick: this.authenticationService.logout.bind(this.authenticationService), } ); - - this.topNavService.placeholder.add(UserInfo, 4); - this.administrationTopAppBarService.placeholder.add(UserInfo, 4); + this.userMenuService.register(); + this.topNavService.placeholder.add(UserMenu, 4); + this.administrationTopAppBarService.placeholder.add(UserMenu, 4); this.serverConfigurationService.configurationContainer.add(AuthenticationProviders, 0); } diff --git a/webapp/packages/plugin-authentication/src/UserInfo.tsx b/webapp/packages/plugin-authentication/src/UserInfo.tsx index 85a7d557dd..f636933138 100644 --- a/webapp/packages/plugin-authentication/src/UserInfo.tsx +++ b/webapp/packages/plugin-authentication/src/UserInfo.tsx @@ -12,32 +12,25 @@ import styled, { css } from 'reshadow'; import { AuthInfoService } from '@cloudbeaver/core-authentication'; import { IconOrImage } from '@cloudbeaver/core-blocks'; import { useService } from '@cloudbeaver/core-di'; -import { composes, useStyles } from '@cloudbeaver/core-theming'; +import { useStyles } from '@cloudbeaver/core-theming'; -const styles = composes( - css` - user { - composes: theme-ripple from global; - } - `, - css` - user { - height: 100%; - padding: 0 16px; - display: flex; - align-items: center; - } - IconOrImage { - display: block; - width: 24px; - } - user-name { - display: block; - line-height: initial; - margin-left: 8px; - } - ` -); +const styles = css` + user { + height: 100%; + padding: 0 16px; + display: flex; + align-items: center; + } + IconOrImage { + display: block; + width: 24px; + } + user-name { + display: block; + line-height: initial; + margin-left: 8px; + } +`; export const UserInfo = observer(function UserInfo() { const authInfoService = useService(AuthInfoService); @@ -48,11 +41,11 @@ export const UserInfo = observer(function UserInfo() { } return styled(style)( - - + + - {authInfoService.userInfo.displayName || authInfoService.userInfo.userId} + {authInfoService.userInfo.displayName || authInfoService.userInfo.userId} ); }); diff --git a/webapp/packages/plugin-authentication/src/UserMenu/ChangeUserPasswordDialog/ChangeUserPasswordDialog.tsx b/webapp/packages/plugin-authentication/src/UserMenu/ChangeUserPasswordDialog/ChangeUserPasswordDialog.tsx new file mode 100644 index 0000000000..4e45f5e29a --- /dev/null +++ b/webapp/packages/plugin-authentication/src/UserMenu/ChangeUserPasswordDialog/ChangeUserPasswordDialog.tsx @@ -0,0 +1,166 @@ +/* + * CloudBeaver - Cloud Database Manager + * Copyright (C) 2020-2021 DBeaver Corp and others + * + * Licensed under the Apache License, Version 2.0. + * you may not use this file except in compliance with the License. + */ + +import { observable } from 'mobx'; +import { observer } from 'mobx-react-lite'; +import { useState, useCallback, useEffect } from 'react'; +import styled, { css } from 'reshadow'; + +import { BASE_CONTAINERS_STYLES, Container, ErrorMessage, Group, InputFieldNew, SubmittingForm, useFocus } from '@cloudbeaver/core-blocks'; +import { useService } from '@cloudbeaver/core-di'; +import { CommonDialogService, CommonDialogWrapper, DialogComponent, DialogComponentProps } from '@cloudbeaver/core-dialogs'; +import { NotificationService } from '@cloudbeaver/core-events'; +import { useTranslate } from '@cloudbeaver/core-localization'; +import { ErrorDetailsDialog } from '@cloudbeaver/core-notifications'; +import { GQLError, GQLErrorCatcher } from '@cloudbeaver/core-sdk'; +import { composes, useStyles } from '@cloudbeaver/core-theming'; + +import { ChangeUserPasswordDialogFooter } from './ChangeUserPasswordDialogFooter'; + +const styles = composes( + css` + ErrorMessage { + composes: theme-background-secondary theme-text-on-secondary from global; + } +`, + css` + CommonDialogWrapper { + min-width: 500px; + } + ErrorMessage { + flex: 1; + } +`); + +interface IPayload { + change: (oldPassword: string, newPassword: string) => Promise; +} + +interface IState { + oldPassword: string; + newPassword: string; + repeatedPassword: string; + error: GQLErrorCatcher; + customError: string; + submitting: boolean; +} + +export const ChangeUserPasswordDialog: DialogComponent = observer( + function ChangeUserPasswordDialog({ + payload, + resolveDialog, + rejectDialog, + }: DialogComponentProps) { + const [focusedRef] = useFocus({}); + const commonDialogService = useService(CommonDialogService); + const notificationService = useService(NotificationService); + const style = useStyles(styles, BASE_CONTAINERS_STYLES); + const translate = useTranslate(); + const [state] = useState(() => observable({ + oldPassword: '', newPassword: '', repeatedPassword: '', error: new GQLErrorCatcher(), customError: '', submitting: false, + })); + const formFilled + = state.newPassword.length > 0 && state.repeatedPassword.length > 0 && state.oldPassword.length > 0; + const disabled = state.submitting; + + const onShowDetails = useCallback(() => { + commonDialogService.open(ErrorDetailsDialog, (state.error.exception as GQLError)); + }, [commonDialogService, state.error]); + + const change = useCallback(async () => { + state.error.clear(); + + if (state.newPassword !== state.repeatedPassword) { + state.customError = 'authentication_user_passwords_not_match'; + return; + } + + try { + state.submitting = true; + await payload.change(state.oldPassword, state.newPassword); + notificationService.logSuccess({ title: 'authentication_user_password_change_success' }); + resolveDialog(); + } catch (exeption) { + if (!state.error.catch(exeption)) { + state.customError = exeption.message; + } + } finally { + state.submitting = false; + } + }, [state, payload, resolveDialog, notificationService]); + + useEffect(() => { + focusedRef.current?.focus(); + }, []); + + return styled(style)( + + {(state.error.responseMessage || state.customError) && ( + + )} + + )} + onReject={rejectDialog} + > + + + + value.trim())} + small + required + > + {translate('authentication_user_old_password')} + + value.trim())} + small + required + > + {translate('authentication_user_new_password')} + + value.trim())} + small + required + > + {translate('authentication_user_password_repeat')} + + + + + + ); + } +); diff --git a/webapp/packages/plugin-authentication/src/UserMenu/ChangeUserPasswordDialog/ChangeUserPasswordDialogFooter.tsx b/webapp/packages/plugin-authentication/src/UserMenu/ChangeUserPasswordDialog/ChangeUserPasswordDialogFooter.tsx new file mode 100644 index 0000000000..4faad9d658 --- /dev/null +++ b/webapp/packages/plugin-authentication/src/UserMenu/ChangeUserPasswordDialog/ChangeUserPasswordDialogFooter.tsx @@ -0,0 +1,60 @@ +/* + * CloudBeaver - Cloud Database Manager + * Copyright (C) 2020-2021 DBeaver Corp and others + * + * Licensed under the Apache License, Version 2.0. + * you may not use this file except in compliance with the License. + */ + +import { observer } from 'mobx-react-lite'; +import styled, { css } from 'reshadow'; + +import { Button } from '@cloudbeaver/core-blocks'; +import { useTranslate } from '@cloudbeaver/core-localization'; + +const styles = css` + footer-container { + display: flex; + width: min-content; + flex: 1; + align-items: center; + justify-content: flex-end; + } + footer-container > :not(:first-child) { + margin-left: 16px; + } + Button { + flex: 0 0 auto; + } +`; + +export interface Props { + onChange: () => void; + onCancel: () => void; + formFilled: boolean; + submitting: boolean; + className?: string; +} + +export const ChangeUserPasswordDialogFooter: React.FC = observer(function ChangeUserPasswordDialogFooter({ + onChange, + onCancel, + formFilled, + submitting, + className, + children, +}) { + const translate = useTranslate(); + + return styled(styles)( + + {children} + + + + ); +}); diff --git a/webapp/packages/plugin-authentication/src/UserMenu/UserMenu.tsx b/webapp/packages/plugin-authentication/src/UserMenu/UserMenu.tsx new file mode 100644 index 0000000000..d061bb1e51 --- /dev/null +++ b/webapp/packages/plugin-authentication/src/UserMenu/UserMenu.tsx @@ -0,0 +1,30 @@ +/* + * CloudBeaver - Cloud Database Manager + * Copyright (C) 2020-2021 DBeaver Corp and others + * + * Licensed under the Apache License, Version 2.0. + * you may not use this file except in compliance with the License. + */ + +import { observer } from 'mobx-react-lite'; +import styled from 'reshadow'; + +import { topMenuStyles } from '@cloudbeaver/core-app'; +import { useService } from '@cloudbeaver/core-di'; +import { MenuTrigger } from '@cloudbeaver/core-dialogs'; +import { useStyles } from '@cloudbeaver/core-theming'; + +import { UserInfo } from '../UserInfo'; +import { UserMenuService } from './UserMenuService'; +import { userMenuStyles } from './userMenuStyles'; + +export const UserMenu = observer(function UserMenu() { + const userMenuService = useService(UserMenuService); + const style = useStyles(userMenuStyles); + + return styled(style)( + + + + ); +}); diff --git a/webapp/packages/plugin-authentication/src/UserMenu/UserMenuService.ts b/webapp/packages/plugin-authentication/src/UserMenu/UserMenuService.ts new file mode 100644 index 0000000000..0969e6561c --- /dev/null +++ b/webapp/packages/plugin-authentication/src/UserMenu/UserMenuService.ts @@ -0,0 +1,58 @@ +/* + * CloudBeaver - Cloud Database Manager + * Copyright (C) 2020-2021 DBeaver Corp and others + * + * Licensed under the Apache License, Version 2.0. + * you may not use this file except in compliance with the License. + */ + +import { AuthInfoService, AUTH_PROVIDER_LOCAL_ID, UsersResource } from '@cloudbeaver/core-authentication'; +import { injectable } from '@cloudbeaver/core-di'; +import { CommonDialogService, IComputedMenuItemOptions, IMenuPanel, StaticMenu } from '@cloudbeaver/core-dialogs'; + +import { ChangeUserPasswordDialog } from './ChangeUserPasswordDialog/ChangeUserPasswordDialog'; + +@injectable() +export class UserMenuService { + static userMenuToken = 'userMenu'; + + private menu = new StaticMenu(); + + getUserMenuToken() { + return UserMenuService.userMenuToken; + } + + constructor( + private authInfoService: AuthInfoService, + private commonDialogService: CommonDialogService, + private usersResource: UsersResource, + ) { } + + register(): void { + this.menu.addRootPanel(this.getUserMenuToken()); + this.addMenuItem( + this.getUserMenuToken(), + { + id: 'changePassword', + order: 0, + isHidden: () => !this.authInfoService.userInfo?.linkedAuthProviders.includes(AUTH_PROVIDER_LOCAL_ID), + title: 'authentication_user_password_change_menu_title', + onClick: async () => { + await this.commonDialogService.open(ChangeUserPasswordDialog, { + change: (oldPassword: string, newPassword: string) => this.usersResource.updateLocalPassword( + oldPassword, newPassword + ), + }); + }, + } + ); + } + + getMenu(): IMenuPanel { + return this.menu.getMenu(this.getUserMenuToken()); + } + + addMenuItem(panelId: string, options: IComputedMenuItemOptions): void { + this.menu.addMenuItem(panelId, options); + } +} diff --git a/webapp/packages/plugin-authentication/src/UserMenu/userMenuStyles.ts b/webapp/packages/plugin-authentication/src/UserMenu/userMenuStyles.ts new file mode 100644 index 0000000000..d320ffcada --- /dev/null +++ b/webapp/packages/plugin-authentication/src/UserMenu/userMenuStyles.ts @@ -0,0 +1,20 @@ +/* + * CloudBeaver - Cloud Database Manager + * Copyright (C) 2020-2021 DBeaver Corp and others + * + * Licensed under the Apache License, Version 2.0. + * you may not use this file except in compliance with the License. + */ + +import { css } from 'reshadow'; + +export const userMenuStyles = css` + MenuTrigger { + padding: 0 16px !important; + height: 48px; + cursor: pointer; + background: none; + border: none; + outline: none !important; + } +`; diff --git a/webapp/packages/plugin-authentication/src/locales/en.ts b/webapp/packages/plugin-authentication/src/locales/en.ts index 5666e746a3..dde48893f4 100644 --- a/webapp/packages/plugin-authentication/src/locales/en.ts +++ b/webapp/packages/plugin-authentication/src/locales/en.ts @@ -27,6 +27,8 @@ export default [ ['authentication_administration_user_create_failed', 'Error creating new user'], ['authentication_user_name', 'User name'], ['authentication_user_credentials', 'Credentials'], + ['authentication_user_old_password', 'Old password'], + ['authentication_user_new_password', 'New password'], ['authentication_user_password', 'User password'], ['authentication_user_password_repeat', 'Repeat password'], ['authentication_user_role', 'User role'], @@ -36,6 +38,10 @@ export default [ ['authentication_user_passwords_not_match', "Passwords don't match"], ['authentication_user_login_already_exists', 'The user with this name already exists'], ['authentication_user_login_cant_be_used', 'Sorry, that name cannot be used'], + ['authentication_user_password_change_dialog_title', 'Change password (local)'], + ['authentication_user_password_change_menu_title', 'Change local password'], + ['authentication_user_password_change_success', 'Password was changed successfully'], + ['authentication_user_password_change_dialog_submit', 'Change'], ['administration_configuration_wizard_configuration_admin', 'Administrator Credentials'], ['administration_configuration_wizard_configuration_admin_name', 'Login'], ['administration_configuration_wizard_configuration_admin_password', 'Password'], diff --git a/webapp/packages/plugin-authentication/src/locales/ru.ts b/webapp/packages/plugin-authentication/src/locales/ru.ts index f1ac90b9f3..79f275e4a5 100644 --- a/webapp/packages/plugin-authentication/src/locales/ru.ts +++ b/webapp/packages/plugin-authentication/src/locales/ru.ts @@ -25,6 +25,8 @@ export default [ ['authentication_administration_user_create_failed', 'Не удалось создать пользователя'], ['authentication_user_name', 'Имя'], ['authentication_user_credentials', 'Учетные данные'], + ['authentication_user_old_password', 'Старый пароль'], + ['authentication_user_new_password', 'Новый пароль'], ['authentication_user_password', 'Пароль'], ['authentication_user_password_repeat', 'Повторите пароль'], ['authentication_user_role', 'Роль'], @@ -34,4 +36,8 @@ export default [ ['authentication_user_passwords_not_match', 'Пароли не совпадают'], ['authentication_user_login_already_exists', 'Пользователь с таким именем уже существует'], ['authentication_user_login_cant_be_used', 'Данное имя нельзя использовать'], + ['authentication_user_password_change_dialog_title', 'Изменение пароля (локального)'], + ['authentication_user_password_change_menu_title', 'Изменить локальный пароль'], + ['authentication_user_password_change_success', 'Пароль успешно изменен'], + ['authentication_user_password_change_dialog_submit', 'Изменить'], ]; diff --git a/webapp/packages/plugin-authentication/src/manifest.ts b/webapp/packages/plugin-authentication/src/manifest.ts index 311dff607d..03622f54e4 100644 --- a/webapp/packages/plugin-authentication/src/manifest.ts +++ b/webapp/packages/plugin-authentication/src/manifest.ts @@ -18,6 +18,7 @@ import { AuthenticationLocaleService } from './AuthenticationLocaleService'; import { AuthenticationService } from './AuthenticationService'; import { AuthDialogService } from './Dialog/AuthDialogService'; import { PluginBootstrap } from './PluginBootstrap'; +import { UserMenuService } from './UserMenu/UserMenuService'; export const manifest: PluginManifest = { info: { @@ -35,5 +36,6 @@ export const manifest: PluginManifest = { ServerConfigurationAuthenticationBootstrap, UserFormService, UserFormBaseBootstrap, + UserMenuService, ], };