@@ -20,7 +20,7 @@ import styles from './SmtpPane.module.scss';
|
||||
|
||||
const SmtpPane = React.memo(() => {
|
||||
const config = useSelector(selectors.selectConfig);
|
||||
const smtpTest = useSelector(selectors.selectSmtpTest);
|
||||
const smtpTestState = useSelector(selectors.selectSmtpTestState);
|
||||
|
||||
const dispatch = useDispatch();
|
||||
const [t] = useTranslation();
|
||||
@@ -196,14 +196,14 @@ const SmtpPane = React.memo(() => {
|
||||
<Button
|
||||
type="button"
|
||||
content={t('action.sendTestEmail')}
|
||||
loading={smtpTest.isLoading}
|
||||
disabled={smtpTest.isLoading}
|
||||
loading={smtpTestState.isLoading}
|
||||
disabled={smtpTestState.isLoading}
|
||||
onClick={handleTestClick}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</Form>
|
||||
{smtpTest.logs && (
|
||||
{smtpTestState.logs && (
|
||||
<>
|
||||
<Divider horizontal>
|
||||
<Header as="h4">
|
||||
@@ -215,7 +215,7 @@ const SmtpPane = React.memo(() => {
|
||||
<TextArea
|
||||
readOnly
|
||||
as={TextareaAutosize}
|
||||
value={smtpTest.logs.join('\n')}
|
||||
value={smtpTestState.logs.join('\n')}
|
||||
className={styles.testLog}
|
||||
/>
|
||||
</>
|
||||
|
||||
@@ -14,6 +14,7 @@ import selectors from '../../../../selectors';
|
||||
import entryActions from '../../../../entry-actions';
|
||||
import { useSteps } from '../../../../hooks';
|
||||
import SelectRoleStep from './SelectRoleStep';
|
||||
import ApiKeyStep from './ApiKeyStep';
|
||||
import ConfirmationStep from '../../ConfirmationStep';
|
||||
import EditUserInformationStep from '../../../users/EditUserInformationStep';
|
||||
import EditUserUsernameStep from '../../../users/EditUserUsernameStep';
|
||||
@@ -28,6 +29,7 @@ const StepTypes = {
|
||||
EDIT_EMAIL: 'EDIT_EMAIL',
|
||||
EDIT_PASSWORD: 'EDIT_PASSWORD',
|
||||
EDIT_ROLE: 'EDIT_ROLE',
|
||||
API_KEY: 'API_KEY',
|
||||
ACTIVATE: 'ACTIVATE',
|
||||
DEACTIVATE: 'DEACTIVATE',
|
||||
DELETE: 'DELETE',
|
||||
@@ -39,6 +41,7 @@ const ActionsStep = React.memo(({ userId, onClose }) => {
|
||||
const activeUsersLimit = useSelector(selectors.selectActiveUsersLimit);
|
||||
const activeUsersTotal = useSelector(selectors.selectActiveUsersTotal);
|
||||
const user = useSelector((state) => selectUserById(state, userId));
|
||||
const isCurrentUser = useSelector((state) => user.id === selectors.selectCurrentUserId(state));
|
||||
|
||||
const dispatch = useDispatch();
|
||||
const [t] = useTranslation();
|
||||
@@ -99,6 +102,10 @@ const ActionsStep = React.memo(({ userId, onClose }) => {
|
||||
openStep(StepTypes.EDIT_ROLE);
|
||||
}, [openStep]);
|
||||
|
||||
const handleApiKeyClick = useCallback(() => {
|
||||
openStep(StepTypes.API_KEY);
|
||||
}, [openStep]);
|
||||
|
||||
const handleActivateClick = useCallback(() => {
|
||||
openStep(StepTypes.ACTIVATE);
|
||||
}, [openStep]);
|
||||
@@ -133,6 +140,8 @@ const ActionsStep = React.memo(({ userId, onClose }) => {
|
||||
onClose={onClose}
|
||||
/>
|
||||
);
|
||||
case StepTypes.API_KEY:
|
||||
return <ApiKeyStep userId={userId} onBack={handleBack} onClose={onClose} />;
|
||||
case StepTypes.ACTIVATE:
|
||||
return (
|
||||
<ConfirmationStep
|
||||
@@ -209,7 +218,7 @@ const ActionsStep = React.memo(({ userId, onClose }) => {
|
||||
})}
|
||||
</Menu.Item>
|
||||
)}
|
||||
{!user.lockedFieldNames.includes('role') && (
|
||||
{!user.lockedFieldNames.includes('role') && !isCurrentUser && (
|
||||
<Menu.Item className={styles.menuItem} onClick={handleEditRoleClick}>
|
||||
<Icon name="sun outline" className={styles.menuItemIcon} />
|
||||
{t('action.editRole', {
|
||||
@@ -217,31 +226,44 @@ const ActionsStep = React.memo(({ userId, onClose }) => {
|
||||
})}
|
||||
</Menu.Item>
|
||||
)}
|
||||
<Menu.Item
|
||||
disabled={
|
||||
user.isDeactivated &&
|
||||
activeUsersLimit !== null &&
|
||||
activeUsersTotal >= activeUsersLimit
|
||||
}
|
||||
className={styles.menuItem}
|
||||
onClick={user.isDeactivated ? handleActivateClick : handleDeactivateClick}
|
||||
>
|
||||
<Icon name={user.isDeactivated ? 'plus' : 'close'} className={styles.menuItemIcon} />
|
||||
{user.isDeactivated
|
||||
? t('action.activateUser', {
|
||||
context: 'title',
|
||||
})
|
||||
: t('action.deactivateUser', {
|
||||
context: 'title',
|
||||
})}
|
||||
<Menu.Item className={styles.menuItem} onClick={handleApiKeyClick}>
|
||||
<Icon name="key" className={styles.menuItemIcon} />
|
||||
{t('common.apiKey', {
|
||||
context: 'title',
|
||||
})}
|
||||
</Menu.Item>
|
||||
{user.isDeactivated && !user.isDefaultAdmin && (
|
||||
<Menu.Item className={styles.menuItem} onClick={handleDeleteClick}>
|
||||
<Icon name="trash alternate outline" className={styles.menuItemIcon} />
|
||||
{t('action.deleteUser', {
|
||||
context: 'title',
|
||||
})}
|
||||
</Menu.Item>
|
||||
{!isCurrentUser && (
|
||||
<>
|
||||
<Menu.Item
|
||||
disabled={
|
||||
user.isDeactivated &&
|
||||
activeUsersLimit !== null &&
|
||||
activeUsersTotal >= activeUsersLimit
|
||||
}
|
||||
className={styles.menuItem}
|
||||
onClick={user.isDeactivated ? handleActivateClick : handleDeactivateClick}
|
||||
>
|
||||
<Icon
|
||||
name={user.isDeactivated ? 'plus' : 'close'}
|
||||
className={styles.menuItemIcon}
|
||||
/>
|
||||
{user.isDeactivated
|
||||
? t('action.activateUser', {
|
||||
context: 'title',
|
||||
})
|
||||
: t('action.deactivateUser', {
|
||||
context: 'title',
|
||||
})}
|
||||
</Menu.Item>
|
||||
{user.isDeactivated && !user.isDefaultAdmin && (
|
||||
<Menu.Item className={styles.menuItem} onClick={handleDeleteClick}>
|
||||
<Icon name="trash alternate outline" className={styles.menuItemIcon} />
|
||||
{t('action.deleteUser', {
|
||||
context: 'title',
|
||||
})}
|
||||
</Menu.Item>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Menu>
|
||||
</Popup.Content>
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
/*!
|
||||
* 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;
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
:global(#app) {
|
||||
.actionButton {
|
||||
background: transparent;
|
||||
box-shadow: none;
|
||||
color: #6b808c;
|
||||
font-weight: normal;
|
||||
margin-top: 8px;
|
||||
padding: 6px 11px;
|
||||
text-align: left;
|
||||
text-decoration: underline;
|
||||
transition: background 0.3s ease;
|
||||
|
||||
&:hover {
|
||||
background: #e9e9e9;
|
||||
}
|
||||
}
|
||||
|
||||
.content {
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.copyButton {
|
||||
background: #ebeef0;
|
||||
box-shadow: none;
|
||||
border-radius: 3px;
|
||||
box-sizing: content-box;
|
||||
color: #516b7a;
|
||||
display: none;
|
||||
height: 30px;
|
||||
margin: 0;
|
||||
min-height: auto;
|
||||
outline: none;
|
||||
padding: 4px;
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: 0;
|
||||
transition: background 85ms ease;
|
||||
width: 20px;
|
||||
|
||||
&:hover {
|
||||
background: #dfe3e6;
|
||||
color: #4c4c4c;
|
||||
}
|
||||
}
|
||||
|
||||
.value input {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.valueWrapper {
|
||||
position: relative;
|
||||
|
||||
&:hover:not(:has(input:focus)) {
|
||||
.copyButton {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6,11 +6,13 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import classNames from 'classnames';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button, Icon, Table } from 'semantic-ui-react';
|
||||
import { useEventCallback } from '../../../../lib/hooks';
|
||||
|
||||
import selectors from '../../../../selectors';
|
||||
import entryActions from '../../../../entry-actions';
|
||||
import { usePopupInClosableContext } from '../../../../hooks';
|
||||
import { UserRoleIcons } from '../../../../constants/Icons';
|
||||
import ActionsStep from './ActionsStep';
|
||||
@@ -22,21 +24,62 @@ const Item = React.memo(({ id }) => {
|
||||
const selectUserById = useMemo(() => selectors.makeSelectUserById(), []);
|
||||
|
||||
const user = useSelector((state) => selectUserById(state, id));
|
||||
const currentUserId = useSelector(selectors.selectCurrentUserId);
|
||||
|
||||
const dispatch = useDispatch();
|
||||
const [t] = useTranslation();
|
||||
|
||||
const ActionsPopup = usePopupInClosableContext(ActionsStep);
|
||||
const handleActionsPopupClose = useEventCallback(() => {
|
||||
if (user.apiKeyState.value) {
|
||||
dispatch(entryActions.clearUserApiKeyValue(id));
|
||||
}
|
||||
}, [id, user.apiKeyState.value, dispatch]);
|
||||
|
||||
const ActionsPopup = usePopupInClosableContext(ActionsStep, {
|
||||
onClose: handleActionsPopupClose,
|
||||
});
|
||||
|
||||
return (
|
||||
<Table.Row className={classNames(user.isDeactivated && styles.wrapperDeactivated)}>
|
||||
<Table.Row
|
||||
className={classNames(styles.wrapper, user.isDeactivated && styles.wrapperDeactivated)}
|
||||
>
|
||||
<Table.Cell>
|
||||
<UserAvatar id={id} />
|
||||
<div className={styles.user}>
|
||||
<UserAvatar id={id} />
|
||||
<div>
|
||||
{user.name}
|
||||
{user.id === currentUserId && (
|
||||
<div className={styles.note}>{t('common.currentUser')}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Table.Cell>
|
||||
<Table.Cell>
|
||||
{user.email}
|
||||
{user.username && <div className={styles.note}>@{user.username}</div>}
|
||||
</Table.Cell>
|
||||
<Table.Cell>
|
||||
{user.phone && (
|
||||
<div className={styles.information}>
|
||||
<Icon name="phone" className={styles.icon} />
|
||||
{user.phone}
|
||||
</div>
|
||||
)}
|
||||
{user.organization && (
|
||||
<div className={styles.information}>
|
||||
<Icon name="building" className={styles.icon} />
|
||||
{user.organization}
|
||||
</div>
|
||||
)}
|
||||
{user.apiKeyPrefix && (
|
||||
<div className={classNames(styles.information, styles.informationApiKey)}>
|
||||
<Icon name="key" className={styles.icon} />
|
||||
{user.apiKeyPrefix}_...
|
||||
</div>
|
||||
)}
|
||||
</Table.Cell>
|
||||
<Table.Cell>{user.name}</Table.Cell>
|
||||
<Table.Cell>{user.username || '-'}</Table.Cell>
|
||||
<Table.Cell>{user.email}</Table.Cell>
|
||||
<Table.Cell className={styles.roleCell}>
|
||||
<Icon name={UserRoleIcons[user.role]} className={styles.roleIcon} />
|
||||
<Icon name={UserRoleIcons[user.role]} className={styles.icon} />
|
||||
{t(`common.${user.role}`)}
|
||||
</Table.Cell>
|
||||
<Table.Cell textAlign="right">
|
||||
|
||||
@@ -9,12 +9,43 @@
|
||||
margin-right: 0;
|
||||
}
|
||||
|
||||
.icon {
|
||||
color: #888888;
|
||||
margin: 0 0.35714286em 0 0;
|
||||
}
|
||||
|
||||
.information {
|
||||
font-size: 13px;
|
||||
|
||||
&:not(:last-of-type) {
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
.informationApiKey {
|
||||
color: #cf513d;
|
||||
|
||||
.icon {
|
||||
color: inherit;
|
||||
}
|
||||
}
|
||||
|
||||
.note {
|
||||
color: #888888;
|
||||
}
|
||||
|
||||
.roleCell {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.roleIcon {
|
||||
margin: 0 0.35714286em 0 0;
|
||||
.user {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.wrapper {
|
||||
color: #212121;
|
||||
}
|
||||
|
||||
.wrapperDeactivated {
|
||||
|
||||
@@ -1,8 +1,3 @@
|
||||
/*!
|
||||
* 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, useState } from 'react';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
@@ -18,7 +13,7 @@ import styles from './UsersPane.module.scss';
|
||||
|
||||
const UsersPane = React.memo(() => {
|
||||
const activeUsersLimit = useSelector(selectors.selectActiveUsersLimit);
|
||||
const users = useSelector(selectors.selectUsersExceptCurrent);
|
||||
const users = useSelector(selectors.selectUsers);
|
||||
const activeUsersTotal = useSelector(selectors.selectActiveUsersTotal);
|
||||
|
||||
const canAdd = useSelector((state) => {
|
||||
@@ -48,7 +43,9 @@ const UsersPane = React.memo(() => {
|
||||
return (
|
||||
user.email.includes(cleanSearch) ||
|
||||
user.name.toLowerCase().includes(cleanSearch) ||
|
||||
(user.username && user.username.includes(cleanSearch))
|
||||
(user.username && user.username.includes(cleanSearch)) ||
|
||||
(user.organization && user.organization.toLowerCase().includes(cleanSearch)) ||
|
||||
(user.apiKeyPrefix && user.apiKeyPrefix.toLowerCase().includes(cleanSearch))
|
||||
);
|
||||
}),
|
||||
[users, isDeactivatedVisible, cleanSearch],
|
||||
@@ -81,9 +78,8 @@ const UsersPane = React.memo(() => {
|
||||
<Table.Header>
|
||||
<Table.Row>
|
||||
<Table.HeaderCell />
|
||||
<Table.HeaderCell width={4}>{t('common.name')}</Table.HeaderCell>
|
||||
<Table.HeaderCell width={4}>{t('common.username')}</Table.HeaderCell>
|
||||
<Table.HeaderCell width={4}>{t('common.email')}</Table.HeaderCell>
|
||||
<Table.HeaderCell width={4}>{t('common.identity')}</Table.HeaderCell>
|
||||
<Table.HeaderCell width={4}>{t('common.information')}</Table.HeaderCell>
|
||||
<Table.HeaderCell>{t('common.role')}</Table.HeaderCell>
|
||||
<Table.HeaderCell />
|
||||
</Table.Row>
|
||||
@@ -101,7 +97,6 @@ const UsersPane = React.memo(() => {
|
||||
className={styles.toggleDeactivatedButton}
|
||||
onClick={handleToggleDeactivatedClick}
|
||||
/>
|
||||
|
||||
{canAdd && (
|
||||
<AddPopup>
|
||||
<Button
|
||||
|
||||
Reference in New Issue
Block a user