1
0
Fork 0
mirror of https://github.com/plankanban/planka.git synced 2025-07-18 20:59:44 +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 b53e5bf94c
commit 680d664279
67 changed files with 1232 additions and 267 deletions

View file

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

View file

@ -7,8 +7,8 @@ export const createUser = (data) => ({
}, },
}); });
export const clearUserCreationError = () => ({ export const clearUserCreateError = () => ({
type: EntryActionTypes.USER_CREATION_ERROR_CLEAR, type: EntryActionTypes.USER_CREATE_ERROR_CLEAR,
payload: {}, 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) => ({ export const uploadCurrentUserAvatar = (file) => ({
type: EntryActionTypes.CURRENT_USER_AVATAR_UPLOAD, type: EntryActionTypes.CURRENT_USER_AVATAR_UPLOAD,
payload: { payload: {

View file

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

View file

@ -9,8 +9,8 @@ export const createUser = (data) => ({
}, },
}); });
export const clearUserCreationError = () => ({ export const clearUserCreateError = () => ({
type: ActionTypes.USER_CREATION_ERROR_CLEAR, type: ActionTypes.USER_CREATE_ERROR_CLEAR,
payload: {}, 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) => ({ export const deleteUser = (id) => ({
type: ActionTypes.USER_DELETE, type: ActionTypes.USER_DELETE,
payload: { 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) => ({ export const uploadUserAvatarRequested = (id) => ({
type: ActionTypes.USER_AVATAR_UPLOAD_REQUESTED, type: ActionTypes.USER_AVATAR_UPLOAD_REQUESTED,
payload: { payload: {
@ -148,10 +209,11 @@ export const uploadUserAvatarRequested = (id) => ({
}, },
}); });
export const uploadUserAvatarSucceeded = (user) => ({ export const uploadUserAvatarSucceeded = (id, avatar) => ({
type: ActionTypes.USER_AVATAR_UPLOAD_SUCCEEDED, type: ActionTypes.USER_AVATAR_UPLOAD_SUCCEEDED,
payload: { 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 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( const uploadUserAvatar = (id, file, headers) => http.post(
`/users/${id}/upload-avatar`, `/users/${id}/upload-avatar`,
{ {
@ -26,6 +30,8 @@ export default {
createUser, createUser,
getCurrentUser, getCurrentUser,
updateUser, updateUser,
updateUserEmail,
updateUserPassword,
uploadUserAvatar, uploadUserAvatar,
deleteUser, 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 PropTypes from 'prop-types';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { import {
@ -6,7 +6,7 @@ import {
} from 'semantic-ui-react'; } from 'semantic-ui-react';
import { Input } from '../../lib/custom-ui'; import { Input } from '../../lib/custom-ui';
import { useDeepCompareCallback, useForm } from '../../hooks'; import { useForm } from '../../hooks';
import styles from './AddProjectModal.module.css'; import styles from './AddProjectModal.module.css';
@ -22,7 +22,7 @@ const AddProjectModal = React.memo(({
const nameField = useRef(null); const nameField = useRef(null);
const handleSubmit = useDeepCompareCallback(() => { const handleSubmit = useCallback(() => {
const cleanData = { const cleanData = {
...data, ...data,
name: data.name.trim(), name: data.name.trim(),

View file

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

View file

@ -6,11 +6,7 @@ import { useTranslation } from 'react-i18next';
import { Button, Form, Input } from 'semantic-ui-react'; import { Button, Form, Input } from 'semantic-ui-react';
import { import {
useClosableForm, useClosableForm, useDidUpdate, useForm, useToggle,
useDeepCompareCallback,
useDidUpdate,
useForm,
useToggle,
} from '../../hooks'; } from '../../hooks';
import styles from './AddList.module.css'; import styles from './AddList.module.css';
@ -62,7 +58,7 @@ const AddList = React.forwardRef(({ children, onCreate }, ref) => {
close, close,
); );
const handleSubmit = useDeepCompareCallback(() => { const handleSubmit = useCallback(() => {
const cleanData = { const cleanData = {
...data, ...data,
name: data.name.trim(), 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 PropTypes from 'prop-types';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Button, Form } from 'semantic-ui-react'; import { Button, Form } from 'semantic-ui-react';
import { withPopup } from '../../lib/popup'; import { withPopup } from '../../lib/popup';
import { Input, Popup } from '../../lib/custom-ui'; import { Input, Popup } from '../../lib/custom-ui';
import { useDeepCompareCallback, useForm } from '../../hooks'; import { useForm } from '../../hooks';
import styles from './AddPopup.module.css'; import styles from './AddPopup.module.css';
@ -18,7 +18,7 @@ const AddStep = React.memo(({ onCreate, onClose }) => {
const nameField = useRef(null); const nameField = useRef(null);
const handleSubmit = useDeepCompareCallback(() => { const handleSubmit = useCallback(() => {
const cleanData = { const cleanData = {
...data, ...data,
name: data.name.trim(), name: data.name.trim(),

View file

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

View file

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

View file

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

View file

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

View file

@ -1,11 +1,11 @@
import dequal from 'dequal'; import dequal from 'dequal';
import React, { useEffect, useRef } from 'react'; import React, { useCallback, useEffect, useRef } from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Button, Form } from 'semantic-ui-react'; import { Button, Form } from 'semantic-ui-react';
import { Input, Popup } from '../../lib/custom-ui'; import { Input, Popup } from '../../lib/custom-ui';
import { useDeepCompareCallback, useForm, useToggle } from '../../hooks'; import { useForm, useToggle } from '../../hooks';
import { import {
createTimer, getTimerParts, startTimer, stopTimer, updateTimer, createTimer, getTimerParts, startTimer, stopTimer, updateTimer,
} from '../../utils/timer'; } from '../../utils/timer';
@ -41,16 +41,16 @@ const EditTimerStep = React.memo(({
const minutesField = useRef(null); const minutesField = useRef(null);
const secondsField = useRef(null); const secondsField = useRef(null);
const handleStartClick = useDeepCompareCallback(() => { const handleStartClick = useCallback(() => {
onUpdate(startTimer(defaultValue)); onUpdate(startTimer(defaultValue));
onClose(); onClose();
}, [defaultValue, onUpdate, onClose]); }, [defaultValue, onUpdate, onClose]);
const handleStopClick = useDeepCompareCallback(() => { const handleStopClick = useCallback(() => {
onUpdate(stopTimer(defaultValue)); onUpdate(stopTimer(defaultValue));
}, [defaultValue, onUpdate]); }, [defaultValue, onUpdate]);
const handleClearClick = useDeepCompareCallback(() => { const handleClearClick = useCallback(() => {
if (defaultValue) { if (defaultValue) {
onUpdate(null); onUpdate(null);
} }
@ -58,12 +58,12 @@ const EditTimerStep = React.memo(({
onClose(); onClose();
}, [defaultValue, onUpdate, onClose]); }, [defaultValue, onUpdate, onClose]);
const handleToggleEditClick = useDeepCompareCallback(() => { const handleToggleEditClick = useCallback(() => {
setData(createData(defaultValue)); setData(createData(defaultValue));
toggleEdit(); toggleEdit();
}, [defaultValue, setData, toggleEdit]); }, [defaultValue, setData, toggleEdit]);
const handleSubmit = useDeepCompareCallback(() => { const handleSubmit = useCallback(() => {
const parts = { const parts = {
hours: parseInt(data.hours, 10), hours: parseInt(data.hours, 10),
minutes: parseInt(data.minutes, 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 Paths from '../../constants/Paths';
import NotificationsPopup from './NotificationsPopup'; import NotificationsPopup from './NotificationsPopup';
import UserPopup from './UserPopup'; import UserPopup from '../UserPopup';
import styles from './Header.module.css'; import styles from './Header.module.css';
@ -14,10 +14,14 @@ const Header = React.memo(
user, user,
notifications, notifications,
isEditable, isEditable,
onUsers,
onNotificationDelete,
onUserUpdate, onUserUpdate,
onUserAvatarUpload, onUserAvatarUpload,
onNotificationDelete, onUserEmailUpdate,
onUsers, onUserEmailUpdateMessageDismiss,
onUserPasswordUpdate,
onUserPasswordUpdateMessageDismiss,
onLogout, onLogout,
}) => ( }) => (
<div className={styles.wrapper}> <div className={styles.wrapper}>
@ -40,11 +44,18 @@ const Header = React.memo(
</Menu.Item> </Menu.Item>
</NotificationsPopup> </NotificationsPopup>
<UserPopup <UserPopup
email={user.email}
name={user.name} name={user.name}
avatar={user.avatar} avatar={user.avatar}
isAvatarUploading={user.isAvatarUploading} isAvatarUploading={user.isAvatarUploading}
emailUpdateForm={user.emailUpdateForm}
passwordUpdateForm={user.passwordUpdateForm}
onUpdate={onUserUpdate} onUpdate={onUserUpdate}
onAvatarUpload={onUserAvatarUpload} onAvatarUpload={onUserAvatarUpload}
onEmailUpdate={onUserEmailUpdate}
onEmailUpdateMessageDismiss={onUserEmailUpdateMessageDismiss}
onPasswordUpdate={onUserPasswordUpdate}
onPasswordUpdateMessageDismiss={onUserPasswordUpdateMessageDismiss}
onLogout={onLogout} onLogout={onLogout}
> >
<Menu.Item className={styles.item}>{user.name}</Menu.Item> <Menu.Item className={styles.item}>{user.name}</Menu.Item>
@ -61,10 +72,14 @@ Header.propTypes = {
notifications: PropTypes.array.isRequired, notifications: PropTypes.array.isRequired,
/* eslint-enable react/forbid-prop-types */ /* eslint-enable react/forbid-prop-types */
isEditable: PropTypes.bool.isRequired, isEditable: PropTypes.bool.isRequired,
onUsers: PropTypes.func.isRequired,
onNotificationDelete: PropTypes.func.isRequired,
onUserUpdate: PropTypes.func.isRequired, onUserUpdate: PropTypes.func.isRequired,
onUserAvatarUpload: PropTypes.func.isRequired, onUserAvatarUpload: PropTypes.func.isRequired,
onNotificationDelete: PropTypes.func.isRequired, onUserEmailUpdate: PropTypes.func.isRequired,
onUsers: PropTypes.func.isRequired, onUserEmailUpdateMessageDismiss: PropTypes.func.isRequired,
onUserPasswordUpdate: PropTypes.func.isRequired,
onUserPasswordUpdateMessageDismiss: PropTypes.func.isRequired,
onLogout: 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 PropTypes from 'prop-types';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Button, Form } from 'semantic-ui-react'; import { Button, Form } from 'semantic-ui-react';
import { Popup } from '../../lib/custom-ui'; import { Popup } from '../../lib/custom-ui';
import { useDeepCompareCallback, useForm } from '../../hooks'; import { useForm } from '../../hooks';
import LabelColors from '../../constants/LabelColors'; import LabelColors from '../../constants/LabelColors';
import Editor from './Editor'; import Editor from './Editor';
@ -18,7 +18,7 @@ const AddStep = React.memo(({ onCreate, onBack }) => {
color: LabelColors.KEYS[0], color: LabelColors.KEYS[0],
})); }));
const handleSubmit = useDeepCompareCallback(() => { const handleSubmit = useCallback(() => {
const cleanData = { const cleanData = {
...data, ...data,
name: data.name.trim() || null, name: data.name.trim() || null,

View file

@ -5,7 +5,7 @@ import { useTranslation } from 'react-i18next';
import { Button, Form } from 'semantic-ui-react'; import { Button, Form } from 'semantic-ui-react';
import { Popup } from '../../lib/custom-ui'; import { Popup } from '../../lib/custom-ui';
import { useDeepCompareCallback, useForm, useSteps } from '../../hooks'; import { useForm, useSteps } from '../../hooks';
import LabelColors from '../../constants/LabelColors'; import LabelColors from '../../constants/LabelColors';
import Editor from './Editor'; import Editor from './Editor';
import DeleteStep from '../DeleteStep'; import DeleteStep from '../DeleteStep';
@ -29,7 +29,7 @@ const EditStep = React.memo(({
const [step, openStep, handleBack] = useSteps(); const [step, openStep, handleBack] = useSteps();
const handleSubmit = useDeepCompareCallback(() => { const handleSubmit = useCallback(() => {
const cleanData = { const cleanData = {
...data, ...data,
name: data.name.trim() || null, 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 { Button, Form, TextArea } from 'semantic-ui-react';
import { import {
useClosableForm, useClosableForm, useDidUpdate, useForm, useToggle,
useDeepCompareCallback,
useDidUpdate,
useForm,
useToggle,
} from '../../hooks'; } from '../../hooks';
import styles from './AddCard.module.css'; import styles from './AddCard.module.css';
@ -36,7 +32,7 @@ const AddCard = React.forwardRef(({ children, onCreate }, ref) => {
setIsOpened(false); setIsOpened(false);
}, []); }, []);
const submit = useDeepCompareCallback(() => { const submit = useCallback(() => {
const cleanData = { const cleanData = {
...data, ...data,
name: data.name.trim(), 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 PropTypes from 'prop-types';
import classNames from 'classnames'; import classNames from 'classnames';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
@ -9,16 +11,45 @@ import {
import { Input } from '../../lib/custom-ui'; import { Input } from '../../lib/custom-ui';
import { import {
useDeepCompareCallback, useDidUpdate, useForm, usePrevious, useToggle,
useDeepCompareEffect,
useDidUpdate,
useForm,
usePrevious,
useToggle,
} from '../../hooks'; } from '../../hooks';
import styles from './Login.module.css'; 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( const Login = React.memo(
({ ({
defaultData, isSubmitting, error, onAuthenticate, onMessageDismiss, defaultData, isSubmitting, error, onAuthenticate, onMessageDismiss,
@ -32,12 +63,13 @@ const Login = React.memo(
...defaultData, ...defaultData,
})); }));
const message = useMemo(() => createMessage(error), [error]);
const [focusPasswordFieldState, focusPasswordField] = useToggle(); const [focusPasswordFieldState, focusPasswordField] = useToggle();
const emailField = useRef(null); const emailField = useRef(null);
const passwordField = useRef(null); const passwordField = useRef(null);
const handleSubmit = useDeepCompareCallback(() => { const handleSubmit = useCallback(() => {
const cleanData = { const cleanData = {
...data, ...data,
email: data.email.trim(), email: data.email.trim(),
@ -60,14 +92,14 @@ const Login = React.memo(
emailField.current.select(); emailField.current.select();
}, []); }, []);
useDeepCompareEffect(() => { useEffect(() => {
if (wasSubmitting && !isSubmitting && error) { if (wasSubmitting && !isSubmitting && error) {
switch (error.message) { switch (error.message) {
case 'emailDoesNotExist': case 'Email does not exist':
emailField.current.select(); emailField.current.select();
break; break;
case 'invalidPassword': case 'Password is not valid':
setData((prevData) => ({ setData((prevData) => ({
...prevData, ...prevData,
password: '', password: '',
@ -98,14 +130,14 @@ const Login = React.memo(
className={styles.formTitle} className={styles.formTitle}
/> />
<div> <div>
{error && ( {message && (
<Message <Message
// eslint-disable-next-line react/jsx-props-no-spreading // eslint-disable-next-line react/jsx-props-no-spreading
{...{ {...{
[error.type || 'error']: true, [message.type]: true,
}} }}
visible visible
content={t(`common.${error.message}`)} content={t(message.content)}
onDismiss={onMessageDismiss} onDismiss={onMessageDismiss}
/> />
)} )}

View file

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

View file

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

View file

@ -2,9 +2,9 @@ import React, { useCallback, useEffect, useRef } from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Button } from 'semantic-ui-react'; 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'; 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 PropTypes from 'prop-types';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Button, Form } from 'semantic-ui-react'; 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'; 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 PropTypes from 'prop-types';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Menu } from 'semantic-ui-react'; import { Menu } from 'semantic-ui-react';
import { withPopup } from '../../../lib/popup'; import { withPopup } from '../../lib/popup';
import { Popup } from '../../../lib/custom-ui'; import { Popup } from '../../lib/custom-ui';
import { useSteps } from '../../../hooks'; import { useSteps } from '../../hooks';
import EditNameStep from './EditNameStep'; import EditNameStep from './EditNameStep';
import EditAvatarStep from './EditAvatarStep'; import EditAvatarStep from './EditAvatarStep';
import EditEmailStep from './EditEmailStep';
import EditPasswordStep from './EditPasswordStep';
import styles from './UserPopup.module.css'; import styles from './UserPopup.module.css';
const StepTypes = { const StepTypes = {
EDIT_NAME: 'EDIT_NAME', EDIT_NAME: 'EDIT_NAME',
EDIT_AVATAR: 'EDIT_AVATAR', EDIT_AVATAR: 'EDIT_AVATAR',
EDIT_EMAIL: 'EDIT_EMAIL',
EDIT_PASSWORD: 'EDIT_PASSWORD',
}; };
const UserStep = React.memo( 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 [t] = useTranslation();
const [step, openStep, handleBack] = useSteps(); const [step, openStep, handleBack] = useSteps();
@ -31,6 +48,14 @@ const UserStep = React.memo(
openStep(StepTypes.EDIT_AVATAR); openStep(StepTypes.EDIT_AVATAR);
}, [openStep]); }, [openStep]);
const handleEmailEditClick = useCallback(() => {
openStep(StepTypes.EDIT_EMAIL);
}, [openStep]);
const handlePasswordEditClick = useCallback(() => {
openStep(StepTypes.EDIT_PASSWORD);
}, [openStep]);
const handleNameUpdate = useCallback( const handleNameUpdate = useCallback(
(newName) => { (newName) => {
onUpdate({ onUpdate({
@ -68,6 +93,31 @@ const UserStep = React.memo(
onBack={handleBack} 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: default:
} }
} }
@ -87,6 +137,16 @@ const UserStep = React.memo(
context: 'title', context: 'title',
})} })}
</Menu.Item> </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}> <Menu.Item className={styles.menuItem} onClick={onLogout}>
{t('action.logOut', { {t('action.logOut', {
context: 'title', context: 'title',
@ -100,11 +160,20 @@ const UserStep = React.memo(
); );
UserStep.propTypes = { UserStep.propTypes = {
email: PropTypes.string.isRequired,
name: PropTypes.string.isRequired, name: PropTypes.string.isRequired,
avatar: PropTypes.string, avatar: PropTypes.string,
isAvatarUploading: PropTypes.bool.isRequired, 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, onUpdate: PropTypes.func.isRequired,
onAvatarUpload: 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, onLogout: PropTypes.func.isRequired,
onClose: PropTypes.func.isRequired, onClose: PropTypes.func.isRequired,
}; };

View file

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

View file

@ -6,7 +6,7 @@ export default {
/* Login */ /* Login */
AUTHENTICATE: 'AUTHENTICATE', AUTHENTICATE: 'AUTHENTICATE',
AUTHENTICATION_ERROR_CLEAR: 'AUTHENTICATION_ERROR_CLEAR', AUTHENTICATE_ERROR_CLEAR: 'AUTHENTICATE_ERROR_CLEAR',
LOGOUT: 'LOGOUT', LOGOUT: 'LOGOUT',
AUTHENTICATE_REQUESTED: 'AUTHENTICATE_REQUESTED', AUTHENTICATE_REQUESTED: 'AUTHENTICATE_REQUESTED',
AUTHENTICATE_SUCCEEDED: 'AUTHENTICATE_SUCCEEDED', AUTHENTICATE_SUCCEEDED: 'AUTHENTICATE_SUCCEEDED',
@ -30,8 +30,10 @@ export default {
/* User */ /* User */
USER_CREATE: 'USER_CREATE', 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_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_DELETE: 'USER_DELETE',
USER_TO_CARD_ADD: 'USER_TO_CARD_ADD', USER_TO_CARD_ADD: 'USER_TO_CARD_ADD',
USER_FROM_CARD_REMOVE: 'USER_FROM_CARD_REMOVE', USER_FROM_CARD_REMOVE: 'USER_FROM_CARD_REMOVE',
@ -48,6 +50,12 @@ export default {
USER_UPDATE_SUCCEEDED: 'USER_UPDATE_SUCCEEDED', USER_UPDATE_SUCCEEDED: 'USER_UPDATE_SUCCEEDED',
USER_UPDATE_FAILED: 'USER_UPDATE_FAILED', USER_UPDATE_FAILED: 'USER_UPDATE_FAILED',
USER_UPDATE_RECEIVED: 'USER_UPDATE_RECEIVED', 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_REQUESTED: 'USER_AVATAR_UPLOAD_REQUESTED',
USER_AVATAR_UPLOAD_SUCCEEDED: 'USER_AVATAR_UPLOAD_SUCCEEDED', USER_AVATAR_UPLOAD_SUCCEEDED: 'USER_AVATAR_UPLOAD_SUCCEEDED',
USER_AVATAR_UPLOAD_FAILED: 'USER_AVATAR_UPLOAD_FAILED', USER_AVATAR_UPLOAD_FAILED: 'USER_AVATAR_UPLOAD_FAILED',

View file

@ -6,7 +6,7 @@ export default {
/* Login */ /* Login */
AUTHENTICATE: `${PREFIX}/AUTHENTICATE`, AUTHENTICATE: `${PREFIX}/AUTHENTICATE`,
AUTHENTICATION_ERROR_CLEAR: `${PREFIX}/AUTHENTICATION_ERROR_CLEAR`, AUTHENTICATE_ERROR_CLEAR: `${PREFIX}/AUTHENTICATE_ERROR_CLEAR`,
LOGOUT: `${PREFIX}/LOGOUT`, LOGOUT: `${PREFIX}/LOGOUT`,
/* Modal */ /* Modal */
@ -17,9 +17,13 @@ export default {
/* User */ /* User */
USER_CREATE: `${PREFIX}/USER_CREATE`, 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`, USER_UPDATE: `${PREFIX}/USER_UPDATE`,
CURRENT_USER_UPDATE: `${PREFIX}/CURRENT_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`, CURRENT_USER_AVATAR_UPLOAD: `${PREFIX}/CURRENT_USER_AVATAR_UPLOAD`,
USER_DELETE: `${PREFIX}/USER_DELETE`, USER_DELETE: `${PREFIX}/USER_DELETE`,
USER_TO_CARD_ADD: `${PREFIX}/USER_TO_CARD_ADD`, 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 { closeModal, createProject } from '../actions/entry';
import AddProjectModal from '../components/AddProjectModal'; import AddProjectModal from '../components/AddProjectModal';
const mapStateToProps = ({ project: { data: defaultData, isSubmitting } }) => ({ const mapStateToProps = ({ projectCreateForm: { data: defaultData, isSubmitting } }) => ({
defaultData, defaultData,
isSubmitting, isSubmitting,
}); });

View file

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

View file

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

View file

@ -1,59 +1,19 @@
import { bindActionCreators } from 'redux'; import { bindActionCreators } from 'redux';
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import { authenticate, clearAuthenticationError } from '../actions/entry'; import { authenticate, clearAuthenticateError } from '../actions/entry';
import Login from '../components/Login'; import Login from '../components/Login';
const mapStateToProps = ({ login: { data: defaultData, isSubmitting, error: externalError } }) => { const mapStateToProps = ({ authenticateForm: { data: defaultData, isSubmitting, error } }) => ({
let error; defaultData,
isSubmitting,
if (externalError) { error,
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 mapDispatchToProps = (dispatch) => bindActionCreators( const mapDispatchToProps = (dispatch) => bindActionCreators(
{ {
onAuthenticate: authenticate, onAuthenticate: authenticate,
onMessageDismiss: clearAuthenticationError, onMessageDismiss: clearAuthenticateError,
}, },
dispatch, 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 usePrevious from './use-previous';
import useField from './use-field'; import useField from './use-field';
import useForm from './use-form'; import useForm from './use-form';
@ -10,8 +8,6 @@ import useClosableForm from './use-closable-form';
import useDidUpdate from './use-did-update'; import useDidUpdate from './use-did-update';
export { export {
useDeepCompareEffect,
useDeepCompareCallback,
usePrevious, usePrevious,
useField, useField,
useForm, 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'; import { useEffect, useRef } from 'react';
export default (value) => { export default (value) => {
const prevValue = useRef(); const prevValue = useRef(value);
useEffect(() => { useEffect(() => {
prevValue.current = value; prevValue.current = value;

View file

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

View file

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

View file

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

View file

@ -2,6 +2,24 @@ import { Model, attr } from 'redux-orm';
import ActionTypes from '../constants/ActionTypes'; 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 { export default class extends Model {
static modelName = 'User'; static modelName = 'User';
@ -17,6 +35,12 @@ export default class extends Model {
isAvatarUploading: attr({ isAvatarUploading: attr({
getDefault: () => false, getDefault: () => false,
}), }),
emailUpdateForm: attr({
getDefault: () => DEFAULT_EMAIL_UPDATE_FORM,
}),
passwordUpdateForm: attr({
getDefault: () => DEFAULT_PASSWORD_UPDATE_FORM,
}),
}; };
static reducer({ type, payload }, User) { static reducer({ type, payload }, User) {
@ -44,6 +68,30 @@ export default class extends Model {
User.withId(payload.id).update(payload.data); User.withId(payload.id).update(payload.data);
break; 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: case ActionTypes.USER_DELETE:
User.withId(payload.id).deleteWithRelated(); User.withId(payload.id).deleteWithRelated();
@ -52,6 +100,73 @@ export default class extends Model {
User.withId(payload.user.id).update(payload.user); User.withId(payload.user.id).update(payload.user);
break; 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: case ActionTypes.USER_AVATAR_UPLOAD_REQUESTED:
User.withId(payload.id).update({ User.withId(payload.id).update({
isAvatarUploading: true, isAvatarUploading: true,
@ -59,8 +174,8 @@ export default class extends Model {
break; break;
case ActionTypes.USER_AVATAR_UPLOAD_SUCCEEDED: case ActionTypes.USER_AVATAR_UPLOAD_SUCCEEDED:
User.withId(payload.user.id).update({ User.withId(payload.id).update({
...payload.user, avatar: payload.avatar,
isAvatarUploading: false, isAvatarUploading: false,
}); });

View file

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

View file

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

View file

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

View file

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

View file

@ -11,7 +11,13 @@ import {
fetchCurrentUserFailed, fetchCurrentUserFailed,
fetchCurrentUserRequested, fetchCurrentUserRequested,
fetchCurrentUserSucceeded, fetchCurrentUserSucceeded,
updateUserEmailFailed,
updateUserEmailRequested,
updateUserEmailSucceeded,
updateUserFailed, updateUserFailed,
updateUserPasswordFailed,
updateUserPasswordRequested,
updateUserPasswordSucceeded,
updateUserRequested, updateUserRequested,
updateUserSucceeded, updateUserSucceeded,
uploadUserAvatarFailed, 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) { export function* uploadUserAvatarRequest(id, file) {
yield put(uploadUserAvatarRequested(id)); yield put(uploadUserAvatarRequested(id));
try { try {
const { item } = yield call(request, api.uploadUserAvatar, id, file); const { item } = yield call(request, api.uploadUserAvatar, id, file);
const action = uploadUserAvatarSucceeded(item); const action = uploadUserAvatarSucceeded(id, item);
yield put(action); yield put(action);
return { return {

View file

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

View file

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

View file

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

View file

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

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({ return this.res.json({
item: user item: user.avatar
}); });
}); });
} }

View file

@ -21,19 +21,31 @@ module.exports = {
} }
}, },
exits: {
conflict: {}
},
fn: async function(inputs, exits) { fn: async function(inputs, exits) {
if (!_.isUndefined(inputs.values.email)) { if (!_.isUndefined(inputs.values.email)) {
inputs.values.email = inputs.values.email.toLowerCase(); inputs.values.email = inputs.values.email.toLowerCase();
} }
let isOnlyPasswordChange = false;
if (!_.isUndefined(inputs.values.password)) { if (!_.isUndefined(inputs.values.password)) {
inputs.values.password = bcrypt.hashSync(inputs.values.password, 10); inputs.values.password = bcrypt.hashSync(inputs.values.password, 10);
if (Object.keys(inputs.values).length === 1) {
isOnlyPasswordChange = true;
}
} }
const user = await User.updateOne({ const user = await User.updateOne({
id: inputs.record.id, id: inputs.record.id,
deletedAt: null deletedAt: null
}).set(inputs.values); })
.set(inputs.values)
.intercept(undefined, 'conflict');
if (user) { if (user) {
if (inputs.record.avatar && user.avatar !== inputs.record.avatar) { if (inputs.record.avatar && user.avatar !== inputs.record.avatar) {
@ -44,28 +56,30 @@ module.exports = {
} catch (unusedError) {} } catch (unusedError) {}
} }
const adminUserIds = await sails.helpers.getAdminUserIds(); if (!isOnlyPasswordChange) {
const adminUserIds = await sails.helpers.getAdminUserIds();
const projectIds = await sails.helpers.getMembershipProjectIdsForUser( const projectIds = await sails.helpers.getMembershipProjectIdsForUser(
user.id 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 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); 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', 'POST /api/users': 'users/create',
'GET /api/users/me': 'users/show', 'GET /api/users/me': 'users/show',
'PATCH /api/users/:id': 'users/update', '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', 'POST /api/users/:id/upload-avatar': 'users/upload-avatar',
'DELETE /api/users/:id': 'users/delete', 'DELETE /api/users/:id': 'users/delete',