1
0
Fork 0
mirror of https://github.com/plankanban/planka.git synced 2025-07-19 05:09:43 +02:00

Add email and password change functionality for a current user, remove deep compare hooks

This commit is contained in:
Maksim Eltyshev 2019-10-18 08:06:34 +05:00
parent e564729598
commit 2566ff376e
67 changed files with 1232 additions and 267 deletions

View file

@ -7,8 +7,8 @@ export const authenticate = (data) => ({
},
});
export const clearAuthenticationError = () => ({
type: EntryActionTypes.AUTHENTICATION_ERROR_CLEAR,
export const clearAuthenticateError = () => ({
type: EntryActionTypes.AUTHENTICATE_ERROR_CLEAR,
payload: {},
});

View file

@ -7,8 +7,8 @@ export const createUser = (data) => ({
},
});
export const clearUserCreationError = () => ({
type: EntryActionTypes.USER_CREATION_ERROR_CLEAR,
export const clearUserCreateError = () => ({
type: EntryActionTypes.USER_CREATE_ERROR_CLEAR,
payload: {},
});
@ -27,6 +27,30 @@ export const updateCurrentUser = (data) => ({
},
});
export const updateCurrentUserEmail = (data) => ({
type: EntryActionTypes.CURRENT_USER_EMAIL_UPDATE,
payload: {
data,
},
});
export const clearCurrentUserEmailUpdateError = () => ({
type: EntryActionTypes.CURRENT_USER_EMAIL_UPDATE_ERROR_CLEAR,
payload: {},
});
export const updateCurrentUserPassword = (data) => ({
type: EntryActionTypes.CURRENT_USER_PASSWORD_UPDATE,
payload: {
data,
},
});
export const clearCurrentUserPasswordUpdateError = () => ({
type: EntryActionTypes.CURRENT_USER_PASSWORD_UPDATE_ERROR_CLEAR,
payload: {},
});
export const uploadCurrentUserAvatar = (file) => ({
type: EntryActionTypes.CURRENT_USER_AVATAR_UPLOAD,
payload: {

View file

@ -9,8 +9,8 @@ export const authenticate = (data) => ({
},
});
export const clearAuthenticationError = () => ({
type: ActionTypes.AUTHENTICATION_ERROR_CLEAR,
export const clearAuthenticateError = () => ({
type: ActionTypes.AUTHENTICATE_ERROR_CLEAR,
payload: {},
});

View file

@ -9,8 +9,8 @@ export const createUser = (data) => ({
},
});
export const clearUserCreationError = () => ({
type: ActionTypes.USER_CREATION_ERROR_CLEAR,
export const clearUserCreateError = () => ({
type: ActionTypes.USER_CREATE_ERROR_CLEAR,
payload: {},
});
@ -22,6 +22,20 @@ export const updateUser = (id, data) => ({
},
});
export const clearUserEmailUpdateError = (id) => ({
type: ActionTypes.USER_EMAIL_UPDATE_ERROR_CLEAR,
payload: {
id,
},
});
export const clearUserPasswordUpdateError = (id) => ({
type: ActionTypes.USER_PASSWORD_UPDATE_ERROR_CLEAR,
payload: {
id,
},
});
export const deleteUser = (id) => ({
type: ActionTypes.USER_DELETE,
payload: {
@ -141,6 +155,53 @@ export const updateUserReceived = (user) => ({
},
});
export const updateUserEmailRequested = (id, data) => ({
type: ActionTypes.USER_EMAIL_UPDATE_REQUESTED,
payload: {
id,
data,
},
});
export const updateUserEmailSucceeded = (id, email) => ({
type: ActionTypes.USER_EMAIL_UPDATE_SUCCEEDED,
payload: {
id,
email,
},
});
export const updateUserEmailFailed = (id, error) => ({
type: ActionTypes.USER_EMAIL_UPDATE_FAILED,
payload: {
id,
error,
},
});
export const updateUserPasswordRequested = (id, data) => ({
type: ActionTypes.USER_PASSWORD_UPDATE_REQUESTED,
payload: {
id,
data,
},
});
export const updateUserPasswordSucceeded = (id) => ({
type: ActionTypes.USER_PASSWORD_UPDATE_SUCCEEDED,
payload: {
id,
},
});
export const updateUserPasswordFailed = (id, error) => ({
type: ActionTypes.USER_PASSWORD_UPDATE_FAILED,
payload: {
id,
error,
},
});
export const uploadUserAvatarRequested = (id) => ({
type: ActionTypes.USER_AVATAR_UPLOAD_REQUESTED,
payload: {
@ -148,10 +209,11 @@ export const uploadUserAvatarRequested = (id) => ({
},
});
export const uploadUserAvatarSucceeded = (user) => ({
export const uploadUserAvatarSucceeded = (id, avatar) => ({
type: ActionTypes.USER_AVATAR_UPLOAD_SUCCEEDED,
payload: {
user,
id,
avatar,
},
});

View file

@ -11,6 +11,10 @@ const getCurrentUser = (headers) => socket.get('/users/me', undefined, headers);
const updateUser = (id, data, headers) => socket.patch(`/users/${id}`, data, headers);
const updateUserEmail = (id, data, headers) => socket.patch(`/users/${id}/email`, data, headers);
const updateUserPassword = (id, data, headers) => socket.patch(`/users/${id}/password`, data, headers);
const uploadUserAvatar = (id, file, headers) => http.post(
`/users/${id}/upload-avatar`,
{
@ -26,6 +30,8 @@ export default {
createUser,
getCurrentUser,
updateUser,
updateUserEmail,
updateUserPassword,
uploadUserAvatar,
deleteUser,
};

View file

@ -1,4 +1,4 @@
import React, { useEffect, useRef } from 'react';
import React, { useCallback, useEffect, useRef } from 'react';
import PropTypes from 'prop-types';
import { useTranslation } from 'react-i18next';
import {
@ -6,7 +6,7 @@ import {
} from 'semantic-ui-react';
import { Input } from '../../lib/custom-ui';
import { useDeepCompareCallback, useForm } from '../../hooks';
import { useForm } from '../../hooks';
import styles from './AddProjectModal.module.css';
@ -22,7 +22,7 @@ const AddProjectModal = React.memo(({
const nameField = useRef(null);
const handleSubmit = useDeepCompareCallback(() => {
const handleSubmit = useCallback(() => {
const cleanData = {
...data,
name: data.name.trim(),

View file

@ -1,17 +1,35 @@
import isEmail from 'validator/lib/isEmail';
import React, { useEffect, useRef } from 'react';
import React, {
useCallback, useEffect, useMemo, useRef,
} from 'react';
import PropTypes from 'prop-types';
import { useTranslation } from 'react-i18next';
import { Button, Form, Message } from 'semantic-ui-react';
import { withPopup } from '../../lib/popup';
import { Input, Popup } from '../../lib/custom-ui';
import {
useDeepCompareCallback, useDeepCompareEffect, useForm, usePrevious,
} from '../../hooks';
import { useForm, usePrevious } from '../../hooks';
import styles from './AddUserPopup.module.css';
const createMessage = (error) => {
if (!error) {
return error;
}
if (error.message === 'User is already exist') {
return {
type: 'error',
content: 'common.userIsAlreadyExist',
};
}
return {
type: 'warning',
content: 'common.unknownError',
};
};
const AddUserPopup = React.memo(
({
defaultData, isSubmitting, error, onCreate, onMessageDismiss, onClose,
@ -26,11 +44,13 @@ const AddUserPopup = React.memo(
...defaultData,
}));
const message = useMemo(() => createMessage(error), [error]);
const emailField = useRef(null);
const passwordField = useRef(null);
const nameField = useRef(null);
const handleSubmit = useDeepCompareCallback(() => {
const handleSubmit = useCallback(() => {
const cleanData = {
...data,
email: data.email.trim(),
@ -59,11 +79,11 @@ const AddUserPopup = React.memo(
emailField.current.select();
}, []);
useDeepCompareEffect(() => {
useEffect(() => {
if (wasSubmitting && !isSubmitting) {
if (!error) {
onClose();
} else if (error.message === 'userIsAlreadyExist') {
} else if (error.message === 'User is already exist') {
emailField.current.select();
}
}
@ -77,14 +97,14 @@ const AddUserPopup = React.memo(
})}
</Popup.Header>
<Popup.Content>
{error && (
{message && (
<Message
// eslint-disable-next-line react/jsx-props-no-spreading
{...{
[error.type || 'error']: true,
[message.type]: true,
}}
visible
content={t(`common.${error.message}`)}
content={t(message.content)}
onDismiss={onMessageDismiss}
/>
)}

View file

@ -6,11 +6,7 @@ import { useTranslation } from 'react-i18next';
import { Button, Form, Input } from 'semantic-ui-react';
import {
useClosableForm,
useDeepCompareCallback,
useDidUpdate,
useForm,
useToggle,
useClosableForm, useDidUpdate, useForm, useToggle,
} from '../../hooks';
import styles from './AddList.module.css';
@ -62,7 +58,7 @@ const AddList = React.forwardRef(({ children, onCreate }, ref) => {
close,
);
const handleSubmit = useDeepCompareCallback(() => {
const handleSubmit = useCallback(() => {
const cleanData = {
...data,
name: data.name.trim(),

View file

@ -1,11 +1,11 @@
import React, { useEffect, useRef } from 'react';
import React, { useCallback, useEffect, useRef } from 'react';
import PropTypes from 'prop-types';
import { useTranslation } from 'react-i18next';
import { Button, Form } from 'semantic-ui-react';
import { withPopup } from '../../lib/popup';
import { Input, Popup } from '../../lib/custom-ui';
import { useDeepCompareCallback, useForm } from '../../hooks';
import { useForm } from '../../hooks';
import styles from './AddPopup.module.css';
@ -18,7 +18,7 @@ const AddStep = React.memo(({ onCreate, onClose }) => {
const nameField = useRef(null);
const handleSubmit = useDeepCompareCallback(() => {
const handleSubmit = useCallback(() => {
const cleanData = {
...data,
name: data.name.trim(),

View file

@ -6,7 +6,7 @@ import { Button, Form } from 'semantic-ui-react';
import { withPopup } from '../../lib/popup';
import { Input, Popup } from '../../lib/custom-ui';
import { useDeepCompareCallback, useForm, useSteps } from '../../hooks';
import { useForm, useSteps } from '../../hooks';
import DeleteStep from '../DeleteStep';
import styles from './EditPopup.module.css';
@ -29,7 +29,7 @@ const EditStep = React.memo(({
const nameField = useRef(null);
const handleSubmit = useDeepCompareCallback(() => {
const handleSubmit = useCallback(() => {
const cleanData = {
...data,
name: data.name.trim(),

View file

@ -4,7 +4,7 @@ import { useTranslation } from 'react-i18next';
import TextareaAutosize from 'react-textarea-autosize';
import { Button, Form, TextArea } from 'semantic-ui-react';
import { useDeepCompareCallback, useForm } from '../../../hooks';
import { useForm } from '../../../hooks';
import styles from './AddComment.module.css';
@ -18,7 +18,7 @@ const AddComment = React.memo(({ onCreate }) => {
const textField = useRef(null);
const submit = useDeepCompareCallback(() => {
const submit = useCallback(() => {
const cleanData = {
...data,
text: data.text.trim(),

View file

@ -7,7 +7,7 @@ import { useTranslation } from 'react-i18next';
import TextareaAutosize from 'react-textarea-autosize';
import { Button, Form, TextArea } from 'semantic-ui-react';
import { useClosableForm, useDeepCompareCallback, useForm } from '../../../hooks';
import { useClosableForm, useForm } from '../../../hooks';
import styles from './EditComment.module.css';
@ -18,7 +18,7 @@ const EditComment = React.forwardRef(({ children, defaultData, onUpdate }, ref)
const textField = useRef(null);
const open = useDeepCompareCallback(() => {
const open = useCallback(() => {
setIsOpened(true);
setData({
text: '',
@ -31,7 +31,7 @@ const EditComment = React.forwardRef(({ children, defaultData, onUpdate }, ref)
setData(null);
}, [setData]);
const submit = useDeepCompareCallback(() => {
const submit = useCallback(() => {
const cleanData = {
...data,
text: data.text.trim(),

View file

@ -7,11 +7,7 @@ import TextareaAutosize from 'react-textarea-autosize';
import { Button, Form, TextArea } from 'semantic-ui-react';
import {
useClosableForm,
useDeepCompareCallback,
useDidUpdate,
useForm,
useToggle,
useClosableForm, useDidUpdate, useForm, useToggle,
} from '../../../hooks';
import styles from './Add.module.css';
@ -36,7 +32,7 @@ const Add = React.forwardRef(({ children, onCreate }, ref) => {
setIsOpened(false);
}, []);
const submit = useDeepCompareCallback(() => {
const submit = useCallback(() => {
const cleanData = {
...data,
name: data.name.trim(),

View file

@ -7,9 +7,7 @@ import DatePicker from 'react-datepicker';
import { Button, Form } from 'semantic-ui-react';
import { Input, Popup } from '../../lib/custom-ui';
import {
useDeepCompareCallback, useDidUpdate, useForm, useToggle,
} from '../../hooks';
import { useDidUpdate, useForm, useToggle } from '../../hooks';
import styles from './EditDueDateStep.module.css';
@ -65,7 +63,7 @@ const EditDueDateStep = React.memo(({
[setData, selectTimeField, t],
);
const handleSubmit = useDeepCompareCallback(() => {
const handleSubmit = useCallback(() => {
if (!nullableDate) {
dateField.current.select();
return;
@ -86,9 +84,9 @@ const EditDueDateStep = React.memo(({
}
onClose();
}, [defaultValue, onUpdate, onClose, data, nullableDate]);
}, [defaultValue, onUpdate, onClose, data, nullableDate, t]);
const handleClearClick = useDeepCompareCallback(() => {
const handleClearClick = useCallback(() => {
if (defaultValue) {
onUpdate(null);
}

View file

@ -1,11 +1,11 @@
import dequal from 'dequal';
import React, { useEffect, useRef } from 'react';
import React, { useCallback, useEffect, useRef } from 'react';
import PropTypes from 'prop-types';
import { useTranslation } from 'react-i18next';
import { Button, Form } from 'semantic-ui-react';
import { Input, Popup } from '../../lib/custom-ui';
import { useDeepCompareCallback, useForm, useToggle } from '../../hooks';
import { useForm, useToggle } from '../../hooks';
import {
createTimer, getTimerParts, startTimer, stopTimer, updateTimer,
} from '../../utils/timer';
@ -41,16 +41,16 @@ const EditTimerStep = React.memo(({
const minutesField = useRef(null);
const secondsField = useRef(null);
const handleStartClick = useDeepCompareCallback(() => {
const handleStartClick = useCallback(() => {
onUpdate(startTimer(defaultValue));
onClose();
}, [defaultValue, onUpdate, onClose]);
const handleStopClick = useDeepCompareCallback(() => {
const handleStopClick = useCallback(() => {
onUpdate(stopTimer(defaultValue));
}, [defaultValue, onUpdate]);
const handleClearClick = useDeepCompareCallback(() => {
const handleClearClick = useCallback(() => {
if (defaultValue) {
onUpdate(null);
}
@ -58,12 +58,12 @@ const EditTimerStep = React.memo(({
onClose();
}, [defaultValue, onUpdate, onClose]);
const handleToggleEditClick = useDeepCompareCallback(() => {
const handleToggleEditClick = useCallback(() => {
setData(createData(defaultValue));
toggleEdit();
}, [defaultValue, setData, toggleEdit]);
const handleSubmit = useDeepCompareCallback(() => {
const handleSubmit = useCallback(() => {
const parts = {
hours: parseInt(data.hours, 10),
minutes: parseInt(data.minutes, 10),

View file

@ -5,7 +5,7 @@ import { Icon, Menu } from 'semantic-ui-react';
import Paths from '../../constants/Paths';
import NotificationsPopup from './NotificationsPopup';
import UserPopup from './UserPopup';
import UserPopup from '../UserPopup';
import styles from './Header.module.css';
@ -14,10 +14,14 @@ const Header = React.memo(
user,
notifications,
isEditable,
onUsers,
onNotificationDelete,
onUserUpdate,
onUserAvatarUpload,
onNotificationDelete,
onUsers,
onUserEmailUpdate,
onUserEmailUpdateMessageDismiss,
onUserPasswordUpdate,
onUserPasswordUpdateMessageDismiss,
onLogout,
}) => (
<div className={styles.wrapper}>
@ -40,11 +44,18 @@ const Header = React.memo(
</Menu.Item>
</NotificationsPopup>
<UserPopup
email={user.email}
name={user.name}
avatar={user.avatar}
isAvatarUploading={user.isAvatarUploading}
emailUpdateForm={user.emailUpdateForm}
passwordUpdateForm={user.passwordUpdateForm}
onUpdate={onUserUpdate}
onAvatarUpload={onUserAvatarUpload}
onEmailUpdate={onUserEmailUpdate}
onEmailUpdateMessageDismiss={onUserEmailUpdateMessageDismiss}
onPasswordUpdate={onUserPasswordUpdate}
onPasswordUpdateMessageDismiss={onUserPasswordUpdateMessageDismiss}
onLogout={onLogout}
>
<Menu.Item className={styles.item}>{user.name}</Menu.Item>
@ -61,10 +72,14 @@ Header.propTypes = {
notifications: PropTypes.array.isRequired,
/* eslint-enable react/forbid-prop-types */
isEditable: PropTypes.bool.isRequired,
onUsers: PropTypes.func.isRequired,
onNotificationDelete: PropTypes.func.isRequired,
onUserUpdate: PropTypes.func.isRequired,
onUserAvatarUpload: PropTypes.func.isRequired,
onNotificationDelete: PropTypes.func.isRequired,
onUsers: PropTypes.func.isRequired,
onUserEmailUpdate: PropTypes.func.isRequired,
onUserEmailUpdateMessageDismiss: PropTypes.func.isRequired,
onUserPasswordUpdate: PropTypes.func.isRequired,
onUserPasswordUpdateMessageDismiss: PropTypes.func.isRequired,
onLogout: PropTypes.func.isRequired,
};

View file

@ -1,10 +1,10 @@
import React from 'react';
import React, { useCallback } from 'react';
import PropTypes from 'prop-types';
import { useTranslation } from 'react-i18next';
import { Button, Form } from 'semantic-ui-react';
import { Popup } from '../../lib/custom-ui';
import { useDeepCompareCallback, useForm } from '../../hooks';
import { useForm } from '../../hooks';
import LabelColors from '../../constants/LabelColors';
import Editor from './Editor';
@ -18,7 +18,7 @@ const AddStep = React.memo(({ onCreate, onBack }) => {
color: LabelColors.KEYS[0],
}));
const handleSubmit = useDeepCompareCallback(() => {
const handleSubmit = useCallback(() => {
const cleanData = {
...data,
name: data.name.trim() || null,

View file

@ -5,7 +5,7 @@ import { useTranslation } from 'react-i18next';
import { Button, Form } from 'semantic-ui-react';
import { Popup } from '../../lib/custom-ui';
import { useDeepCompareCallback, useForm, useSteps } from '../../hooks';
import { useForm, useSteps } from '../../hooks';
import LabelColors from '../../constants/LabelColors';
import Editor from './Editor';
import DeleteStep from '../DeleteStep';
@ -29,7 +29,7 @@ const EditStep = React.memo(({
const [step, openStep, handleBack] = useSteps();
const handleSubmit = useDeepCompareCallback(() => {
const handleSubmit = useCallback(() => {
const cleanData = {
...data,
name: data.name.trim() || null,

View file

@ -7,11 +7,7 @@ import TextareaAutosize from 'react-textarea-autosize';
import { Button, Form, TextArea } from 'semantic-ui-react';
import {
useClosableForm,
useDeepCompareCallback,
useDidUpdate,
useForm,
useToggle,
useClosableForm, useDidUpdate, useForm, useToggle,
} from '../../hooks';
import styles from './AddCard.module.css';
@ -36,7 +32,7 @@ const AddCard = React.forwardRef(({ children, onCreate }, ref) => {
setIsOpened(false);
}, []);
const submit = useDeepCompareCallback(() => {
const submit = useCallback(() => {
const cleanData = {
...data,
name: data.name.trim(),

View file

@ -1,4 +1,6 @@
import React, { useEffect, useRef } from 'react';
import React, {
useCallback, useEffect, useMemo, useRef,
} from 'react';
import PropTypes from 'prop-types';
import classNames from 'classnames';
import { useTranslation } from 'react-i18next';
@ -9,16 +11,45 @@ import {
import { Input } from '../../lib/custom-ui';
import {
useDeepCompareCallback,
useDeepCompareEffect,
useDidUpdate,
useForm,
usePrevious,
useToggle,
useDidUpdate, useForm, usePrevious, useToggle,
} from '../../hooks';
import styles from './Login.module.css';
const createMessage = (error) => {
if (!error) {
return error;
}
switch (error.message) {
case 'Email does not exist':
return {
type: 'error',
content: 'common.emailDoesNotExist',
};
case 'Password is not valid':
return {
type: 'error',
content: 'common.invalidPassword',
};
case 'Failed to fetch':
return {
type: 'warning',
content: 'common.noInternetConnection',
};
case 'Network request failed':
return {
type: 'warning',
content: 'common.serverConnectionFailed',
};
default:
return {
type: 'warning',
content: 'common.unknownError',
};
}
};
const Login = React.memo(
({
defaultData, isSubmitting, error, onAuthenticate, onMessageDismiss,
@ -32,12 +63,13 @@ const Login = React.memo(
...defaultData,
}));
const message = useMemo(() => createMessage(error), [error]);
const [focusPasswordFieldState, focusPasswordField] = useToggle();
const emailField = useRef(null);
const passwordField = useRef(null);
const handleSubmit = useDeepCompareCallback(() => {
const handleSubmit = useCallback(() => {
const cleanData = {
...data,
email: data.email.trim(),
@ -60,14 +92,14 @@ const Login = React.memo(
emailField.current.select();
}, []);
useDeepCompareEffect(() => {
useEffect(() => {
if (wasSubmitting && !isSubmitting && error) {
switch (error.message) {
case 'emailDoesNotExist':
case 'Email does not exist':
emailField.current.select();
break;
case 'invalidPassword':
case 'Password is not valid':
setData((prevData) => ({
...prevData,
password: '',
@ -98,14 +130,14 @@ const Login = React.memo(
className={styles.formTitle}
/>
<div>
{error && (
{message && (
<Message
// eslint-disable-next-line react/jsx-props-no-spreading
{...{
[error.type || 'error']: true,
[message.type]: true,
}}
visible
content={t(`common.${error.message}`)}
content={t(message.content)}
onDismiss={onMessageDismiss}
/>
)}

View file

@ -6,7 +6,7 @@ import { Button, Form } from 'semantic-ui-react';
import { withPopup } from '../../lib/popup';
import { Input, Popup } from '../../lib/custom-ui';
import { useDeepCompareCallback, useForm, useSteps } from '../../hooks';
import { useForm, useSteps } from '../../hooks';
import DeleteStep from '../DeleteStep';
import styles from './EditPopup.module.css';
@ -29,7 +29,7 @@ const EditStep = React.memo(({
const nameField = useRef(null);
const handleSubmit = useDeepCompareCallback(() => {
const handleSubmit = useCallback(() => {
const cleanData = {
...data,
name: data.name.trim(),

View file

@ -2,7 +2,7 @@ import React, { useCallback, useEffect, useRef } from 'react';
import PropTypes from 'prop-types';
import classNames from 'classnames';
import { useDeepCompareEffect, useForceUpdate, usePrevious } from '../../hooks';
import { useForceUpdate, usePrevious } from '../../hooks';
import { formatTimer } from '../../utils/timer';
import styles from './Timer.module.css';
@ -50,7 +50,7 @@ const Timer = React.memo(({
clearInterval(interval.current);
}, []);
useDeepCompareEffect(() => {
useEffect(() => {
if (prevStartedAt) {
if (!startedAt) {
stop();

View file

@ -2,9 +2,9 @@ import React, { useCallback, useEffect, useRef } from 'react';
import PropTypes from 'prop-types';
import { useTranslation } from 'react-i18next';
import { Button } from 'semantic-ui-react';
import { Popup } from '../../../lib/custom-ui';
import { Popup } from '../../lib/custom-ui';
import User from '../../User';
import User from '../User';
import styles from './EditAvatarStep.module.css';

View file

@ -0,0 +1,185 @@
import isEmail from 'validator/lib/isEmail';
import React, {
useCallback, useEffect, useMemo, useRef,
} from 'react';
import PropTypes from 'prop-types';
import { useTranslation } from 'react-i18next';
import { Button, Form, Message } from 'semantic-ui-react';
import { Input, Popup } from '../../lib/custom-ui';
import {
useDidUpdate, useForm, usePrevious, useToggle,
} from '../../hooks';
import styles from './EditNameStep.module.css';
const createMessage = (error) => {
if (!error) {
return error;
}
switch (error.message) {
case 'User is already exist':
return {
type: 'error',
content: 'common.userIsAlreadyExist',
};
case 'Current password is not valid':
return {
type: 'error',
content: 'common.invalidCurrentPassword',
};
default:
return {
type: 'warning',
content: 'common.unknownError',
};
}
};
const EditEmailStep = React.memo(({
defaultData, email, isSubmitting, error, onUpdate, onMessageDismiss, onBack, onClose,
}) => {
const [t] = useTranslation();
const wasSubmitting = usePrevious(isSubmitting);
const [data, handleFieldChange, setData] = useForm({
email: '',
currentPassword: '',
...defaultData,
});
const message = useMemo(() => createMessage(error), [error]);
const [focusCurrentPasswordFieldState, focusCurrentPasswordField] = useToggle();
const emailField = useRef(null);
const currentPasswordField = useRef(null);
const handleSubmit = useCallback(() => {
const cleanData = {
...data,
email: data.email.trim(),
};
if (!isEmail(cleanData.email)) {
emailField.current.select();
return;
}
if (cleanData.email === email) {
onClose();
return;
}
if (!cleanData.currentPassword) {
currentPasswordField.current.focus();
return;
}
onUpdate(cleanData);
}, [email, onUpdate, onClose, data]);
useEffect(() => {
emailField.current.select();
}, []);
useEffect(() => {
if (wasSubmitting && !isSubmitting) {
if (error) {
switch (error.message) {
case 'User is already exist':
emailField.current.select();
break;
case 'Current password is not valid':
setData((prevData) => ({
...prevData,
currentPassword: '',
}));
focusCurrentPasswordField();
break;
default:
}
} else {
onClose();
}
}
}, [isSubmitting, wasSubmitting, error, onClose, setData, focusCurrentPasswordField]);
useDidUpdate(() => {
currentPasswordField.current.focus();
}, [focusCurrentPasswordFieldState]);
return (
<>
<Popup.Header onBack={onBack}>
{t('common.editEmail', {
context: 'title',
})}
</Popup.Header>
<Popup.Content>
{message && (
<Message
// eslint-disable-next-line react/jsx-props-no-spreading
{...{
[message.type]: true,
}}
visible
content={t(message.content)}
onDismiss={onMessageDismiss}
/>
)}
<Form onSubmit={handleSubmit}>
<div className={styles.text}>{t('common.newEmail')}</div>
<Input
fluid
ref={emailField}
name="email"
value={data.email}
placeholder={email}
className={styles.field}
onChange={handleFieldChange}
/>
{data.email.trim() !== email && (
<>
<div className={styles.text}>{t('common.currentPassword')}</div>
<Input
fluid
ref={currentPasswordField}
type="password"
name="currentPassword"
value={data.currentPassword}
className={styles.field}
onChange={handleFieldChange}
/>
</>
)}
<Button
positive
content={t('action.save')}
loading={isSubmitting}
disabled={isSubmitting}
/>
</Form>
</Popup.Content>
</>
);
});
EditEmailStep.propTypes = {
defaultData: PropTypes.object.isRequired, // eslint-disable-line react/forbid-prop-types
email: PropTypes.string.isRequired,
isSubmitting: PropTypes.bool.isRequired,
error: PropTypes.object, // eslint-disable-line react/forbid-prop-types
onUpdate: PropTypes.func.isRequired,
onMessageDismiss: PropTypes.func.isRequired,
onBack: PropTypes.func.isRequired,
onClose: PropTypes.func.isRequired,
};
EditEmailStep.defaultProps = {
error: undefined,
};
export default EditEmailStep;

View file

@ -2,9 +2,9 @@ import React, { useCallback, useEffect, useRef } from 'react';
import PropTypes from 'prop-types';
import { useTranslation } from 'react-i18next';
import { Button, Form } from 'semantic-ui-react';
import { Input, Popup } from '../../../lib/custom-ui';
import { Input, Popup } from '../../lib/custom-ui';
import { useField } from '../../../hooks';
import { useField } from '../../hooks';
import styles from './EditNameStep.module.css';

View file

@ -0,0 +1,10 @@
.field {
margin-bottom: 8px;
}
.text {
color: #444444;
font-size: 12px;
font-weight: bold;
padding-bottom: 6px;
}

View file

@ -0,0 +1,153 @@
import React, {
useCallback, useEffect, useMemo, useRef,
} from 'react';
import PropTypes from 'prop-types';
import { useTranslation } from 'react-i18next';
import { Button, Form, Message } from 'semantic-ui-react';
import { Input, Popup } from '../../lib/custom-ui';
import {
useDidUpdate, useForm, usePrevious, useToggle,
} from '../../hooks';
import styles from './EditNameStep.module.css';
const createMessage = (error) => {
if (!error) {
return error;
}
switch (error.message) {
case 'Current password is not valid':
return {
type: 'error',
content: 'common.invalidCurrentPassword',
};
default:
return {
type: 'warning',
content: 'common.unknownError',
};
}
};
const EditPasswordStep = React.memo(({
defaultData, isSubmitting, error, onUpdate, onMessageDismiss, onBack, onClose,
}) => {
const [t] = useTranslation();
const wasSubmitting = usePrevious(isSubmitting);
const [data, handleFieldChange, setData] = useForm({
password: '',
currentPassword: '',
...defaultData,
});
const message = useMemo(() => createMessage(error), [error]);
const [focusCurrentPasswordFieldState, focusCurrentPasswordField] = useToggle();
const passwordField = useRef(null);
const currentPasswordField = useRef(null);
const handleSubmit = useCallback(() => {
if (!data.password) {
passwordField.current.select();
return;
}
if (!data.currentPassword) {
currentPasswordField.current.focus();
return;
}
onUpdate(data);
}, [onUpdate, data]);
useEffect(() => {
passwordField.current.select();
}, []);
useEffect(() => {
if (wasSubmitting && !isSubmitting) {
if (!error) {
onClose();
} else if (error.message === 'Current password is not valid') {
setData((prevData) => ({
...prevData,
currentPassword: '',
}));
focusCurrentPasswordField();
}
}
}, [isSubmitting, wasSubmitting, error, onClose, setData, focusCurrentPasswordField]);
useDidUpdate(() => {
currentPasswordField.current.focus();
}, [focusCurrentPasswordFieldState]);
return (
<>
<Popup.Header onBack={onBack}>
{t('common.editPassword', {
context: 'title',
})}
</Popup.Header>
<Popup.Content>
{message && (
<Message
// eslint-disable-next-line react/jsx-props-no-spreading
{...{
[message.type]: true,
}}
visible
content={t(message.content)}
onDismiss={onMessageDismiss}
/>
)}
<Form onSubmit={handleSubmit}>
<div className={styles.text}>{t('common.newPassword')}</div>
<Input
fluid
ref={passwordField}
name="password"
value={data.password}
className={styles.field}
onChange={handleFieldChange}
/>
<div className={styles.text}>{t('common.currentPassword')}</div>
<Input
fluid
ref={currentPasswordField}
type="password"
name="currentPassword"
value={data.currentPassword}
className={styles.field}
onChange={handleFieldChange}
/>
<Button
positive
content={t('action.save')}
loading={isSubmitting}
disabled={isSubmitting}
/>
</Form>
</Popup.Content>
</>
);
});
EditPasswordStep.propTypes = {
defaultData: PropTypes.object.isRequired, // eslint-disable-line react/forbid-prop-types
isSubmitting: PropTypes.bool.isRequired,
error: PropTypes.object, // eslint-disable-line react/forbid-prop-types
onUpdate: PropTypes.func.isRequired,
onMessageDismiss: PropTypes.func.isRequired,
onBack: PropTypes.func.isRequired,
onClose: PropTypes.func.isRequired,
};
EditPasswordStep.defaultProps = {
error: undefined,
};
export default EditPasswordStep;

View file

@ -0,0 +1,3 @@
.field {
margin-bottom: 8px;
}

View file

@ -2,23 +2,40 @@ import React, { useCallback } from 'react';
import PropTypes from 'prop-types';
import { useTranslation } from 'react-i18next';
import { Menu } from 'semantic-ui-react';
import { withPopup } from '../../../lib/popup';
import { Popup } from '../../../lib/custom-ui';
import { withPopup } from '../../lib/popup';
import { Popup } from '../../lib/custom-ui';
import { useSteps } from '../../../hooks';
import { useSteps } from '../../hooks';
import EditNameStep from './EditNameStep';
import EditAvatarStep from './EditAvatarStep';
import EditEmailStep from './EditEmailStep';
import EditPasswordStep from './EditPasswordStep';
import styles from './UserPopup.module.css';
const StepTypes = {
EDIT_NAME: 'EDIT_NAME',
EDIT_AVATAR: 'EDIT_AVATAR',
EDIT_EMAIL: 'EDIT_EMAIL',
EDIT_PASSWORD: 'EDIT_PASSWORD',
};
const UserStep = React.memo(
({
name, avatar, isAvatarUploading, onUpdate, onAvatarUpload, onLogout, onClose,
email,
name,
avatar,
isAvatarUploading,
emailUpdateForm,
passwordUpdateForm,
onUpdate,
onAvatarUpload,
onEmailUpdate,
onEmailUpdateMessageDismiss,
onPasswordUpdate,
onPasswordUpdateMessageDismiss,
onLogout,
onClose,
}) => {
const [t] = useTranslation();
const [step, openStep, handleBack] = useSteps();
@ -31,6 +48,14 @@ const UserStep = React.memo(
openStep(StepTypes.EDIT_AVATAR);
}, [openStep]);
const handleEmailEditClick = useCallback(() => {
openStep(StepTypes.EDIT_EMAIL);
}, [openStep]);
const handlePasswordEditClick = useCallback(() => {
openStep(StepTypes.EDIT_PASSWORD);
}, [openStep]);
const handleNameUpdate = useCallback(
(newName) => {
onUpdate({
@ -68,6 +93,31 @@ const UserStep = React.memo(
onBack={handleBack}
/>
);
case StepTypes.EDIT_EMAIL:
return (
<EditEmailStep
defaultData={emailUpdateForm.data}
email={email}
isSubmitting={emailUpdateForm.isSubmitting}
error={emailUpdateForm.error}
onUpdate={onEmailUpdate}
onMessageDismiss={onEmailUpdateMessageDismiss}
onBack={handleBack}
onClose={onClose}
/>
);
case StepTypes.EDIT_PASSWORD:
return (
<EditPasswordStep
defaultData={passwordUpdateForm.data}
isSubmitting={passwordUpdateForm.isSubmitting}
error={passwordUpdateForm.error}
onUpdate={onPasswordUpdate}
onMessageDismiss={onPasswordUpdateMessageDismiss}
onBack={handleBack}
onClose={onClose}
/>
);
default:
}
}
@ -87,6 +137,16 @@ const UserStep = React.memo(
context: 'title',
})}
</Menu.Item>
<Menu.Item className={styles.menuItem} onClick={handleEmailEditClick}>
{t('action.editEmail', {
context: 'title',
})}
</Menu.Item>
<Menu.Item className={styles.menuItem} onClick={handlePasswordEditClick}>
{t('action.editPassword', {
context: 'title',
})}
</Menu.Item>
<Menu.Item className={styles.menuItem} onClick={onLogout}>
{t('action.logOut', {
context: 'title',
@ -100,11 +160,20 @@ const UserStep = React.memo(
);
UserStep.propTypes = {
email: PropTypes.string.isRequired,
name: PropTypes.string.isRequired,
avatar: PropTypes.string,
isAvatarUploading: PropTypes.bool.isRequired,
/* eslint-disable react/forbid-prop-types */
emailUpdateForm: PropTypes.object.isRequired,
passwordUpdateForm: PropTypes.object.isRequired,
/* eslint-enable react/forbid-prop-types */
onUpdate: PropTypes.func.isRequired,
onAvatarUpload: PropTypes.func.isRequired,
onEmailUpdate: PropTypes.func.isRequired,
onEmailUpdateMessageDismiss: PropTypes.func.isRequired,
onPasswordUpdate: PropTypes.func.isRequired,
onPasswordUpdateMessageDismiss: PropTypes.func.isRequired,
onLogout: PropTypes.func.isRequired,
onClose: PropTypes.func.isRequired,
};

View file

@ -1,9 +1,7 @@
import React, { useCallback } from 'react';
import PropTypes from 'prop-types';
import { useTranslation } from 'react-i18next';
import {
Button, Modal, Table,
} from 'semantic-ui-react';
import { Button, Modal, Table } from 'semantic-ui-react';
import AddUserPopupContainer from '../../containers/AddUserPopupContainer';
import Item from './Item';

View file

@ -6,7 +6,7 @@ export default {
/* Login */
AUTHENTICATE: 'AUTHENTICATE',
AUTHENTICATION_ERROR_CLEAR: 'AUTHENTICATION_ERROR_CLEAR',
AUTHENTICATE_ERROR_CLEAR: 'AUTHENTICATE_ERROR_CLEAR',
LOGOUT: 'LOGOUT',
AUTHENTICATE_REQUESTED: 'AUTHENTICATE_REQUESTED',
AUTHENTICATE_SUCCEEDED: 'AUTHENTICATE_SUCCEEDED',
@ -30,8 +30,10 @@ export default {
/* User */
USER_CREATE: 'USER_CREATE',
USER_CREATION_ERROR_CLEAR: 'USER_CREATION_ERROR_CLEAR',
USER_CREATE_ERROR_CLEAR: 'USER_CREATE_ERROR_CLEAR',
USER_UPDATE: 'USER_UPDATE',
USER_EMAIL_UPDATE_ERROR_CLEAR: 'USER_EMAIL_UPDATE_ERROR_CLEAR',
USER_PASSWORD_UPDATE_ERROR_CLEAR: 'USER_PASSWORD_UPDATE_ERROR_CLEAR',
USER_DELETE: 'USER_DELETE',
USER_TO_CARD_ADD: 'USER_TO_CARD_ADD',
USER_FROM_CARD_REMOVE: 'USER_FROM_CARD_REMOVE',
@ -48,6 +50,12 @@ export default {
USER_UPDATE_SUCCEEDED: 'USER_UPDATE_SUCCEEDED',
USER_UPDATE_FAILED: 'USER_UPDATE_FAILED',
USER_UPDATE_RECEIVED: 'USER_UPDATE_RECEIVED',
USER_EMAIL_UPDATE_REQUESTED: 'USER_EMAIL_UPDATE_REQUESTED',
USER_EMAIL_UPDATE_SUCCEEDED: 'USER_EMAIL_UPDATE_SUCCEEDED',
USER_EMAIL_UPDATE_FAILED: 'USER_EMAIL_UPDATE_FAILED',
USER_PASSWORD_UPDATE_REQUESTED: 'USER_PASSWORD_UPDATE_REQUESTED',
USER_PASSWORD_UPDATE_SUCCEEDED: 'USER_PASSWORD_UPDATE_SUCCEEDED',
USER_PASSWORD_UPDATE_FAILED: 'USER_PASSWORD_UPDATE_FAILED',
USER_AVATAR_UPLOAD_REQUESTED: 'USER_AVATAR_UPLOAD_REQUESTED',
USER_AVATAR_UPLOAD_SUCCEEDED: 'USER_AVATAR_UPLOAD_SUCCEEDED',
USER_AVATAR_UPLOAD_FAILED: 'USER_AVATAR_UPLOAD_FAILED',

View file

@ -6,7 +6,7 @@ export default {
/* Login */
AUTHENTICATE: `${PREFIX}/AUTHENTICATE`,
AUTHENTICATION_ERROR_CLEAR: `${PREFIX}/AUTHENTICATION_ERROR_CLEAR`,
AUTHENTICATE_ERROR_CLEAR: `${PREFIX}/AUTHENTICATE_ERROR_CLEAR`,
LOGOUT: `${PREFIX}/LOGOUT`,
/* Modal */
@ -17,9 +17,13 @@ export default {
/* User */
USER_CREATE: `${PREFIX}/USER_CREATE`,
USER_CREATION_ERROR_CLEAR: `${PREFIX}/USER_CREATION_ERROR_CLEAR`,
USER_CREATE_ERROR_CLEAR: `${PREFIX}/USER_CREATE_ERROR_CLEAR`,
USER_UPDATE: `${PREFIX}/USER_UPDATE`,
CURRENT_USER_UPDATE: `${PREFIX}/CURRENT_USER_UPDATE`,
CURRENT_USER_EMAIL_UPDATE: `${PREFIX}/CURRENT_USER_EMAIL_UPDATE`,
CURRENT_USER_EMAIL_UPDATE_ERROR_CLEAR: `${PREFIX}/CURRENT_USER_EMAIL_UPDATE_ERROR_CLEAR`,
CURRENT_USER_PASSWORD_UPDATE: `${PREFIX}/CURRENT_USER_PASSWORD_UPDATE`,
CURRENT_USER_PASSWORD_UPDATE_ERROR_CLEAR: `${PREFIX}/CURRENT_USER_PASSWORD_UPDATE_ERROR_CLEAR`,
CURRENT_USER_AVATAR_UPLOAD: `${PREFIX}/CURRENT_USER_AVATAR_UPLOAD`,
USER_DELETE: `${PREFIX}/USER_DELETE`,
USER_TO_CARD_ADD: `${PREFIX}/USER_TO_CARD_ADD`,

View file

@ -4,7 +4,7 @@ import { connect } from 'react-redux';
import { closeModal, createProject } from '../actions/entry';
import AddProjectModal from '../components/AddProjectModal';
const mapStateToProps = ({ project: { data: defaultData, isSubmitting } }) => ({
const mapStateToProps = ({ projectCreateForm: { data: defaultData, isSubmitting } }) => ({
defaultData,
isSubmitting,
});

View file

@ -1,36 +1,19 @@
import { bindActionCreators } from 'redux';
import { connect } from 'react-redux';
import { clearUserCreationError, createUser } from '../actions/entry';
import { clearUserCreateError, createUser } from '../actions/entry';
import AddUserPopup from '../components/AddUserPopup';
const mapStateToProps = ({ user: { data: defaultData, isSubmitting, error: externalError } }) => {
let error;
if (externalError) {
if (externalError.message === 'User is already exist') {
error = {
message: 'userIsAlreadyExist',
};
} else {
error = {
type: 'warning',
message: 'unknownError',
};
}
}
return {
defaultData,
isSubmitting,
error,
};
};
const mapStateToProps = ({ userCreateForm: { data: defaultData, isSubmitting, error } }) => ({
defaultData,
isSubmitting,
error,
});
const mapDispatchToProps = (dispatch) => bindActionCreators(
{
onCreate: createUser,
onMessageDismiss: clearUserCreationError,
onMessageDismiss: clearUserCreateError,
},
dispatch,
);

View file

@ -3,10 +3,14 @@ import { connect } from 'react-redux';
import { currentUserSelector, notificationsForCurrentUserSelector } from '../selectors';
import {
clearCurrentUserEmailUpdateError,
clearCurrentUserPasswordUpdateError,
deleteNotification,
logout,
openUsersModal,
updateCurrentUser,
updateCurrentUserEmail,
updateCurrentUserPassword,
uploadCurrentUserAvatar,
} from '../actions/entry';
import Header from '../components/Header';
@ -24,10 +28,14 @@ const mapStateToProps = (state) => {
const mapDispatchToProps = (dispatch) => bindActionCreators(
{
onUsers: openUsersModal, // TODO: rename
onNotificationDelete: deleteNotification,
onUserUpdate: updateCurrentUser,
onUserAvatarUpload: uploadCurrentUserAvatar,
onNotificationDelete: deleteNotification,
onUsers: openUsersModal, // TODO: rename
onUserEmailUpdate: updateCurrentUserEmail,
onUserEmailUpdateMessageDismiss: clearCurrentUserEmailUpdateError,
onUserPasswordUpdate: updateCurrentUserPassword,
onUserPasswordUpdateMessageDismiss: clearCurrentUserPasswordUpdateError,
onLogout: logout,
},
dispatch,

View file

@ -1,59 +1,19 @@
import { bindActionCreators } from 'redux';
import { connect } from 'react-redux';
import { authenticate, clearAuthenticationError } from '../actions/entry';
import { authenticate, clearAuthenticateError } from '../actions/entry';
import Login from '../components/Login';
const mapStateToProps = ({ login: { data: defaultData, isSubmitting, error: externalError } }) => {
let error;
if (externalError) {
switch (externalError.message) {
case 'Email does not exist':
error = {
message: 'emailDoesNotExist',
};
break;
case 'Password is not valid':
error = {
message: 'invalidPassword',
};
break;
case 'Failed to fetch':
error = {
type: 'warning',
message: 'noInternetConnection',
};
break;
case 'Network request failed':
error = {
type: 'warning',
message: 'serverConnectionFailed',
};
break;
default:
error = {
type: 'warning',
message: 'unknownError',
};
}
}
return {
defaultData,
isSubmitting,
error,
};
};
const mapStateToProps = ({ authenticateForm: { data: defaultData, isSubmitting, error } }) => ({
defaultData,
isSubmitting,
error,
});
const mapDispatchToProps = (dispatch) => bindActionCreators(
{
onAuthenticate: authenticate,
onMessageDismiss: clearAuthenticationError,
onMessageDismiss: clearAuthenticateError,
},
dispatch,
);

View file

@ -1,5 +1,3 @@
import useDeepCompareEffect from './use-deep-compare-effect';
import useDeepCompareCallback from './use-deep-compare-callback';
import usePrevious from './use-previous';
import useField from './use-field';
import useForm from './use-form';
@ -10,8 +8,6 @@ import useClosableForm from './use-closable-form';
import useDidUpdate from './use-did-update';
export {
useDeepCompareEffect,
useDeepCompareCallback,
usePrevious,
useField,
useForm,

View file

@ -1,8 +0,0 @@
import { useCallback } from 'react';
import useDeepCompareMemoize from './use-deep-compare-memoize';
export default (callback, dependencies) => useCallback(
callback,
useDeepCompareMemoize(dependencies),
);

View file

@ -1,7 +0,0 @@
import { useEffect } from 'react';
import useDeepCompareMemoize from './use-deep-compare-memoize';
export default (effect, dependencies) => {
useEffect(effect, useDeepCompareMemoize(dependencies));
};

View file

@ -1,12 +0,0 @@
import dequal from 'dequal';
import { useRef } from 'react';
export default (value) => {
const currentValue = useRef();
if (!dequal(value, currentValue.current)) {
currentValue.current = value;
}
return currentValue.current;
};

View file

@ -1,7 +1,7 @@
import { useEffect, useRef } from 'react';
export default (value) => {
const prevValue = useRef();
const prevValue = useRef(value);
useEffect(() => {
prevValue.current = value;

View file

@ -35,6 +35,7 @@ export default {
createLabel_title: 'Create Label',
createNewOneOrSelectExistingOne: 'Create a new one or select<br />an existing one',
createProject_title: 'Create Project',
currentPassword: 'Current password',
date: 'Date',
dueDate: 'Due date',
deleteBoard_title: 'Delete Board',
@ -49,8 +50,10 @@ export default {
editAvatar_title: 'Edit Avatar',
editBoard_title: 'Edit Board',
editDueDate_title: 'Edit Due Date',
editEmail_title: 'Edit E-mail',
editLabel_title: 'Edit Label',
editName_title: 'Edit Name',
editPassword_title: 'Edit Password',
editProject_title: 'Edit Project',
editTimer_title: 'Edit Timer',
enterCardTitle: 'Enter card title...',
@ -61,11 +64,14 @@ export default {
filterByLabels_title: 'Filter By Labels',
filterByMembers_title: 'Filter By Members',
hours: 'Hours',
invalidCurrentPassword: 'Invalid current password',
labels: 'Labels',
listActions_title: 'List Actions',
members: 'Members',
minutes: 'Minutes',
name: 'Name',
newEmail: 'New e-mail',
newPassword: 'New password',
noConnectionToServer: 'No connection to server',
notifications: 'Notifications',
noUnreadNotifications: 'No unread notifications',
@ -121,7 +127,9 @@ export default {
editAvatar_title: 'Edit Avatar',
editDueDate_title: 'Edit Due Date',
editDescription_title: 'Edit Description',
editEmail_title: 'Edit E-mail',
editName_title: 'Edit Name',
editPassword_title: 'Edit Password',
editTask_title: 'Edit Task',
editTimer_title: 'Edit Timer',
editTitle_title: 'Edit Title',

View file

@ -2,7 +2,7 @@ export default {
translation: {
common: {
email: 'E-mail',
emailDoesNotExist: 'Email does not exist',
emailDoesNotExist: 'E-mail does not exist',
invalidPassword: 'Invalid password',
logInToPlanka: 'Log in to Planka',
noInternetConnection: 'No internet connection',

View file

@ -39,6 +39,7 @@ export default {
createLabel: 'Создание метки',
createNewOneOrSelectExistingOne: 'Создайте новую или выберите<br />уже существующую',
createProject: 'Создание проекта',
currentPassword: 'Текущий пароль',
date: 'Дата',
dueDate: 'Срок',
deleteBoard: 'Удаление доски',
@ -53,8 +54,10 @@ export default {
editAvatar: 'Изменение аватара',
editBoard: 'Изменение доски',
editDueDate: 'Изменение срока',
editEmail: 'Изменение e-mail',
editLabel: 'Изменения метки',
editName: 'Изменение имени',
editPassword: 'Изменение пароля',
editProject: 'Изменение проекта',
editTimer: 'Изменение таймера',
enterCardTitle: 'Введите заголовок для этой карточки...',
@ -65,11 +68,14 @@ export default {
filterByLabels: 'Фильтр по меткам',
filterByMembers: 'Фильтр по участникам',
hours: 'Часы',
invalidCurrentPassword: 'Неверный текущий пароль',
labels: 'Метки',
listActions: 'Действия со списком',
members: 'Участники',
minutes: 'Минуты',
name: 'Имя',
newEmail: 'Новый e-mail',
newPassword: 'Новый пароль',
noConnectionToServer: 'Нет соединения с сервером',
notifications: 'Уведомления',
noUnreadNotifications: 'Уведомлений нет',
@ -121,7 +127,9 @@ export default {
editAvatar: 'Изменить аватар',
editDueDate: 'Изменить срок',
editDescription: 'Изменить описание',
editEmail: 'Изменить e-mail',
editName: 'Изменить имя',
editPassword: 'Изменить пароль',
editTask: 'Изменить задачу',
editTimer: 'Изменить таймер',
editTitle: 'Изменить название',

View file

@ -2,6 +2,24 @@ import { Model, attr } from 'redux-orm';
import ActionTypes from '../constants/ActionTypes';
const DEFAULT_EMAIL_UPDATE_FORM = {
data: {
email: '',
currentPassword: '',
},
isSubmitting: false,
error: null,
};
const DEFAULT_PASSWORD_UPDATE_FORM = {
data: {
password: '',
currentPassword: '',
},
isSubmitting: false,
error: null,
};
export default class extends Model {
static modelName = 'User';
@ -17,6 +35,12 @@ export default class extends Model {
isAvatarUploading: attr({
getDefault: () => false,
}),
emailUpdateForm: attr({
getDefault: () => DEFAULT_EMAIL_UPDATE_FORM,
}),
passwordUpdateForm: attr({
getDefault: () => DEFAULT_PASSWORD_UPDATE_FORM,
}),
};
static reducer({ type, payload }, User) {
@ -44,6 +68,30 @@ export default class extends Model {
User.withId(payload.id).update(payload.data);
break;
case ActionTypes.USER_EMAIL_UPDATE_ERROR_CLEAR: {
const userModel = User.withId(payload.id);
userModel.update({
emailUpdateForm: {
...userModel.emailUpdateForm,
error: null,
},
});
break;
}
case ActionTypes.USER_PASSWORD_UPDATE_ERROR_CLEAR: {
const userModel = User.withId(payload.id);
userModel.update({
passwordUpdateForm: {
...userModel.passwordUpdateForm,
error: null,
},
});
break;
}
case ActionTypes.USER_DELETE:
User.withId(payload.id).deleteWithRelated();
@ -52,6 +100,73 @@ export default class extends Model {
User.withId(payload.user.id).update(payload.user);
break;
case ActionTypes.USER_EMAIL_UPDATE_REQUESTED: {
const userModel = User.withId(payload.id);
userModel.update({
emailUpdateForm: {
...userModel.emailUpdateForm,
data: payload.data,
isSubmitting: true,
},
});
break;
}
case ActionTypes.USER_EMAIL_UPDATE_SUCCEEDED: {
User.withId(payload.id).update({
email: payload.email,
emailUpdateForm: DEFAULT_EMAIL_UPDATE_FORM,
});
break;
}
case ActionTypes.USER_EMAIL_UPDATE_FAILED: {
const userModel = User.withId(payload.id);
userModel.update({
emailUpdateForm: {
...userModel.emailUpdateForm,
isSubmitting: false,
error: payload.error,
},
});
break;
}
case ActionTypes.USER_PASSWORD_UPDATE_REQUESTED: {
const userModel = User.withId(payload.id);
userModel.update({
passwordUpdateForm: {
...userModel.passwordUpdateForm,
data: payload.data,
isSubmitting: true,
},
});
break;
}
case ActionTypes.USER_PASSWORD_UPDATE_SUCCEEDED: {
User.withId(payload.id).update({
passwordUpdateForm: DEFAULT_PASSWORD_UPDATE_FORM,
});
break;
}
case ActionTypes.USER_PASSWORD_UPDATE_FAILED: {
const userModel = User.withId(payload.id);
userModel.update({
passwordUpdateForm: {
...userModel.passwordUpdateForm,
isSubmitting: false,
error: payload.error,
},
});
break;
}
case ActionTypes.USER_AVATAR_UPLOAD_REQUESTED:
User.withId(payload.id).update({
isAvatarUploading: true,
@ -59,8 +174,8 @@ export default class extends Model {
break;
case ActionTypes.USER_AVATAR_UPLOAD_SUCCEEDED:
User.withId(payload.user.id).update({
...payload.user,
User.withId(payload.id).update({
avatar: payload.avatar,
isAvatarUploading: false,
});

View file

@ -14,7 +14,7 @@ import {
} from './models';
const orm = new ORM({
stateSelector: ({ db }) => db,
stateSelector: (state) => state.orm,
});
orm.register(

View file

@ -1,4 +1,4 @@
import ActionTypes from '../constants/ActionTypes';
import ActionTypes from '../../constants/ActionTypes';
const initialState = {
data: {
@ -19,7 +19,7 @@ export default (state = initialState, { type, payload }) => {
...payload.data,
},
};
case ActionTypes.AUTHENTICATION_ERROR_CLEAR:
case ActionTypes.AUTHENTICATE_ERROR_CLEAR:
return {
...state,
error: null,

View file

@ -0,0 +1,11 @@
import { combineReducers } from 'redux';
import authenticate from './authenticate';
import userCreate from './user-create';
import projectCreate from './project-create';
export default combineReducers({
authenticate,
userCreate,
projectCreate,
});

View file

@ -1,4 +1,4 @@
import ActionTypes from '../constants/ActionTypes';
import ActionTypes from '../../constants/ActionTypes';
const initialState = {
data: {

View file

@ -1,4 +1,4 @@
import ActionTypes from '../constants/ActionTypes';
import ActionTypes from '../../constants/ActionTypes';
const initialState = {
data: {
@ -19,7 +19,7 @@ export default (state = initialState, { type, payload }) => {
...payload.data,
},
};
case ActionTypes.USER_CREATION_ERROR_CLEAR:
case ActionTypes.USER_CREATE_ERROR_CLEAR:
return {
...state,
error: null,

View file

@ -2,20 +2,20 @@ import { combineReducers } from 'redux';
import router from './router';
import socket from './socket';
import db from './db';
import orm from './orm';
import auth from './auth';
import login from './login';
import app from './app';
import user from './user';
import project from './project';
import authenticateForm from './forms/authenticate';
import userCreateForm from './forms/user-create';
import projectCreateForm from './forms/project-create';
export default combineReducers({
router,
socket,
db,
orm,
auth,
login,
app,
user,
project,
authenticateForm,
userCreateForm,
projectCreateForm,
});

View file

@ -11,7 +11,13 @@ import {
fetchCurrentUserFailed,
fetchCurrentUserRequested,
fetchCurrentUserSucceeded,
updateUserEmailFailed,
updateUserEmailRequested,
updateUserEmailSucceeded,
updateUserFailed,
updateUserPasswordFailed,
updateUserPasswordRequested,
updateUserPasswordSucceeded,
updateUserRequested,
updateUserSucceeded,
uploadUserAvatarFailed,
@ -92,13 +98,61 @@ export function* updateUserRequest(id, data) {
}
}
export function* updateUserEmailRequest(id, data) {
yield put(updateUserEmailRequested(id, data));
try {
const { item } = yield call(request, api.updateUserEmail, id, data);
const action = updateUserEmailSucceeded(id, item);
yield put(action);
return {
success: true,
payload: action.payload,
};
} catch (error) {
const action = updateUserEmailFailed(id, error);
yield put(action);
return {
success: false,
payload: action.payload,
};
}
}
export function* updateUserPasswordRequest(id, data) {
yield put(updateUserPasswordRequested(id, data));
try {
yield call(request, api.updateUserPassword, id, data);
const action = updateUserPasswordSucceeded(id);
yield put(action);
return {
success: true,
payload: action.payload,
};
} catch (error) {
const action = updateUserPasswordFailed(id, error);
yield put(action);
return {
success: false,
payload: action.payload,
};
}
}
export function* uploadUserAvatarRequest(id, file) {
yield put(uploadUserAvatarRequested(id));
try {
const { item } = yield call(request, api.uploadUserAvatar, id, file);
const action = uploadUserAvatarSucceeded(item);
const action = uploadUserAvatarSucceeded(id, item);
yield put(action);
return {

View file

@ -5,6 +5,8 @@ import {
createUserRequest,
deleteCardMembershipRequest,
deleteUserRequest,
updateUserEmailRequest,
updateUserPasswordRequest,
updateUserRequest,
uploadUserAvatarRequest,
} from '../requests';
@ -12,7 +14,9 @@ import { currentUserIdSelector, pathSelector } from '../../../selectors';
import {
addUserToBoardFilter,
addUserToCard,
clearUserCreationError,
clearUserCreateError,
clearUserEmailUpdateError,
clearUserPasswordUpdateError,
createUser,
deleteUser,
updateUser,
@ -25,8 +29,8 @@ export function* createUserService(data) {
yield call(createUserRequest, data);
}
export function* clearUserCreationErrorService() {
yield put(clearUserCreationError());
export function* clearUserCreateErrorService() {
yield put(clearUserCreateError());
}
export function* updateUserService(id, data) {
@ -40,10 +44,54 @@ export function* updateCurrentUserService(data) {
yield call(updateUserService, id, data);
}
export function* updateUserEmailService(id, data) {
yield call(updateUserEmailRequest, id, data);
}
export function* updateCurrentUserEmailService(data) {
const id = yield select(currentUserIdSelector);
yield call(updateUserEmailService, id, data);
}
export function* clearUserEmailUpdateErrorService(id) {
yield put(clearUserEmailUpdateError(id));
}
export function* clearCurrentUserEmailUpdateErrorService() {
const id = yield select(currentUserIdSelector);
yield call(clearUserEmailUpdateErrorService, id);
}
export function* updateUserPasswordService(id, data) {
yield call(updateUserPasswordRequest, id, data);
}
export function* updateCurrentUserPasswordService(data) {
const id = yield select(currentUserIdSelector);
yield call(updateUserPasswordService, id, data);
}
export function* clearUserPasswordUpdateErrorService(id) {
yield put(clearUserPasswordUpdateError(id));
}
export function* clearCurrentUserPasswordUpdateErrorService() {
const id = yield select(currentUserIdSelector);
yield call(clearUserPasswordUpdateErrorService, id);
}
export function* uploadUserAvatarService(id, file) {
yield call(uploadUserAvatarRequest, id, file);
}
export function* uploadCurrentUserAvatarService(file) {
const id = yield select(currentUserIdSelector);
yield call(uploadUserAvatarRequest, id, file);
yield call(uploadUserAvatarService, id, file);
}
export function* deleteUserService(id) {

View file

@ -4,13 +4,17 @@ import {
addUserToCardService,
addUserToCurrentCardService,
addUserToFilterInCurrentBoardService,
clearUserCreationErrorService,
clearCurrentUserEmailUpdateErrorService,
clearCurrentUserPasswordUpdateErrorService,
clearUserCreateErrorService,
createUserService,
deleteUserService,
removeUserFromCardService,
removeUserFromCurrentCardService,
removeUserFromFilterInCurrentBoardService,
updateUserService,
updateCurrentUserEmailService,
updateCurrentUserPasswordService,
updateCurrentUserService,
uploadCurrentUserAvatarService,
} from '../services';
@ -19,7 +23,7 @@ import EntryActionTypes from '../../../constants/EntryActionTypes';
export default function* () {
yield all([
takeLatest(EntryActionTypes.USER_CREATE, ({ payload: { data } }) => createUserService(data)),
takeLatest(EntryActionTypes.USER_CREATION_ERROR_CLEAR, () => clearUserCreationErrorService()),
takeLatest(EntryActionTypes.USER_CREATE_ERROR_CLEAR, () => clearUserCreateErrorService()),
takeLatest(
EntryActionTypes.USER_UPDATE,
({ payload: { id, data } }) => updateUserService(id, data),
@ -28,6 +32,22 @@ export default function* () {
EntryActionTypes.CURRENT_USER_UPDATE,
({ payload: { data } }) => updateCurrentUserService(data),
),
takeLatest(
EntryActionTypes.CURRENT_USER_EMAIL_UPDATE,
({ payload: { data } }) => updateCurrentUserEmailService(data),
),
takeLatest(
EntryActionTypes.CURRENT_USER_EMAIL_UPDATE_ERROR_CLEAR,
() => clearCurrentUserEmailUpdateErrorService(),
),
takeLatest(
EntryActionTypes.CURRENT_USER_PASSWORD_UPDATE,
({ payload: { data } }) => updateCurrentUserPasswordService(data),
),
takeLatest(
EntryActionTypes.CURRENT_USER_PASSWORD_UPDATE_ERROR_CLEAR,
() => clearCurrentUserPasswordUpdateErrorService(),
),
takeLatest(
EntryActionTypes.CURRENT_USER_AVATAR_UPLOAD,
({ payload: { file } }) => uploadCurrentUserAvatarService(file),

View file

@ -1,13 +1,13 @@
import { call, put } from 'redux-saga/effects';
import { authenticateRequest } from '../requests';
import { authenticate, clearAuthenticationError } from '../../../actions';
import { authenticate, clearAuthenticateError } from '../../../actions';
export function* authenticateService(data) {
yield put(authenticate(data));
yield call(authenticateRequest, data);
}
export function* clearAuthenticationErrorService() {
yield put(clearAuthenticationError());
export function* clearAuthenticateErrorService() {
yield put(clearAuthenticateError());
}

View file

@ -1,14 +1,11 @@
import { all, takeLatest } from 'redux-saga/effects';
import { authenticateService, clearAuthenticationErrorService } from '../services';
import { authenticateService, clearAuthenticateErrorService } from '../services';
import EntryActionTypes from '../../../constants/EntryActionTypes';
export default function* () {
yield all([
takeLatest(EntryActionTypes.AUTHENTICATE, ({ payload: { data } }) => authenticateService(data)),
takeLatest(
EntryActionTypes.AUTHENTICATION_ERROR_CLEAR,
() => clearAuthenticationErrorService(),
),
takeLatest(EntryActionTypes.AUTHENTICATE_ERROR_CLEAR, () => clearAuthenticateErrorService()),
]);
}

View file

@ -0,0 +1,83 @@
const bcrypt = require('bcrypt');
const Errors = {
USER_NOT_FOUND: {
notFound: 'User is not found'
},
CURRENT_PASSWORD_NOT_VALID: {
forbidden: 'Current password is not valid'
},
USER_EXIST: {
conflict: 'User is already exist'
}
};
module.exports = {
inputs: {
id: {
type: 'string',
regex: /^[0-9]+$/,
required: true
},
email: {
type: 'string',
isEmail: true,
required: true
},
currentPassword: {
type: 'string',
isNotEmptyString: true
}
},
exits: {
notFound: {
responseType: 'notFound'
},
forbidden: {
responseType: 'forbidden'
},
conflict: {
responseType: 'conflict'
}
},
fn: async function(inputs, exits) {
const { currentUser } = this.req;
if (inputs.id === currentUser.id) {
if (!inputs.currentPassword) {
throw Errors.CURRENT_PASSWORD_NOT_VALID;
}
} else if (!currentUser.isAdmin) {
throw Errors.USER_NOT_FOUND; // Forbidden
}
let user = await sails.helpers.getUser(inputs.id);
if (!user) {
throw Errors.USER_NOT_FOUND;
}
if (
inputs.id === currentUser.id &&
!bcrypt.compareSync(inputs.currentPassword, user.password)
) {
throw Errors.CURRENT_PASSWORD_NOT_VALID;
}
const values = _.pick(inputs, ['email']);
user = await sails.helpers
.updateUser(user, values, this.req)
.intercept('conflict', () => Errors.USER_EXIST);
if (!user) {
throw Errors.USER_NOT_FOUND;
}
return exits.success({
item: user.email
});
}
};

View file

@ -0,0 +1,74 @@
const bcrypt = require('bcrypt');
const Errors = {
USER_NOT_FOUND: {
notFound: 'User is not found'
},
CURRENT_PASSWORD_NOT_VALID: {
forbidden: 'Current password is not valid'
}
};
module.exports = {
inputs: {
id: {
type: 'string',
regex: /^[0-9]+$/,
required: true
},
password: {
type: 'string',
required: true
},
currentPassword: {
type: 'string',
isNotEmptyString: true
}
},
exits: {
notFound: {
responseType: 'notFound'
},
forbidden: {
responseType: 'forbidden'
}
},
fn: async function(inputs, exits) {
const { currentUser } = this.req;
if (inputs.id === currentUser.id) {
if (!inputs.currentPassword) {
throw Errors.CURRENT_PASSWORD_NOT_VALID;
}
} else if (!currentUser.isAdmin) {
throw Errors.USER_NOT_FOUND; // Forbidden
}
let user = await sails.helpers.getUser(inputs.id);
if (!user) {
throw Errors.USER_NOT_FOUND;
}
if (
inputs.id === currentUser.id &&
!bcrypt.compareSync(inputs.currentPassword, user.password)
) {
throw Errors.CURRENT_PASSWORD_NOT_VALID;
}
const values = _.pick(inputs, ['password']);
user = await sails.helpers.updateUser(user, values, this.req);
if (!user) {
throw Errors.USER_NOT_FOUND;
}
return exits.success({
item: null
});
}
};

View file

@ -118,7 +118,7 @@ module.exports = {
}
return this.res.json({
item: user
item: user.avatar
});
});
}

View file

@ -21,19 +21,31 @@ module.exports = {
}
},
exits: {
conflict: {}
},
fn: async function(inputs, exits) {
if (!_.isUndefined(inputs.values.email)) {
inputs.values.email = inputs.values.email.toLowerCase();
}
let isOnlyPasswordChange = false;
if (!_.isUndefined(inputs.values.password)) {
inputs.values.password = bcrypt.hashSync(inputs.values.password, 10);
if (Object.keys(inputs.values).length === 1) {
isOnlyPasswordChange = true;
}
}
const user = await User.updateOne({
id: inputs.record.id,
deletedAt: null
}).set(inputs.values);
})
.set(inputs.values)
.intercept(undefined, 'conflict');
if (user) {
if (inputs.record.avatar && user.avatar !== inputs.record.avatar) {
@ -44,28 +56,30 @@ module.exports = {
} catch (unusedError) {}
}
const adminUserIds = await sails.helpers.getAdminUserIds();
if (!isOnlyPasswordChange) {
const adminUserIds = await sails.helpers.getAdminUserIds();
const projectIds = await sails.helpers.getMembershipProjectIdsForUser(
user.id
);
const userIdsForProject = await sails.helpers.getMembershipUserIdsForProject(
projectIds
);
const userIds = _.union([user.id], adminUserIds, userIdsForProject);
userIds.forEach(userId => {
sails.sockets.broadcast(
`user:${userId}`,
'userUpdate',
{
item: user
},
inputs.request
const projectIds = await sails.helpers.getMembershipProjectIdsForUser(
user.id
);
});
const userIdsForProject = await sails.helpers.getMembershipUserIdsForProject(
projectIds
);
const userIds = _.union([user.id], adminUserIds, userIdsForProject);
userIds.forEach(userId => {
sails.sockets.broadcast(
`user:${userId}`,
'userUpdate',
{
item: user
},
inputs.request
);
});
}
}
return exits.success(user);

View file

@ -0,0 +1,36 @@
/**
* forbidden.js
*
* A custom response.
*
* Example usage:
* ```
* return res.forbidden();
* // -or-
* return res.forbidden(optionalData);
* ```
*
* Or with actions2:
* ```
* exits: {
* somethingHappened: {
* responseType: 'forbidden'
* }
* }
* ```
*
* ```
* throw 'somethingHappened';
* // -or-
* throw { somethingHappened: optionalData }
* ```
*/
module.exports = function forbidden(message) {
const { res } = this;
return res.status(403).json({
code: 'E_FORBIDDEN',
message
});
};

View file

@ -15,6 +15,8 @@ module.exports.routes = {
'POST /api/users': 'users/create',
'GET /api/users/me': 'users/show',
'PATCH /api/users/:id': 'users/update',
'PATCH /api/users/:id/email': 'users/update-email',
'PATCH /api/users/:id/password': 'users/update-password',
'POST /api/users/:id/upload-avatar': 'users/upload-avatar',
'DELETE /api/users/:id': 'users/delete',