feat: Add API key authentication (#1254)

Closes #945
This commit is contained in:
Samuel
2025-11-06 20:56:48 +01:00
committed by GitHub
parent 5a2564f575
commit b4cbd32bf2
75 changed files with 1501 additions and 94 deletions
@@ -121,7 +121,7 @@ const ActionsStep = React.memo(({ boardMembershipId, title, onBack, onClose }) =
)}
{user.organization && (
<div className={styles.information}>
<Icon name="briefcase" className={styles.informationIcon} />
<Icon name="building" className={styles.informationIcon} />
{user.organization}
</div>
)}
@@ -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
@@ -42,14 +42,19 @@ const createMessage = (error) => {
}
};
const EditUserEmailStep = React.memo(({ id, withPasswordConfirmation, onBack, onClose }) => {
const EditUserEmailStep = React.memo(({ id, onBack, onClose }) => {
const selectUserById = useMemo(() => selectors.makeSelectUserById(), []);
const {
email,
isSsoUser,
emailUpdateForm: { data: defaultData, isSubmitting, error },
} = useSelector((state) => selectUserById(state, id));
const withPasswordConfirmation = useSelector(
(state) => id === selectors.selectCurrentUserId(state) && !isSsoUser,
);
const dispatch = useDispatch();
const [t] = useTranslation();
const wasSubmitting = usePrevious(isSubmitting);
@@ -199,13 +204,11 @@ const EditUserEmailStep = React.memo(({ id, withPasswordConfirmation, onBack, on
EditUserEmailStep.propTypes = {
id: PropTypes.string.isRequired,
withPasswordConfirmation: PropTypes.bool,
onBack: PropTypes.func,
onClose: PropTypes.func.isRequired,
};
EditUserEmailStep.defaultProps = {
withPasswordConfirmation: false,
onBack: undefined,
};
@@ -38,14 +38,17 @@ const createMessage = (error) => {
}
};
const EditUserPasswordStep = React.memo(({ id, withPasswordConfirmation, onBack, onClose }) => {
const EditUserPasswordStep = React.memo(({ id, onBack, onClose }) => {
const selectUserById = useMemo(() => selectors.makeSelectUserById(), []);
const {
data: defaultData,
isSubmitting,
error,
} = useSelector((state) => selectUserById(state, id).passwordUpdateForm);
isSsoUser,
passwordUpdateForm: { data: defaultData, isSubmitting, error },
} = useSelector((state) => selectUserById(state, id));
const withPasswordConfirmation = useSelector(
(state) => id === selectors.selectCurrentUserId(state) && !isSsoUser,
);
const dispatch = useDispatch();
const [t] = useTranslation();
@@ -168,13 +171,11 @@ const EditUserPasswordStep = React.memo(({ id, withPasswordConfirmation, onBack,
EditUserPasswordStep.propTypes = {
id: PropTypes.string.isRequired,
withPasswordConfirmation: PropTypes.bool,
onBack: PropTypes.func,
onClose: PropTypes.func.isRequired,
};
EditUserPasswordStep.defaultProps = {
withPasswordConfirmation: false,
onBack: undefined,
};
@@ -42,14 +42,19 @@ const createMessage = (error) => {
}
};
const EditUserUsernameStep = React.memo(({ id, withPasswordConfirmation, onBack, onClose }) => {
const EditUserUsernameStep = React.memo(({ id, onBack, onClose }) => {
const selectUserById = useMemo(() => selectors.makeSelectUserById(), []);
const {
username,
isSsoUser,
usernameUpdateForm: { data: defaultData, isSubmitting, error },
} = useSelector((state) => selectUserById(state, id));
const withPasswordConfirmation = useSelector(
(state) => id === selectors.selectCurrentUserId(state) && !isSsoUser,
);
const dispatch = useDispatch();
const [t] = useTranslation();
const wasSubmitting = usePrevious(isSubmitting);
@@ -199,13 +204,11 @@ const EditUserUsernameStep = React.memo(({ id, withPasswordConfirmation, onBack,
EditUserUsernameStep.propTypes = {
id: PropTypes.string.isRequired,
withPasswordConfirmation: PropTypes.bool,
onBack: PropTypes.func,
onClose: PropTypes.func.isRequired,
};
EditUserUsernameStep.defaultProps = {
withPasswordConfirmation: false,
onBack: undefined,
};
@@ -80,7 +80,7 @@ const AccountPane = React.memo(() => {
</Divider>
{isUsernameEditable && (
<div className={styles.action}>
<EditUserUsernamePopup id={user.id} withPasswordConfirmation={!user.isSsoUser}>
<EditUserUsernamePopup id={user.id}>
<Button className={styles.actionButton}>
{t('action.editUsername', {
context: 'title',
@@ -91,7 +91,7 @@ const AccountPane = React.memo(() => {
)}
{isEmailEditable && (
<div className={styles.action}>
<EditUserEmailPopup id={user.id} withPasswordConfirmation={!user.isSsoUser}>
<EditUserEmailPopup id={user.id}>
<Button className={styles.actionButton}>
{t('action.editEmail', {
context: 'title',
@@ -102,7 +102,7 @@ const AccountPane = React.memo(() => {
)}
{isPasswordEditable && (
<div className={styles.action}>
<EditUserPasswordPopup id={user.id} withPasswordConfirmation={!user.isSsoUser}>
<EditUserPasswordPopup id={user.id}>
<Button className={styles.actionButton}>
{t('action.editPassword', {
context: 'title',