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:
Daniel Hiller
2026-08-08 00:16:35 +02:00
parent a6f6af8a6c
commit a9d0c228df
19 changed files with 1057 additions and 479 deletions
@@ -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;
@@ -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;
@@ -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;
}
}
@@ -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;
@@ -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;
+2
View File
@@ -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',
+2
View File
@@ -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',