diff --git a/CHANGELOG.md b/CHANGELOG.md index d1534bca120..562ef21fda7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [1.2.0] - Unreleased ### Added -- +- Added password reset functionality () ### Changed - UI models (like DEXTR) were redesigned to be more interactive () diff --git a/cvat-core/src/api-implementation.js b/cvat-core/src/api-implementation.js index 5fc7958c28f..39c0d9112cb 100644 --- a/cvat-core/src/api-implementation.js +++ b/cvat-core/src/api-implementation.js @@ -99,6 +99,14 @@ await serverProxy.server.changePassword(oldPassword, newPassword1, newPassword2); }; + cvat.server.requestPasswordReset.implementation = async (email) => { + await serverProxy.server.requestPasswordReset(email); + }; + + cvat.server.resetPassword.implementation = async(newPassword1, newPassword2, uid, token) => { + await serverProxy.server.resetPassword(newPassword1, newPassword2, uid, token); + }; + cvat.server.authorized.implementation = async () => { const result = await serverProxy.server.authorized(); return result; diff --git a/cvat-core/src/api.js b/cvat-core/src/api.js index 9d040683bcc..1b70911839b 100644 --- a/cvat-core/src/api.js +++ b/cvat-core/src/api.js @@ -199,6 +199,9 @@ function build() { * @method changePassword * @async * @memberof module:API.cvat.server + * @param {string} oldPassword Current password for the account + * @param {string} newPassword1 New password for the account + * @param {string} newPassword2 Confirmation password for the account * @throws {module:API.cvat.exceptions.PluginError} * @throws {module:API.cvat.exceptions.ServerError} */ @@ -207,6 +210,38 @@ function build() { .apiWrapper(cvat.server.changePassword, oldPassword, newPassword1, newPassword2); return result; }, + /** + * Method allows to reset user password + * @method requestPasswordReset + * @async + * @memberof module:API.cvat.server + * @param {string} email A email address for the account + * @throws {module:API.cvat.exceptions.PluginError} + * @throws {module:API.cvat.exceptions.ServerError} + */ + async requestPasswordReset(email) { + const result = await PluginRegistry + .apiWrapper(cvat.server.requestPasswordReset, email); + return result; + }, + /** + * Method allows to confirm reset user password + * @method resetPassword + * @async + * @memberof module:API.cvat.server + * @param {string} newPassword1 New password for the account + * @param {string} newPassword2 Confirmation password for the account + * @param {string} uid User id + * @param {string} token Request authentication token + * @throws {module:API.cvat.exceptions.PluginError} + * @throws {module:API.cvat.exceptions.ServerError} + */ + async resetPassword(newPassword1, newPassword2, uid, token) { + const result = await PluginRegistry + .apiWrapper(cvat.server.resetPassword, newPassword1, newPassword2, + uid, token); + return result; + }, /** * Method allows to know whether you are authorized on the server * @method authorized diff --git a/cvat-core/src/server-proxy.js b/cvat-core/src/server-proxy.js index 4a0c9ebd8b6..58724d35c09 100644 --- a/cvat-core/src/server-proxy.js +++ b/cvat-core/src/server-proxy.js @@ -264,6 +264,41 @@ } } + async function requestPasswordReset(email) { + try { + const data = JSON.stringify({ + email, + }); + await Axios.post(`${config.backendAPI}/auth/password/reset`, data, { + proxy: config.proxy, + headers: { + 'Content-Type': 'application/json', + }, + }); + } catch (errorData) { + throw generateError(errorData); + } + } + + async function resetPassword(newPassword1, newPassword2, uid, token) { + try { + const data = JSON.stringify({ + new_password1: newPassword1, + new_password2: newPassword2, + uid, + token, + }); + await Axios.post(`${config.backendAPI}/auth/password/reset/confirm`, data, { + proxy: config.proxy, + headers: { + 'Content-Type': 'application/json', + }, + }); + } catch (errorData) { + throw generateError(errorData); + } + } + async function authorized() { try { await module.exports.users.getSelf(); @@ -787,6 +822,8 @@ login, logout, changePassword, + requestPasswordReset, + resetPassword, authorized, register, request: serverRequest, diff --git a/cvat-ui/package-lock.json b/cvat-ui/package-lock.json index fbbd3cea567..4cfa9ddf44f 100644 --- a/cvat-ui/package-lock.json +++ b/cvat-ui/package-lock.json @@ -1,6 +1,6 @@ { "name": "cvat-ui", - "version": "1.8.4", + "version": "1.9.0", "lockfileVersion": 1, "requires": true, "dependencies": { diff --git a/cvat-ui/package.json b/cvat-ui/package.json index 1065c3f82ef..e587666f44c 100644 --- a/cvat-ui/package.json +++ b/cvat-ui/package.json @@ -1,6 +1,6 @@ { "name": "cvat-ui", - "version": "1.8.4", + "version": "1.9.0", "description": "CVAT single-page application", "main": "src/index.tsx", "scripts": { diff --git a/cvat-ui/src/actions/auth-actions.ts b/cvat-ui/src/actions/auth-actions.ts index f12049fa93a..ad09019c2c6 100644 --- a/cvat-ui/src/actions/auth-actions.ts +++ b/cvat-ui/src/actions/auth-actions.ts @@ -25,6 +25,12 @@ export enum AuthActionTypes { CHANGE_PASSWORD_SUCCESS = 'CHANGE_PASSWORD_SUCCESS', CHANGE_PASSWORD_FAILED = 'CHANGE_PASSWORD_FAILED', SWITCH_CHANGE_PASSWORD_DIALOG = 'SWITCH_CHANGE_PASSWORD_DIALOG', + REQUEST_PASSWORD_RESET = 'REQUEST_PASSWORD_RESET', + REQUEST_PASSWORD_RESET_SUCCESS = 'REQUEST_PASSWORD_RESET_SUCCESS', + REQUEST_PASSWORD_RESET_FAILED = 'REQUEST_PASSWORD_RESET_FAILED', + RESET_PASSWORD = 'RESET_PASSWORD_CONFIRM', + RESET_PASSWORD_SUCCESS = 'RESET_PASSWORD_CONFIRM_SUCCESS', + RESET_PASSWORD_FAILED = 'RESET_PASSWORD_CONFIRM_FAILED', LOAD_AUTH_ACTIONS = 'LOAD_AUTH_ACTIONS', LOAD_AUTH_ACTIONS_SUCCESS = 'LOAD_AUTH_ACTIONS_SUCCESS', LOAD_AUTH_ACTIONS_FAILED = 'LOAD_AUTH_ACTIONS_FAILED', @@ -50,9 +56,22 @@ export const authActions = { switchChangePasswordDialog: (showChangePasswordDialog: boolean) => ( createAction(AuthActionTypes.SWITCH_CHANGE_PASSWORD_DIALOG, { showChangePasswordDialog }) ), + requestPasswordReset: () => createAction(AuthActionTypes.REQUEST_PASSWORD_RESET), + requestPasswordResetSuccess: () => createAction(AuthActionTypes.REQUEST_PASSWORD_RESET_SUCCESS), + requestPasswordResetFailed: (error: any) => ( + createAction(AuthActionTypes.REQUEST_PASSWORD_RESET_FAILED, { error }) + ), + resetPassword: () => createAction(AuthActionTypes.RESET_PASSWORD), + resetPasswordSuccess: () => createAction(AuthActionTypes.RESET_PASSWORD_SUCCESS), + resetPasswordFailed: (error: any) => ( + createAction(AuthActionTypes.RESET_PASSWORD_FAILED, { error }) + ), loadServerAuthActions: () => createAction(AuthActionTypes.LOAD_AUTH_ACTIONS), - loadServerAuthActionsSuccess: (allowChangePassword: boolean) => ( - createAction(AuthActionTypes.LOAD_AUTH_ACTIONS_SUCCESS, { allowChangePassword }) + loadServerAuthActionsSuccess: (allowChangePassword: boolean, allowResetPassword: boolean) => ( + createAction(AuthActionTypes.LOAD_AUTH_ACTIONS_SUCCESS, { + allowChangePassword, + allowResetPassword, + }) ), loadServerAuthActionsFailed: (error: any) => ( createAction(AuthActionTypes.LOAD_AUTH_ACTIONS_FAILED, { error }) @@ -135,16 +154,49 @@ export const changePasswordAsync = (oldPassword: string, } }; +export const requestPasswordResetAsync = (email: string): ThunkAction => async (dispatch) => { + dispatch(authActions.requestPasswordReset()); + + try { + await cvat.server.requestPasswordReset(email); + dispatch(authActions.requestPasswordResetSuccess()); + } catch (error) { + dispatch(authActions.requestPasswordResetFailed(error)); + } +}; + +export const resetPasswordAsync = ( + newPassword1: string, + newPassword2: string, + uid: string, + token: string, +): ThunkAction => async (dispatch) => { + dispatch(authActions.resetPassword()); + + try { + await cvat.server.resetPassword(newPassword1, newPassword2, uid, token); + dispatch(authActions.resetPasswordSuccess()); + } catch (error) { + dispatch(authActions.resetPasswordFailed(error)); + } +}; + export const loadAuthActionsAsync = (): ThunkAction => async (dispatch) => { dispatch(authActions.loadServerAuthActions()); try { const promises: Promise[] = [ isReachable(`${cvat.config.backendAPI}/auth/password/change`, 'OPTIONS'), + isReachable(`${cvat.config.backendAPI}/auth/password/reset`, 'OPTIONS'), ]; - const [allowChangePassword] = await Promise.all(promises); - - dispatch(authActions.loadServerAuthActionsSuccess(allowChangePassword)); + const [ + allowChangePassword, + allowResetPassword] = await Promise.all(promises); + + dispatch(authActions.loadServerAuthActionsSuccess( + allowChangePassword, + allowResetPassword, + )); } catch (error) { dispatch(authActions.loadServerAuthActionsFailed(error)); } diff --git a/cvat-ui/src/components/cvat-app.tsx b/cvat-ui/src/components/cvat-app.tsx index 5c1af2cf926..c32717c73df 100644 --- a/cvat-ui/src/components/cvat-app.tsx +++ b/cvat-ui/src/components/cvat-app.tsx @@ -23,6 +23,8 @@ import ModelsPageContainer from 'containers/models-page/models-page'; import AnnotationPageContainer from 'containers/annotation-page/annotation-page'; import LoginPageContainer from 'containers/login-page/login-page'; import RegisterPageContainer from 'containers/register-page/register-page'; +import ResetPasswordPageComponent from 'components/reset-password-page/reset-password-page'; +import ResetPasswordPageConfirmComponent from 'components/reset-password-confirm-page/reset-password-confirm-page'; import Header from 'components/header/header'; import { customWaViewHit } from 'utils/enviroment'; import showPlatformNotification, { stopNotifications, platformInfo } from 'utils/platform-checker'; @@ -61,7 +63,6 @@ interface CVATAppProps { userAgreementsInitialized: boolean; authActionsFetching: boolean; authActionsInitialized: boolean; - allowChangePassword: boolean; notifications: NotificationsState; user: any; } @@ -332,6 +333,8 @@ class CVATApplication extends React.PureComponent + + diff --git a/cvat-ui/src/components/login-page/login-page.tsx b/cvat-ui/src/components/login-page/login-page.tsx index 87cebb51446..23dff9e1e0f 100644 --- a/cvat-ui/src/components/login-page/login-page.tsx +++ b/cvat-ui/src/components/login-page/login-page.tsx @@ -14,6 +14,7 @@ import CookieDrawer from './cookie-policy-drawer'; interface LoginPageComponentProps { fetching: boolean; + renderResetPassword: boolean; onLogin: (username: string, password: string) => void; } @@ -29,6 +30,7 @@ function LoginPageComponent(props: LoginPageComponentProps & RouteComponentProps const { fetching, onLogin, + renderResetPassword, } = props; return ( @@ -50,6 +52,16 @@ function LoginPageComponent(props: LoginPageComponentProps & RouteComponentProps + { renderResetPassword + && ( + + + + Forgot your password? + + + + )} diff --git a/cvat-ui/src/components/reset-password-confirm-page/reset-password-confirm-form.tsx b/cvat-ui/src/components/reset-password-confirm-page/reset-password-confirm-form.tsx new file mode 100644 index 00000000000..38d4fdb06ee --- /dev/null +++ b/cvat-ui/src/components/reset-password-confirm-page/reset-password-confirm-form.tsx @@ -0,0 +1,156 @@ +// Copyright (C) 2020 Intel Corporation +// +// SPDX-License-Identifier: MIT + +import React from 'react'; +import { withRouter, RouteComponentProps } from 'react-router-dom'; +import Form, { FormComponentProps } from 'antd/lib/form/Form'; +import Button from 'antd/lib/button'; +import Icon from 'antd/lib/icon'; +import Input from 'antd/lib/input'; + +import patterns from 'utils/validation-patterns'; + +export interface ResetPasswordConfirmData { + newPassword1: string; + newPassword2: string; + uid: string; + token: string; +} + +type ResetPasswordConfirmFormProps = { + fetching: boolean; + onSubmit(resetPasswordConfirmData: ResetPasswordConfirmData): void; +} & FormComponentProps & RouteComponentProps; + +class ResetPasswordConfirmFormComponent extends React.PureComponent { + private validateConfirmation = (_: any, value: string, callback: Function): void => { + const { form } = this.props; + if (value && value !== form.getFieldValue('newPassword1')) { + callback('Passwords do not match!'); + } else { + callback(); + } + }; + + private validatePassword = (_: any, value: string, callback: Function): void => { + const { form } = this.props; + if (!patterns.validatePasswordLength.pattern.test(value)) { + callback(patterns.validatePasswordLength.message); + } + + if (!patterns.passwordContainsNumericCharacters.pattern.test(value)) { + callback(patterns.passwordContainsNumericCharacters.message); + } + + if (!patterns.passwordContainsUpperCaseCharacter.pattern.test(value)) { + callback(patterns.passwordContainsUpperCaseCharacter.message); + } + + if (!patterns.passwordContainsLowerCaseCharacter.pattern.test(value)) { + callback(patterns.passwordContainsLowerCaseCharacter.message); + } + + if (value) { + form.validateFields(['newPassword2'], { force: true }); + } + callback(); + }; + + private handleSubmit = (e: React.FormEvent): void => { + e.preventDefault(); + const { + form, + onSubmit, + location, + } = this.props; + + const params = new URLSearchParams(location.search); + const uid = params.get('uid'); + const token = params.get('token'); + + form.validateFields((error, values): void => { + if (!error) { + const validatedFields = { + ...values, + uid, + token, + }; + + onSubmit(validatedFields); + } + }); + }; + + private renderNewPasswordField(): JSX.Element { + const { form } = this.props; + + return ( + + {form.getFieldDecorator('newPassword1', { + rules: [{ + required: true, + message: 'Please input new password!', + }, { + validator: this.validatePassword, + }], + })(} + placeholder='New password' + />)} + + ); + } + + private renderNewPasswordConfirmationField(): JSX.Element { + const { form } = this.props; + + return ( + + {form.getFieldDecorator('newPassword2', { + rules: [{ + required: true, + message: 'Please confirm your new password!', + }, { + validator: this.validateConfirmation, + }], + })(} + placeholder='Confirm new password' + />)} + + ); + } + + public render(): JSX.Element { + const { fetching } = this.props; + + return ( +
+ {this.renderNewPasswordField()} + {this.renderNewPasswordConfirmationField()} + + + + +
+ ); + } +} + +export default withRouter( + Form.create()(ResetPasswordConfirmFormComponent), +); diff --git a/cvat-ui/src/components/reset-password-confirm-page/reset-password-confirm-page.tsx b/cvat-ui/src/components/reset-password-confirm-page/reset-password-confirm-page.tsx new file mode 100644 index 00000000000..50a4af5dcd4 --- /dev/null +++ b/cvat-ui/src/components/reset-password-confirm-page/reset-password-confirm-page.tsx @@ -0,0 +1,83 @@ +// Copyright (C) 2020 Intel Corporation +// +// SPDX-License-Identifier: MIT + +import React from 'react'; +import { Link } from 'react-router-dom'; +import { connect } from 'react-redux'; +import Title from 'antd/lib/typography/Title'; +import Text from 'antd/lib/typography/Text'; +import { Row, Col } from 'antd/lib/grid'; + +import { CombinedState } from 'reducers/interfaces'; +import { resetPasswordAsync } from 'actions/auth-actions'; + +import ResetPasswordConfirmForm, { ResetPasswordConfirmData } from './reset-password-confirm-form'; + +interface StateToProps { + fetching: boolean; +} + +interface DispatchToProps { + onResetPasswordConfirm: typeof resetPasswordAsync; +} + +interface ResetPasswordConfirmPageComponentProps { + fetching: boolean; + onResetPasswordConfirm: ( + newPassword1: string, + newPassword2: string, + uid: string, + token: string) => void; +} + +function mapStateToProps(state: CombinedState): StateToProps { + return { + fetching: state.auth.fetching, + }; +} + +const mapDispatchToProps: DispatchToProps = { + onResetPasswordConfirm: resetPasswordAsync, +}; + +function ResetPasswordPagePageComponent( + props: ResetPasswordConfirmPageComponentProps, +): JSX.Element { + const sizes = { + xs: { span: 14 }, + sm: { span: 14 }, + md: { span: 10 }, + lg: { span: 4 }, + xl: { span: 4 }, + }; + + const { + fetching, + onResetPasswordConfirm, + } = props; + + return ( + + + Change password + { + onResetPasswordConfirm( + resetPasswordConfirmData.newPassword1, + resetPasswordConfirmData.newPassword2, + resetPasswordConfirmData.uid, + resetPasswordConfirmData.token, + ); + }} + /> + + + ); +} + +export default connect( + mapStateToProps, + mapDispatchToProps, +)(ResetPasswordPagePageComponent); diff --git a/cvat-ui/src/components/reset-password-page/reset-password-form.tsx b/cvat-ui/src/components/reset-password-page/reset-password-form.tsx new file mode 100644 index 00000000000..9b0b1b3b0e8 --- /dev/null +++ b/cvat-ui/src/components/reset-password-page/reset-password-form.tsx @@ -0,0 +1,81 @@ +// Copyright (C) 2020 Intel Corporation +// +// SPDX-License-Identifier: MIT + +import React from 'react'; +import Form, { FormComponentProps } from 'antd/lib/form/Form'; +import Button from 'antd/lib/button'; +import Icon from 'antd/lib/icon'; +import Input from 'antd/lib/input'; + +export interface ResetPasswordData { + email: string; +} + +type ResetPasswordFormProps = { + fetching: boolean; + onSubmit(resetPasswordData: ResetPasswordData): void; +} & FormComponentProps; + +class ResetPasswordFormComponent extends React.PureComponent { + private handleSubmit = (e: React.FormEvent): void => { + e.preventDefault(); + const { + form, + onSubmit, + } = this.props; + + form.validateFields((error, values): void => { + if (!error) { + onSubmit(values); + } + }); + }; + + private renderEmailField(): JSX.Element { + const { form } = this.props; + + return ( + + {form.getFieldDecorator('email', { + rules: [{ + type: 'email', + message: 'The input is not valid E-mail!', + }, { + required: true, + message: 'Please specify an email address', + }], + })( + } + placeholder='Email address' + />, + )} + + ); + } + + public render(): JSX.Element { + const { fetching } = this.props; + return ( +
+ {this.renderEmailField()} + + + + +
+ ); + } +} + +export default Form.create()(ResetPasswordFormComponent); diff --git a/cvat-ui/src/components/reset-password-page/reset-password-page.tsx b/cvat-ui/src/components/reset-password-page/reset-password-page.tsx new file mode 100644 index 00000000000..0b7ebcca986 --- /dev/null +++ b/cvat-ui/src/components/reset-password-page/reset-password-page.tsx @@ -0,0 +1,79 @@ +// Copyright (C) 2020 Intel Corporation +// +// SPDX-License-Identifier: MIT + +import React from 'react'; +import { Link } from 'react-router-dom'; +import { connect } from 'react-redux'; +import Title from 'antd/lib/typography/Title'; +import Text from 'antd/lib/typography/Text'; +import { Row, Col } from 'antd/lib/grid'; + +import { requestPasswordResetAsync } from 'actions/auth-actions'; +import { CombinedState } from 'reducers/interfaces'; +import ResetPasswordForm, { ResetPasswordData } from './reset-password-form'; + +interface StateToProps { + fetching: boolean; +} + +interface DispatchToProps { + onResetPassword: typeof requestPasswordResetAsync; +} + +interface ResetPasswordPageComponentProps { + fetching: boolean; + onResetPassword: (email: string) => void; +} + +function mapStateToProps(state: CombinedState): StateToProps { + return { + fetching: state.auth.fetching, + }; +} + +const mapDispatchToProps: DispatchToProps = { + onResetPassword: requestPasswordResetAsync, +}; + +function ResetPasswordPagePageComponent(props: ResetPasswordPageComponentProps): JSX.Element { + const sizes = { + xs: { span: 14 }, + sm: { span: 14 }, + md: { span: 10 }, + lg: { span: 4 }, + xl: { span: 4 }, + }; + + const { + fetching, + onResetPassword, + } = props; + + return ( + + + Reset password + { + onResetPassword(resetPasswordData.email); + }} + /> + + + + Go to + login page + + + + + + ); +} + +export default connect( + mapStateToProps, + mapDispatchToProps, +)(ResetPasswordPagePageComponent); diff --git a/cvat-ui/src/containers/login-page/login-page.tsx b/cvat-ui/src/containers/login-page/login-page.tsx index fdf40ac7649..0605d44dc74 100644 --- a/cvat-ui/src/containers/login-page/login-page.tsx +++ b/cvat-ui/src/containers/login-page/login-page.tsx @@ -9,6 +9,7 @@ import { loginAsync } from 'actions/auth-actions'; interface StateToProps { fetching: boolean; + renderResetPassword: boolean; } interface DispatchToProps { @@ -18,6 +19,7 @@ interface DispatchToProps { function mapStateToProps(state: CombinedState): StateToProps { return { fetching: state.auth.fetching, + renderResetPassword: state.auth.allowResetPassword, }; } diff --git a/cvat-ui/src/index.tsx b/cvat-ui/src/index.tsx index f1f7f7f17e2..ce3355c455b 100644 --- a/cvat-ui/src/index.tsx +++ b/cvat-ui/src/index.tsx @@ -57,6 +57,7 @@ interface StateToProps { authActionsFetching: boolean; authActionsInitialized: boolean; allowChangePassword: boolean; + allowResetPassword: boolean; notifications: NotificationsState; user: any; keyMap: Record; @@ -105,6 +106,7 @@ function mapStateToProps(state: CombinedState): StateToProps { authActionsFetching: auth.authActionsFetching, authActionsInitialized: auth.authActionsInitialized, allowChangePassword: auth.allowChangePassword, + allowResetPassword: auth.allowResetPassword, notifications: state.notifications, user: auth.user, keyMap: shortcuts.keyMap, diff --git a/cvat-ui/src/reducers/auth-reducer.ts b/cvat-ui/src/reducers/auth-reducer.ts index d433e7d58bd..da424cca88f 100644 --- a/cvat-ui/src/reducers/auth-reducer.ts +++ b/cvat-ui/src/reducers/auth-reducer.ts @@ -14,6 +14,7 @@ const defaultState: AuthState = { authActionsInitialized: false, allowChangePassword: false, showChangePasswordDialog: false, + allowResetPassword: false, }; export default function (state = defaultState, action: AuthActions | boundariesActions): AuthState { @@ -83,7 +84,6 @@ export default function (state = defaultState, action: AuthActions | boundariesA ...state, fetching: false, showChangePasswordDialog: false, - }; case AuthActionTypes.CHANGE_PASSWORD_FAILED: return { @@ -97,6 +97,36 @@ export default function (state = defaultState, action: AuthActions | boundariesA ? !state.showChangePasswordDialog : action.payload.showChangePasswordDialog, }; + case AuthActionTypes.REQUEST_PASSWORD_RESET: + return { + ...state, + fetching: true, + }; + case AuthActionTypes.REQUEST_PASSWORD_RESET_SUCCESS: + return { + ...state, + fetching: false, + }; + case AuthActionTypes.REQUEST_PASSWORD_RESET_FAILED: + return { + ...state, + fetching: false, + }; + case AuthActionTypes.RESET_PASSWORD: + return { + ...state, + fetching: true, + }; + case AuthActionTypes.RESET_PASSWORD_SUCCESS: + return { + ...state, + fetching: false, + }; + case AuthActionTypes.RESET_PASSWORD_FAILED: + return { + ...state, + fetching: false, + }; case AuthActionTypes.LOAD_AUTH_ACTIONS: return { ...state, @@ -108,6 +138,7 @@ export default function (state = defaultState, action: AuthActions | boundariesA authActionsFetching: false, authActionsInitialized: true, allowChangePassword: action.payload.allowChangePassword, + allowResetPassword: action.payload.allowResetPassword, }; case AuthActionTypes.LOAD_AUTH_ACTIONS_FAILED: return { @@ -115,6 +146,7 @@ export default function (state = defaultState, action: AuthActions | boundariesA authActionsFetching: false, authActionsInitialized: true, allowChangePassword: false, + allowResetPassword: false, }; case BoundariesActionTypes.RESET_AFTER_ERROR: { return { ...defaultState }; diff --git a/cvat-ui/src/reducers/interfaces.ts b/cvat-ui/src/reducers/interfaces.ts index 9a48b52e5f7..79f5531afae 100644 --- a/cvat-ui/src/reducers/interfaces.ts +++ b/cvat-ui/src/reducers/interfaces.ts @@ -17,6 +17,7 @@ export interface AuthState { authActionsInitialized: boolean; showChangePasswordDialog: boolean; allowChangePassword: boolean; + allowResetPassword: boolean; } export interface TasksQuery { @@ -184,6 +185,8 @@ export interface NotificationsState { logout: null | ErrorState; register: null | ErrorState; changePassword: null | ErrorState; + requestPasswordReset: null | ErrorState; + resetPassword: null | ErrorState; loadAuthActions: null | ErrorState; }; tasks: { @@ -253,6 +256,8 @@ export interface NotificationsState { auth: { changePasswordDone: string; registerDone: string; + requestPasswordResetDone: string; + resetPasswordDone: string; }; }; } diff --git a/cvat-ui/src/reducers/notifications-reducer.ts b/cvat-ui/src/reducers/notifications-reducer.ts index e87d22b89af..96863e6bc3f 100644 --- a/cvat-ui/src/reducers/notifications-reducer.ts +++ b/cvat-ui/src/reducers/notifications-reducer.ts @@ -27,6 +27,8 @@ const defaultState: NotificationsState = { logout: null, register: null, changePassword: null, + requestPasswordReset: null, + resetPassword: null, loadAuthActions: null, }, tasks: { @@ -96,6 +98,8 @@ const defaultState: NotificationsState = { auth: { changePasswordDone: '', registerDone: '', + requestPasswordResetDone: '', + resetPasswordDone: '', }, }, }; @@ -208,6 +212,61 @@ export default function (state = defaultState, action: AnyAction): Notifications }, }; } + case AuthActionTypes.REQUEST_PASSWORD_RESET_SUCCESS: { + return { + ...state, + messages: { + ...state.messages, + auth: { + ...state.messages.auth, + requestPasswordResetDone: `Check your email for a link to reset your password. + If it doesn’t appear within a few minutes, check your spam folder.`, + }, + }, + }; + } + case AuthActionTypes.REQUEST_PASSWORD_RESET_FAILED: { + return { + ...state, + errors: { + ...state.errors, + auth: { + ...state.errors.auth, + requestPasswordReset: { + message: 'Could not reset password on the server.', + reason: action.payload.error.toString(), + }, + }, + }, + }; + } + case AuthActionTypes.RESET_PASSWORD_SUCCESS: { + return { + ...state, + messages: { + ...state.messages, + auth: { + ...state.messages.auth, + resetPasswordDone: 'Password has been reset with the new password.', + }, + }, + }; + } + case AuthActionTypes.RESET_PASSWORD_FAILED: { + return { + ...state, + errors: { + ...state.errors, + auth: { + ...state.errors.auth, + resetPassword: { + message: 'Could not set new password on the server.', + reason: action.payload.error.toString(), + }, + }, + }, + }; + } case AuthActionTypes.LOAD_AUTH_ACTIONS_FAILED: { return { ...state, diff --git a/cvat/apps/authentication/serializers.py b/cvat/apps/authentication/serializers.py index 1269b8fcb72..e04ca18ba2b 100644 --- a/cvat/apps/authentication/serializers.py +++ b/cvat/apps/authentication/serializers.py @@ -1,16 +1,31 @@ from rest_auth.registration.serializers import RegisterSerializer +from rest_auth.serializers import PasswordResetSerializer from rest_framework import serializers +from django.conf import settings + class RegisterSerializerEx(RegisterSerializer): - first_name = serializers.CharField(required=False) - last_name = serializers.CharField(required=False) + first_name = serializers.CharField(required=False) + last_name = serializers.CharField(required=False) + + def get_cleaned_data(self): + data = super().get_cleaned_data() + data.update({ + 'first_name': self.validated_data.get('first_name', ''), + 'last_name': self.validated_data.get('last_name', ''), + }) - def get_cleaned_data(self): - data = super().get_cleaned_data() - data.update({ - 'first_name': self.validated_data.get('first_name', ''), - 'last_name': self.validated_data.get('last_name', ''), - }) + return data - return data +class PasswordResetSerializerEx(PasswordResetSerializer): + def get_email_options(self): + domain = None + if hasattr(settings, 'UI_HOST') and settings.UI_HOST: + domain = settings.UI_HOST + if hasattr(settings, 'UI_PORT') and settings.UI_PORT: + domain += ':{}'.format(settings.UI_PORT) + return { + 'email_template_name': 'authentication/password_reset_email.html', + 'domain_override': domain + } diff --git a/cvat/apps/authentication/templates/authentication/password_reset_email.html b/cvat/apps/authentication/templates/authentication/password_reset_email.html new file mode 100644 index 00000000000..542e266a077 --- /dev/null +++ b/cvat/apps/authentication/templates/authentication/password_reset_email.html @@ -0,0 +1,14 @@ +{% load i18n %}{% autoescape off %} +{% blocktrans %}You're receiving this email because you requested a password reset for your user account at {{ site_name }}.{% endblocktrans %} + +{% trans "Please go to the following page and choose a new password:" %} +{% block reset_link %} +{{ protocol }}://{{ domain }}/auth/password/reset/confirm?uid={{ uid }}&token={{ token }} +{% endblock %} +{% trans "Your username, in case you've forgotten:" %} {{ user.get_username }} + +{% trans "Thanks for using our site!" %} + +{% blocktrans %}The {{ site_name }} team{% endblocktrans %} + +{% endautoescape %} diff --git a/cvat/settings/base.py b/cvat/settings/base.py index 6a9a7a6a882..37d26546968 100644 --- a/cvat/settings/base.py +++ b/cvat/settings/base.py @@ -154,7 +154,11 @@ def generate_ssh_keys(): } REST_AUTH_REGISTER_SERIALIZERS = { - 'REGISTER_SERIALIZER': 'cvat.apps.restrictions.serializers.RestrictedRegisterSerializer' + 'REGISTER_SERIALIZER': 'cvat.apps.restrictions.serializers.RestrictedRegisterSerializer', +} + +REST_AUTH_SERIALIZERS = { + 'PASSWORD_RESET_SERIALIZER': 'cvat.apps.authentication.serializers.PasswordResetSerializerEx', } if os.getenv('DJANGO_LOG_VIEWER_HOST'):