diff --git a/client/src/components/common/AdministrationModal/UserEditModal/ApiKeyPane.jsx b/client/src/components/common/AdministrationModal/UserEditModal/ApiKeyPane.jsx new file mode 100644 index 00000000..7206b048 --- /dev/null +++ b/client/src/components/common/AdministrationModal/UserEditModal/ApiKeyPane.jsx @@ -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 ( + + {t('common.areYouSureYouWantToRegenerateThisApiKey')} + + + + ); + } + + if (confirmState === ConfirmStates.DELETE) { + return ( + + {t('common.areYouSureYouWantToDeleteThisApiKey')} + + + + ); + } + + return ( + + {user.apiKeyPrefix ? ( + <> + {!user.apiKeyState.isCreating && + (user.apiKeyState.value ? ( + <> + + + + + + + + > + ) : ( + + ))} + + + > + ) : ( + <> + {t('common.noApiKeyCreated')} + + > + )} + + ); +}); + +ApiKeyPane.propTypes = { + userId: PropTypes.string.isRequired, + onClose: PropTypes.func.isRequired, +}; + +export default ApiKeyPane; diff --git a/client/src/components/common/AdministrationModal/UsersPane/ApiKeyStep.module.scss b/client/src/components/common/AdministrationModal/UserEditModal/ApiKeyPane.module.scss similarity index 82% rename from client/src/components/common/AdministrationModal/UsersPane/ApiKeyStep.module.scss rename to client/src/components/common/AdministrationModal/UserEditModal/ApiKeyPane.module.scss index a2cc686a..7309242d 100644 --- a/client/src/components/common/AdministrationModal/UsersPane/ApiKeyStep.module.scss +++ b/client/src/components/common/AdministrationModal/UserEditModal/ApiKeyPane.module.scss @@ -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; diff --git a/client/src/components/common/AdministrationModal/UserEditModal/ProfilePane.jsx b/client/src/components/common/AdministrationModal/UserEditModal/ProfilePane.jsx new file mode 100644 index 00000000..0151bed3 --- /dev/null +++ b/client/src/components/common/AdministrationModal/UserEditModal/ProfilePane.jsx @@ -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 ( + + + + + + + + {user.avatar && ( + + )} + + + + + + {usernameMessage && ( + + )} + {emailMessage && ( + + )} + {passwordMessage && ( + + )} + + + + + + {t('common.name')} + + + {isUsernameEditable && ( + + {t('common.username')} + + + {isUsernameUnlocked ? ( + + + + ) : ( + + + + )} + + + )} + {isEmailEditable && ( + + {t('common.email')} + + + {isEmailUnlocked ? ( + + + + ) : ( + + + + )} + + + )} + + {t('common.phone')} + + + + + + {t('common.organization')} + + + + + + + + {!isPasswordVisible ? ( + + + + ) : ( + + + {t('common.newPassword')} + + + + {t('common.confirmPassword')} + + + + + + + )} + + {withPasswordConfirmation && credentialsChanged && ( + <> + {t('common.currentPassword')} + + > + )} + + + + + {!isCurrentUser && user.isTotpEnabled && ( + <> + + + + {t('common.twoFactorAuthentication')} + {t('common.reset2faWarning')} + + setIsTotpResetModalOpen(true)} + /> + + {isTotpResetModalOpen && ( + setIsTotpResetModalOpen(false)} /> + )} + > + )} + + ); +}); + +ProfilePane.propTypes = { + userId: PropTypes.string.isRequired, +}; + +export default ProfilePane; diff --git a/client/src/components/common/AdministrationModal/UserEditModal/ProfilePane.module.scss b/client/src/components/common/AdministrationModal/UserEditModal/ProfilePane.module.scss new file mode 100644 index 00000000..ed1005d2 --- /dev/null +++ b/client/src/components/common/AdministrationModal/UserEditModal/ProfilePane.module.scss @@ -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; + } +} diff --git a/client/src/components/common/AdministrationModal/UserEditModal/TotpAdminResetModal.jsx b/client/src/components/common/AdministrationModal/UserEditModal/TotpAdminResetModal.jsx new file mode 100644 index 00000000..08e66086 --- /dev/null +++ b/client/src/components/common/AdministrationModal/UserEditModal/TotpAdminResetModal.jsx @@ -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 ( + + {t('common.reset2fa_title')} + + {t('common.reset2faWarning')} + {error && error.message === 'Invalid current password' && ( + + )} + + + {t('common.currentPassword')} + setPassword(value)} + /> + + + + + + + + + ); +}); + +TotpAdminResetModal.propTypes = { + userId: PropTypes.string.isRequired, + onClose: PropTypes.func.isRequired, +}; + +export default TotpAdminResetModal; diff --git a/client/src/components/common/AdministrationModal/UserEditModal/UserEditModal.jsx b/client/src/components/common/AdministrationModal/UserEditModal/UserEditModal.jsx new file mode 100644 index 00000000..97d88fbe --- /dev/null +++ b/client/src/components/common/AdministrationModal/UserEditModal/UserEditModal.jsx @@ -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: () => , + }, + { + menuItem: t('common.apiKey', { + context: 'title', + }), + render: () => , + }, + ]; + + return ( + + {user.name} + + + + + ); +}); + +UserEditModal.propTypes = { + userId: PropTypes.string.isRequired, + onClose: PropTypes.func.isRequired, +}; + +export default UserEditModal; diff --git a/client/src/components/common/AdministrationModal/UserEditModal/UserEditModal.module.scss b/client/src/components/common/AdministrationModal/UserEditModal/UserEditModal.module.scss new file mode 100644 index 00000000..e3cdce84 --- /dev/null +++ b/client/src/components/common/AdministrationModal/UserEditModal/UserEditModal.module.scss @@ -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 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; + } + } + } +} diff --git a/client/src/components/common/AdministrationModal/UserEditModal/index.js b/client/src/components/common/AdministrationModal/UserEditModal/index.js new file mode 100644 index 00000000..554dcf93 --- /dev/null +++ b/client/src/components/common/AdministrationModal/UserEditModal/index.js @@ -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; diff --git a/client/src/components/common/AdministrationModal/UsersPane/ActionsStep.jsx b/client/src/components/common/AdministrationModal/UsersPane/ActionsStep.jsx index fc20b6ea..b9e3c663 100644 --- a/client/src/components/common/AdministrationModal/UsersPane/ActionsStep.jsx +++ b/client/src/components/common/AdministrationModal/UsersPane/ActionsStep.jsx @@ -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 ; - case StepTypes.EDIT_AVATAR: - return ; - case StepTypes.EDIT_USERNAME: - return ; - case StepTypes.EDIT_EMAIL: - return ; - case StepTypes.EDIT_PASSWORD: - return ; case StepTypes.EDIT_ROLE: return ( { onClose={onClose} /> ); - case StepTypes.API_KEY: - return ; - case StepTypes.RESET_TOTP: - return ; case StepTypes.ACTIVATE: return ( { - + {t('action.editInformation', { context: 'title', })} - - - {t('action.editAvatar', { - context: 'title', - })} - - {!user.lockedFieldNames.includes('username') && ( - - - {t('action.editUsername', { - context: 'title', - })} - - )} - {!user.lockedFieldNames.includes('email') && ( - - - {t('action.editEmail', { - context: 'title', - })} - - )} - {!user.lockedFieldNames.includes('password') && ( - - - {t('action.editPassword', { - context: 'title', - })} - - )} {!user.lockedFieldNames.includes('role') && !isCurrentUser && ( @@ -248,20 +167,6 @@ const ActionsStep = React.memo(({ userId, onClose }) => { })} )} - - - {t('common.apiKey', { - context: 'title', - })} - - {user.isTotpEnabled && !isCurrentUser && ( - - - {t('common.reset2fa', { - context: 'title', - })} - - )} {!isCurrentUser && ( <> { ActionsStep.propTypes = { userId: PropTypes.string.isRequired, + onEdit: PropTypes.func.isRequired, onClose: PropTypes.func.isRequired, }; diff --git a/client/src/components/common/AdministrationModal/UsersPane/ApiKeyStep.jsx b/client/src/components/common/AdministrationModal/UsersPane/ApiKeyStep.jsx deleted file mode 100644 index 3a3c039a..00000000 --- a/client/src/components/common/AdministrationModal/UsersPane/ApiKeyStep.jsx +++ /dev/null @@ -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 ( - - ); - case StepTypes.DELETE: - return ( - - ); - default: - } - } - - return ( - <> - - {t('common.apiKey', { - context: 'title', - })} - - - {user.apiKeyPrefix ? ( - <> - {!user.apiKeyState.isCreating && - (user.apiKeyState.value ? ( - <> - - - - - - - - > - ) : ( - - ))} - - - > - ) : ( - <> - {t('common.noApiKeyCreated')} - - > - )} - - > - ); -}); - -ApiKeyStep.propTypes = { - userId: PropTypes.string.isRequired, - onBack: PropTypes.func.isRequired, - onClose: PropTypes.func.isRequired, -}; - -export default ApiKeyStep; diff --git a/client/src/components/common/AdministrationModal/UsersPane/Item.jsx b/client/src/components/common/AdministrationModal/UsersPane/Item.jsx index 9ce9642e..2289c484 100755 --- a/client/src/components/common/AdministrationModal/UsersPane/Item.jsx +++ b/client/src/components/common/AdministrationModal/UsersPane/Item.jsx @@ -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 }) => { - {user.name} + {/* eslint-disable-next-line jsx-a11y/click-events-have-key-events, + jsx-a11y/no-static-element-interactions */} + onEdit(id)}> + {user.name} + {user.id === currentUserId && ( {t('common.currentUser')} )} + + {user.email} + {user.username && @{user.username}} + - + {user.email} {user.username && @{user.username}} - + {user.phone && ( @@ -83,7 +91,7 @@ const Item = React.memo(({ id }) => { {t(`common.${user.role}`)} - + @@ -95,6 +103,7 @@ const Item = React.memo(({ id }) => { Item.propTypes = { id: PropTypes.string.isRequired, + onEdit: PropTypes.func.isRequired, }; export default Item; diff --git a/client/src/components/common/AdministrationModal/UsersPane/Item.module.scss b/client/src/components/common/AdministrationModal/UsersPane/Item.module.scss index b4140570..515ca193 100644 --- a/client/src/components/common/AdministrationModal/UsersPane/Item.module.scss +++ b/client/src/components/common/AdministrationModal/UsersPane/Item.module.scss @@ -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; + } + } } diff --git a/client/src/components/common/AdministrationModal/UsersPane/ResetTotpStep.jsx b/client/src/components/common/AdministrationModal/UsersPane/ResetTotpStep.jsx deleted file mode 100644 index dcc69767..00000000 --- a/client/src/components/common/AdministrationModal/UsersPane/ResetTotpStep.jsx +++ /dev/null @@ -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 ( - <> - - {t('common.reset2fa', { - context: 'title', - })} - - - {t('common.reset2faWarning')} - {message && ( - - )} - - {t('common.currentPassword')} - - - - - > - ); -}); - -ResetTotpStep.propTypes = { - userId: PropTypes.string.isRequired, - onBack: PropTypes.func, - onClose: PropTypes.func.isRequired, -}; - -ResetTotpStep.defaultProps = { - onBack: undefined, -}; - -export default ResetTotpStep; diff --git a/client/src/components/common/AdministrationModal/UsersPane/ResetTotpStep.module.scss b/client/src/components/common/AdministrationModal/UsersPane/ResetTotpStep.module.scss deleted file mode 100644 index c98644e9..00000000 --- a/client/src/components/common/AdministrationModal/UsersPane/ResetTotpStep.module.scss +++ /dev/null @@ -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; - } -} diff --git a/client/src/components/common/AdministrationModal/UsersPane/UsersPane.jsx b/client/src/components/common/AdministrationModal/UsersPane/UsersPane.jsx index 39013854..6a6b1aa4 100755 --- a/client/src/components/common/AdministrationModal/UsersPane/UsersPane.jsx +++ b/client/src/components/common/AdministrationModal/UsersPane/UsersPane.jsx @@ -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(() => { {filteredUsers.map((user) => ( - + ))} @@ -112,6 +118,7 @@ const UsersPane = React.memo(() => { + {editingUserId && } ); }); diff --git a/client/src/components/common/AdministrationModal/UsersPane/UsersPane.module.scss b/client/src/components/common/AdministrationModal/UsersPane/UsersPane.module.scss index 166f2be7..d0870033 100644 --- a/client/src/components/common/AdministrationModal/UsersPane/UsersPane.module.scss +++ b/client/src/components/common/AdministrationModal/UsersPane/UsersPane.module.scss @@ -26,6 +26,7 @@ } .tableWrapper { + container-type: inline-size; margin-right: -2.5rem; max-height: calc(100vh - 338px); overflow: auto; diff --git a/client/src/components/users/EditUserInformationStep.jsx b/client/src/components/users/EditUserInformationStep.jsx deleted file mode 100644 index eaa8ee2d..00000000 --- a/client/src/components/users/EditUserInformationStep.jsx +++ /dev/null @@ -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 ( - <> - - {t('common.editInformation', { - context: 'title', - })} - - - - - > - ); -}); - -EditUserInformationStep.propTypes = { - id: PropTypes.string.isRequired, - onBack: PropTypes.func, - onClose: PropTypes.func.isRequired, -}; - -EditUserInformationStep.defaultProps = { - onBack: undefined, -}; - -export default EditUserInformationStep; diff --git a/client/src/locales/de-DE/core.js b/client/src/locales/de-DE/core.js index 550ad4cd..6cb4d1c4 100644 --- a/client/src/locales/de-DE/core.js +++ b/client/src/locales/de-DE/core.js @@ -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', diff --git a/client/src/locales/en-US/core.js b/client/src/locales/en-US/core.js index 821ff87f..250ba4ab 100644 --- a/client/src/locales/en-US/core.js +++ b/client/src/locales/en-US/core.js @@ -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',
{t('common.areYouSureYouWantToRegenerateThisApiKey')}
{t('common.areYouSureYouWantToDeleteThisApiKey')}
{t('common.reset2faWarning')}