feat: filter webhooks per project, board, and account
Adds optional project, board, and acting-user scope to each webhook so events from a specific board or project can be routed to a single endpoint without an external automation tool in the middle (closes #1457). A webhook now fires for an event only when every set scope matches: empty scope still means "fire for everything", keeping existing webhooks working unchanged. Scope filtering is centralized in the sendWebhooks helper, which auto-derives projectId/boardId from the event payload, so existing call sites are untouched.
This commit is contained in:
@@ -15,6 +15,9 @@ import styles from './WebhooksPane.module.scss';
|
||||
|
||||
const WebhooksPane = React.memo(() => {
|
||||
const webhookIds = useSelector(selectors.selectWebhookIds);
|
||||
const projects = useSelector(selectors.selectProjects);
|
||||
const boards = useSelector(selectors.selectBoards);
|
||||
const users = useSelector(selectors.selectUsers);
|
||||
|
||||
const dispatch = useDispatch();
|
||||
|
||||
@@ -27,7 +30,13 @@ const WebhooksPane = React.memo(() => {
|
||||
|
||||
return (
|
||||
<Tab.Pane attached={false} className={styles.wrapper}>
|
||||
<Webhooks ids={webhookIds} onCreate={handleCreate} />
|
||||
<Webhooks
|
||||
ids={webhookIds}
|
||||
projects={projects}
|
||||
boards={boards}
|
||||
users={users}
|
||||
onCreate={handleCreate}
|
||||
/>
|
||||
</Tab.Pane>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
|
||||
*/
|
||||
|
||||
import React, { useCallback, useImperativeHandle } from 'react';
|
||||
import React, { useCallback, useImperativeHandle, useMemo } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Dropdown, Input } from 'semantic-ui-react';
|
||||
@@ -13,134 +13,224 @@ import WEBHOOK_EVENTS from '../../../constants/WebhookEvents';
|
||||
|
||||
import styles from './Editor.module.scss';
|
||||
|
||||
const Editor = React.forwardRef(({ data, isReadOnly, onFieldChange }, ref) => {
|
||||
const [t] = useTranslation();
|
||||
const Editor = React.forwardRef(
|
||||
({ data, projects, boards, users, isReadOnly, onFieldChange }, ref) => {
|
||||
const [t] = useTranslation();
|
||||
|
||||
const [nameFieldRef, handleNameFieldRef] = useNestedRef('inputRef');
|
||||
const [urlFieldRef, handleUrlFieldRef] = useNestedRef('inputRef');
|
||||
const [nameFieldRef, handleNameFieldRef] = useNestedRef('inputRef');
|
||||
const [urlFieldRef, handleUrlFieldRef] = useNestedRef('inputRef');
|
||||
|
||||
const focusNameField = useCallback(() => {
|
||||
nameFieldRef.current.focus({
|
||||
preventScroll: true,
|
||||
});
|
||||
}, [nameFieldRef]);
|
||||
const focusNameField = useCallback(() => {
|
||||
nameFieldRef.current.focus({
|
||||
preventScroll: true,
|
||||
});
|
||||
}, [nameFieldRef]);
|
||||
|
||||
const selectNameField = useCallback(() => {
|
||||
nameFieldRef.current.select();
|
||||
}, [nameFieldRef]);
|
||||
const selectNameField = useCallback(() => {
|
||||
nameFieldRef.current.select();
|
||||
}, [nameFieldRef]);
|
||||
|
||||
const selectUrlField = useCallback(() => {
|
||||
urlFieldRef.current.select();
|
||||
}, [urlFieldRef]);
|
||||
const selectUrlField = useCallback(() => {
|
||||
urlFieldRef.current.select();
|
||||
}, [urlFieldRef]);
|
||||
|
||||
useImperativeHandle(
|
||||
ref,
|
||||
() => ({
|
||||
focusNameField,
|
||||
selectNameField,
|
||||
selectUrlField,
|
||||
}),
|
||||
[focusNameField, selectNameField, selectUrlField],
|
||||
);
|
||||
useImperativeHandle(
|
||||
ref,
|
||||
() => ({
|
||||
focusNameField,
|
||||
selectNameField,
|
||||
selectUrlField,
|
||||
}),
|
||||
[focusNameField, selectNameField, selectUrlField],
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={styles.text}>{t('common.title')}</div>
|
||||
<Input
|
||||
fluid
|
||||
ref={handleNameFieldRef}
|
||||
name="name"
|
||||
value={data.name}
|
||||
maxLength={128}
|
||||
readOnly={isReadOnly}
|
||||
className={styles.field}
|
||||
onChange={onFieldChange}
|
||||
/>
|
||||
<div className={styles.text}>{t('common.url')}</div>
|
||||
<Input
|
||||
fluid
|
||||
ref={handleUrlFieldRef}
|
||||
name="url"
|
||||
value={data.url}
|
||||
maxLength={2048}
|
||||
readOnly={isReadOnly}
|
||||
className={styles.field}
|
||||
onChange={onFieldChange}
|
||||
/>
|
||||
<div className={styles.text}>
|
||||
{t('common.accessToken')} (
|
||||
{t('common.optional', {
|
||||
context: 'inline',
|
||||
})}
|
||||
)
|
||||
</div>
|
||||
<Input
|
||||
fluid
|
||||
name="accessToken"
|
||||
value={data.accessToken}
|
||||
maxLength={512}
|
||||
readOnly={isReadOnly}
|
||||
className={styles.field}
|
||||
onChange={onFieldChange}
|
||||
/>
|
||||
{data.excludedEvents.length === 0 && (
|
||||
<>
|
||||
<div className={styles.text}>
|
||||
{t('common.events')} (
|
||||
{t('common.optional', {
|
||||
context: 'inline',
|
||||
})}
|
||||
)
|
||||
</div>
|
||||
<Dropdown
|
||||
selection
|
||||
multiple
|
||||
fluid
|
||||
name="events"
|
||||
options={WEBHOOK_EVENTS.map((event) => ({
|
||||
text: event,
|
||||
value: event,
|
||||
}))}
|
||||
value={data.events}
|
||||
placeholder="All"
|
||||
readOnly={isReadOnly}
|
||||
className={styles.field}
|
||||
onChange={onFieldChange}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{data.events.length === 0 && (
|
||||
<>
|
||||
<div className={styles.text}>
|
||||
{t('common.excludedEvents')} (
|
||||
{t('common.optional', {
|
||||
context: 'inline',
|
||||
})}
|
||||
)
|
||||
</div>
|
||||
<Dropdown
|
||||
selection
|
||||
multiple
|
||||
fluid
|
||||
name="excludedEvents"
|
||||
options={WEBHOOK_EVENTS.map((event) => ({
|
||||
text: event,
|
||||
value: event,
|
||||
}))}
|
||||
value={data.excludedEvents}
|
||||
placeholder="None"
|
||||
readOnly={isReadOnly}
|
||||
className={styles.field}
|
||||
onChange={onFieldChange}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
});
|
||||
const handleScopeChange = useCallback(
|
||||
(_, { name, value }) => {
|
||||
onFieldChange(null, { name, value: value === '' ? null : value });
|
||||
},
|
||||
[onFieldChange],
|
||||
);
|
||||
|
||||
const projectOptions = useMemo(
|
||||
() => projects.map((project) => ({ text: project.name, value: project.id })),
|
||||
[projects],
|
||||
);
|
||||
|
||||
const boardOptions = useMemo(() => {
|
||||
const filtered = data.projectId
|
||||
? boards.filter((board) => board.projectId === data.projectId)
|
||||
: boards;
|
||||
return filtered.map((board) => ({ text: board.name, value: board.id }));
|
||||
}, [boards, data.projectId]);
|
||||
|
||||
const userOptions = useMemo(
|
||||
() =>
|
||||
users.map((user) => ({
|
||||
text: user.name || user.username || user.email,
|
||||
value: user.id,
|
||||
})),
|
||||
[users],
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={styles.text}>{t('common.title')}</div>
|
||||
<Input
|
||||
fluid
|
||||
ref={handleNameFieldRef}
|
||||
name="name"
|
||||
value={data.name}
|
||||
maxLength={128}
|
||||
readOnly={isReadOnly}
|
||||
className={styles.field}
|
||||
onChange={onFieldChange}
|
||||
/>
|
||||
<div className={styles.text}>{t('common.url')}</div>
|
||||
<Input
|
||||
fluid
|
||||
ref={handleUrlFieldRef}
|
||||
name="url"
|
||||
value={data.url}
|
||||
maxLength={2048}
|
||||
readOnly={isReadOnly}
|
||||
className={styles.field}
|
||||
onChange={onFieldChange}
|
||||
/>
|
||||
<div className={styles.text}>
|
||||
{t('common.accessToken')} (
|
||||
{t('common.optional', {
|
||||
context: 'inline',
|
||||
})}
|
||||
)
|
||||
</div>
|
||||
<Input
|
||||
fluid
|
||||
name="accessToken"
|
||||
value={data.accessToken}
|
||||
maxLength={512}
|
||||
readOnly={isReadOnly}
|
||||
className={styles.field}
|
||||
onChange={onFieldChange}
|
||||
/>
|
||||
<div className={styles.text}>
|
||||
{t('common.project')} (
|
||||
{t('common.optional', {
|
||||
context: 'inline',
|
||||
})}
|
||||
)
|
||||
</div>
|
||||
<Dropdown
|
||||
selection
|
||||
clearable
|
||||
fluid
|
||||
name="projectId"
|
||||
options={projectOptions}
|
||||
value={data.projectId || ''}
|
||||
placeholder={t('common.all')}
|
||||
disabled={isReadOnly}
|
||||
className={styles.field}
|
||||
onChange={handleScopeChange}
|
||||
/>
|
||||
<div className={styles.text}>
|
||||
{t('common.board')} (
|
||||
{t('common.optional', {
|
||||
context: 'inline',
|
||||
})}
|
||||
)
|
||||
</div>
|
||||
<Dropdown
|
||||
selection
|
||||
clearable
|
||||
fluid
|
||||
name="boardId"
|
||||
options={boardOptions}
|
||||
value={data.boardId || ''}
|
||||
placeholder={t('common.all')}
|
||||
disabled={isReadOnly}
|
||||
className={styles.field}
|
||||
onChange={handleScopeChange}
|
||||
/>
|
||||
<div className={styles.text}>
|
||||
{t('common.account')} (
|
||||
{t('common.optional', {
|
||||
context: 'inline',
|
||||
})}
|
||||
)
|
||||
</div>
|
||||
<Dropdown
|
||||
selection
|
||||
clearable
|
||||
fluid
|
||||
name="userId"
|
||||
options={userOptions}
|
||||
value={data.userId || ''}
|
||||
placeholder={t('common.all')}
|
||||
disabled={isReadOnly}
|
||||
className={styles.field}
|
||||
onChange={handleScopeChange}
|
||||
/>
|
||||
{data.excludedEvents.length === 0 && (
|
||||
<>
|
||||
<div className={styles.text}>
|
||||
{t('common.events')} (
|
||||
{t('common.optional', {
|
||||
context: 'inline',
|
||||
})}
|
||||
)
|
||||
</div>
|
||||
<Dropdown
|
||||
selection
|
||||
multiple
|
||||
fluid
|
||||
name="events"
|
||||
options={WEBHOOK_EVENTS.map((event) => ({
|
||||
text: event,
|
||||
value: event,
|
||||
}))}
|
||||
value={data.events}
|
||||
placeholder="All"
|
||||
readOnly={isReadOnly}
|
||||
className={styles.field}
|
||||
onChange={onFieldChange}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{data.events.length === 0 && (
|
||||
<>
|
||||
<div className={styles.text}>
|
||||
{t('common.excludedEvents')} (
|
||||
{t('common.optional', {
|
||||
context: 'inline',
|
||||
})}
|
||||
)
|
||||
</div>
|
||||
<Dropdown
|
||||
selection
|
||||
multiple
|
||||
fluid
|
||||
name="excludedEvents"
|
||||
options={WEBHOOK_EVENTS.map((event) => ({
|
||||
text: event,
|
||||
value: event,
|
||||
}))}
|
||||
value={data.excludedEvents}
|
||||
placeholder="None"
|
||||
readOnly={isReadOnly}
|
||||
className={styles.field}
|
||||
onChange={onFieldChange}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
Editor.propTypes = {
|
||||
data: PropTypes.object.isRequired, // eslint-disable-line react/forbid-prop-types
|
||||
projects: PropTypes.array.isRequired, // eslint-disable-line react/forbid-prop-types
|
||||
boards: PropTypes.array.isRequired, // eslint-disable-line react/forbid-prop-types
|
||||
users: PropTypes.array.isRequired, // eslint-disable-line react/forbid-prop-types
|
||||
isReadOnly: PropTypes.bool,
|
||||
onFieldChange: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
@@ -20,7 +20,7 @@ import ConfirmationStep from '../../common/ConfirmationStep';
|
||||
import styles from './Item.module.scss';
|
||||
import { useToggle } from '../../../lib/hooks';
|
||||
|
||||
const Item = React.memo(({ id }) => {
|
||||
const Item = React.memo(({ id, projects, boards, users }) => {
|
||||
const selectWebhookById = useMemo(() => selectors.makeSelectWebhookById(), []);
|
||||
|
||||
const webhook = useSelector((state) => selectWebhookById(state, id));
|
||||
@@ -36,6 +36,9 @@ const Item = React.memo(({ id }) => {
|
||||
accessToken: webhook.accessToken,
|
||||
events: webhook.events,
|
||||
excludedEvents: webhook.excludedEvents,
|
||||
projectId: webhook.projectId,
|
||||
boardId: webhook.boardId,
|
||||
userId: webhook.userId,
|
||||
}),
|
||||
[webhook],
|
||||
);
|
||||
@@ -47,6 +50,9 @@ const Item = React.memo(({ id }) => {
|
||||
accessToken: defaultData.accessToken || '',
|
||||
events: defaultData.events || [],
|
||||
excludedEvents: defaultData.excludedEvents || [],
|
||||
projectId: defaultData.projectId || null,
|
||||
boardId: defaultData.boardId || null,
|
||||
userId: defaultData.userId || null,
|
||||
}));
|
||||
|
||||
const cleanData = useMemo(
|
||||
@@ -57,6 +63,9 @@ const Item = React.memo(({ id }) => {
|
||||
accessToken: data.accessToken.trim() || null,
|
||||
events: data.events.length === 0 ? null : data.events,
|
||||
excludedEvents: data.excludedEvents.length === 0 ? null : data.excludedEvents,
|
||||
projectId: data.projectId || null,
|
||||
boardId: data.boardId || null,
|
||||
userId: data.userId || null,
|
||||
}),
|
||||
[data],
|
||||
);
|
||||
@@ -99,6 +108,9 @@ const Item = React.memo(({ id }) => {
|
||||
<Editor
|
||||
ref={editorRef}
|
||||
data={data}
|
||||
projects={projects}
|
||||
boards={boards}
|
||||
users={users}
|
||||
isReadOnly={!webhook.isPersisted}
|
||||
onFieldChange={handleFieldChange}
|
||||
/>
|
||||
@@ -132,6 +144,9 @@ const Item = React.memo(({ id }) => {
|
||||
|
||||
Item.propTypes = {
|
||||
id: PropTypes.string.isRequired,
|
||||
projects: PropTypes.array.isRequired, // eslint-disable-line react/forbid-prop-types
|
||||
boards: PropTypes.array.isRequired, // eslint-disable-line react/forbid-prop-types
|
||||
users: PropTypes.array.isRequired, // eslint-disable-line react/forbid-prop-types
|
||||
};
|
||||
|
||||
export default Item;
|
||||
|
||||
@@ -15,6 +15,9 @@ import Item from './Item';
|
||||
import Editor from './Editor';
|
||||
|
||||
const DEFAULT_DATA = {
|
||||
projectId: null,
|
||||
boardId: null,
|
||||
userId: null,
|
||||
name: '',
|
||||
url: '',
|
||||
accessToken: '',
|
||||
@@ -22,7 +25,7 @@ const DEFAULT_DATA = {
|
||||
excludedEvents: [],
|
||||
};
|
||||
|
||||
const Webhooks = React.memo(({ ids, onCreate }) => {
|
||||
const Webhooks = React.memo(({ ids, projects, boards, users, onCreate }) => {
|
||||
const [t] = useTranslation();
|
||||
|
||||
const [data, handleFieldChange, setData] = useForm(DEFAULT_DATA);
|
||||
@@ -38,6 +41,9 @@ const Webhooks = React.memo(({ ids, onCreate }) => {
|
||||
accessToken: data.accessToken.trim() || null,
|
||||
events: data.events.length === 0 ? null : data.events,
|
||||
excludedEvents: data.excludedEvents.length === 0 ? null : data.excludedEvents,
|
||||
projectId: data.projectId || null,
|
||||
boardId: data.boardId || null,
|
||||
userId: data.userId || null,
|
||||
};
|
||||
|
||||
if (!cleanData.name) {
|
||||
@@ -72,14 +78,21 @@ const Webhooks = React.memo(({ ids, onCreate }) => {
|
||||
{ids.length > 0 && (
|
||||
<Accordion styled fluid>
|
||||
{ids.map((id) => (
|
||||
<Item key={id} id={id} />
|
||||
<Item key={id} id={id} projects={projects} boards={boards} users={users} />
|
||||
))}
|
||||
</Accordion>
|
||||
)}
|
||||
{ids.length < 10 && (
|
||||
<Segment>
|
||||
<Form onSubmit={handleCreateSubmit}>
|
||||
<Editor ref={editorRef} data={data} onFieldChange={handleFieldChange} />
|
||||
<Editor
|
||||
ref={editorRef}
|
||||
data={data}
|
||||
projects={projects}
|
||||
boards={boards}
|
||||
users={users}
|
||||
onFieldChange={handleFieldChange}
|
||||
/>
|
||||
<Button positive>{t('action.addWebhook')}</Button>
|
||||
</Form>
|
||||
</Segment>
|
||||
@@ -90,6 +103,9 @@ const Webhooks = React.memo(({ ids, onCreate }) => {
|
||||
|
||||
Webhooks.propTypes = {
|
||||
ids: PropTypes.array.isRequired, // eslint-disable-line react/forbid-prop-types
|
||||
projects: PropTypes.array.isRequired, // eslint-disable-line react/forbid-prop-types
|
||||
boards: PropTypes.array.isRequired, // eslint-disable-line react/forbid-prop-types
|
||||
users: PropTypes.array.isRequired, // eslint-disable-line react/forbid-prop-types
|
||||
onCreate: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
|
||||
@@ -18,11 +18,21 @@ export default class extends BaseModel {
|
||||
accessToken: attr(),
|
||||
events: attr(),
|
||||
excludedEvents: attr(),
|
||||
projectId: fk({
|
||||
to: 'Project',
|
||||
as: 'project',
|
||||
relatedName: 'webhooks',
|
||||
}),
|
||||
boardId: fk({
|
||||
to: 'Board',
|
||||
as: 'board',
|
||||
relatedName: 'webhooks',
|
||||
}),
|
||||
userId: fk({
|
||||
to: 'User',
|
||||
as: 'user',
|
||||
relatedName: 'webhooks',
|
||||
}),
|
||||
};
|
||||
|
||||
static reducer({ type, payload }, Webhook) {
|
||||
|
||||
@@ -12,6 +12,8 @@ import { isLocalId } from '../utils/local-id';
|
||||
import { isListArchiveOrTrash } from '../utils/record-helpers';
|
||||
import { ListTypes } from '../constants/Enums';
|
||||
|
||||
export const selectBoards = createSelector(orm, ({ Board }) => Board.all().toRefArray());
|
||||
|
||||
export const makeSelectBoardById = () =>
|
||||
createSelector(
|
||||
orm,
|
||||
@@ -466,6 +468,7 @@ export const selectIsBoardWithIdExists = createSelector(
|
||||
);
|
||||
|
||||
export default {
|
||||
selectBoards,
|
||||
makeSelectBoardById,
|
||||
selectBoardById,
|
||||
makeSelectCurrentUserMembershipByBoardId,
|
||||
|
||||
@@ -10,6 +10,8 @@ import { selectPath } from './router';
|
||||
import { selectCurrentUserId } from './users';
|
||||
import { isLocalId } from '../utils/local-id';
|
||||
|
||||
export const selectProjects = createSelector(orm, ({ Project }) => Project.all().toRefArray());
|
||||
|
||||
export const makeSelectProjectById = () =>
|
||||
createSelector(
|
||||
orm,
|
||||
@@ -311,6 +313,7 @@ export const selectIsCurrentUserManagerForCurrentProject = createSelector(
|
||||
);
|
||||
|
||||
export default {
|
||||
selectProjects,
|
||||
makeSelectProjectById,
|
||||
selectProjectById,
|
||||
makeSelectBoardIdsByProjectId,
|
||||
|
||||
Reference in New Issue
Block a user