feat: Replace user edit popups with a single modal
Editing a user now opens a modal with an information tab and an API key tab instead of a chain of popups. The user name in the list opens it, and the admin two-factor reset moves in as well.
This commit is contained in:
@@ -0,0 +1,148 @@
|
|||||||
|
/*!
|
||||||
|
* Copyright (c) 2024 PLANKA Software GmbH
|
||||||
|
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { useCallback, useMemo, useState } from 'react';
|
||||||
|
import PropTypes from 'prop-types';
|
||||||
|
import { useDispatch, useSelector } from 'react-redux';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { Button, Icon, Input, Message, Tab } from 'semantic-ui-react';
|
||||||
|
|
||||||
|
import selectors from '../../../../selectors';
|
||||||
|
import entryActions from '../../../../entry-actions';
|
||||||
|
|
||||||
|
import styles from './ApiKeyPane.module.scss';
|
||||||
|
|
||||||
|
const ConfirmStates = {
|
||||||
|
REGENERATE: 'REGENERATE',
|
||||||
|
DELETE: 'DELETE',
|
||||||
|
};
|
||||||
|
|
||||||
|
const ApiKeyPane = React.memo(({ userId, onClose }) => {
|
||||||
|
const selectUserById = useMemo(() => selectors.makeSelectUserById(), []);
|
||||||
|
|
||||||
|
const user = useSelector((state) => selectUserById(state, userId));
|
||||||
|
|
||||||
|
const dispatch = useDispatch();
|
||||||
|
const [t] = useTranslation();
|
||||||
|
const [confirmState, setConfirmState] = useState(null);
|
||||||
|
const [isCopied, setIsCopied] = useState(false);
|
||||||
|
|
||||||
|
const handleGenerateClick = useCallback(() => {
|
||||||
|
if (user.apiKeyPrefix) {
|
||||||
|
setConfirmState(ConfirmStates.REGENERATE);
|
||||||
|
} else {
|
||||||
|
dispatch(entryActions.createUserApiKey(userId));
|
||||||
|
}
|
||||||
|
}, [userId, user.apiKeyPrefix, dispatch]);
|
||||||
|
|
||||||
|
const handleRegenerateConfirm = useCallback(() => {
|
||||||
|
dispatch(entryActions.createUserApiKey(userId));
|
||||||
|
setConfirmState(null);
|
||||||
|
}, [userId, dispatch]);
|
||||||
|
|
||||||
|
const handleDeleteConfirm = useCallback(() => {
|
||||||
|
dispatch(entryActions.deleteUserApiKey(userId));
|
||||||
|
onClose();
|
||||||
|
}, [userId, onClose, dispatch]);
|
||||||
|
|
||||||
|
const handleCancelConfirm = useCallback(() => {
|
||||||
|
setConfirmState(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleDeleteClick = useCallback(() => {
|
||||||
|
setConfirmState(ConfirmStates.DELETE);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleCopyClick = useCallback(() => {
|
||||||
|
if (isCopied) return;
|
||||||
|
navigator.clipboard.writeText(user.apiKeyState.value);
|
||||||
|
setIsCopied(true);
|
||||||
|
setTimeout(() => setIsCopied(false), 1000);
|
||||||
|
}, [user.apiKeyState.value, isCopied]);
|
||||||
|
|
||||||
|
if (confirmState === ConfirmStates.REGENERATE) {
|
||||||
|
return (
|
||||||
|
<Tab.Pane attached={false} className={styles.pane}>
|
||||||
|
<p>{t('common.areYouSureYouWantToRegenerateThisApiKey')}</p>
|
||||||
|
<Button content={t('action.regenerateApiKey')} onClick={handleRegenerateConfirm} />
|
||||||
|
<Button content={t('action.cancel')} onClick={handleCancelConfirm} />
|
||||||
|
</Tab.Pane>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (confirmState === ConfirmStates.DELETE) {
|
||||||
|
return (
|
||||||
|
<Tab.Pane attached={false} className={styles.pane}>
|
||||||
|
<p>{t('common.areYouSureYouWantToDeleteThisApiKey')}</p>
|
||||||
|
<Button negative content={t('action.deleteApiKey')} onClick={handleDeleteConfirm} />
|
||||||
|
<Button content={t('action.cancel')} onClick={handleCancelConfirm} />
|
||||||
|
</Tab.Pane>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Tab.Pane attached={false} className={styles.pane}>
|
||||||
|
{user.apiKeyPrefix ? (
|
||||||
|
<>
|
||||||
|
{!user.apiKeyState.isCreating &&
|
||||||
|
(user.apiKeyState.value ? (
|
||||||
|
<>
|
||||||
|
<Message
|
||||||
|
positive
|
||||||
|
header={t('common.apiKeyCreated', { context: 'title' })}
|
||||||
|
content={t('common.saveThisKeyItWillNotBeShownAgain')}
|
||||||
|
/>
|
||||||
|
<div className={styles.valueWrapper}>
|
||||||
|
<Input fluid readOnly value={user.apiKeyState.value} className={styles.value} />
|
||||||
|
<Button className={styles.copyButton} onClick={handleCopyClick}>
|
||||||
|
<Icon fitted name={isCopied ? 'check' : 'copy'} />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<Message
|
||||||
|
warning
|
||||||
|
header={`${user.apiKeyPrefix}_...`}
|
||||||
|
content={t('common.fullKeyIsHiddenForSecurityReasons')}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
<Button
|
||||||
|
fluid
|
||||||
|
content={t('action.regenerateApiKey')}
|
||||||
|
loading={user.apiKeyState.isCreating}
|
||||||
|
disabled={user.apiKeyState.isCreating}
|
||||||
|
className={styles.actionButton}
|
||||||
|
onClick={handleGenerateClick}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
fluid
|
||||||
|
content={t('action.deleteApiKey')}
|
||||||
|
className={styles.actionButton}
|
||||||
|
onClick={handleDeleteClick}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className={styles.content}>{t('common.noApiKeyCreated')}</div>
|
||||||
|
<Button
|
||||||
|
fluid
|
||||||
|
positive
|
||||||
|
content={t('action.createApiKey')}
|
||||||
|
loading={user.apiKeyState.isCreating}
|
||||||
|
disabled={user.apiKeyState.isCreating}
|
||||||
|
onClick={handleGenerateClick}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Tab.Pane>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
ApiKeyPane.propTypes = {
|
||||||
|
userId: PropTypes.string.isRequired,
|
||||||
|
onClose: PropTypes.func.isRequired,
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ApiKeyPane;
|
||||||
+11
-5
@@ -7,7 +7,7 @@
|
|||||||
.actionButton {
|
.actionButton {
|
||||||
background: transparent;
|
background: transparent;
|
||||||
box-shadow: none;
|
box-shadow: none;
|
||||||
color: #6b808c;
|
color: #666666;
|
||||||
font-weight: normal;
|
font-weight: normal;
|
||||||
margin-top: 8px;
|
margin-top: 8px;
|
||||||
padding: 6px 11px;
|
padding: 6px 11px;
|
||||||
@@ -16,7 +16,7 @@
|
|||||||
transition: background 0.3s ease;
|
transition: background 0.3s ease;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
background: #e9e9e9;
|
background: #f3f3f3;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -29,7 +29,7 @@
|
|||||||
box-shadow: none;
|
box-shadow: none;
|
||||||
border-radius: 3px;
|
border-radius: 3px;
|
||||||
box-sizing: content-box;
|
box-sizing: content-box;
|
||||||
color: #516b7a;
|
color: #444444;
|
||||||
display: none;
|
display: none;
|
||||||
height: 30px;
|
height: 30px;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
@@ -43,11 +43,17 @@
|
|||||||
width: 20px;
|
width: 20px;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
background: #dfe3e6;
|
background: #dcdfe1;
|
||||||
color: #4c4c4c;
|
color: #444444;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.pane {
|
||||||
|
border: none !important;
|
||||||
|
box-shadow: none !important;
|
||||||
|
padding: 16px 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
.value input {
|
.value input {
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
@@ -0,0 +1,500 @@
|
|||||||
|
/*!
|
||||||
|
* 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 isEmail from 'validator/lib/isEmail';
|
||||||
|
import React, { useCallback, useMemo, useState } from 'react';
|
||||||
|
import PropTypes from 'prop-types';
|
||||||
|
import { useDispatch, useSelector } from 'react-redux';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { Button, Divider, Form, Icon, Message, Tab } from 'semantic-ui-react';
|
||||||
|
import { FilePicker, Input } from '../../../../lib/custom-ui';
|
||||||
|
import { useDidUpdate, usePrevious } from '../../../../lib/hooks';
|
||||||
|
|
||||||
|
import selectors from '../../../../selectors';
|
||||||
|
import entryActions from '../../../../entry-actions';
|
||||||
|
import { useForm } from '../../../../hooks';
|
||||||
|
import { isUsername, isPassword } from '../../../../utils/validator';
|
||||||
|
import UserAvatar from '../../../users/UserAvatar';
|
||||||
|
import TotpAdminResetModal from './TotpAdminResetModal';
|
||||||
|
|
||||||
|
import styles from './ProfilePane.module.scss';
|
||||||
|
|
||||||
|
const ERROR_MESSAGE_BY_KEY = {
|
||||||
|
'Email already in use': { type: 'error', content: 'common.emailAlreadyInUse' },
|
||||||
|
'Username already in use': { type: 'error', content: 'common.usernameAlreadyInUse' },
|
||||||
|
'Invalid current password': { type: 'error', content: 'common.invalidCurrentPassword' },
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildMessage = (error) => {
|
||||||
|
if (!error) return null;
|
||||||
|
return ERROR_MESSAGE_BY_KEY[error.message] || { type: 'warning', content: 'common.unknownError' };
|
||||||
|
};
|
||||||
|
|
||||||
|
const ProfilePane = React.memo(({ userId }) => {
|
||||||
|
const selectUserById = useMemo(() => selectors.makeSelectUserById(), []);
|
||||||
|
const user = useSelector((state) => selectUserById(state, userId));
|
||||||
|
|
||||||
|
const isCurrentUser = useSelector((state) => userId === selectors.selectCurrentUserId(state));
|
||||||
|
const withPasswordConfirmation = isCurrentUser;
|
||||||
|
|
||||||
|
const {
|
||||||
|
emailUpdateForm: { isSubmitting: isEmailSubmitting, error: emailError },
|
||||||
|
usernameUpdateForm: { isSubmitting: isUsernameSubmitting, error: usernameError },
|
||||||
|
passwordUpdateForm: { isSubmitting: isPasswordSubmitting, error: passwordError },
|
||||||
|
} = user;
|
||||||
|
const isSubmitting = isEmailSubmitting || isUsernameSubmitting || isPasswordSubmitting;
|
||||||
|
|
||||||
|
const wasUsernameSubmitting = usePrevious(isUsernameSubmitting);
|
||||||
|
const wasEmailSubmitting = usePrevious(isEmailSubmitting);
|
||||||
|
const wasPasswordSubmitting = usePrevious(isPasswordSubmitting);
|
||||||
|
|
||||||
|
const dispatch = useDispatch();
|
||||||
|
const [t] = useTranslation();
|
||||||
|
const [currentPassword, setCurrentPassword] = useState('');
|
||||||
|
const [isUsernameUnlocked, setIsUsernameUnlocked] = useState(!user.username);
|
||||||
|
const [isEmailUnlocked, setIsEmailUnlocked] = useState(false);
|
||||||
|
const [isPasswordVisible, setIsPasswordVisible] = useState(false);
|
||||||
|
const [isTotpResetModalOpen, setIsTotpResetModalOpen] = useState(false);
|
||||||
|
|
||||||
|
const defaultInfoData = useMemo(
|
||||||
|
() => ({
|
||||||
|
name: user.name,
|
||||||
|
phone: user.phone,
|
||||||
|
organization: user.organization,
|
||||||
|
}),
|
||||||
|
[user.name, user.phone, user.organization],
|
||||||
|
);
|
||||||
|
|
||||||
|
const [data, handleFieldChange, setData] = useForm(() => ({
|
||||||
|
name: defaultInfoData.name || '',
|
||||||
|
phone: defaultInfoData.phone || '',
|
||||||
|
organization: defaultInfoData.organization || '',
|
||||||
|
username: user.username || '',
|
||||||
|
email: user.email || '',
|
||||||
|
newPassword: '',
|
||||||
|
confirmPassword: '',
|
||||||
|
}));
|
||||||
|
|
||||||
|
const cleanData = useMemo(
|
||||||
|
() => ({
|
||||||
|
name: data.name.trim(),
|
||||||
|
phone: data.phone.trim() || null,
|
||||||
|
organization: data.organization.trim() || null,
|
||||||
|
username: data.username.trim() || null,
|
||||||
|
email: data.email.trim(),
|
||||||
|
newPassword: data.newPassword,
|
||||||
|
}),
|
||||||
|
[data],
|
||||||
|
);
|
||||||
|
|
||||||
|
const infoChanged = useMemo(
|
||||||
|
() =>
|
||||||
|
!dequal(
|
||||||
|
{
|
||||||
|
name: cleanData.name,
|
||||||
|
phone: cleanData.phone,
|
||||||
|
organization: cleanData.organization,
|
||||||
|
},
|
||||||
|
defaultInfoData,
|
||||||
|
),
|
||||||
|
[cleanData, defaultInfoData],
|
||||||
|
);
|
||||||
|
|
||||||
|
const usernameChanged = cleanData.username !== (user.username || null);
|
||||||
|
const emailChanged = cleanData.email !== user.email;
|
||||||
|
const passwordEntered =
|
||||||
|
cleanData.newPassword.length > 0 && cleanData.newPassword === data.confirmPassword;
|
||||||
|
const credentialsChanged = usernameChanged || emailChanged || passwordEntered;
|
||||||
|
const anyChanged = infoChanged || credentialsChanged;
|
||||||
|
|
||||||
|
const handleAvatarFileSelect = useCallback(
|
||||||
|
(file) => {
|
||||||
|
dispatch(entryActions.updateUserAvatar(userId, { file }));
|
||||||
|
},
|
||||||
|
[userId, dispatch],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleAvatarDeleteClick = useCallback(() => {
|
||||||
|
dispatch(entryActions.updateUser(userId, { avatar: null }));
|
||||||
|
}, [userId, dispatch]);
|
||||||
|
|
||||||
|
const handleCurrentPasswordChange = useCallback((_, { value }) => {
|
||||||
|
setCurrentPassword(value);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleUnlockUsername = useCallback(() => {
|
||||||
|
setIsUsernameUnlocked(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleCancelUsername = useCallback(() => {
|
||||||
|
setData((prev) => ({ ...prev, username: user.username || '' }));
|
||||||
|
setIsUsernameUnlocked(false);
|
||||||
|
}, [setData, user.username]);
|
||||||
|
|
||||||
|
const handleUnlockEmail = useCallback(() => {
|
||||||
|
setIsEmailUnlocked(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleCancelEmail = useCallback(() => {
|
||||||
|
setData((prev) => ({ ...prev, email: user.email || '' }));
|
||||||
|
setIsEmailUnlocked(false);
|
||||||
|
}, [setData, user.email]);
|
||||||
|
|
||||||
|
const handleTogglePassword = useCallback(() => {
|
||||||
|
setIsPasswordVisible(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleCancelPassword = useCallback(() => {
|
||||||
|
setData((prev) => ({ ...prev, newPassword: '', confirmPassword: '' }));
|
||||||
|
setIsPasswordVisible(false);
|
||||||
|
}, [setData]);
|
||||||
|
|
||||||
|
const usernameMessage = useMemo(() => buildMessage(usernameError), [usernameError]);
|
||||||
|
const emailMessage = useMemo(() => buildMessage(emailError), [emailError]);
|
||||||
|
const passwordMessage = useMemo(() => buildMessage(passwordError), [passwordError]);
|
||||||
|
|
||||||
|
const handleDismissUsernameError = useCallback(() => {
|
||||||
|
dispatch(entryActions.clearUserUsernameUpdateError(userId));
|
||||||
|
}, [userId, dispatch]);
|
||||||
|
|
||||||
|
const handleDismissEmailError = useCallback(() => {
|
||||||
|
dispatch(entryActions.clearUserEmailUpdateError(userId));
|
||||||
|
}, [userId, dispatch]);
|
||||||
|
|
||||||
|
const handleDismissPasswordError = useCallback(() => {
|
||||||
|
dispatch(entryActions.clearUserPasswordUpdateError(userId));
|
||||||
|
}, [userId, dispatch]);
|
||||||
|
|
||||||
|
useDidUpdate(() => {
|
||||||
|
if (wasUsernameSubmitting && !isUsernameSubmitting && !usernameError) {
|
||||||
|
setIsUsernameUnlocked(false);
|
||||||
|
}
|
||||||
|
}, [isUsernameSubmitting, wasUsernameSubmitting, usernameError]);
|
||||||
|
|
||||||
|
useDidUpdate(() => {
|
||||||
|
if (wasEmailSubmitting && !isEmailSubmitting && !emailError) {
|
||||||
|
setIsEmailUnlocked(false);
|
||||||
|
}
|
||||||
|
}, [isEmailSubmitting, wasEmailSubmitting, emailError]);
|
||||||
|
|
||||||
|
useDidUpdate(() => {
|
||||||
|
if (wasPasswordSubmitting && !isPasswordSubmitting && !passwordError) {
|
||||||
|
setData((prev) => ({ ...prev, newPassword: '', confirmPassword: '' }));
|
||||||
|
setIsPasswordVisible(false);
|
||||||
|
}
|
||||||
|
}, [isPasswordSubmitting, wasPasswordSubmitting, passwordError]);
|
||||||
|
|
||||||
|
useDidUpdate(() => {
|
||||||
|
const isInvalidPw = (e) => e && e.message === 'Invalid current password';
|
||||||
|
if (isInvalidPw(usernameError) || isInvalidPw(emailError) || isInvalidPw(passwordError)) {
|
||||||
|
setCurrentPassword('');
|
||||||
|
}
|
||||||
|
}, [usernameError, emailError, passwordError]);
|
||||||
|
|
||||||
|
const handleSubmit = useCallback(() => {
|
||||||
|
const isNameEditable = !user.lockedFieldNames.includes('name');
|
||||||
|
|
||||||
|
if (isNameEditable && !cleanData.name) return;
|
||||||
|
|
||||||
|
if (infoChanged) {
|
||||||
|
const infoData = {
|
||||||
|
phone: cleanData.phone,
|
||||||
|
organization: cleanData.organization,
|
||||||
|
};
|
||||||
|
if (isNameEditable) {
|
||||||
|
infoData.name = cleanData.name;
|
||||||
|
}
|
||||||
|
dispatch(entryActions.updateUser(userId, infoData));
|
||||||
|
}
|
||||||
|
|
||||||
|
const pw = withPasswordConfirmation ? currentPassword : undefined;
|
||||||
|
|
||||||
|
if (usernameChanged && cleanData.username && isUsername(cleanData.username)) {
|
||||||
|
const usernameData = { username: cleanData.username };
|
||||||
|
if (pw !== undefined) usernameData.currentPassword = pw;
|
||||||
|
dispatch(entryActions.updateUserUsername(userId, usernameData));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (emailChanged && isEmail(cleanData.email)) {
|
||||||
|
const emailData = { email: cleanData.email };
|
||||||
|
if (pw !== undefined) emailData.currentPassword = pw;
|
||||||
|
dispatch(entryActions.updateUserEmail(userId, emailData));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (passwordEntered && isPassword(cleanData.newPassword)) {
|
||||||
|
const passwordData = { password: cleanData.newPassword };
|
||||||
|
if (pw !== undefined) passwordData.currentPassword = pw;
|
||||||
|
dispatch(entryActions.updateUserPassword(userId, passwordData));
|
||||||
|
}
|
||||||
|
}, [
|
||||||
|
userId,
|
||||||
|
user.lockedFieldNames,
|
||||||
|
withPasswordConfirmation,
|
||||||
|
currentPassword,
|
||||||
|
infoChanged,
|
||||||
|
usernameChanged,
|
||||||
|
emailChanged,
|
||||||
|
passwordEntered,
|
||||||
|
cleanData,
|
||||||
|
dispatch,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const isNameEditable = !user.lockedFieldNames.includes('name');
|
||||||
|
const isUsernameEditable = !user.lockedFieldNames.includes('username');
|
||||||
|
const isEmailEditable = !user.lockedFieldNames.includes('email');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Tab.Pane attached={false} className={styles.pane}>
|
||||||
|
<div className={styles.avatarArea}>
|
||||||
|
<UserAvatar id={userId} size="massive" />
|
||||||
|
<div className={styles.avatarButtons}>
|
||||||
|
<FilePicker accept="image/*" onSelect={handleAvatarFileSelect}>
|
||||||
|
<Button icon="pencil" content={t('action.edit')} className={styles.avatarButton} />
|
||||||
|
</FilePicker>
|
||||||
|
{user.avatar && (
|
||||||
|
<Button
|
||||||
|
negative
|
||||||
|
icon="trash alternate outline"
|
||||||
|
content={t('action.delete')}
|
||||||
|
className={styles.avatarButton}
|
||||||
|
onClick={handleAvatarDeleteClick}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Divider />
|
||||||
|
|
||||||
|
{usernameMessage && (
|
||||||
|
<Message
|
||||||
|
{...{ [usernameMessage.type]: true }}
|
||||||
|
visible
|
||||||
|
content={t(usernameMessage.content)}
|
||||||
|
onDismiss={handleDismissUsernameError}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{emailMessage && (
|
||||||
|
<Message
|
||||||
|
{...{ [emailMessage.type]: true }}
|
||||||
|
visible
|
||||||
|
content={t(emailMessage.content)}
|
||||||
|
onDismiss={handleDismissEmailError}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{passwordMessage && (
|
||||||
|
<Message
|
||||||
|
{...{ [passwordMessage.type]: true }}
|
||||||
|
visible
|
||||||
|
content={t(passwordMessage.content)}
|
||||||
|
onDismiss={handleDismissPasswordError}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Form onSubmit={handleSubmit}>
|
||||||
|
<div className={styles.twoColumns}>
|
||||||
|
<div className={styles.column}>
|
||||||
|
<div className={styles.fieldGroup}>
|
||||||
|
<div className={styles.text}>{t('common.name')}</div>
|
||||||
|
<Input
|
||||||
|
fluid
|
||||||
|
name="name"
|
||||||
|
value={data.name}
|
||||||
|
maxLength={128}
|
||||||
|
disabled={!isNameEditable}
|
||||||
|
onChange={handleFieldChange}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{isUsernameEditable && (
|
||||||
|
<div className={styles.fieldGroup}>
|
||||||
|
<div className={styles.text}>{t('common.username')}</div>
|
||||||
|
<div className={styles.lockedField}>
|
||||||
|
<Input
|
||||||
|
fluid
|
||||||
|
name="username"
|
||||||
|
value={data.username}
|
||||||
|
placeholder={user.username || ''}
|
||||||
|
maxLength={32}
|
||||||
|
disabled={!isUsernameUnlocked}
|
||||||
|
className={styles.lockedInput}
|
||||||
|
onChange={handleFieldChange}
|
||||||
|
/>
|
||||||
|
{isUsernameUnlocked ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={styles.unlockButton}
|
||||||
|
title={t('action.cancel')}
|
||||||
|
onClick={handleCancelUsername}
|
||||||
|
>
|
||||||
|
<Icon fitted name="close" size="small" />
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={styles.unlockButton}
|
||||||
|
onClick={handleUnlockUsername}
|
||||||
|
>
|
||||||
|
<Icon fitted name="pencil" size="small" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{isEmailEditable && (
|
||||||
|
<div className={styles.fieldGroup}>
|
||||||
|
<div className={styles.text}>{t('common.email')}</div>
|
||||||
|
<div className={styles.lockedField}>
|
||||||
|
<Input
|
||||||
|
fluid
|
||||||
|
name="email"
|
||||||
|
value={data.email}
|
||||||
|
placeholder={user.email}
|
||||||
|
maxLength={256}
|
||||||
|
disabled={!isEmailUnlocked}
|
||||||
|
className={styles.lockedInput}
|
||||||
|
onChange={handleFieldChange}
|
||||||
|
/>
|
||||||
|
{isEmailUnlocked ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={styles.unlockButton}
|
||||||
|
title={t('action.cancel')}
|
||||||
|
onClick={handleCancelEmail}
|
||||||
|
>
|
||||||
|
<Icon fitted name="close" size="small" />
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={styles.unlockButton}
|
||||||
|
onClick={handleUnlockEmail}
|
||||||
|
>
|
||||||
|
<Icon fitted name="pencil" size="small" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className={styles.fieldGroup}>
|
||||||
|
<div className={styles.text}>{t('common.phone')}</div>
|
||||||
|
<Input
|
||||||
|
fluid
|
||||||
|
name="phone"
|
||||||
|
value={data.phone}
|
||||||
|
maxLength={128}
|
||||||
|
onChange={handleFieldChange}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className={styles.column}>
|
||||||
|
<div className={styles.fieldGroup}>
|
||||||
|
<div className={styles.text}>{t('common.organization')}</div>
|
||||||
|
<Input
|
||||||
|
fluid
|
||||||
|
name="organization"
|
||||||
|
value={data.organization}
|
||||||
|
maxLength={128}
|
||||||
|
onChange={handleFieldChange}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Divider />
|
||||||
|
|
||||||
|
{!isPasswordVisible ? (
|
||||||
|
<div className={styles.passwordToggleWrapper}>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
icon="lock"
|
||||||
|
content={t('action.changePassword')}
|
||||||
|
onClick={handleTogglePassword}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className={styles.passwordRow}>
|
||||||
|
<div className={styles.passwordField}>
|
||||||
|
<div className={styles.text}>{t('common.newPassword')}</div>
|
||||||
|
<Input.Password
|
||||||
|
withStrengthBar
|
||||||
|
fluid
|
||||||
|
name="newPassword"
|
||||||
|
value={data.newPassword}
|
||||||
|
maxLength={256}
|
||||||
|
onChange={handleFieldChange}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className={styles.passwordField}>
|
||||||
|
<div className={styles.text}>{t('common.confirmPassword')}</div>
|
||||||
|
<Input.Password
|
||||||
|
fluid
|
||||||
|
name="confirmPassword"
|
||||||
|
value={data.confirmPassword}
|
||||||
|
maxLength={256}
|
||||||
|
onChange={handleFieldChange}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={styles.passwordCancelButton}
|
||||||
|
title={t('action.cancel')}
|
||||||
|
onClick={handleCancelPassword}
|
||||||
|
>
|
||||||
|
<Icon fitted name="close" size="small" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{withPasswordConfirmation && credentialsChanged && (
|
||||||
|
<>
|
||||||
|
<div className={styles.text}>{t('common.currentPassword')}</div>
|
||||||
|
<Input.Password
|
||||||
|
fluid
|
||||||
|
name="currentPassword"
|
||||||
|
value={currentPassword}
|
||||||
|
maxLength={256}
|
||||||
|
className={styles.field}
|
||||||
|
onChange={handleCurrentPasswordChange}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Button
|
||||||
|
positive
|
||||||
|
disabled={!anyChanged || isSubmitting}
|
||||||
|
loading={isSubmitting}
|
||||||
|
content={t('action.save')}
|
||||||
|
/>
|
||||||
|
</Form>
|
||||||
|
|
||||||
|
{!isCurrentUser && user.isTotpEnabled && (
|
||||||
|
<>
|
||||||
|
<Divider />
|
||||||
|
<div className={styles.totpResetRow}>
|
||||||
|
<div>
|
||||||
|
<strong>{t('common.twoFactorAuthentication')}</strong>
|
||||||
|
<p className={styles.totpResetHint}>{t('common.reset2faWarning')}</p>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
negative
|
||||||
|
icon="shield alternate"
|
||||||
|
content={t('action.reset2fa')}
|
||||||
|
onClick={() => setIsTotpResetModalOpen(true)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{isTotpResetModalOpen && (
|
||||||
|
<TotpAdminResetModal userId={userId} onClose={() => setIsTotpResetModalOpen(false)} />
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Tab.Pane>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
ProfilePane.propTypes = {
|
||||||
|
userId: PropTypes.string.isRequired,
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ProfilePane;
|
||||||
+140
@@ -0,0 +1,140 @@
|
|||||||
|
/*!
|
||||||
|
* Copyright (c) 2024 PLANKA Software GmbH
|
||||||
|
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
|
||||||
|
*/
|
||||||
|
|
||||||
|
:global(#app) {
|
||||||
|
.avatarArea {
|
||||||
|
align-items: center;
|
||||||
|
display: flex;
|
||||||
|
gap: 16px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatarButton {
|
||||||
|
margin: 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatarButtons {
|
||||||
|
align-items: stretch;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field {
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fieldGroup {
|
||||||
|
margin-bottom: 8px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.column {
|
||||||
|
flex: 1 1 220px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.twoColumns {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0 16px;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lockedField {
|
||||||
|
align-items: center;
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lockedInput {
|
||||||
|
flex: 1;
|
||||||
|
margin-bottom: 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pane {
|
||||||
|
border: none !important;
|
||||||
|
box-shadow: none !important;
|
||||||
|
padding: 16px 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.passwordField {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.passwordCancelButton {
|
||||||
|
align-self: flex-start;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
border-radius: 3px;
|
||||||
|
color: #666666;
|
||||||
|
cursor: pointer;
|
||||||
|
flex-shrink: 0;
|
||||||
|
height: 38px;
|
||||||
|
margin-top: 22px;
|
||||||
|
outline: none;
|
||||||
|
padding: 4px 6px;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: rgba(9, 30, 66, 0.1);
|
||||||
|
// against the dark-mode hover bg. --text-primary flips per
|
||||||
|
// theme so the hovered text stays readable in both.
|
||||||
|
color: #1b1c1d;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.passwordRow {
|
||||||
|
align-items: flex-start;
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.passwordToggleWrapper {
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text {
|
||||||
|
color: #444444;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: bold;
|
||||||
|
padding-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.unlockButton {
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
border-radius: 3px;
|
||||||
|
color: #666666;
|
||||||
|
cursor: pointer;
|
||||||
|
flex-shrink: 0;
|
||||||
|
height: 28px;
|
||||||
|
outline: none;
|
||||||
|
padding: 4px 6px;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: rgba(9, 30, 66, 0.1);
|
||||||
|
// against the dark-mode hover bg. --text-primary flips per
|
||||||
|
// theme so the hovered text stays readable in both.
|
||||||
|
color: #1b1c1d;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.totpResetRow {
|
||||||
|
align-items: center;
|
||||||
|
display: flex;
|
||||||
|
gap: 16px;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.totpResetHint {
|
||||||
|
color: #666666;
|
||||||
|
font-size: 12px;
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
}
|
||||||
+82
@@ -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, useMemo, useRef, useState } from 'react';
|
||||||
|
import PropTypes from 'prop-types';
|
||||||
|
import { useDispatch, useSelector } from 'react-redux';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { Button, Form, Message, Modal } from 'semantic-ui-react';
|
||||||
|
import { Input } from '../../../../lib/custom-ui';
|
||||||
|
|
||||||
|
import selectors from '../../../../selectors';
|
||||||
|
import entryActions from '../../../../entry-actions';
|
||||||
|
|
||||||
|
const TotpAdminResetModal = React.memo(({ userId, onClose }) => {
|
||||||
|
const selectUserById = useMemo(() => selectors.makeSelectUserById(), []);
|
||||||
|
const user = useSelector((state) => selectUserById(state, userId));
|
||||||
|
|
||||||
|
const dispatch = useDispatch();
|
||||||
|
const [t] = useTranslation();
|
||||||
|
const [password, setPassword] = useState('');
|
||||||
|
const wasDisablingRef = useRef(false);
|
||||||
|
|
||||||
|
const totpState = user.totpState || {};
|
||||||
|
const { isDisabling, error } = totpState;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (wasDisablingRef.current && !isDisabling && !error) {
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
wasDisablingRef.current = isDisabling;
|
||||||
|
}, [isDisabling, error, onClose]);
|
||||||
|
|
||||||
|
const handleSubmit = useCallback(() => {
|
||||||
|
if (!password) return;
|
||||||
|
dispatch(entryActions.disableUserTotp(userId, { currentPassword: password }));
|
||||||
|
}, [dispatch, userId, password]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal open centered size="tiny" closeOnDimmerClick={false} onClose={onClose}>
|
||||||
|
<Modal.Header>{t('common.reset2fa_title')}</Modal.Header>
|
||||||
|
<Modal.Content>
|
||||||
|
<p>{t('common.reset2faWarning')}</p>
|
||||||
|
{error && error.message === 'Invalid current password' && (
|
||||||
|
<Message error content={t('common.invalidCurrentPassword')} />
|
||||||
|
)}
|
||||||
|
<Form onSubmit={handleSubmit}>
|
||||||
|
<Form.Field>
|
||||||
|
<label htmlFor="totp-admin-reset-password">{t('common.currentPassword')}</label>
|
||||||
|
<Input.Password
|
||||||
|
fluid
|
||||||
|
id="totp-admin-reset-password"
|
||||||
|
value={password}
|
||||||
|
maxLength={256}
|
||||||
|
autoFocus
|
||||||
|
onChange={(_, { value }) => setPassword(value)}
|
||||||
|
/>
|
||||||
|
</Form.Field>
|
||||||
|
</Form>
|
||||||
|
</Modal.Content>
|
||||||
|
<Modal.Actions>
|
||||||
|
<Button content={t('action.cancel')} floated="left" onClick={onClose} />
|
||||||
|
<Button
|
||||||
|
negative
|
||||||
|
icon="shield alternate"
|
||||||
|
content={t('action.reset2fa')}
|
||||||
|
loading={isDisabling}
|
||||||
|
disabled={isDisabling || !password}
|
||||||
|
onClick={handleSubmit}
|
||||||
|
/>
|
||||||
|
</Modal.Actions>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
TotpAdminResetModal.propTypes = {
|
||||||
|
userId: PropTypes.string.isRequired,
|
||||||
|
onClose: PropTypes.func.isRequired,
|
||||||
|
};
|
||||||
|
|
||||||
|
export default TotpAdminResetModal;
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
/*!
|
||||||
|
* Copyright (c) 2024 PLANKA Software GmbH
|
||||||
|
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { useMemo } from 'react';
|
||||||
|
import PropTypes from 'prop-types';
|
||||||
|
import { useSelector } from 'react-redux';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { Modal, Tab } from 'semantic-ui-react';
|
||||||
|
|
||||||
|
import selectors from '../../../../selectors';
|
||||||
|
import ProfilePane from './ProfilePane';
|
||||||
|
import ApiKeyPane from './ApiKeyPane';
|
||||||
|
|
||||||
|
import styles from './UserEditModal.module.scss';
|
||||||
|
|
||||||
|
const UserEditModal = React.memo(({ userId, onClose }) => {
|
||||||
|
const selectUserById = useMemo(() => selectors.makeSelectUserById(), []);
|
||||||
|
const user = useSelector((state) => selectUserById(state, userId));
|
||||||
|
|
||||||
|
const [t] = useTranslation();
|
||||||
|
|
||||||
|
const panes = [
|
||||||
|
{
|
||||||
|
menuItem: t('common.information'),
|
||||||
|
render: () => <ProfilePane userId={userId} />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
menuItem: t('common.apiKey', {
|
||||||
|
context: 'title',
|
||||||
|
}),
|
||||||
|
render: () => <ApiKeyPane userId={userId} onClose={onClose} />,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
open
|
||||||
|
closeIcon
|
||||||
|
size="small"
|
||||||
|
centered={false}
|
||||||
|
className={styles.wrapper}
|
||||||
|
onClose={onClose}
|
||||||
|
>
|
||||||
|
<Modal.Header className={styles.header}>{user.name}</Modal.Header>
|
||||||
|
<Modal.Content>
|
||||||
|
<Tab
|
||||||
|
menu={{
|
||||||
|
secondary: true,
|
||||||
|
pointing: true,
|
||||||
|
}}
|
||||||
|
panes={panes}
|
||||||
|
/>
|
||||||
|
</Modal.Content>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
UserEditModal.propTypes = {
|
||||||
|
userId: PropTypes.string.isRequired,
|
||||||
|
onClose: PropTypes.func.isRequired,
|
||||||
|
};
|
||||||
|
|
||||||
|
export default UserEditModal;
|
||||||
+36
@@ -0,0 +1,36 @@
|
|||||||
|
/*!
|
||||||
|
* Copyright (c) 2024 PLANKA Software GmbH
|
||||||
|
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
|
||||||
|
*/
|
||||||
|
|
||||||
|
:global(#app) {
|
||||||
|
.header {
|
||||||
|
padding-right: 2.5rem !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wrapper {
|
||||||
|
@media (min-width: 768px) {
|
||||||
|
width: 680px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default Semantic UI buttons inside this modal — i.e. anywhere
|
||||||
|
// a <Button> is rendered without an explicit colour modifier
|
||||||
|
// (positive / negative / primary / secondary / a named hue).
|
||||||
|
// Their default grey background uses --surface-divider which
|
||||||
|
// resolves to a near-black dark grey in dark mode and looks
|
||||||
|
// misplaced against the dark surface. We give them the same
|
||||||
|
// translucent-overlay tokens the row edit pencil and card field
|
||||||
|
// chips use, so they read correctly in both themes and pick up
|
||||||
|
// the surface's hue subtly. The :not() chain preserves every
|
||||||
|
// explicitly-coloured Semantic UI button.
|
||||||
|
:global(.ui.button):not(:global(.positive)):not(:global(.negative)):not(:global(.primary)):not(:global(.secondary)):not(:global(.red)):not(:global(.orange)):not(:global(.yellow)):not(:global(.olive)):not(:global(.green)):not(:global(.teal)):not(:global(.blue)):not(:global(.violet)):not(:global(.purple)):not(:global(.pink)):not(:global(.brown)):not(:global(.grey)):not(:global(.black)):not(:global(.inverted)) {
|
||||||
|
background: rgba(22, 49, 75, 0.15);
|
||||||
|
color: #1b1c1d;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: rgba(22, 49, 75, 0.22);
|
||||||
|
color: #1b1c1d;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 UserEditModal from './UserEditModal';
|
||||||
|
|
||||||
|
export default UserEditModal;
|
||||||
@@ -14,32 +14,18 @@ import selectors from '../../../../selectors';
|
|||||||
import entryActions from '../../../../entry-actions';
|
import entryActions from '../../../../entry-actions';
|
||||||
import { useSteps } from '../../../../hooks';
|
import { useSteps } from '../../../../hooks';
|
||||||
import SelectRoleStep from './SelectRoleStep';
|
import SelectRoleStep from './SelectRoleStep';
|
||||||
import ApiKeyStep from './ApiKeyStep';
|
|
||||||
import ResetTotpStep from './ResetTotpStep';
|
|
||||||
import ConfirmationStep from '../../ConfirmationStep';
|
import ConfirmationStep from '../../ConfirmationStep';
|
||||||
import EditUserInformationStep from '../../../users/EditUserInformationStep';
|
|
||||||
import EditUserAvatarStep from '../../../users/EditUserAvatarStep';
|
|
||||||
import EditUserUsernameStep from '../../../users/EditUserUsernameStep';
|
|
||||||
import EditUserEmailStep from '../../../users/EditUserEmailStep';
|
|
||||||
import EditUserPasswordStep from '../../../users/EditUserPasswordStep';
|
|
||||||
|
|
||||||
import styles from './ActionsStep.module.scss';
|
import styles from './ActionsStep.module.scss';
|
||||||
|
|
||||||
const StepTypes = {
|
const StepTypes = {
|
||||||
EDIT_INFORMATION: 'EDIT_INFORMATION',
|
|
||||||
EDIT_AVATAR: 'EDIT_AVATAR',
|
|
||||||
EDIT_USERNAME: 'EDIT_USERNAME',
|
|
||||||
EDIT_EMAIL: 'EDIT_EMAIL',
|
|
||||||
EDIT_PASSWORD: 'EDIT_PASSWORD',
|
|
||||||
EDIT_ROLE: 'EDIT_ROLE',
|
EDIT_ROLE: 'EDIT_ROLE',
|
||||||
API_KEY: 'API_KEY',
|
|
||||||
RESET_TOTP: 'RESET_TOTP',
|
|
||||||
ACTIVATE: 'ACTIVATE',
|
ACTIVATE: 'ACTIVATE',
|
||||||
DEACTIVATE: 'DEACTIVATE',
|
DEACTIVATE: 'DEACTIVATE',
|
||||||
DELETE: 'DELETE',
|
DELETE: 'DELETE',
|
||||||
};
|
};
|
||||||
|
|
||||||
const ActionsStep = React.memo(({ userId, onClose }) => {
|
const ActionsStep = React.memo(({ userId, onEdit, onClose }) => {
|
||||||
const selectUserById = useMemo(() => selectors.makeSelectUserById(), []);
|
const selectUserById = useMemo(() => selectors.makeSelectUserById(), []);
|
||||||
|
|
||||||
const activeUsersLimit = useSelector(selectors.selectActiveUsersLimit);
|
const activeUsersLimit = useSelector(selectors.selectActiveUsersLimit);
|
||||||
@@ -86,38 +72,15 @@ const ActionsStep = React.memo(({ userId, onClose }) => {
|
|||||||
dispatch(entryActions.deleteUser(userId));
|
dispatch(entryActions.deleteUser(userId));
|
||||||
}, [userId, dispatch]);
|
}, [userId, dispatch]);
|
||||||
|
|
||||||
const handleEditInformationClick = useCallback(() => {
|
const handleEditClick = useCallback(() => {
|
||||||
openStep(StepTypes.EDIT_INFORMATION);
|
onEdit(userId);
|
||||||
}, [openStep]);
|
onClose();
|
||||||
|
}, [userId, onEdit, onClose]);
|
||||||
const handleEditAvatarClick = useCallback(() => {
|
|
||||||
openStep(StepTypes.EDIT_AVATAR);
|
|
||||||
}, [openStep]);
|
|
||||||
|
|
||||||
const handleEditUsernameClick = useCallback(() => {
|
|
||||||
openStep(StepTypes.EDIT_USERNAME);
|
|
||||||
}, [openStep]);
|
|
||||||
|
|
||||||
const handleEditEmailClick = useCallback(() => {
|
|
||||||
openStep(StepTypes.EDIT_EMAIL);
|
|
||||||
}, [openStep]);
|
|
||||||
|
|
||||||
const handleEditPasswordClick = useCallback(() => {
|
|
||||||
openStep(StepTypes.EDIT_PASSWORD);
|
|
||||||
}, [openStep]);
|
|
||||||
|
|
||||||
const handleEditRoleClick = useCallback(() => {
|
const handleEditRoleClick = useCallback(() => {
|
||||||
openStep(StepTypes.EDIT_ROLE);
|
openStep(StepTypes.EDIT_ROLE);
|
||||||
}, [openStep]);
|
}, [openStep]);
|
||||||
|
|
||||||
const handleApiKeyClick = useCallback(() => {
|
|
||||||
openStep(StepTypes.API_KEY);
|
|
||||||
}, [openStep]);
|
|
||||||
|
|
||||||
const handleResetTotpClick = useCallback(() => {
|
|
||||||
openStep(StepTypes.RESET_TOTP);
|
|
||||||
}, [openStep]);
|
|
||||||
|
|
||||||
const handleActivateClick = useCallback(() => {
|
const handleActivateClick = useCallback(() => {
|
||||||
openStep(StepTypes.ACTIVATE);
|
openStep(StepTypes.ACTIVATE);
|
||||||
}, [openStep]);
|
}, [openStep]);
|
||||||
@@ -132,16 +95,6 @@ const ActionsStep = React.memo(({ userId, onClose }) => {
|
|||||||
|
|
||||||
if (step) {
|
if (step) {
|
||||||
switch (step.type) {
|
switch (step.type) {
|
||||||
case StepTypes.EDIT_INFORMATION:
|
|
||||||
return <EditUserInformationStep id={userId} onBack={handleBack} onClose={onClose} />;
|
|
||||||
case StepTypes.EDIT_AVATAR:
|
|
||||||
return <EditUserAvatarStep id={userId} onBack={handleBack} onClose={onClose} />;
|
|
||||||
case StepTypes.EDIT_USERNAME:
|
|
||||||
return <EditUserUsernameStep id={userId} onBack={handleBack} onClose={onClose} />;
|
|
||||||
case StepTypes.EDIT_EMAIL:
|
|
||||||
return <EditUserEmailStep id={userId} onBack={handleBack} onClose={onClose} />;
|
|
||||||
case StepTypes.EDIT_PASSWORD:
|
|
||||||
return <EditUserPasswordStep id={userId} onBack={handleBack} onClose={onClose} />;
|
|
||||||
case StepTypes.EDIT_ROLE:
|
case StepTypes.EDIT_ROLE:
|
||||||
return (
|
return (
|
||||||
<SelectRoleStep
|
<SelectRoleStep
|
||||||
@@ -154,10 +107,6 @@ const ActionsStep = React.memo(({ userId, onClose }) => {
|
|||||||
onClose={onClose}
|
onClose={onClose}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
case StepTypes.API_KEY:
|
|
||||||
return <ApiKeyStep userId={userId} onBack={handleBack} onClose={onClose} />;
|
|
||||||
case StepTypes.RESET_TOTP:
|
|
||||||
return <ResetTotpStep userId={userId} onBack={handleBack} onClose={onClose} />;
|
|
||||||
case StepTypes.ACTIVATE:
|
case StepTypes.ACTIVATE:
|
||||||
return (
|
return (
|
||||||
<ConfirmationStep
|
<ConfirmationStep
|
||||||
@@ -204,42 +153,12 @@ const ActionsStep = React.memo(({ userId, onClose }) => {
|
|||||||
</Popup.Header>
|
</Popup.Header>
|
||||||
<Popup.Content>
|
<Popup.Content>
|
||||||
<Menu secondary vertical className={styles.menu}>
|
<Menu secondary vertical className={styles.menu}>
|
||||||
<Menu.Item className={styles.menuItem} onClick={handleEditInformationClick}>
|
<Menu.Item className={styles.menuItem} onClick={handleEditClick}>
|
||||||
<Icon name="info" className={styles.menuItemIcon} />
|
<Icon name="info" className={styles.menuItemIcon} />
|
||||||
{t('action.editInformation', {
|
{t('action.editInformation', {
|
||||||
context: 'title',
|
context: 'title',
|
||||||
})}
|
})}
|
||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
<Menu.Item className={styles.menuItem} onClick={handleEditAvatarClick}>
|
|
||||||
<Icon name="image outline" className={styles.menuItemIcon} />
|
|
||||||
{t('action.editAvatar', {
|
|
||||||
context: 'title',
|
|
||||||
})}
|
|
||||||
</Menu.Item>
|
|
||||||
{!user.lockedFieldNames.includes('username') && (
|
|
||||||
<Menu.Item className={styles.menuItem} onClick={handleEditUsernameClick}>
|
|
||||||
<Icon name="at" className={styles.menuItemIcon} />
|
|
||||||
{t('action.editUsername', {
|
|
||||||
context: 'title',
|
|
||||||
})}
|
|
||||||
</Menu.Item>
|
|
||||||
)}
|
|
||||||
{!user.lockedFieldNames.includes('email') && (
|
|
||||||
<Menu.Item className={styles.menuItem} onClick={handleEditEmailClick}>
|
|
||||||
<Icon name="mail outline" className={styles.menuItemIcon} />
|
|
||||||
{t('action.editEmail', {
|
|
||||||
context: 'title',
|
|
||||||
})}
|
|
||||||
</Menu.Item>
|
|
||||||
)}
|
|
||||||
{!user.lockedFieldNames.includes('password') && (
|
|
||||||
<Menu.Item className={styles.menuItem} onClick={handleEditPasswordClick}>
|
|
||||||
<Icon name="keyboard outline" className={styles.menuItemIcon} />
|
|
||||||
{t('action.editPassword', {
|
|
||||||
context: 'title',
|
|
||||||
})}
|
|
||||||
</Menu.Item>
|
|
||||||
)}
|
|
||||||
{!user.lockedFieldNames.includes('role') && !isCurrentUser && (
|
{!user.lockedFieldNames.includes('role') && !isCurrentUser && (
|
||||||
<Menu.Item className={styles.menuItem} onClick={handleEditRoleClick}>
|
<Menu.Item className={styles.menuItem} onClick={handleEditRoleClick}>
|
||||||
<Icon name="sun outline" className={styles.menuItemIcon} />
|
<Icon name="sun outline" className={styles.menuItemIcon} />
|
||||||
@@ -248,20 +167,6 @@ const ActionsStep = React.memo(({ userId, onClose }) => {
|
|||||||
})}
|
})}
|
||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
)}
|
)}
|
||||||
<Menu.Item className={styles.menuItem} onClick={handleApiKeyClick}>
|
|
||||||
<Icon name="key" className={styles.menuItemIcon} />
|
|
||||||
{t('common.apiKey', {
|
|
||||||
context: 'title',
|
|
||||||
})}
|
|
||||||
</Menu.Item>
|
|
||||||
{user.isTotpEnabled && !isCurrentUser && (
|
|
||||||
<Menu.Item className={styles.menuItem} onClick={handleResetTotpClick}>
|
|
||||||
<Icon name="shield alternate" className={styles.menuItemIcon} />
|
|
||||||
{t('common.reset2fa', {
|
|
||||||
context: 'title',
|
|
||||||
})}
|
|
||||||
</Menu.Item>
|
|
||||||
)}
|
|
||||||
{!isCurrentUser && (
|
{!isCurrentUser && (
|
||||||
<>
|
<>
|
||||||
<Menu.Item
|
<Menu.Item
|
||||||
@@ -303,6 +208,7 @@ const ActionsStep = React.memo(({ userId, onClose }) => {
|
|||||||
|
|
||||||
ActionsStep.propTypes = {
|
ActionsStep.propTypes = {
|
||||||
userId: PropTypes.string.isRequired,
|
userId: PropTypes.string.isRequired,
|
||||||
|
onEdit: PropTypes.func.isRequired,
|
||||||
onClose: PropTypes.func.isRequired,
|
onClose: PropTypes.func.isRequired,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,169 +0,0 @@
|
|||||||
/*!
|
|
||||||
* Copyright (c) 2025 PLANKA Software GmbH
|
|
||||||
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
|
|
||||||
*/
|
|
||||||
|
|
||||||
import React, { useCallback, useMemo, useState } from 'react';
|
|
||||||
import PropTypes from 'prop-types';
|
|
||||||
import { useDispatch, useSelector } from 'react-redux';
|
|
||||||
import { useTranslation } from 'react-i18next';
|
|
||||||
import { Button, Icon, Input, Message } from 'semantic-ui-react';
|
|
||||||
import { Popup } from '../../../../lib/custom-ui';
|
|
||||||
|
|
||||||
import selectors from '../../../../selectors';
|
|
||||||
import entryActions from '../../../../entry-actions';
|
|
||||||
import { useSteps } from '../../../../hooks';
|
|
||||||
import ConfirmationStep from '../../ConfirmationStep';
|
|
||||||
|
|
||||||
import styles from './ApiKeyStep.module.scss';
|
|
||||||
|
|
||||||
const StepTypes = {
|
|
||||||
REGENERATE: 'REGENERATE',
|
|
||||||
DELETE: 'DELETE',
|
|
||||||
};
|
|
||||||
|
|
||||||
const ApiKeyStep = React.memo(({ userId, onBack, onClose }) => {
|
|
||||||
const selectUserById = useMemo(() => selectors.makeSelectUserById(), []);
|
|
||||||
|
|
||||||
const user = useSelector((state) => selectUserById(state, userId));
|
|
||||||
|
|
||||||
const dispatch = useDispatch();
|
|
||||||
const [t] = useTranslation();
|
|
||||||
const [step, openStep, handleBack] = useSteps();
|
|
||||||
const [isCopied, setIsCopied] = useState(false);
|
|
||||||
|
|
||||||
const handleGenerateClick = useCallback(() => {
|
|
||||||
if (user.apiKeyPrefix) {
|
|
||||||
openStep(StepTypes.REGENERATE);
|
|
||||||
} else {
|
|
||||||
dispatch(entryActions.createUserApiKey(userId));
|
|
||||||
}
|
|
||||||
}, [userId, user.apiKeyPrefix, dispatch, openStep]);
|
|
||||||
|
|
||||||
const handleRegenerateConfirm = useCallback(() => {
|
|
||||||
dispatch(entryActions.createUserApiKey(userId));
|
|
||||||
handleBack();
|
|
||||||
}, [userId, dispatch, handleBack]);
|
|
||||||
|
|
||||||
const handleDeleteConfirm = useCallback(() => {
|
|
||||||
dispatch(entryActions.deleteUserApiKey(userId));
|
|
||||||
onClose();
|
|
||||||
}, [userId, onClose, dispatch]);
|
|
||||||
|
|
||||||
const handleDeleteClick = useCallback(() => {
|
|
||||||
openStep(StepTypes.DELETE);
|
|
||||||
}, [openStep]);
|
|
||||||
|
|
||||||
const handleCopyClick = useCallback(() => {
|
|
||||||
if (isCopied) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
navigator.clipboard.writeText(user.apiKeyState.value);
|
|
||||||
|
|
||||||
setIsCopied(true);
|
|
||||||
setTimeout(() => {
|
|
||||||
setIsCopied(false);
|
|
||||||
}, 1000);
|
|
||||||
}, [user.apiKeyState.value, isCopied]);
|
|
||||||
|
|
||||||
if (step) {
|
|
||||||
switch (step.type) {
|
|
||||||
case StepTypes.REGENERATE:
|
|
||||||
return (
|
|
||||||
<ConfirmationStep
|
|
||||||
title="common.regenerateApiKey"
|
|
||||||
content="common.areYouSureYouWantToRegenerateThisApiKey"
|
|
||||||
buttonContent="action.regenerateApiKey"
|
|
||||||
onConfirm={handleRegenerateConfirm}
|
|
||||||
onBack={handleBack}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
case StepTypes.DELETE:
|
|
||||||
return (
|
|
||||||
<ConfirmationStep
|
|
||||||
title="common.deleteApiKey"
|
|
||||||
content="common.areYouSureYouWantToDeleteThisApiKey"
|
|
||||||
buttonContent="action.deleteApiKey"
|
|
||||||
onConfirm={handleDeleteConfirm}
|
|
||||||
onBack={handleBack}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
default:
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<Popup.Header onBack={onBack}>
|
|
||||||
{t('common.apiKey', {
|
|
||||||
context: 'title',
|
|
||||||
})}
|
|
||||||
</Popup.Header>
|
|
||||||
<Popup.Content>
|
|
||||||
{user.apiKeyPrefix ? (
|
|
||||||
<>
|
|
||||||
{!user.apiKeyState.isCreating &&
|
|
||||||
(user.apiKeyState.value ? (
|
|
||||||
<>
|
|
||||||
<Message
|
|
||||||
positive
|
|
||||||
header={t('common.apiKeyCreated', {
|
|
||||||
context: 'title',
|
|
||||||
})}
|
|
||||||
content={t('common.saveThisKeyItWillNotBeShownAgain')}
|
|
||||||
/>
|
|
||||||
<div className={styles.valueWrapper}>
|
|
||||||
<Input fluid readOnly value={user.apiKeyState.value} className={styles.value} />
|
|
||||||
<Button className={styles.copyButton} onClick={handleCopyClick}>
|
|
||||||
<Icon fitted name={isCopied ? 'check' : 'copy'} />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<Message
|
|
||||||
warning
|
|
||||||
header={`${user.apiKeyPrefix}_...`}
|
|
||||||
content={t('common.fullKeyIsHiddenForSecurityReasons')}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
<Button
|
|
||||||
fluid
|
|
||||||
content={t('action.regenerateApiKey')}
|
|
||||||
loading={user.apiKeyState.isCreating}
|
|
||||||
disabled={user.apiKeyState.isCreating}
|
|
||||||
className={styles.actionButton}
|
|
||||||
onClick={handleGenerateClick}
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
fluid
|
|
||||||
content={t('action.deleteApiKey')}
|
|
||||||
className={styles.actionButton}
|
|
||||||
onClick={handleDeleteClick}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<div className={styles.content}>{t('common.noApiKeyCreated')}</div>
|
|
||||||
<Button
|
|
||||||
fluid
|
|
||||||
positive
|
|
||||||
content={t('action.createApiKey')}
|
|
||||||
loading={user.apiKeyState.isCreating}
|
|
||||||
disabled={user.apiKeyState.isCreating}
|
|
||||||
onClick={handleGenerateClick}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Popup.Content>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
ApiKeyStep.propTypes = {
|
|
||||||
userId: PropTypes.string.isRequired,
|
|
||||||
onBack: PropTypes.func.isRequired,
|
|
||||||
onClose: PropTypes.func.isRequired,
|
|
||||||
};
|
|
||||||
|
|
||||||
export default ApiKeyStep;
|
|
||||||
@@ -20,7 +20,7 @@ import UserAvatar from '../../../users/UserAvatar';
|
|||||||
|
|
||||||
import styles from './Item.module.scss';
|
import styles from './Item.module.scss';
|
||||||
|
|
||||||
const Item = React.memo(({ id }) => {
|
const Item = React.memo(({ id, onEdit }) => {
|
||||||
const selectUserById = useMemo(() => selectors.makeSelectUserById(), []);
|
const selectUserById = useMemo(() => selectors.makeSelectUserById(), []);
|
||||||
|
|
||||||
const user = useSelector((state) => selectUserById(state, id));
|
const user = useSelector((state) => selectUserById(state, id));
|
||||||
@@ -47,18 +47,26 @@ const Item = React.memo(({ id }) => {
|
|||||||
<div className={styles.user}>
|
<div className={styles.user}>
|
||||||
<UserAvatar id={id} />
|
<UserAvatar id={id} />
|
||||||
<div>
|
<div>
|
||||||
{user.name}
|
{/* eslint-disable-next-line jsx-a11y/click-events-have-key-events,
|
||||||
|
jsx-a11y/no-static-element-interactions */}
|
||||||
|
<span className={styles.nameLink} onClick={() => onEdit(id)}>
|
||||||
|
{user.name}
|
||||||
|
</span>
|
||||||
{user.id === currentUserId && (
|
{user.id === currentUserId && (
|
||||||
<div className={styles.note}>{t('common.currentUser')}</div>
|
<div className={styles.note}>{t('common.currentUser')}</div>
|
||||||
)}
|
)}
|
||||||
|
<div className={styles.mobileIdentity}>
|
||||||
|
{user.email}
|
||||||
|
{user.username && <div className={styles.note}>@{user.username}</div>}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Table.Cell>
|
</Table.Cell>
|
||||||
<Table.Cell>
|
<Table.Cell className={styles.identityCell}>
|
||||||
{user.email}
|
{user.email}
|
||||||
{user.username && <div className={styles.note}>@{user.username}</div>}
|
{user.username && <div className={styles.note}>@{user.username}</div>}
|
||||||
</Table.Cell>
|
</Table.Cell>
|
||||||
<Table.Cell>
|
<Table.Cell className={styles.informationCell}>
|
||||||
{user.phone && (
|
{user.phone && (
|
||||||
<div className={styles.information}>
|
<div className={styles.information}>
|
||||||
<Icon name="phone" className={styles.icon} />
|
<Icon name="phone" className={styles.icon} />
|
||||||
@@ -83,7 +91,7 @@ const Item = React.memo(({ id }) => {
|
|||||||
{t(`common.${user.role}`)}
|
{t(`common.${user.role}`)}
|
||||||
</Table.Cell>
|
</Table.Cell>
|
||||||
<Table.Cell textAlign="right">
|
<Table.Cell textAlign="right">
|
||||||
<ActionsPopup userId={id}>
|
<ActionsPopup userId={id} onEdit={onEdit}>
|
||||||
<Button className={styles.button}>
|
<Button className={styles.button}>
|
||||||
<Icon fitted name="pencil" />
|
<Icon fitted name="pencil" />
|
||||||
</Button>
|
</Button>
|
||||||
@@ -95,6 +103,7 @@ const Item = React.memo(({ id }) => {
|
|||||||
|
|
||||||
Item.propTypes = {
|
Item.propTypes = {
|
||||||
id: PropTypes.string.isRequired,
|
id: PropTypes.string.isRequired,
|
||||||
|
onEdit: PropTypes.func.isRequired,
|
||||||
};
|
};
|
||||||
|
|
||||||
export default Item;
|
export default Item;
|
||||||
|
|||||||
@@ -51,4 +51,37 @@
|
|||||||
.wrapperDeactivated {
|
.wrapperDeactivated {
|
||||||
opacity: 0.64;
|
opacity: 0.64;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.nameLink {
|
||||||
|
color: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
text-decoration: none;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobileIdentity {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
@container (max-width: 600px) {
|
||||||
|
.identityCell {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobileIdentity {
|
||||||
|
color: #6b6b6b;
|
||||||
|
display: block;
|
||||||
|
font-size: 13px;
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@container (max-width: 450px) {
|
||||||
|
.informationCell {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,131 +0,0 @@
|
|||||||
/*!
|
|
||||||
* Copyright (c) 2024 PLANKA Software GmbH
|
|
||||||
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
|
|
||||||
*/
|
|
||||||
|
|
||||||
import React, { useCallback, useMemo, useRef, useEffect } 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 { Input, Popup } from '../../../../lib/custom-ui';
|
|
||||||
|
|
||||||
import selectors from '../../../../selectors';
|
|
||||||
import entryActions from '../../../../entry-actions';
|
|
||||||
import { useForm, useNestedRef } from '../../../../hooks';
|
|
||||||
|
|
||||||
import styles from './ResetTotpStep.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 ResetTotpStep = React.memo(({ userId, onBack, onClose }) => {
|
|
||||||
const selectUserById = useMemo(() => selectors.makeSelectUserById(), []);
|
|
||||||
const user = useSelector((state) => selectUserById(state, userId));
|
|
||||||
|
|
||||||
const dispatch = useDispatch();
|
|
||||||
const [t] = useTranslation();
|
|
||||||
|
|
||||||
const { isDisabling, error } = user.totpState || {};
|
|
||||||
const wasDisablingRef = useRef(false);
|
|
||||||
|
|
||||||
const [data, handleFieldChange] = useForm({
|
|
||||||
currentPassword: '',
|
|
||||||
});
|
|
||||||
|
|
||||||
const [currentPasswordFieldRef, handleCurrentPasswordFieldRef] = useNestedRef('inputRef');
|
|
||||||
|
|
||||||
const message = useMemo(() => createMessage(error), [error]);
|
|
||||||
|
|
||||||
// The admin confirms with their own password, so the only signal that the
|
|
||||||
// reset went through is the request finishing without an error.
|
|
||||||
useEffect(() => {
|
|
||||||
if (wasDisablingRef.current && !isDisabling && !error) {
|
|
||||||
onClose();
|
|
||||||
}
|
|
||||||
|
|
||||||
wasDisablingRef.current = isDisabling;
|
|
||||||
}, [isDisabling, error, onClose]);
|
|
||||||
|
|
||||||
const handleSubmit = useCallback(() => {
|
|
||||||
if (!data.currentPassword) {
|
|
||||||
currentPasswordFieldRef.current.focus();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
dispatch(
|
|
||||||
entryActions.disableUserTotp(userId, {
|
|
||||||
currentPassword: data.currentPassword,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
}, [dispatch, userId, data.currentPassword, currentPasswordFieldRef]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<Popup.Header onBack={onBack}>
|
|
||||||
{t('common.reset2fa', {
|
|
||||||
context: 'title',
|
|
||||||
})}
|
|
||||||
</Popup.Header>
|
|
||||||
<Popup.Content>
|
|
||||||
<p className={styles.warning}>{t('common.reset2faWarning')}</p>
|
|
||||||
{message && (
|
|
||||||
<Message
|
|
||||||
{...{
|
|
||||||
[message.type]: true,
|
|
||||||
}}
|
|
||||||
visible
|
|
||||||
content={t(message.content)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<Form onSubmit={handleSubmit}>
|
|
||||||
<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
|
|
||||||
negative
|
|
||||||
content={t('action.reset2fa')}
|
|
||||||
icon="shield alternate"
|
|
||||||
loading={isDisabling}
|
|
||||||
disabled={isDisabling}
|
|
||||||
/>
|
|
||||||
</Form>
|
|
||||||
</Popup.Content>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
ResetTotpStep.propTypes = {
|
|
||||||
userId: PropTypes.string.isRequired,
|
|
||||||
onBack: PropTypes.func,
|
|
||||||
onClose: PropTypes.func.isRequired,
|
|
||||||
};
|
|
||||||
|
|
||||||
ResetTotpStep.defaultProps = {
|
|
||||||
onBack: undefined,
|
|
||||||
};
|
|
||||||
|
|
||||||
export default ResetTotpStep;
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
/*!
|
|
||||||
* 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;
|
|
||||||
}
|
|
||||||
|
|
||||||
.warning {
|
|
||||||
color: #444444;
|
|
||||||
font-size: 12px;
|
|
||||||
margin-bottom: 12px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -13,6 +13,7 @@ import selectors from '../../../../selectors';
|
|||||||
import { useField, useNestedRef, usePopupInClosableContext } from '../../../../hooks';
|
import { useField, useNestedRef, usePopupInClosableContext } from '../../../../hooks';
|
||||||
import Item from './Item';
|
import Item from './Item';
|
||||||
import AddStep from './AddStep';
|
import AddStep from './AddStep';
|
||||||
|
import UserEditModal from '../UserEditModal';
|
||||||
|
|
||||||
import styles from './UsersPane.module.scss';
|
import styles from './UsersPane.module.scss';
|
||||||
|
|
||||||
@@ -28,6 +29,7 @@ const UsersPane = React.memo(() => {
|
|||||||
const [isDeactivatedVisible, setIsDeactivatedVisible] = useState(false); // TODO: refactor?
|
const [isDeactivatedVisible, setIsDeactivatedVisible] = useState(false); // TODO: refactor?
|
||||||
|
|
||||||
const [searchFieldRef, handleSearchFieldRef] = useNestedRef('inputRef');
|
const [searchFieldRef, handleSearchFieldRef] = useNestedRef('inputRef');
|
||||||
|
const [editingUserId, setEditingUserId] = useState(null);
|
||||||
|
|
||||||
const filteredUsers = useMemo(
|
const filteredUsers = useMemo(
|
||||||
() =>
|
() =>
|
||||||
@@ -55,6 +57,10 @@ const UsersPane = React.memo(() => {
|
|||||||
setIsDeactivatedVisible(!isDeactivatedVisible);
|
setIsDeactivatedVisible(!isDeactivatedVisible);
|
||||||
}, [isDeactivatedVisible]);
|
}, [isDeactivatedVisible]);
|
||||||
|
|
||||||
|
const handleEditClose = useCallback(() => {
|
||||||
|
setEditingUserId(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
searchFieldRef.current.focus();
|
searchFieldRef.current.focus();
|
||||||
}, [searchFieldRef]);
|
}, [searchFieldRef]);
|
||||||
@@ -86,7 +92,7 @@ const UsersPane = React.memo(() => {
|
|||||||
</Table.Header>
|
</Table.Header>
|
||||||
<Table.Body>
|
<Table.Body>
|
||||||
{filteredUsers.map((user) => (
|
{filteredUsers.map((user) => (
|
||||||
<Item key={user.id} id={user.id} />
|
<Item key={user.id} id={user.id} onEdit={setEditingUserId} />
|
||||||
))}
|
))}
|
||||||
</Table.Body>
|
</Table.Body>
|
||||||
</Table>
|
</Table>
|
||||||
@@ -112,6 +118,7 @@ const UsersPane = React.memo(() => {
|
|||||||
</Button>
|
</Button>
|
||||||
</AddPopup>
|
</AddPopup>
|
||||||
</div>
|
</div>
|
||||||
|
{editingUserId && <UserEditModal userId={editingUserId} onClose={handleEditClose} />}
|
||||||
</Tab.Pane>
|
</Tab.Pane>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -26,6 +26,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.tableWrapper {
|
.tableWrapper {
|
||||||
|
container-type: inline-size;
|
||||||
margin-right: -2.5rem;
|
margin-right: -2.5rem;
|
||||||
max-height: calc(100vh - 338px);
|
max-height: calc(100vh - 338px);
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
|
|||||||
@@ -1,44 +0,0 @@
|
|||||||
/*!
|
|
||||||
* 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;
|
|
||||||
@@ -140,6 +140,7 @@ export default {
|
|||||||
color: 'Farbe',
|
color: 'Farbe',
|
||||||
comments: 'Kommentare',
|
comments: 'Kommentare',
|
||||||
confirmCodesSaved: 'Ich habe diese Codes an einem sicheren Ort gespeichert.',
|
confirmCodesSaved: 'Ich habe diese Codes an einem sicheren Ort gespeichert.',
|
||||||
|
confirmPassword: 'Passwort bestätigen',
|
||||||
contentExceedsLimit: 'Inhalt überschreitet {{limit}}',
|
contentExceedsLimit: 'Inhalt überschreitet {{limit}}',
|
||||||
contentOfThisAttachmentIsTooBigToDisplay:
|
contentOfThisAttachmentIsTooBigToDisplay:
|
||||||
'Der Inhalt dieses Anhangs ist zu groß für die Anzeige.',
|
'Der Inhalt dieses Anhangs ist zu groß für die Anzeige.',
|
||||||
@@ -468,6 +469,7 @@ export default {
|
|||||||
assignAsOwner: 'Als Eigentümer zuweisen',
|
assignAsOwner: 'Als Eigentümer zuweisen',
|
||||||
back: 'Zurück',
|
back: 'Zurück',
|
||||||
cancel: 'Abbrechen',
|
cancel: 'Abbrechen',
|
||||||
|
changePassword: 'Passwort ändern',
|
||||||
copy: 'Kopieren',
|
copy: 'Kopieren',
|
||||||
copyAll: 'Alle kopieren',
|
copyAll: 'Alle kopieren',
|
||||||
copyCard_title: 'Karte Kopieren',
|
copyCard_title: 'Karte Kopieren',
|
||||||
|
|||||||
@@ -121,6 +121,7 @@ export default {
|
|||||||
color: 'Color',
|
color: 'Color',
|
||||||
comments: 'Comments',
|
comments: 'Comments',
|
||||||
confirmCodesSaved: 'I have saved these codes in a safe place.',
|
confirmCodesSaved: 'I have saved these codes in a safe place.',
|
||||||
|
confirmPassword: 'Confirm password',
|
||||||
contentExceedsLimit: 'Content exceeds {{limit}}',
|
contentExceedsLimit: 'Content exceeds {{limit}}',
|
||||||
contentOfThisAttachmentIsTooBigToDisplay: 'Content of this attachment is too big to display.',
|
contentOfThisAttachmentIsTooBigToDisplay: 'Content of this attachment is too big to display.',
|
||||||
copy_inline: 'copy',
|
copy_inline: 'copy',
|
||||||
@@ -442,6 +443,7 @@ export default {
|
|||||||
assignAsOwner: 'Assign as owner',
|
assignAsOwner: 'Assign as owner',
|
||||||
back: 'Back',
|
back: 'Back',
|
||||||
cancel: 'Cancel',
|
cancel: 'Cancel',
|
||||||
|
changePassword: 'Change password',
|
||||||
copy: 'Copy',
|
copy: 'Copy',
|
||||||
copyAll: 'Copy all',
|
copyAll: 'Copy all',
|
||||||
copyCard_title: 'Copy Card',
|
copyCard_title: 'Copy Card',
|
||||||
|
|||||||
Reference in New Issue
Block a user