mirror of
https://github.com/plankanban/planka.git
synced 2025-12-26 09:15:01 +03:00
@@ -0,0 +1,212 @@
|
||||
/*!
|
||||
* Copyright (c) 2024 PLANKA Software GmbH
|
||||
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
|
||||
*/
|
||||
|
||||
import isEmail from 'validator/lib/isEmail';
|
||||
import React, { useCallback, useEffect, useMemo } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button, Form, Message } from 'semantic-ui-react';
|
||||
import { useDidUpdate, usePrevious, useToggle } from '../../../lib/hooks';
|
||||
import { Input, Popup } from '../../../lib/custom-ui';
|
||||
|
||||
import selectors from '../../../selectors';
|
||||
import entryActions from '../../../entry-actions';
|
||||
import { useForm, useNestedRef } from '../../../hooks';
|
||||
|
||||
import styles from './EditUserEmailStep.module.scss';
|
||||
|
||||
const createMessage = (error) => {
|
||||
if (!error) {
|
||||
return error;
|
||||
}
|
||||
|
||||
switch (error.message) {
|
||||
case 'Email already in use':
|
||||
return {
|
||||
type: 'error',
|
||||
content: 'common.emailAlreadyInUse',
|
||||
};
|
||||
case 'Invalid current password':
|
||||
return {
|
||||
type: 'error',
|
||||
content: 'common.invalidCurrentPassword',
|
||||
};
|
||||
default:
|
||||
return {
|
||||
type: 'warning',
|
||||
content: 'common.unknownError',
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const EditUserEmailStep = React.memo(({ id, withPasswordConfirmation, onBack, onClose }) => {
|
||||
const selectUserById = useMemo(() => selectors.makeSelectUserById(), []);
|
||||
|
||||
const {
|
||||
email,
|
||||
emailUpdateForm: { data: defaultData, isSubmitting, error },
|
||||
} = useSelector((state) => selectUserById(state, id));
|
||||
|
||||
const dispatch = useDispatch();
|
||||
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 [emailFieldRef, handleEmailFieldRef] = useNestedRef('inputRef');
|
||||
const [currentPasswordFieldRef, handleCurrentPasswordFieldRef] = useNestedRef('inputRef');
|
||||
|
||||
const handleSubmit = useCallback(() => {
|
||||
const cleanData = {
|
||||
...data,
|
||||
email: data.email.trim(),
|
||||
};
|
||||
|
||||
if (!isEmail(cleanData.email)) {
|
||||
emailFieldRef.current.select();
|
||||
return;
|
||||
}
|
||||
|
||||
if (cleanData.email === email) {
|
||||
onClose();
|
||||
return;
|
||||
}
|
||||
|
||||
if (withPasswordConfirmation) {
|
||||
if (!cleanData.currentPassword) {
|
||||
currentPasswordFieldRef.current.focus();
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
delete cleanData.currentPassword;
|
||||
}
|
||||
|
||||
dispatch(entryActions.updateUserEmail(id, cleanData));
|
||||
}, [
|
||||
id,
|
||||
withPasswordConfirmation,
|
||||
onClose,
|
||||
email,
|
||||
dispatch,
|
||||
data,
|
||||
emailFieldRef,
|
||||
currentPasswordFieldRef,
|
||||
]);
|
||||
|
||||
const handleMessageDismiss = useCallback(() => {
|
||||
dispatch(entryActions.clearUserEmailUpdateError(id));
|
||||
}, [id, dispatch]);
|
||||
|
||||
useEffect(() => {
|
||||
emailFieldRef.current.focus({
|
||||
preventScroll: true,
|
||||
});
|
||||
}, [emailFieldRef]);
|
||||
|
||||
useDidUpdate(() => {
|
||||
if (wasSubmitting && !isSubmitting) {
|
||||
if (error) {
|
||||
switch (error.message) {
|
||||
case 'Email already in use':
|
||||
emailFieldRef.current.select();
|
||||
|
||||
break;
|
||||
case 'Invalid current password':
|
||||
setData((prevData) => ({
|
||||
...prevData,
|
||||
currentPassword: '',
|
||||
}));
|
||||
focusCurrentPasswordField();
|
||||
|
||||
break;
|
||||
default:
|
||||
}
|
||||
} else {
|
||||
onClose();
|
||||
}
|
||||
}
|
||||
}, [isSubmitting, wasSubmitting, error, onClose]);
|
||||
|
||||
useDidUpdate(() => {
|
||||
currentPasswordFieldRef.current.focus();
|
||||
}, [focusCurrentPasswordFieldState]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Popup.Header onBack={onBack}>
|
||||
{t('common.editEmail', {
|
||||
context: 'title',
|
||||
})}
|
||||
</Popup.Header>
|
||||
<Popup.Content>
|
||||
{message && (
|
||||
<Message
|
||||
{...{
|
||||
[message.type]: true,
|
||||
}}
|
||||
visible
|
||||
content={t(message.content)}
|
||||
onDismiss={handleMessageDismiss}
|
||||
/>
|
||||
)}
|
||||
<Form onSubmit={handleSubmit}>
|
||||
<div className={styles.text}>{t('common.newEmail')}</div>
|
||||
<Input
|
||||
fluid
|
||||
ref={handleEmailFieldRef}
|
||||
name="email"
|
||||
value={data.email}
|
||||
placeholder={email}
|
||||
maxLength={256}
|
||||
className={styles.field}
|
||||
onChange={handleFieldChange}
|
||||
/>
|
||||
{withPasswordConfirmation && (
|
||||
<>
|
||||
<div className={styles.text}>{t('common.currentPassword')}</div>
|
||||
<Input.Password
|
||||
fluid
|
||||
ref={handleCurrentPasswordFieldRef}
|
||||
name="currentPassword"
|
||||
value={data.currentPassword}
|
||||
maxLength={256}
|
||||
className={styles.field}
|
||||
onChange={handleFieldChange}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<Button
|
||||
positive
|
||||
content={t('action.save')}
|
||||
loading={isSubmitting}
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
</Form>
|
||||
</Popup.Content>
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
EditUserEmailStep.propTypes = {
|
||||
id: PropTypes.string.isRequired,
|
||||
withPasswordConfirmation: PropTypes.bool,
|
||||
onBack: PropTypes.func,
|
||||
onClose: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
EditUserEmailStep.defaultProps = {
|
||||
withPasswordConfirmation: false,
|
||||
onBack: undefined,
|
||||
};
|
||||
|
||||
export default EditUserEmailStep;
|
||||
@@ -0,0 +1,17 @@
|
||||
/*!
|
||||
* Copyright (c) 2024 PLANKA Software GmbH
|
||||
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
|
||||
*/
|
||||
|
||||
:global(#app) {
|
||||
.field {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.text {
|
||||
color: #444444;
|
||||
font-size: 12px;
|
||||
font-weight: bold;
|
||||
padding-bottom: 6px;
|
||||
}
|
||||
}
|
||||
8
client/src/components/users/EditUserEmailStep/index.js
Normal file
8
client/src/components/users/EditUserEmailStep/index.js
Normal file
@@ -0,0 +1,8 @@
|
||||
/*!
|
||||
* Copyright (c) 2024 PLANKA Software GmbH
|
||||
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
|
||||
*/
|
||||
|
||||
import EditUserEmailStep from './EditUserEmailStep';
|
||||
|
||||
export default EditUserEmailStep;
|
||||
@@ -0,0 +1,116 @@
|
||||
/*!
|
||||
* Copyright (c) 2024 PLANKA Software GmbH
|
||||
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
|
||||
*/
|
||||
|
||||
import { dequal } from 'dequal';
|
||||
import omit from 'lodash/omit';
|
||||
import React, { useCallback, useMemo } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button, Form, Input } from 'semantic-ui-react';
|
||||
|
||||
import selectors from '../../../selectors';
|
||||
import entryActions from '../../../entry-actions';
|
||||
import { useForm, useNestedRef } from '../../../hooks';
|
||||
|
||||
import styles from './EditUserInformation.module.scss';
|
||||
|
||||
const EditUserInformation = React.memo(({ id, onUpdate }) => {
|
||||
const selectUserById = useMemo(() => selectors.makeSelectUserById(), []);
|
||||
|
||||
const user = useSelector((state) => selectUserById(state, id));
|
||||
|
||||
const dispatch = useDispatch();
|
||||
const [t] = useTranslation();
|
||||
|
||||
const defaultData = useMemo(
|
||||
() => ({
|
||||
name: user.name,
|
||||
phone: user.phone,
|
||||
organization: user.organization,
|
||||
}),
|
||||
[user.name, user.phone, user.organization],
|
||||
);
|
||||
|
||||
const [data, handleFieldChange] = useForm(() => ({
|
||||
name: '',
|
||||
...defaultData,
|
||||
phone: defaultData.phone || '',
|
||||
organization: defaultData.organization || '',
|
||||
}));
|
||||
|
||||
const cleanData = useMemo(
|
||||
() => ({
|
||||
...data,
|
||||
name: data.name.trim(),
|
||||
phone: data.phone.trim() || null,
|
||||
organization: data.organization.trim() || null,
|
||||
}),
|
||||
[data],
|
||||
);
|
||||
|
||||
const [nameFieldRef, handleNameFieldRef] = useNestedRef('inputRef');
|
||||
|
||||
const isNameEditable = !user.lockedFieldNames.includes('name');
|
||||
|
||||
const handleSubmit = useCallback(() => {
|
||||
if (isNameEditable && !cleanData.name) {
|
||||
nameFieldRef.current.select();
|
||||
return;
|
||||
}
|
||||
|
||||
dispatch(entryActions.updateUser(id, isNameEditable ? cleanData : omit(cleanData, 'name')));
|
||||
|
||||
if (onUpdate) {
|
||||
onUpdate();
|
||||
}
|
||||
}, [id, onUpdate, dispatch, cleanData, nameFieldRef, isNameEditable]);
|
||||
|
||||
return (
|
||||
<Form onSubmit={handleSubmit}>
|
||||
<div className={styles.text}>{t('common.name')}</div>
|
||||
<Input
|
||||
fluid
|
||||
ref={handleNameFieldRef}
|
||||
name="name"
|
||||
value={data.name}
|
||||
maxLength={128}
|
||||
disabled={!isNameEditable}
|
||||
className={styles.field}
|
||||
onChange={handleFieldChange}
|
||||
/>
|
||||
<div className={styles.text}>{t('common.phone')}</div>
|
||||
<Input
|
||||
fluid
|
||||
name="phone"
|
||||
value={data.phone}
|
||||
maxLength={128}
|
||||
className={styles.field}
|
||||
onChange={handleFieldChange}
|
||||
/>
|
||||
<div className={styles.text}>{t('common.organization')}</div>
|
||||
<Input
|
||||
fluid
|
||||
name="organization"
|
||||
value={data.organization}
|
||||
maxLength={128}
|
||||
className={styles.field}
|
||||
onChange={handleFieldChange}
|
||||
/>
|
||||
<Button positive disabled={dequal(cleanData, defaultData)} content={t('action.save')} />
|
||||
</Form>
|
||||
);
|
||||
});
|
||||
|
||||
EditUserInformation.propTypes = {
|
||||
id: PropTypes.string.isRequired,
|
||||
onUpdate: PropTypes.func,
|
||||
};
|
||||
|
||||
EditUserInformation.defaultProps = {
|
||||
onUpdate: undefined,
|
||||
};
|
||||
|
||||
export default EditUserInformation;
|
||||
@@ -0,0 +1,17 @@
|
||||
/*!
|
||||
* Copyright (c) 2024 PLANKA Software GmbH
|
||||
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
|
||||
*/
|
||||
|
||||
:global(#app) {
|
||||
.field {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.text {
|
||||
color: #444444;
|
||||
font-size: 12px;
|
||||
font-weight: bold;
|
||||
padding-bottom: 6px;
|
||||
}
|
||||
}
|
||||
8
client/src/components/users/EditUserInformation/index.js
Normal file
8
client/src/components/users/EditUserInformation/index.js
Normal file
@@ -0,0 +1,8 @@
|
||||
/*!
|
||||
* Copyright (c) 2024 PLANKA Software GmbH
|
||||
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
|
||||
*/
|
||||
|
||||
import EditUserInformation from './EditUserInformation';
|
||||
|
||||
export default EditUserInformation;
|
||||
44
client/src/components/users/EditUserInformationStep.jsx
Normal file
44
client/src/components/users/EditUserInformationStep.jsx
Normal file
@@ -0,0 +1,44 @@
|
||||
/*!
|
||||
* Copyright (c) 2024 PLANKA Software GmbH
|
||||
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
|
||||
*/
|
||||
|
||||
import React, { useCallback } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Popup } from '../../lib/custom-ui';
|
||||
|
||||
import EditUserInformation from './EditUserInformation';
|
||||
|
||||
const EditUserInformationStep = React.memo(({ id, onBack, onClose }) => {
|
||||
const [t] = useTranslation();
|
||||
|
||||
const handleUpdate = useCallback(() => {
|
||||
onClose();
|
||||
}, [onClose]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Popup.Header onBack={onBack}>
|
||||
{t('common.editInformation', {
|
||||
context: 'title',
|
||||
})}
|
||||
</Popup.Header>
|
||||
<Popup.Content>
|
||||
<EditUserInformation id={id} onUpdate={handleUpdate} />
|
||||
</Popup.Content>
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
EditUserInformationStep.propTypes = {
|
||||
id: PropTypes.string.isRequired,
|
||||
onBack: PropTypes.func,
|
||||
onClose: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
EditUserInformationStep.defaultProps = {
|
||||
onBack: undefined,
|
||||
};
|
||||
|
||||
export default EditUserInformationStep;
|
||||
@@ -0,0 +1,181 @@
|
||||
/*!
|
||||
* Copyright (c) 2024 PLANKA Software GmbH
|
||||
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
|
||||
*/
|
||||
|
||||
import omit from 'lodash/omit';
|
||||
import React, { useCallback, useEffect, useMemo } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button, Form, Message } from 'semantic-ui-react';
|
||||
import { useDidUpdate, usePrevious, useToggle } from '../../../lib/hooks';
|
||||
import { Input, Popup } from '../../../lib/custom-ui';
|
||||
|
||||
import selectors from '../../../selectors';
|
||||
import entryActions from '../../../entry-actions';
|
||||
import { useForm, useNestedRef } from '../../../hooks';
|
||||
import { isPassword } from '../../../utils/validator';
|
||||
|
||||
import styles from './EditUserPasswordStep.module.scss';
|
||||
|
||||
const createMessage = (error) => {
|
||||
if (!error) {
|
||||
return error;
|
||||
}
|
||||
|
||||
switch (error.message) {
|
||||
case 'Invalid current password':
|
||||
return {
|
||||
type: 'error',
|
||||
content: 'common.invalidCurrentPassword',
|
||||
};
|
||||
default:
|
||||
return {
|
||||
type: 'warning',
|
||||
content: 'common.unknownError',
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const EditUserPasswordStep = React.memo(({ id, withPasswordConfirmation, onBack, onClose }) => {
|
||||
const selectUserById = useMemo(() => selectors.makeSelectUserById(), []);
|
||||
|
||||
const {
|
||||
data: defaultData,
|
||||
isSubmitting,
|
||||
error,
|
||||
} = useSelector((state) => selectUserById(state, id).passwordUpdateForm);
|
||||
|
||||
const dispatch = useDispatch();
|
||||
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 [passwordFieldRef, handlePasswordFieldRef] = useNestedRef('inputRef');
|
||||
const [currentPasswordFieldRef, handleCurrentPasswordFieldRef] = useNestedRef('inputRef');
|
||||
|
||||
const handleSubmit = useCallback(() => {
|
||||
if (!data.password || !isPassword(data.password)) {
|
||||
passwordFieldRef.current.select();
|
||||
return;
|
||||
}
|
||||
|
||||
if (withPasswordConfirmation && !data.currentPassword) {
|
||||
currentPasswordFieldRef.current.focus();
|
||||
return;
|
||||
}
|
||||
|
||||
dispatch(
|
||||
entryActions.updateUserPassword(
|
||||
id,
|
||||
withPasswordConfirmation ? data : omit(data, 'currentPassword'),
|
||||
),
|
||||
);
|
||||
}, [id, withPasswordConfirmation, dispatch, data, passwordFieldRef, currentPasswordFieldRef]);
|
||||
|
||||
const handleMessageDismiss = useCallback(() => {
|
||||
dispatch(entryActions.clearUserPasswordUpdateError(id));
|
||||
}, [id, dispatch]);
|
||||
|
||||
useEffect(() => {
|
||||
passwordFieldRef.current.focus({
|
||||
preventScroll: true,
|
||||
});
|
||||
}, [passwordFieldRef]);
|
||||
|
||||
useDidUpdate(() => {
|
||||
if (wasSubmitting && !isSubmitting) {
|
||||
if (!error) {
|
||||
onClose();
|
||||
} else if (error.message === 'Invalid current password') {
|
||||
setData((prevData) => ({
|
||||
...prevData,
|
||||
currentPassword: '',
|
||||
}));
|
||||
focusCurrentPasswordField();
|
||||
}
|
||||
}
|
||||
}, [isSubmitting, wasSubmitting, error, onClose]);
|
||||
|
||||
useDidUpdate(() => {
|
||||
currentPasswordFieldRef.current.focus();
|
||||
}, [focusCurrentPasswordFieldState]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Popup.Header onBack={onBack}>
|
||||
{t('common.editPassword', {
|
||||
context: 'title',
|
||||
})}
|
||||
</Popup.Header>
|
||||
<Popup.Content>
|
||||
{message && (
|
||||
<Message
|
||||
{...{
|
||||
[message.type]: true,
|
||||
}}
|
||||
visible
|
||||
content={t(message.content)}
|
||||
onDismiss={handleMessageDismiss}
|
||||
/>
|
||||
)}
|
||||
<Form onSubmit={handleSubmit}>
|
||||
<div className={styles.text}>{t('common.newPassword')}</div>
|
||||
<Input.Password
|
||||
withStrengthBar
|
||||
fluid
|
||||
ref={handlePasswordFieldRef}
|
||||
name="password"
|
||||
value={data.password}
|
||||
maxLength={256}
|
||||
className={styles.field}
|
||||
onChange={handleFieldChange}
|
||||
/>
|
||||
{withPasswordConfirmation && (
|
||||
<>
|
||||
<div className={styles.text}>{t('common.currentPassword')}</div>
|
||||
<Input.Password
|
||||
fluid
|
||||
ref={handleCurrentPasswordFieldRef}
|
||||
name="currentPassword"
|
||||
value={data.currentPassword}
|
||||
maxLength={256}
|
||||
className={styles.field}
|
||||
onChange={handleFieldChange}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<Button
|
||||
positive
|
||||
content={t('action.save')}
|
||||
loading={isSubmitting}
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
</Form>
|
||||
</Popup.Content>
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
EditUserPasswordStep.propTypes = {
|
||||
id: PropTypes.string.isRequired,
|
||||
withPasswordConfirmation: PropTypes.bool,
|
||||
onBack: PropTypes.func,
|
||||
onClose: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
EditUserPasswordStep.defaultProps = {
|
||||
withPasswordConfirmation: false,
|
||||
onBack: undefined,
|
||||
};
|
||||
|
||||
export default EditUserPasswordStep;
|
||||
@@ -0,0 +1,17 @@
|
||||
/*!
|
||||
* Copyright (c) 2024 PLANKA Software GmbH
|
||||
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
|
||||
*/
|
||||
|
||||
:global(#app) {
|
||||
.field {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.text {
|
||||
color: #444444;
|
||||
font-size: 12px;
|
||||
font-weight: bold;
|
||||
padding-bottom: 6px;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
/*!
|
||||
* Copyright (c) 2024 PLANKA Software GmbH
|
||||
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
|
||||
*/
|
||||
|
||||
import EditUserPasswordStep from './EditUserPasswordStep';
|
||||
|
||||
export default EditUserPasswordStep;
|
||||
@@ -0,0 +1,212 @@
|
||||
/*!
|
||||
* Copyright (c) 2024 PLANKA Software GmbH
|
||||
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
|
||||
*/
|
||||
|
||||
import React, { useCallback, useEffect, useMemo } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button, Form, Message } from 'semantic-ui-react';
|
||||
import { useDidUpdate, usePrevious, useToggle } from '../../../lib/hooks';
|
||||
import { Input, Popup } from '../../../lib/custom-ui';
|
||||
|
||||
import selectors from '../../../selectors';
|
||||
import entryActions from '../../../entry-actions';
|
||||
import { useForm, useNestedRef } from '../../../hooks';
|
||||
import { isUsername } from '../../../utils/validator';
|
||||
|
||||
import styles from './EditUserUsernameStep.module.scss';
|
||||
|
||||
const createMessage = (error) => {
|
||||
if (!error) {
|
||||
return error;
|
||||
}
|
||||
|
||||
switch (error.message) {
|
||||
case 'Username already in use':
|
||||
return {
|
||||
type: 'error',
|
||||
content: 'common.usernameAlreadyInUse',
|
||||
};
|
||||
case 'Invalid current password':
|
||||
return {
|
||||
type: 'error',
|
||||
content: 'common.invalidCurrentPassword',
|
||||
};
|
||||
default:
|
||||
return {
|
||||
type: 'warning',
|
||||
content: 'common.unknownError',
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const EditUserUsernameStep = React.memo(({ id, withPasswordConfirmation, onBack, onClose }) => {
|
||||
const selectUserById = useMemo(() => selectors.makeSelectUserById(), []);
|
||||
|
||||
const {
|
||||
username,
|
||||
usernameUpdateForm: { data: defaultData, isSubmitting, error },
|
||||
} = useSelector((state) => selectUserById(state, id));
|
||||
|
||||
const dispatch = useDispatch();
|
||||
const [t] = useTranslation();
|
||||
const wasSubmitting = usePrevious(isSubmitting);
|
||||
|
||||
const [data, handleFieldChange, setData] = useForm({
|
||||
username: '',
|
||||
currentPassword: '',
|
||||
...defaultData,
|
||||
});
|
||||
|
||||
const message = useMemo(() => createMessage(error), [error]);
|
||||
const [focusCurrentPasswordFieldState, focusCurrentPasswordField] = useToggle();
|
||||
|
||||
const [usernameFieldRef, handleUsernameFieldRef] = useNestedRef('inputRef');
|
||||
const [currentPasswordFieldRef, handleCurrentPasswordFieldRef] = useNestedRef('inputRef');
|
||||
|
||||
const handleSubmit = useCallback(() => {
|
||||
const cleanData = {
|
||||
...data,
|
||||
username: data.username.trim() || null,
|
||||
};
|
||||
|
||||
if (!cleanData.username || !isUsername(cleanData.username)) {
|
||||
usernameFieldRef.current.select();
|
||||
return;
|
||||
}
|
||||
|
||||
if (cleanData.username === username) {
|
||||
onClose();
|
||||
return;
|
||||
}
|
||||
|
||||
if (withPasswordConfirmation) {
|
||||
if (!cleanData.currentPassword) {
|
||||
currentPasswordFieldRef.current.focus();
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
delete cleanData.currentPassword;
|
||||
}
|
||||
|
||||
dispatch(entryActions.updateUserUsername(id, cleanData));
|
||||
}, [
|
||||
id,
|
||||
withPasswordConfirmation,
|
||||
onClose,
|
||||
username,
|
||||
dispatch,
|
||||
data,
|
||||
usernameFieldRef,
|
||||
currentPasswordFieldRef,
|
||||
]);
|
||||
|
||||
const handleMessageDismiss = useCallback(() => {
|
||||
dispatch(entryActions.clearUserUsernameUpdateError(id));
|
||||
}, [id, dispatch]);
|
||||
|
||||
useEffect(() => {
|
||||
usernameFieldRef.current.focus({
|
||||
preventScroll: true,
|
||||
});
|
||||
}, [usernameFieldRef]);
|
||||
|
||||
useDidUpdate(() => {
|
||||
if (wasSubmitting && !isSubmitting) {
|
||||
if (error) {
|
||||
switch (error.message) {
|
||||
case 'Username already in use':
|
||||
usernameFieldRef.current.select();
|
||||
|
||||
break;
|
||||
case 'Invalid current password':
|
||||
setData((prevData) => ({
|
||||
...prevData,
|
||||
currentPassword: '',
|
||||
}));
|
||||
focusCurrentPasswordField();
|
||||
|
||||
break;
|
||||
default:
|
||||
}
|
||||
} else {
|
||||
onClose();
|
||||
}
|
||||
}
|
||||
}, [isSubmitting, wasSubmitting, error, onClose]);
|
||||
|
||||
useDidUpdate(() => {
|
||||
currentPasswordFieldRef.current.focus();
|
||||
}, [focusCurrentPasswordFieldState]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Popup.Header onBack={onBack}>
|
||||
{t('common.editUsername', {
|
||||
context: 'title',
|
||||
})}
|
||||
</Popup.Header>
|
||||
<Popup.Content>
|
||||
{message && (
|
||||
<Message
|
||||
{...{
|
||||
[message.type]: true,
|
||||
}}
|
||||
visible
|
||||
content={t(message.content)}
|
||||
onDismiss={handleMessageDismiss}
|
||||
/>
|
||||
)}
|
||||
<Form onSubmit={handleSubmit}>
|
||||
<div className={styles.text}>{t('common.newUsername')}</div>
|
||||
<Input
|
||||
fluid
|
||||
ref={handleUsernameFieldRef}
|
||||
name="username"
|
||||
value={data.username}
|
||||
placeholder={username}
|
||||
maxLength={16}
|
||||
className={styles.field}
|
||||
onChange={handleFieldChange}
|
||||
/>
|
||||
{withPasswordConfirmation && (
|
||||
<>
|
||||
<div className={styles.text}>{t('common.currentPassword')}</div>
|
||||
<Input.Password
|
||||
fluid
|
||||
ref={handleCurrentPasswordFieldRef}
|
||||
name="currentPassword"
|
||||
value={data.currentPassword}
|
||||
maxLength={256}
|
||||
className={styles.field}
|
||||
onChange={handleFieldChange}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<Button
|
||||
positive
|
||||
content={t('action.save')}
|
||||
loading={isSubmitting}
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
</Form>
|
||||
</Popup.Content>
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
EditUserUsernameStep.propTypes = {
|
||||
id: PropTypes.string.isRequired,
|
||||
withPasswordConfirmation: PropTypes.bool,
|
||||
onBack: PropTypes.func,
|
||||
onClose: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
EditUserUsernameStep.defaultProps = {
|
||||
withPasswordConfirmation: false,
|
||||
onBack: undefined,
|
||||
};
|
||||
|
||||
export default EditUserUsernameStep;
|
||||
@@ -0,0 +1,17 @@
|
||||
/*!
|
||||
* Copyright (c) 2024 PLANKA Software GmbH
|
||||
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
|
||||
*/
|
||||
|
||||
:global(#app) {
|
||||
.field {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.text {
|
||||
color: #444444;
|
||||
font-size: 12px;
|
||||
font-weight: bold;
|
||||
padding-bottom: 6px;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
/*!
|
||||
* Copyright (c) 2024 PLANKA Software GmbH
|
||||
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
|
||||
*/
|
||||
|
||||
import EditUserUsernameStep from './EditUserUsernameStep';
|
||||
|
||||
export default EditUserUsernameStep;
|
||||
113
client/src/components/users/UserAvatar/UserAvatar.jsx
Executable file
113
client/src/components/users/UserAvatar/UserAvatar.jsx
Executable file
@@ -0,0 +1,113 @@
|
||||
/*!
|
||||
* Copyright (c) 2024 PLANKA Software GmbH
|
||||
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
|
||||
*/
|
||||
|
||||
import upperFirst from 'lodash/upperFirst';
|
||||
import camelCase from 'lodash/camelCase';
|
||||
import initials from 'initials';
|
||||
import React, { useMemo } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import classNames from 'classnames';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import selectors from '../../../selectors';
|
||||
import { StaticUserIds } from '../../../constants/StaticUsers';
|
||||
|
||||
import styles from './UserAvatar.module.scss';
|
||||
|
||||
const Sizes = {
|
||||
TINY: 'tiny',
|
||||
SMALL: 'small',
|
||||
MEDIUM: 'medium',
|
||||
LARGE: 'large',
|
||||
MASSIVE: 'massive',
|
||||
};
|
||||
|
||||
const COLORS = [
|
||||
'emerald',
|
||||
'peter-river',
|
||||
'wisteria',
|
||||
'carrot',
|
||||
'alizarin',
|
||||
'turquoise',
|
||||
'midnight-blue',
|
||||
];
|
||||
|
||||
const getColor = (name) => {
|
||||
let sum = 0;
|
||||
for (let i = 0; i < name.length; i += 1) {
|
||||
sum += name.charCodeAt(i);
|
||||
}
|
||||
|
||||
return COLORS[sum % COLORS.length];
|
||||
};
|
||||
|
||||
const UserAvatar = React.memo(
|
||||
({ id, size, isDisabled, withCreatorIndicator, className, onClick }) => {
|
||||
const selectUserById = useMemo(() => selectors.makeSelectUserById(), []);
|
||||
|
||||
const user = useSelector((state) => selectUserById(state, id));
|
||||
|
||||
const [t] = useTranslation();
|
||||
|
||||
const contentNode = (
|
||||
<span
|
||||
title={
|
||||
user.id === StaticUserIds.DELETED
|
||||
? t(`common.${user.name}`, {
|
||||
context: 'title',
|
||||
})
|
||||
: user.name
|
||||
}
|
||||
className={classNames(
|
||||
styles.wrapper,
|
||||
styles[`wrapper${upperFirst(size)}`],
|
||||
onClick && styles.wrapperHoverable,
|
||||
!user.avatar && styles[`background${upperFirst(camelCase(getColor(user.name)))}`],
|
||||
)}
|
||||
style={{
|
||||
background: user.avatar && `url("${user.avatar.thumbnailUrls.cover180}") center / cover`,
|
||||
}}
|
||||
>
|
||||
{!user.avatar && <span className={styles.initials}>{initials(user.name).slice(0, 2)}</span>}
|
||||
{withCreatorIndicator && <span className={styles.creatorIndicator}>+</span>}
|
||||
</span>
|
||||
);
|
||||
|
||||
return onClick ? (
|
||||
<button
|
||||
data-id={id}
|
||||
type="button"
|
||||
disabled={isDisabled}
|
||||
className={classNames(styles.button, className)}
|
||||
onClick={onClick}
|
||||
>
|
||||
{contentNode}
|
||||
</button>
|
||||
) : (
|
||||
<span className={className}>{contentNode}</span>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
UserAvatar.propTypes = {
|
||||
id: PropTypes.string,
|
||||
size: PropTypes.oneOf(Object.values(Sizes)),
|
||||
isDisabled: PropTypes.bool,
|
||||
withCreatorIndicator: PropTypes.bool,
|
||||
className: PropTypes.string,
|
||||
onClick: PropTypes.func,
|
||||
};
|
||||
|
||||
UserAvatar.defaultProps = {
|
||||
id: undefined,
|
||||
size: Sizes.MEDIUM,
|
||||
isDisabled: false,
|
||||
withCreatorIndicator: false,
|
||||
className: undefined,
|
||||
onClick: undefined,
|
||||
};
|
||||
|
||||
export default UserAvatar;
|
||||
116
client/src/components/users/UserAvatar/UserAvatar.module.scss
Normal file
116
client/src/components/users/UserAvatar/UserAvatar.module.scss
Normal file
@@ -0,0 +1,116 @@
|
||||
/*!
|
||||
* Copyright (c) 2024 PLANKA Software GmbH
|
||||
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
|
||||
*/
|
||||
|
||||
:global(#app) {
|
||||
.button {
|
||||
background: transparent;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
display: inline-block;
|
||||
outline: none;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
// TODO: provide for different sizes
|
||||
.creatorIndicator {
|
||||
background: #fff;
|
||||
border-radius: 50%;
|
||||
bottom: 0;
|
||||
color: #6a808b;
|
||||
font-size: 12px;
|
||||
height: 10px;
|
||||
line-height: 10px;
|
||||
position: absolute;
|
||||
right: 0;
|
||||
width: 10px;
|
||||
}
|
||||
|
||||
.initials {
|
||||
margin: 0 auto;
|
||||
text-transform: capitalize;
|
||||
}
|
||||
|
||||
.wrapper {
|
||||
border-radius: 50%;
|
||||
color: #fff;
|
||||
display: inline-block;
|
||||
line-height: 1;
|
||||
position: relative;
|
||||
text-align: center;
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
.wrapperHoverable:hover {
|
||||
opacity: 0.75;
|
||||
}
|
||||
|
||||
/* Sizes */
|
||||
|
||||
.wrapperTiny {
|
||||
font-size: 10px;
|
||||
height: 24px;
|
||||
line-height: 20px;
|
||||
padding: 2px 0;
|
||||
width: 24px;
|
||||
}
|
||||
|
||||
.wrapperSmall {
|
||||
font-size: 12px;
|
||||
height: 28px;
|
||||
padding: 8px 0;
|
||||
width: 28px;
|
||||
}
|
||||
|
||||
.wrapperMedium {
|
||||
font-size: 14px;
|
||||
height: 32px;
|
||||
padding: 10px 0;
|
||||
width: 32px;
|
||||
}
|
||||
|
||||
.wrapperLarge {
|
||||
font-size: 14px;
|
||||
height: 36px;
|
||||
padding: 12px 0 10px;
|
||||
width: 36px;
|
||||
}
|
||||
|
||||
.wrapperMassive {
|
||||
font-size: 36px;
|
||||
height: 100px;
|
||||
padding: 32px 0 10px;
|
||||
width: 100px;
|
||||
}
|
||||
|
||||
/* Backgrounds */
|
||||
|
||||
.backgroundEmerald {
|
||||
background: #2ecc71;
|
||||
}
|
||||
|
||||
.backgroundPeterRiver {
|
||||
background: #3498db;
|
||||
}
|
||||
|
||||
.backgroundWisteria {
|
||||
background: #8e44ad;
|
||||
}
|
||||
|
||||
.backgroundCarrot {
|
||||
background: #e67e22;
|
||||
}
|
||||
|
||||
.backgroundAlizarin {
|
||||
background: #e74c3c;
|
||||
}
|
||||
|
||||
.backgroundTurquoise {
|
||||
background: #1abc9c;
|
||||
}
|
||||
|
||||
.backgroundMidnightBlue {
|
||||
background: #2c3e50;
|
||||
}
|
||||
}
|
||||
8
client/src/components/users/UserAvatar/index.js
Normal file
8
client/src/components/users/UserAvatar/index.js
Normal file
@@ -0,0 +1,8 @@
|
||||
/*!
|
||||
* Copyright (c) 2024 PLANKA Software GmbH
|
||||
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
|
||||
*/
|
||||
|
||||
import UserAvatar from './UserAvatar';
|
||||
|
||||
export default UserAvatar;
|
||||
22
client/src/components/users/UserSettingsModal/AboutPane.jsx
Normal file
22
client/src/components/users/UserSettingsModal/AboutPane.jsx
Normal file
@@ -0,0 +1,22 @@
|
||||
/*!
|
||||
* Copyright (c) 2024 PLANKA Software GmbH
|
||||
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { Image, Tab } from 'semantic-ui-react';
|
||||
|
||||
import version from '../../../version';
|
||||
|
||||
import logo from '../../../assets/images/logo.png';
|
||||
|
||||
import styles from './AboutPane.module.scss';
|
||||
|
||||
const AboutPane = React.memo(() => (
|
||||
<Tab.Pane attached={false} className={styles.wrapper}>
|
||||
<Image centered src={logo} size="large" />
|
||||
<div className={styles.version}>{version}</div>
|
||||
</Tab.Pane>
|
||||
));
|
||||
|
||||
export default AboutPane;
|
||||
@@ -0,0 +1,16 @@
|
||||
/*!
|
||||
* Copyright (c) 2024 PLANKA Software GmbH
|
||||
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
|
||||
*/
|
||||
|
||||
:global(#app) {
|
||||
.version {
|
||||
font-weight: bold;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.wrapper {
|
||||
border: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
/*!
|
||||
* Copyright (c) 2024 PLANKA Software GmbH
|
||||
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
|
||||
*/
|
||||
|
||||
import React, { useCallback } from 'react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button, Divider, Dropdown, Header, Tab } from 'semantic-ui-react';
|
||||
|
||||
import { usePopupInClosableContext } from '../../../../hooks';
|
||||
import locales from '../../../../locales';
|
||||
import EditAvatarStep from './EditAvatarStep';
|
||||
import EditUserInformation from '../../EditUserInformation';
|
||||
import EditUserUsernameStep from '../../EditUserUsernameStep';
|
||||
import EditUserEmailStep from '../../EditUserEmailStep';
|
||||
import EditUserPasswordStep from '../../EditUserPasswordStep';
|
||||
import UserAvatar from '../../UserAvatar';
|
||||
|
||||
import selectors from '../../../../selectors';
|
||||
import entryActions from '../../../../entry-actions';
|
||||
|
||||
import styles from './AccountPane.module.scss';
|
||||
|
||||
const AccountPane = React.memo(() => {
|
||||
const user = useSelector(selectors.selectCurrentUser);
|
||||
|
||||
const dispatch = useDispatch();
|
||||
const [t] = useTranslation();
|
||||
|
||||
const handleLanguageChange = useCallback(
|
||||
(_, { value }) => {
|
||||
// FIXME: hack
|
||||
dispatch(entryActions.updateCurrentUserLanguage(value === 'auto' ? null : value));
|
||||
},
|
||||
[dispatch],
|
||||
);
|
||||
|
||||
const EditAvatarPopup = usePopupInClosableContext(EditAvatarStep);
|
||||
const EditUserUsernamePopup = usePopupInClosableContext(EditUserUsernameStep);
|
||||
const EditUserEmailPopup = usePopupInClosableContext(EditUserEmailStep);
|
||||
const EditUserPasswordPopup = usePopupInClosableContext(EditUserPasswordStep);
|
||||
|
||||
const isUsernameEditable = !user.lockedFieldNames.includes('username');
|
||||
const isEmailEditable = !user.lockedFieldNames.includes('email');
|
||||
const isPasswordEditable = !user.lockedFieldNames.includes('password');
|
||||
|
||||
return (
|
||||
<Tab.Pane attached={false} className={styles.wrapper}>
|
||||
<EditAvatarPopup>
|
||||
<UserAvatar id={user.id} size="massive" isDisabled={user.isAvatarUpdating} />
|
||||
</EditAvatarPopup>
|
||||
<br />
|
||||
<br />
|
||||
<EditUserInformation id={user.id} />
|
||||
<Divider horizontal section>
|
||||
<Header as="h4">
|
||||
{t('common.language', {
|
||||
context: 'title',
|
||||
})}
|
||||
</Header>
|
||||
</Divider>
|
||||
<Dropdown
|
||||
fluid
|
||||
selection
|
||||
options={[
|
||||
{
|
||||
value: 'auto',
|
||||
text: t('common.detectAutomatically'),
|
||||
},
|
||||
...locales.map((locale) => ({
|
||||
value: locale.language,
|
||||
flag: locale.country,
|
||||
text: locale.name,
|
||||
})),
|
||||
]}
|
||||
value={user.language || 'auto'}
|
||||
onChange={handleLanguageChange}
|
||||
/>
|
||||
{(isUsernameEditable || isEmailEditable || isPasswordEditable) && (
|
||||
<>
|
||||
<Divider horizontal section>
|
||||
<Header as="h4">
|
||||
{t('common.authentication', {
|
||||
context: 'title',
|
||||
})}
|
||||
</Header>
|
||||
</Divider>
|
||||
{isUsernameEditable && (
|
||||
<div className={styles.action}>
|
||||
<EditUserUsernamePopup id={user.id} withPasswordConfirmation={!user.isSsoUser}>
|
||||
<Button className={styles.actionButton}>
|
||||
{t('action.editUsername', {
|
||||
context: 'title',
|
||||
})}
|
||||
</Button>
|
||||
</EditUserUsernamePopup>
|
||||
</div>
|
||||
)}
|
||||
{isEmailEditable && (
|
||||
<div className={styles.action}>
|
||||
<EditUserEmailPopup id={user.id} withPasswordConfirmation={!user.isSsoUser}>
|
||||
<Button className={styles.actionButton}>
|
||||
{t('action.editEmail', {
|
||||
context: 'title',
|
||||
})}
|
||||
</Button>
|
||||
</EditUserEmailPopup>
|
||||
</div>
|
||||
)}
|
||||
{isPasswordEditable && (
|
||||
<div className={styles.action}>
|
||||
<EditUserPasswordPopup id={user.id} withPasswordConfirmation={!user.isSsoUser}>
|
||||
<Button className={styles.actionButton}>
|
||||
{t('action.editPassword', {
|
||||
context: 'title',
|
||||
})}
|
||||
</Button>
|
||||
</EditUserPasswordPopup>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Tab.Pane>
|
||||
);
|
||||
});
|
||||
|
||||
export default AccountPane;
|
||||
@@ -0,0 +1,38 @@
|
||||
/*!
|
||||
* Copyright (c) 2024 PLANKA Software GmbH
|
||||
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
|
||||
*/
|
||||
|
||||
:global(#app) {
|
||||
.action {
|
||||
border: none;
|
||||
border-radius: 0.28571429rem;
|
||||
display: inline-block;
|
||||
height: 36px;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
transition: background 0.3s ease;
|
||||
width: 100%;
|
||||
|
||||
&:hover {
|
||||
background: #e9e9e9;
|
||||
}
|
||||
}
|
||||
|
||||
.actionButton {
|
||||
background: transparent;
|
||||
color: #6b808c;
|
||||
font-weight: normal;
|
||||
height: 36px;
|
||||
line-height: 24px;
|
||||
padding: 6px 12px;
|
||||
text-align: left;
|
||||
text-decoration: underline;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.wrapper {
|
||||
border: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
/*!
|
||||
* Copyright (c) 2024 PLANKA Software GmbH
|
||||
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
|
||||
*/
|
||||
|
||||
import React, { useCallback, useEffect, useRef } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button } from 'semantic-ui-react';
|
||||
import { FilePicker, Popup } from '../../../../lib/custom-ui';
|
||||
|
||||
import selectors from '../../../../selectors';
|
||||
import entryActions from '../../../../entry-actions';
|
||||
|
||||
import styles from './EditAvatarStep.module.scss';
|
||||
|
||||
const EditAvatarStep = React.memo(({ onClose }) => {
|
||||
const defaultValue = useSelector((state) => selectors.selectCurrentUser(state).avatar);
|
||||
|
||||
const dispatch = useDispatch();
|
||||
const [t] = useTranslation();
|
||||
|
||||
const fieldRef = useRef(null);
|
||||
|
||||
const handleFileSelect = useCallback(
|
||||
(file) => {
|
||||
dispatch(
|
||||
entryActions.updateCurrentUserAvatar({
|
||||
file,
|
||||
}),
|
||||
);
|
||||
|
||||
onClose();
|
||||
},
|
||||
[onClose, dispatch],
|
||||
);
|
||||
|
||||
const handleDeleteClick = useCallback(() => {
|
||||
dispatch(
|
||||
entryActions.updateCurrentUser({
|
||||
avatar: null,
|
||||
}),
|
||||
);
|
||||
|
||||
onClose();
|
||||
}, [onClose, dispatch]);
|
||||
|
||||
useEffect(() => {
|
||||
fieldRef.current.focus();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Popup.Header>
|
||||
{t('common.editAvatar', {
|
||||
context: 'title',
|
||||
})}
|
||||
</Popup.Header>
|
||||
<Popup.Content>
|
||||
<div className={styles.action}>
|
||||
<FilePicker accept="image/*" onSelect={handleFileSelect}>
|
||||
<Button
|
||||
ref={fieldRef}
|
||||
content={t('action.uploadNewAvatar')}
|
||||
className={styles.actionButton}
|
||||
/>
|
||||
</FilePicker>
|
||||
</div>
|
||||
{defaultValue && (
|
||||
<Button negative content={t('action.deleteAvatar')} onClick={handleDeleteClick} />
|
||||
)}
|
||||
</Popup.Content>
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
EditAvatarStep.propTypes = {
|
||||
onClose: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
export default EditAvatarStep;
|
||||
@@ -0,0 +1,33 @@
|
||||
/*!
|
||||
* Copyright (c) 2024 PLANKA Software GmbH
|
||||
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
|
||||
*/
|
||||
|
||||
:global(#app) {
|
||||
.action {
|
||||
border: none;
|
||||
border-radius: 0.28571429rem;
|
||||
display: inline-block;
|
||||
height: 36px;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
transition: background 0.3s ease;
|
||||
width: 100%;
|
||||
|
||||
&:hover {
|
||||
background: #e9e9e9;
|
||||
}
|
||||
}
|
||||
|
||||
.actionButton {
|
||||
background: transparent;
|
||||
color: #6b808c;
|
||||
font-weight: normal;
|
||||
height: 36px;
|
||||
line-height: 24px;
|
||||
padding: 6px 12px;
|
||||
text-align: left;
|
||||
text-decoration: underline;
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
/*!
|
||||
* Copyright (c) 2024 PLANKA Software GmbH
|
||||
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
|
||||
*/
|
||||
|
||||
import AccountPane from './AccountPane';
|
||||
|
||||
export default AccountPane;
|
||||
@@ -0,0 +1,35 @@
|
||||
/*!
|
||||
* Copyright (c) 2024 PLANKA Software GmbH
|
||||
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
|
||||
*/
|
||||
|
||||
import React, { useCallback } from 'react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { Tab } from 'semantic-ui-react';
|
||||
|
||||
import selectors from '../../../selectors';
|
||||
import entryActions from '../../../entry-actions';
|
||||
import NotificationServices from '../../notification-services/NotificationServices';
|
||||
|
||||
import styles from './NotificationsPane.module.scss';
|
||||
|
||||
const NotificationsPane = React.memo(() => {
|
||||
const notificationServiceIds = useSelector(selectors.selectNotificationServiceIdsForCurrentUser);
|
||||
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const handleCreate = useCallback(
|
||||
(data) => {
|
||||
dispatch(entryActions.createNotificationServiceInCurrentUser(data));
|
||||
},
|
||||
[dispatch],
|
||||
);
|
||||
|
||||
return (
|
||||
<Tab.Pane attached={false} className={styles.wrapper}>
|
||||
<NotificationServices ids={notificationServiceIds} onCreate={handleCreate} />
|
||||
</Tab.Pane>
|
||||
);
|
||||
});
|
||||
|
||||
export default NotificationsPane;
|
||||
@@ -0,0 +1,11 @@
|
||||
/*!
|
||||
* Copyright (c) 2024 PLANKA Software GmbH
|
||||
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
|
||||
*/
|
||||
|
||||
:global(#app) {
|
||||
.wrapper {
|
||||
border: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
/*!
|
||||
* Copyright (c) 2024 PLANKA Software GmbH
|
||||
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
|
||||
*/
|
||||
|
||||
import React, { useCallback } from 'react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Radio, Tab } from 'semantic-ui-react';
|
||||
|
||||
import selectors from '../../../selectors';
|
||||
import entryActions from '../../../entry-actions';
|
||||
|
||||
import styles from './PreferencesPane.module.scss';
|
||||
|
||||
const PreferencesPane = React.memo(() => {
|
||||
const user = useSelector(selectors.selectCurrentUser);
|
||||
|
||||
const dispatch = useDispatch();
|
||||
const [t] = useTranslation();
|
||||
|
||||
const handleChange = useCallback(
|
||||
(_, { name: fieldName, checked }) => {
|
||||
dispatch(
|
||||
entryActions.updateCurrentUser({
|
||||
[fieldName]: checked,
|
||||
}),
|
||||
);
|
||||
},
|
||||
[dispatch],
|
||||
);
|
||||
|
||||
return (
|
||||
<Tab.Pane attached={false} className={styles.wrapper}>
|
||||
<Radio
|
||||
toggle
|
||||
name="subscribeToOwnCards"
|
||||
checked={user.subscribeToOwnCards}
|
||||
label={t('common.subscribeToMyOwnCardsByDefault')}
|
||||
className={styles.radio}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
<Radio
|
||||
toggle
|
||||
name="subscribeToCardWhenCommenting"
|
||||
checked={user.subscribeToCardWhenCommenting}
|
||||
label={t('common.subscribeToCardWhenCommenting')}
|
||||
className={styles.radio}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
<Radio
|
||||
toggle
|
||||
name="turnOffRecentCardHighlighting"
|
||||
checked={user.turnOffRecentCardHighlighting}
|
||||
label={t('common.turnOffRecentCardHighlighting')}
|
||||
className={styles.radio}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
</Tab.Pane>
|
||||
);
|
||||
});
|
||||
|
||||
export default PreferencesPane;
|
||||
@@ -0,0 +1,20 @@
|
||||
/*!
|
||||
* Copyright (c) 2024 PLANKA Software GmbH
|
||||
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
|
||||
*/
|
||||
|
||||
:global(#app) {
|
||||
.radio {
|
||||
margin-bottom: 16px;
|
||||
width: 100%;
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.wrapper {
|
||||
border: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
/*!
|
||||
* Copyright (c) 2024 PLANKA Software GmbH
|
||||
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
|
||||
*/
|
||||
|
||||
import React, { useCallback } from 'react';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Tab } from 'semantic-ui-react';
|
||||
|
||||
import entryActions from '../../../entry-actions';
|
||||
import { useClosableModal } from '../../../hooks';
|
||||
import AccountPane from './AccountPane';
|
||||
import PreferencesPane from './PreferencesPane';
|
||||
import NotificationsPane from './NotificationsPane';
|
||||
import AboutPane from './AboutPane';
|
||||
|
||||
const UserSettingsModal = React.memo(() => {
|
||||
const dispatch = useDispatch();
|
||||
const [t] = useTranslation();
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
dispatch(entryActions.closeModal());
|
||||
}, [dispatch]);
|
||||
|
||||
const [ClosableModal] = useClosableModal();
|
||||
|
||||
const panes = [
|
||||
{
|
||||
menuItem: t('common.account', {
|
||||
context: 'title',
|
||||
}),
|
||||
render: () => <AccountPane />,
|
||||
},
|
||||
{
|
||||
menuItem: t('common.preferences', {
|
||||
context: 'title',
|
||||
}),
|
||||
render: () => <PreferencesPane />,
|
||||
},
|
||||
{
|
||||
menuItem: t('common.notifications', {
|
||||
context: 'title',
|
||||
}),
|
||||
render: () => <NotificationsPane />,
|
||||
},
|
||||
{
|
||||
menuItem: t('common.aboutPlanka', {
|
||||
context: 'title',
|
||||
}),
|
||||
render: () => <AboutPane />,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<ClosableModal open closeIcon size="small" centered={false} onClose={handleClose}>
|
||||
<ClosableModal.Content>
|
||||
<Tab
|
||||
menu={{
|
||||
secondary: true,
|
||||
pointing: true,
|
||||
}}
|
||||
panes={panes}
|
||||
/>
|
||||
</ClosableModal.Content>
|
||||
</ClosableModal>
|
||||
);
|
||||
});
|
||||
|
||||
export default UserSettingsModal;
|
||||
8
client/src/components/users/UserSettingsModal/index.js
Normal file
8
client/src/components/users/UserSettingsModal/index.js
Normal file
@@ -0,0 +1,8 @@
|
||||
/*!
|
||||
* Copyright (c) 2024 PLANKA Software GmbH
|
||||
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
|
||||
*/
|
||||
|
||||
import UserSettingsModal from './UserSettingsModal';
|
||||
|
||||
export default UserSettingsModal;
|
||||
97
client/src/components/users/UserStep/UserStep.jsx
Executable file
97
client/src/components/users/UserStep/UserStep.jsx
Executable file
@@ -0,0 +1,97 @@
|
||||
/*!
|
||||
* Copyright (c) 2024 PLANKA Software GmbH
|
||||
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
|
||||
*/
|
||||
|
||||
import React, { useCallback } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button, Menu } from 'semantic-ui-react';
|
||||
import { Popup } from '../../../lib/custom-ui';
|
||||
|
||||
import selectors from '../../../selectors';
|
||||
import entryActions from '../../../entry-actions';
|
||||
import { UserRoles } from '../../../constants/Enums';
|
||||
|
||||
import styles from './UserStep.module.scss';
|
||||
|
||||
const UserStep = React.memo(({ onClose }) => {
|
||||
const isLogouting = useSelector(selectors.selectIsLogouting);
|
||||
|
||||
const withAdministration = useSelector(
|
||||
(state) => selectors.selectCurrentUser(state).role === UserRoles.ADMIN,
|
||||
);
|
||||
|
||||
const dispatch = useDispatch();
|
||||
const [t] = useTranslation();
|
||||
|
||||
const handleSettingsClick = useCallback(() => {
|
||||
dispatch(entryActions.openUserSettingsModal());
|
||||
onClose();
|
||||
}, [onClose, dispatch]);
|
||||
|
||||
const handleLogoutClick = useCallback(() => {
|
||||
dispatch(entryActions.logout());
|
||||
}, [dispatch]);
|
||||
|
||||
const handleAdministrationClick = useCallback(() => {
|
||||
dispatch(entryActions.openAdministrationModal());
|
||||
onClose();
|
||||
}, [onClose, dispatch]);
|
||||
|
||||
let logoutMenuItemProps;
|
||||
if (isLogouting) {
|
||||
logoutMenuItemProps = {
|
||||
as: Button,
|
||||
fluid: true,
|
||||
basic: true,
|
||||
loading: true,
|
||||
disabled: true,
|
||||
};
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Popup.Header>
|
||||
{t('common.userActions', {
|
||||
context: 'title',
|
||||
})}
|
||||
</Popup.Header>
|
||||
<Popup.Content>
|
||||
<Menu secondary vertical className={styles.menu}>
|
||||
<Menu.Item className={styles.menuItem} onClick={handleSettingsClick}>
|
||||
{t('common.settings', {
|
||||
context: 'title',
|
||||
})}
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
{...logoutMenuItemProps} // eslint-disable-line react/jsx-props-no-spreading
|
||||
className={styles.menuItem}
|
||||
onClick={handleLogoutClick}
|
||||
>
|
||||
{t('action.logOut', {
|
||||
context: 'title',
|
||||
})}
|
||||
</Menu.Item>
|
||||
{withAdministration && (
|
||||
<>
|
||||
<hr className={styles.divider} />
|
||||
<Menu.Item className={styles.menuItem} onClick={handleAdministrationClick}>
|
||||
{t('common.administration', {
|
||||
context: 'title',
|
||||
})}
|
||||
</Menu.Item>
|
||||
</>
|
||||
)}
|
||||
</Menu>
|
||||
</Popup.Content>
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
UserStep.propTypes = {
|
||||
onClose: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
export default UserStep;
|
||||
23
client/src/components/users/UserStep/UserStep.module.scss
Normal file
23
client/src/components/users/UserStep/UserStep.module.scss
Normal file
@@ -0,0 +1,23 @@
|
||||
/*!
|
||||
* Copyright (c) 2024 PLANKA Software GmbH
|
||||
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
|
||||
*/
|
||||
|
||||
:global(#app) {
|
||||
.divider {
|
||||
background: #eee;
|
||||
border: 0;
|
||||
height: 1px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.menu {
|
||||
margin: -7px -12px -5px;
|
||||
width: calc(100% + 24px);
|
||||
}
|
||||
|
||||
.menuItem {
|
||||
margin: 0;
|
||||
padding-left: 14px;
|
||||
}
|
||||
}
|
||||
8
client/src/components/users/UserStep/index.js
Executable file
8
client/src/components/users/UserStep/index.js
Executable file
@@ -0,0 +1,8 @@
|
||||
/*!
|
||||
* Copyright (c) 2024 PLANKA Software GmbH
|
||||
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
|
||||
*/
|
||||
|
||||
import UserStep from './UserStep';
|
||||
|
||||
export default UserStep;
|
||||
Reference in New Issue
Block a user