feat(plugin-authentication): CB-999 user password change (public)

This commit is contained in:
Naumov Alexey
2021-06-10 17:46:04 +03:00
parent 8a0efcb362
commit 3dbdea99ac
15 changed files with 413 additions and 36 deletions
+1
View File
@@ -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';
@@ -39,6 +39,10 @@ export class AuthProviderService {
return provider.get();
}
hashValue(value: string): string {
return md5(value).toUpperCase();
}
async processCredentials(providerId: string, credentials: Record<string, any>): Promise<Record<string, any>> {
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;
}
@@ -145,6 +145,13 @@ export class UsersResource extends CachedMapResource<string, AdminUser, UserReso
});
}
async updateLocalPassword(oldPassword: string, newPassword: string): Promise<void> {
await this.graphQLService.sdk.authChangeLocalPassword({
oldPassword: this.authProviderService.hashValue(oldPassword),
newPassword: this.authProviderService.hashValue(newPassword),
});
}
async delete(key: ResourceKey<string>): Promise<void> {
await ResourceKeyUtils.forEachAsync(key, async key => {
if (this.isActiveUser(key)) {
@@ -0,0 +1,3 @@
query authChangeLocalPassword($oldPassword: String!, $newPassword: String!) {
authChangeLocalPassword(oldPassword: $oldPassword, newPassword: $newPassword)
}
+21
View File
@@ -19,6 +19,7 @@ export interface Scalars {
export interface Query {
activeUser?: Maybe<UserInfo>;
allConnections: ConnectionInfo[];
authChangeLocalPassword: Scalars['Boolean'];
authLogin: UserAuthToken;
authLogout?: Maybe<Scalars['Boolean']>;
authModels: DatabaseAuthModel[];
@@ -76,6 +77,11 @@ export interface QueryAllConnectionsArgs {
id?: Maybe<Scalars['ID']>;
}
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<Query, 'authChangeLocalPassword'>;
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<AsyncTaskCancelMutation> {
return withWrapper(() => client.request<AsyncTaskCancelMutation>(AsyncTaskCancelDocument, variables));
},
authChangeLocalPassword(variables: AuthChangeLocalPasswordQueryVariables): Promise<AuthChangeLocalPasswordQuery> {
return withWrapper(() => client.request<AuthChangeLocalPasswordQuery>(AuthChangeLocalPasswordDocument, variables));
},
authLogin(variables: AuthLoginQueryVariables): Promise<AuthLoginQuery> {
return withWrapper(() => client.request<AuthLoginQuery>(AuthLoginDocument, variables));
},
@@ -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);
}
@@ -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)(
<user as='div'>
<user-icon as="div">
<user>
<user-icon>
<IconOrImage icon='user' viewBox='0 0 28 28' />
</user-icon>
<user-name as='div'>{authInfoService.userInfo.displayName || authInfoService.userInfo.userId}</user-name>
<user-name>{authInfoService.userInfo.displayName || authInfoService.userInfo.userId}</user-name>
</user>
);
});
@@ -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<void>;
}
interface IState {
oldPassword: string;
newPassword: string;
repeatedPassword: string;
error: GQLErrorCatcher;
customError: string;
submitting: boolean;
}
export const ChangeUserPasswordDialog: DialogComponent<IPayload, null> = observer(
function ChangeUserPasswordDialog({
payload,
resolveDialog,
rejectDialog,
}: DialogComponentProps<IPayload, null>) {
const [focusedRef] = useFocus<HTMLInputElement>({});
const commonDialogService = useService(CommonDialogService);
const notificationService = useService(NotificationService);
const style = useStyles(styles, BASE_CONTAINERS_STYLES);
const translate = useTranslate();
const [state] = useState<IState>(() => 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)(
<CommonDialogWrapper
title={translate('authentication_user_password_change_dialog_title')}
footer={(
<ChangeUserPasswordDialogFooter
submitting={state.submitting}
formFilled={formFilled}
onCancel={rejectDialog}
onChange={change}
>
{(state.error.responseMessage || state.customError) && (
<ErrorMessage
text={state.error.responseMessage || translate(state.customError)}
hasDetails={state.error.hasDetails}
onShowDetails={onShowDetails}
/>
)}
</ChangeUserPasswordDialogFooter>
)}
onReject={rejectDialog}
>
<SubmittingForm onSubmit={change}>
<Container>
<Group small gap center>
<InputFieldNew
ref={focusedRef}
type='password'
name='oldPassword'
state={state}
disabled={disabled}
mapValue={(value => value.trim())}
small
required
>
{translate('authentication_user_old_password')}
</InputFieldNew>
<InputFieldNew
type='password'
name='newPassword'
autoComplete='new-password'
state={state}
disabled={disabled}
mapValue={(value => value.trim())}
small
required
>
{translate('authentication_user_new_password')}
</InputFieldNew>
<InputFieldNew
type='password'
name='repeatedPassword'
state={state}
disabled={disabled}
mapValue={(value => value.trim())}
small
required
>
{translate('authentication_user_password_repeat')}
</InputFieldNew>
</Group>
</Container>
</SubmittingForm>
</CommonDialogWrapper>
);
}
);
@@ -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<Props> = observer(function ChangeUserPasswordDialogFooter({
onChange,
onCancel,
formFilled,
submitting,
className,
children,
}) {
const translate = useTranslate();
return styled(styles)(
<footer-container className={className}>
{children}
<Button disabled={submitting} type="button" mod={['outlined']} onClick={onCancel}>
{translate('ui_processing_cancel')}
</Button>
<Button disabled={submitting || !formFilled} loading={submitting} type="button" mod={['unelevated']} onClick={onChange}>
{translate('authentication_user_password_change_dialog_submit')}
</Button>
</footer-container>
);
});
@@ -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)(
<MenuTrigger panel={userMenuService.getMenu()} style={[topMenuStyles, userMenuStyles]}>
<UserInfo />
</MenuTrigger>
);
});
@@ -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);
}
}
@@ -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;
}
`;
@@ -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'],
@@ -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', 'Изменить'],
];
@@ -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,
],
};