mirror of
https://github.com/plankanban/planka.git
synced 2026-08-28 18:17:51 +08:00
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 {
|
||||
background: transparent;
|
||||
box-shadow: none;
|
||||
color: #6b808c;
|
||||
color: #666666;
|
||||
font-weight: normal;
|
||||
margin-top: 8px;
|
||||
padding: 6px 11px;
|
||||
@@ -16,7 +16,7 @@
|
||||
transition: background 0.3s ease;
|
||||
|
||||
&:hover {
|
||||
background: #e9e9e9;
|
||||
background: #f3f3f3;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,7 +29,7 @@
|
||||
box-shadow: none;
|
||||
border-radius: 3px;
|
||||
box-sizing: content-box;
|
||||
color: #516b7a;
|
||||
color: #444444;
|
||||
display: none;
|
||||
height: 30px;
|
||||
margin: 0;
|
||||
@@ -43,11 +43,17 @@
|
||||
width: 20px;
|
||||
|
||||
&:hover {
|
||||
background: #dfe3e6;
|
||||
color: #4c4c4c;
|
||||
background: #dcdfe1;
|
||||
color: #444444;
|
||||
}
|
||||
}
|
||||
|
||||
.pane {
|
||||
border: none !important;
|
||||
box-shadow: none !important;
|
||||
padding: 16px 0 !important;
|
||||
}
|
||||
|
||||
.value input {
|
||||
overflow: hidden;
|
||||
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 { useSteps } from '../../../../hooks';
|
||||
import SelectRoleStep from './SelectRoleStep';
|
||||
import ApiKeyStep from './ApiKeyStep';
|
||||
import ResetTotpStep from './ResetTotpStep';
|
||||
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';
|
||||
|
||||
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',
|
||||
API_KEY: 'API_KEY',
|
||||
RESET_TOTP: 'RESET_TOTP',
|
||||
ACTIVATE: 'ACTIVATE',
|
||||
DEACTIVATE: 'DEACTIVATE',
|
||||
DELETE: 'DELETE',
|
||||
};
|
||||
|
||||
const ActionsStep = React.memo(({ userId, onClose }) => {
|
||||
const ActionsStep = React.memo(({ userId, onEdit, onClose }) => {
|
||||
const selectUserById = useMemo(() => selectors.makeSelectUserById(), []);
|
||||
|
||||
const activeUsersLimit = useSelector(selectors.selectActiveUsersLimit);
|
||||
@@ -86,38 +72,15 @@ const ActionsStep = React.memo(({ userId, onClose }) => {
|
||||
dispatch(entryActions.deleteUser(userId));
|
||||
}, [userId, dispatch]);
|
||||
|
||||
const handleEditInformationClick = useCallback(() => {
|
||||
openStep(StepTypes.EDIT_INFORMATION);
|
||||
}, [openStep]);
|
||||
|
||||
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 handleEditClick = useCallback(() => {
|
||||
onEdit(userId);
|
||||
onClose();
|
||||
}, [userId, onEdit, onClose]);
|
||||
|
||||
const handleEditRoleClick = useCallback(() => {
|
||||
openStep(StepTypes.EDIT_ROLE);
|
||||
}, [openStep]);
|
||||
|
||||
const handleApiKeyClick = useCallback(() => {
|
||||
openStep(StepTypes.API_KEY);
|
||||
}, [openStep]);
|
||||
|
||||
const handleResetTotpClick = useCallback(() => {
|
||||
openStep(StepTypes.RESET_TOTP);
|
||||
}, [openStep]);
|
||||
|
||||
const handleActivateClick = useCallback(() => {
|
||||
openStep(StepTypes.ACTIVATE);
|
||||
}, [openStep]);
|
||||
@@ -132,16 +95,6 @@ const ActionsStep = React.memo(({ userId, onClose }) => {
|
||||
|
||||
if (step) {
|
||||
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:
|
||||
return (
|
||||
<SelectRoleStep
|
||||
@@ -154,10 +107,6 @@ const ActionsStep = React.memo(({ userId, 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:
|
||||
return (
|
||||
<ConfirmationStep
|
||||
@@ -204,42 +153,12 @@ const ActionsStep = React.memo(({ userId, onClose }) => {
|
||||
</Popup.Header>
|
||||
<Popup.Content>
|
||||
<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} />
|
||||
{t('action.editInformation', {
|
||||
context: 'title',
|
||||
})}
|
||||
</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 && (
|
||||
<Menu.Item className={styles.menuItem} onClick={handleEditRoleClick}>
|
||||
<Icon name="sun outline" className={styles.menuItemIcon} />
|
||||
@@ -248,20 +167,6 @@ const ActionsStep = React.memo(({ userId, onClose }) => {
|
||||
})}
|
||||
</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 && (
|
||||
<>
|
||||
<Menu.Item
|
||||
@@ -303,6 +208,7 @@ const ActionsStep = React.memo(({ userId, onClose }) => {
|
||||
|
||||
ActionsStep.propTypes = {
|
||||
userId: PropTypes.string.isRequired,
|
||||
onEdit: 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';
|
||||
|
||||
const Item = React.memo(({ id }) => {
|
||||
const Item = React.memo(({ id, onEdit }) => {
|
||||
const selectUserById = useMemo(() => selectors.makeSelectUserById(), []);
|
||||
|
||||
const user = useSelector((state) => selectUserById(state, id));
|
||||
@@ -47,18 +47,26 @@ const Item = React.memo(({ id }) => {
|
||||
<div className={styles.user}>
|
||||
<UserAvatar id={id} />
|
||||
<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 && (
|
||||
<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>
|
||||
</Table.Cell>
|
||||
<Table.Cell>
|
||||
<Table.Cell className={styles.identityCell}>
|
||||
{user.email}
|
||||
{user.username && <div className={styles.note}>@{user.username}</div>}
|
||||
</Table.Cell>
|
||||
<Table.Cell>
|
||||
<Table.Cell className={styles.informationCell}>
|
||||
{user.phone && (
|
||||
<div className={styles.information}>
|
||||
<Icon name="phone" className={styles.icon} />
|
||||
@@ -83,7 +91,7 @@ const Item = React.memo(({ id }) => {
|
||||
{t(`common.${user.role}`)}
|
||||
</Table.Cell>
|
||||
<Table.Cell textAlign="right">
|
||||
<ActionsPopup userId={id}>
|
||||
<ActionsPopup userId={id} onEdit={onEdit}>
|
||||
<Button className={styles.button}>
|
||||
<Icon fitted name="pencil" />
|
||||
</Button>
|
||||
@@ -95,6 +103,7 @@ const Item = React.memo(({ id }) => {
|
||||
|
||||
Item.propTypes = {
|
||||
id: PropTypes.string.isRequired,
|
||||
onEdit: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
export default Item;
|
||||
|
||||
@@ -51,4 +51,37 @@
|
||||
.wrapperDeactivated {
|
||||
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 Item from './Item';
|
||||
import AddStep from './AddStep';
|
||||
import UserEditModal from '../UserEditModal';
|
||||
|
||||
import styles from './UsersPane.module.scss';
|
||||
|
||||
@@ -28,6 +29,7 @@ const UsersPane = React.memo(() => {
|
||||
const [isDeactivatedVisible, setIsDeactivatedVisible] = useState(false); // TODO: refactor?
|
||||
|
||||
const [searchFieldRef, handleSearchFieldRef] = useNestedRef('inputRef');
|
||||
const [editingUserId, setEditingUserId] = useState(null);
|
||||
|
||||
const filteredUsers = useMemo(
|
||||
() =>
|
||||
@@ -55,6 +57,10 @@ const UsersPane = React.memo(() => {
|
||||
setIsDeactivatedVisible(!isDeactivatedVisible);
|
||||
}, [isDeactivatedVisible]);
|
||||
|
||||
const handleEditClose = useCallback(() => {
|
||||
setEditingUserId(null);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
searchFieldRef.current.focus();
|
||||
}, [searchFieldRef]);
|
||||
@@ -86,7 +92,7 @@ const UsersPane = React.memo(() => {
|
||||
</Table.Header>
|
||||
<Table.Body>
|
||||
{filteredUsers.map((user) => (
|
||||
<Item key={user.id} id={user.id} />
|
||||
<Item key={user.id} id={user.id} onEdit={setEditingUserId} />
|
||||
))}
|
||||
</Table.Body>
|
||||
</Table>
|
||||
@@ -112,6 +118,7 @@ const UsersPane = React.memo(() => {
|
||||
</Button>
|
||||
</AddPopup>
|
||||
</div>
|
||||
{editingUserId && <UserEditModal userId={editingUserId} onClose={handleEditClose} />}
|
||||
</Tab.Pane>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -26,6 +26,7 @@
|
||||
}
|
||||
|
||||
.tableWrapper {
|
||||
container-type: inline-size;
|
||||
margin-right: -2.5rem;
|
||||
max-height: calc(100vh - 338px);
|
||||
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',
|
||||
comments: 'Kommentare',
|
||||
confirmCodesSaved: 'Ich habe diese Codes an einem sicheren Ort gespeichert.',
|
||||
confirmPassword: 'Passwort bestätigen',
|
||||
contentExceedsLimit: 'Inhalt überschreitet {{limit}}',
|
||||
contentOfThisAttachmentIsTooBigToDisplay:
|
||||
'Der Inhalt dieses Anhangs ist zu groß für die Anzeige.',
|
||||
@@ -468,6 +469,7 @@ export default {
|
||||
assignAsOwner: 'Als Eigentümer zuweisen',
|
||||
back: 'Zurück',
|
||||
cancel: 'Abbrechen',
|
||||
changePassword: 'Passwort ändern',
|
||||
copy: 'Kopieren',
|
||||
copyAll: 'Alle kopieren',
|
||||
copyCard_title: 'Karte Kopieren',
|
||||
|
||||
@@ -121,6 +121,7 @@ export default {
|
||||
color: 'Color',
|
||||
comments: 'Comments',
|
||||
confirmCodesSaved: 'I have saved these codes in a safe place.',
|
||||
confirmPassword: 'Confirm password',
|
||||
contentExceedsLimit: 'Content exceeds {{limit}}',
|
||||
contentOfThisAttachmentIsTooBigToDisplay: 'Content of this attachment is too big to display.',
|
||||
copy_inline: 'copy',
|
||||
@@ -442,6 +443,7 @@ export default {
|
||||
assignAsOwner: 'Assign as owner',
|
||||
back: 'Back',
|
||||
cancel: 'Cancel',
|
||||
changePassword: 'Change password',
|
||||
copy: 'Copy',
|
||||
copyAll: 'Copy all',
|
||||
copyCard_title: 'Copy Card',
|
||||
|
||||
Reference in New Issue
Block a user