diff --git a/frontend/src/component/admin/banners/BannerModal/BannerForm.tsx b/frontend/src/component/admin/banners/BannerModal/BannerForm.tsx
index 19535c6280b7..52370967f49b 100644
--- a/frontend/src/component/admin/banners/BannerModal/BannerForm.tsx
+++ b/frontend/src/component/admin/banners/BannerModal/BannerForm.tsx
@@ -10,7 +10,7 @@ import { ChangeEvent, Dispatch, SetStateAction, useState } from 'react';
import { Visibility } from '@mui/icons-material';
import { BannerDialog } from 'component/banners/Banner/BannerDialog/BannerDialog';
-const StyledForm = styled('form')(({ theme }) => ({
+const StyledForm = styled('div')(({ theme }) => ({
display: 'flex',
flexDirection: 'column',
gap: theme.spacing(4),
diff --git a/frontend/src/component/admin/serviceAccounts/ServiceAccountsTable/ServiceAccountModal/ServiceAccountTokens/ServiceAccountTokens.tsx b/frontend/src/component/admin/serviceAccounts/ServiceAccountsTable/ServiceAccountModal/ServiceAccountTokens/ServiceAccountTokens.tsx
index d488f1c8800a..d6e8d62fabd0 100644
--- a/frontend/src/component/admin/serviceAccounts/ServiceAccountsTable/ServiceAccountModal/ServiceAccountTokens/ServiceAccountTokens.tsx
+++ b/frontend/src/component/admin/serviceAccounts/ServiceAccountsTable/ServiceAccountModal/ServiceAccountTokens/ServiceAccountTokens.tsx
@@ -23,13 +23,7 @@ import {
IPersonalAPIToken,
} from 'interfaces/personalAPIToken';
import { useMemo, useState } from 'react';
-import {
- useTable,
- SortingRule,
- useSortBy,
- useFlexLayout,
- Column,
-} from 'react-table';
+import { useTable, SortingRule, useSortBy, useFlexLayout } from 'react-table';
import { sortTypes } from 'utils/sortTypes';
import { ServiceAccountCreateTokenDialog } from './ServiceAccountCreateTokenDialog/ServiceAccountCreateTokenDialog';
import { ServiceAccountTokenDialog } from 'component/admin/serviceAccounts/ServiceAccountsTable/ServiceAccountTokenDialog/ServiceAccountTokenDialog';
@@ -157,66 +151,62 @@ export const ServiceAccountTokens = ({
};
const columns = useMemo(
- () =>
- [
- {
- Header: 'Description',
- accessor: 'description',
- Cell: HighlightCell,
- minWidth: 100,
- searchable: true,
- },
- {
- Header: 'Expires',
- accessor: 'expiresAt',
- Cell: ({ value }: { value: string }) => {
- const date = new Date(value);
- if (
- date.getFullYear() >
- new Date().getFullYear() + 100
- ) {
- return Never;
- }
- return ;
- },
- maxWidth: 150,
- },
- {
- Header: 'Created',
- accessor: 'createdAt',
- Cell: DateCell,
- maxWidth: 150,
- },
- {
- Header: 'Last seen',
- accessor: 'seenAt',
- Cell: TimeAgoCell,
- maxWidth: 150,
- },
- {
- Header: 'Actions',
- id: 'Actions',
- align: 'center',
- Cell: ({ row: { original: rowToken } }: any) => (
-
-
-
- {
- setSelectedToken(rowToken);
- setDeleteOpen(true);
- }}
- >
-
-
-
-
-
- ),
- maxWidth: 100,
- disableSortBy: true,
+ () => [
+ {
+ Header: 'Description',
+ accessor: 'description',
+ Cell: HighlightCell,
+ minWidth: 100,
+ searchable: true,
+ },
+ {
+ Header: 'Expires',
+ accessor: 'expiresAt',
+ Cell: ({ value }: { value: string }) => {
+ const date = new Date(value);
+ if (date.getFullYear() > new Date().getFullYear() + 100) {
+ return Never;
+ }
+ return ;
},
- ] as Column[],
+ maxWidth: 150,
+ },
+ {
+ Header: 'Created',
+ accessor: 'createdAt',
+ Cell: DateCell,
+ maxWidth: 150,
+ },
+ {
+ Header: 'Last seen',
+ accessor: 'seenAt',
+ Cell: TimeAgoCell,
+ maxWidth: 150,
+ },
+ {
+ Header: 'Actions',
+ id: 'Actions',
+ align: 'center',
+ Cell: ({ row: { original: rowToken } }: any) => (
+
+
+
+ {
+ setSelectedToken(rowToken);
+ setDeleteOpen(true);
+ }}
+ >
+
+
+
+
+
+ ),
+ maxWidth: 100,
+ disableSortBy: true,
+ },
+ ],
[setSelectedToken, setDeleteOpen],
);
@@ -228,7 +218,7 @@ export const ServiceAccountTokens = ({
const { headerGroups, rows, prepareRow, setHiddenColumns } = useTable(
{
- columns,
+ columns: columns as any[],
data,
initialState,
sortTypes,
diff --git a/frontend/src/component/incomingWebhooks/IncomingWebhooksModal/IncomingWebhooksForm/IncomingWebhooksForm.tsx b/frontend/src/component/incomingWebhooks/IncomingWebhooksModal/IncomingWebhooksForm/IncomingWebhooksForm.tsx
new file mode 100644
index 000000000000..b0a6c5bd511d
--- /dev/null
+++ b/frontend/src/component/incomingWebhooks/IncomingWebhooksModal/IncomingWebhooksForm/IncomingWebhooksForm.tsx
@@ -0,0 +1,248 @@
+import {
+ Alert,
+ FormControl,
+ FormControlLabel,
+ Link,
+ Radio,
+ RadioGroup,
+ styled,
+} from '@mui/material';
+import Input from 'component/common/Input/Input';
+import { FormSwitch } from 'component/common/FormSwitch/FormSwitch';
+import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
+import { IIncomingWebhook } from 'interfaces/incomingWebhook';
+import {
+ IncomingWebhooksFormErrors,
+ TokenGeneration,
+} from './useIncomingWebhooksForm';
+import { IncomingWebhooksFormURL } from './IncomingWebhooksFormURL';
+import { IncomingWebhooksTokens } from './IncomingWebhooksTokens/IncomingWebhooksTokens';
+
+const StyledRaisedSection = styled('div')(({ theme }) => ({
+ background: theme.palette.background.elevation1,
+ padding: theme.spacing(2, 3),
+ borderRadius: theme.shape.borderRadiusLarge,
+ marginBottom: theme.spacing(4),
+}));
+
+const StyledInputDescription = styled('p')(({ theme }) => ({
+ display: 'flex',
+ color: theme.palette.text.primary,
+ marginBottom: theme.spacing(1),
+ '&:not(:first-of-type)': {
+ marginTop: theme.spacing(4),
+ },
+}));
+
+const StyledInputSecondaryDescription = styled('p')(({ theme }) => ({
+ color: theme.palette.text.secondary,
+ marginBottom: theme.spacing(1),
+}));
+
+const StyledInput = styled(Input)(({ theme }) => ({
+ width: '100%',
+ maxWidth: theme.spacing(50),
+}));
+
+const StyledSecondarySection = styled('div')(({ theme }) => ({
+ padding: theme.spacing(3),
+ backgroundColor: theme.palette.background.elevation2,
+ borderRadius: theme.shape.borderRadiusMedium,
+ marginTop: theme.spacing(4),
+ marginBottom: theme.spacing(2),
+}));
+
+const StyledInlineContainer = styled('div')(({ theme }) => ({
+ padding: theme.spacing(0, 4),
+ '& > p:not(:first-of-type)': {
+ marginTop: theme.spacing(2),
+ },
+}));
+
+interface IIncomingWebhooksFormProps {
+ incomingWebhook?: IIncomingWebhook;
+ enabled: boolean;
+ setEnabled: React.Dispatch>;
+ name: string;
+ setName: React.Dispatch>;
+ description: string;
+ setDescription: React.Dispatch>;
+ tokenGeneration: TokenGeneration;
+ setTokenGeneration: React.Dispatch>;
+ tokenName: string;
+ setTokenName: React.Dispatch>;
+ errors: IncomingWebhooksFormErrors;
+ validateName: (name: string) => boolean;
+ validateTokenName: (
+ tokenGeneration: TokenGeneration,
+ name: string,
+ ) => boolean;
+ validated: boolean;
+}
+
+export const IncomingWebhooksForm = ({
+ incomingWebhook,
+ enabled,
+ setEnabled,
+ name,
+ setName,
+ description,
+ setDescription,
+ tokenGeneration,
+ setTokenGeneration,
+ tokenName,
+ setTokenName,
+ errors,
+ validateName,
+ validateTokenName,
+ validated,
+}: IIncomingWebhooksFormProps) => {
+ const handleOnBlur = (callback: Function) => {
+ setTimeout(() => callback(), 300);
+ };
+
+ const showErrors = validated && Object.values(errors).some(Boolean);
+
+ return (
+
+
+
+
+ Incoming webhook status
+
+
+
+ What is your new incoming webhook name?
+
+
{
+ validateName(e.target.value);
+ setName(e.target.value);
+ }}
+ onBlur={(e) => handleOnBlur(() => validateName(e.target.value))}
+ autoComplete='off'
+ />
+
+ What is your new incoming webhook description?
+
+ setDescription(e.target.value)}
+ autoComplete='off'
+ />
+
+ Token
+
+ In order to connect your newly created incoming
+ webhook, you will also need a token.{' '}
+
+ Read more about API tokens
+
+ .
+
+
+ {
+ const tokenGeneration = e.target
+ .value as TokenGeneration;
+
+ if (validated) {
+ validateTokenName(
+ tokenGeneration,
+ tokenName,
+ );
+ }
+ setTokenGeneration(tokenGeneration);
+ }}
+ name='token-generation'
+ >
+ }
+ label='I want to generate a token later'
+ />
+ }
+ label='Generate a token now'
+ />
+
+
+
+
+ A new incoming webhook token will be generated
+ for the incoming webhook, so you can get started
+ right away.
+
+
+
+ What is your new token name?
+
+ {
+ validateTokenName(
+ tokenGeneration,
+ e.target.value,
+ );
+ setTokenName(e.target.value);
+ }}
+ autoComplete='off'
+ />
+ >
+ }
+ />
+
+
+ }
+ elseShow={
+ <>
+
+ Incoming webhook tokens
+
+
+ >
+ }
+ />
+ (
+
+
+ {Object.values(errors)
+ .filter(Boolean)
+ .map((error) => (
+ - {error}
+ ))}
+
+
+ )}
+ />
+
+ );
+};
diff --git a/frontend/src/component/incomingWebhooks/IncomingWebhooksModal/IncomingWebhooksForm/IncomingWebhooksFormURL.tsx b/frontend/src/component/incomingWebhooks/IncomingWebhooksModal/IncomingWebhooksForm/IncomingWebhooksFormURL.tsx
new file mode 100644
index 000000000000..bd378715d35b
--- /dev/null
+++ b/frontend/src/component/incomingWebhooks/IncomingWebhooksModal/IncomingWebhooksForm/IncomingWebhooksFormURL.tsx
@@ -0,0 +1,70 @@
+import { IconButton, Tooltip, styled } from '@mui/material';
+import CopyIcon from '@mui/icons-material/FileCopy';
+import copy from 'copy-to-clipboard';
+import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig';
+import useToast from 'hooks/useToast';
+
+const StyledIncomingWebhookUrlSection = styled('div')(({ theme }) => ({
+ display: 'flex',
+ flexDirection: 'column',
+ alignItems: 'center',
+ padding: theme.spacing(1.5),
+ gap: theme.spacing(1.5),
+ border: `1px solid ${theme.palette.divider}`,
+ borderRadius: theme.shape.borderRadiusMedium,
+ marginBottom: theme.spacing(4),
+}));
+
+const StyledIncomingWebhookUrlSectionDescription = styled('p')(({ theme }) => ({
+ fontSize: theme.fontSizes.smallBody,
+ color: theme.palette.text.secondary,
+}));
+
+const StyledIncomingWebhookUrl = styled('div')(({ theme }) => ({
+ fontSize: theme.fontSizes.smallBody,
+ backgroundColor: theme.palette.background.elevation2,
+ padding: theme.spacing(1),
+ width: '100%',
+ borderRadius: theme.shape.borderRadiusMedium,
+ display: 'flex',
+ justifyContent: 'space-between',
+ alignItems: 'center',
+ wordBreak: 'break-all',
+}));
+
+interface IIncomingWebhooksFormURLProps {
+ name: string;
+}
+
+export const IncomingWebhooksFormURL = ({
+ name,
+}: IIncomingWebhooksFormURLProps) => {
+ const { uiConfig } = useUiConfig();
+ const { setToastData } = useToast();
+
+ const url = `${uiConfig.unleashUrl}/api/incoming-webhook/${name}`;
+
+ const onCopyToClipboard = () => {
+ copy(url);
+ setToastData({
+ type: 'success',
+ title: 'Copied to clipboard',
+ });
+ };
+
+ return (
+
+
+ Incoming webhook URL:
+
+
+ {url}
+
+
+
+
+
+
+
+ );
+};
diff --git a/frontend/src/component/incomingWebhooks/IncomingWebhooksModal/IncomingWebhooksForm/IncomingWebhooksTokens/IncomingWebhooksTokens.tsx b/frontend/src/component/incomingWebhooks/IncomingWebhooksModal/IncomingWebhooksForm/IncomingWebhooksTokens/IncomingWebhooksTokens.tsx
new file mode 100644
index 000000000000..794fc2cccfa4
--- /dev/null
+++ b/frontend/src/component/incomingWebhooks/IncomingWebhooksModal/IncomingWebhooksForm/IncomingWebhooksTokens/IncomingWebhooksTokens.tsx
@@ -0,0 +1,305 @@
+import { Delete } from '@mui/icons-material';
+import {
+ Button,
+ IconButton,
+ styled,
+ Tooltip,
+ Typography,
+ useMediaQuery,
+ useTheme,
+} from '@mui/material';
+import { Search } from 'component/common/Search/Search';
+import { TablePlaceholder, VirtualizedTable } from 'component/common/Table';
+import { ActionCell } from 'component/common/Table/cells/ActionCell/ActionCell';
+import { DateCell } from 'component/common/Table/cells/DateCell/DateCell';
+import { HighlightCell } from 'component/common/Table/cells/HighlightCell/HighlightCell';
+import { SearchHighlightProvider } from 'component/common/Table/SearchHighlightContext/SearchHighlightContext';
+import { PAT_LIMIT } from '@server/util/constants';
+import { useIncomingWebhookTokens } from 'hooks/api/getters/useIncomingWebhookTokens/useIncomingWebhookTokens';
+import { useSearch } from 'hooks/useSearch';
+import { useMemo, useState } from 'react';
+import { useTable, SortingRule, useSortBy, useFlexLayout } from 'react-table';
+import { sortTypes } from 'utils/sortTypes';
+import { IncomingWebhooksTokensCreateDialog } from './IncomingWebhooksTokensCreateDialog';
+import { IncomingWebhooksTokensDialog } from './IncomingWebhooksTokensDialog';
+import { useConditionallyHiddenColumns } from 'hooks/useConditionallyHiddenColumns';
+import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
+import { Dialogue } from 'component/common/Dialogue/Dialogue';
+import {
+ IncomingWebhookTokenPayload,
+ useIncomingWebhookTokensApi,
+} from 'hooks/api/actions/useIncomingWebhookTokensApi/useIncomingWebhookTokensApi';
+import useToast from 'hooks/useToast';
+import { formatUnknownError } from 'utils/formatUnknownError';
+import {
+ IIncomingWebhook,
+ IIncomingWebhookToken,
+} from 'interfaces/incomingWebhook';
+import { useIncomingWebhooks } from 'hooks/api/getters/useIncomingWebhooks/useIncomingWebhooks';
+
+const StyledHeader = styled('div')(({ theme }) => ({
+ display: 'flex',
+ justifyContent: 'space-between',
+ alignItems: 'center',
+ marginBottom: theme.spacing(2),
+ gap: theme.spacing(2),
+ '& > div': {
+ [theme.breakpoints.down('md')]: {
+ marginTop: 0,
+ },
+ },
+}));
+
+const StyledTablePlaceholder = styled('div')(({ theme }) => ({
+ display: 'flex',
+ flexDirection: 'column',
+ alignItems: 'center',
+ padding: theme.spacing(3),
+}));
+
+const StyledPlaceholderTitle = styled(Typography)(({ theme }) => ({
+ fontSize: theme.fontSizes.bodySize,
+ marginBottom: theme.spacing(0.5),
+}));
+
+const StyledPlaceholderSubtitle = styled(Typography)(({ theme }) => ({
+ fontSize: theme.fontSizes.smallBody,
+ color: theme.palette.text.secondary,
+ marginBottom: theme.spacing(1.5),
+}));
+
+export type PageQueryType = Partial<
+ Record<'sort' | 'order' | 'search', string>
+>;
+
+const defaultSort: SortingRule = { id: 'createdAt', desc: true };
+
+interface IIncomingWebhooksTokensProps {
+ incomingWebhook: IIncomingWebhook;
+}
+
+export const IncomingWebhooksTokens = ({
+ incomingWebhook,
+}: IIncomingWebhooksTokensProps) => {
+ const theme = useTheme();
+ const { setToastData, setToastApiError } = useToast();
+ const isSmallScreen = useMediaQuery(theme.breakpoints.down('md'));
+ const { incomingWebhookTokens, refetch: refetchTokens } =
+ useIncomingWebhookTokens(incomingWebhook.id);
+ const { refetch } = useIncomingWebhooks();
+ const { addIncomingWebhookToken, removeIncomingWebhookToken } =
+ useIncomingWebhookTokensApi();
+
+ const [initialState] = useState(() => ({
+ sortBy: [defaultSort],
+ }));
+
+ const [searchValue, setSearchValue] = useState('');
+ const [createOpen, setCreateOpen] = useState(false);
+ const [tokenOpen, setTokenOpen] = useState(false);
+ const [deleteOpen, setDeleteOpen] = useState(false);
+ const [newToken, setNewToken] = useState('');
+ const [selectedToken, setSelectedToken] = useState();
+
+ const onCreateClick = async (newToken: IncomingWebhookTokenPayload) => {
+ try {
+ const { token } = await addIncomingWebhookToken(
+ incomingWebhook.id,
+ newToken,
+ );
+ refetch();
+ refetchTokens();
+ setCreateOpen(false);
+ setNewToken(token);
+ setTokenOpen(true);
+ setToastData({
+ title: 'Token created successfully',
+ type: 'success',
+ });
+ } catch (error: unknown) {
+ setToastApiError(formatUnknownError(error));
+ }
+ };
+
+ const onDeleteClick = async () => {
+ if (selectedToken) {
+ try {
+ await removeIncomingWebhookToken(
+ incomingWebhook.id,
+ selectedToken.id,
+ );
+ refetch();
+ refetchTokens();
+ setDeleteOpen(false);
+ setToastData({
+ title: 'Token deleted successfully',
+ type: 'success',
+ });
+ } catch (error: unknown) {
+ setToastApiError(formatUnknownError(error));
+ }
+ }
+ };
+
+ const columns = useMemo(
+ () => [
+ {
+ Header: 'Name',
+ accessor: 'name',
+ Cell: HighlightCell,
+ minWidth: 100,
+ searchable: true,
+ },
+ {
+ Header: 'Created',
+ accessor: 'createdAt',
+ Cell: DateCell,
+ maxWidth: 150,
+ },
+ {
+ Header: 'Actions',
+ id: 'Actions',
+ align: 'center',
+ Cell: ({ row: { original: rowToken } }: any) => (
+
+
+
+ {
+ setSelectedToken(rowToken);
+ setDeleteOpen(true);
+ }}
+ >
+
+
+
+
+
+ ),
+ maxWidth: 100,
+ disableSortBy: true,
+ },
+ ],
+ [setSelectedToken, setDeleteOpen],
+ );
+
+ const { data, getSearchText, getSearchContext } = useSearch(
+ columns,
+ searchValue,
+ incomingWebhookTokens,
+ );
+
+ const { headerGroups, rows, prepareRow, setHiddenColumns } = useTable(
+ {
+ columns: columns as any[],
+ data,
+ initialState,
+ sortTypes,
+ autoResetHiddenColumns: false,
+ autoResetSortBy: false,
+ disableSortRemove: true,
+ disableMultiSort: true,
+ },
+ useSortBy,
+ useFlexLayout,
+ );
+
+ useConditionallyHiddenColumns(
+ [
+ {
+ condition: isSmallScreen,
+ columns: ['createdAt'],
+ },
+ ],
+ setHiddenColumns,
+ columns,
+ );
+
+ return (
+ <>
+
+
+
+
+
+
+
+ 0}
+ show={
+
+ No tokens found matching “
+ {searchValue}
+ ”
+
+ }
+ elseShow={
+
+
+ You have no tokens for this incoming webhook
+ yet.
+
+
+ Create a token to start using this incoming
+ webhook.
+
+
+
+ }
+ />
+ }
+ />
+
+
+ {
+ setDeleteOpen(false);
+ }}
+ title='Delete token?'
+ >
+
+ Any applications or scripts using this token "
+ {selectedToken?.name}" will no longer be
+ able to make requests to this incoming webhook. You cannot
+ undo this action.
+
+
+ >
+ );
+};
diff --git a/frontend/src/component/incomingWebhooks/IncomingWebhooksModal/IncomingWebhooksForm/IncomingWebhooksTokens/IncomingWebhooksTokensCreateDialog.tsx b/frontend/src/component/incomingWebhooks/IncomingWebhooksModal/IncomingWebhooksForm/IncomingWebhooksTokens/IncomingWebhooksTokensCreateDialog.tsx
new file mode 100644
index 000000000000..6e94f60515e7
--- /dev/null
+++ b/frontend/src/component/incomingWebhooks/IncomingWebhooksModal/IncomingWebhooksForm/IncomingWebhooksTokens/IncomingWebhooksTokensCreateDialog.tsx
@@ -0,0 +1,94 @@
+import { useEffect, useState } from 'react';
+import { Dialogue } from 'component/common/Dialogue/Dialogue';
+import { IncomingWebhookTokenPayload } from 'hooks/api/actions/useIncomingWebhookTokensApi/useIncomingWebhookTokensApi';
+import { IIncomingWebhookToken } from 'interfaces/incomingWebhook';
+import { styled } from '@mui/material';
+import Input from 'component/common/Input/Input';
+
+const StyledForm = styled('div')(({ theme }) => ({
+ minHeight: theme.spacing(12),
+}));
+
+const StyledInputSecondaryDescription = styled('p')(({ theme }) => ({
+ color: theme.palette.text.secondary,
+ marginBottom: theme.spacing(1),
+}));
+
+const StyledInput = styled(Input)(({ theme }) => ({
+ width: '100%',
+ maxWidth: theme.spacing(50),
+}));
+
+interface IIncomingWebhooksTokensCreateDialogProps {
+ open: boolean;
+ setOpen: React.Dispatch>;
+ tokens: IIncomingWebhookToken[];
+ onCreateClick: (newToken: IncomingWebhookTokenPayload) => void;
+}
+
+export const IncomingWebhooksTokensCreateDialog = ({
+ open,
+ setOpen,
+ tokens,
+ onCreateClick,
+}: IIncomingWebhooksTokensCreateDialogProps) => {
+ const [name, setName] = useState('');
+
+ const [nameError, setNameError] = useState('');
+
+ useEffect(() => {
+ setName('');
+ setNameError('');
+ }, [open]);
+
+ const isNameUnique = (name: string) =>
+ !tokens?.some((token) => token.name === name);
+
+ const validateName = (name: string) => {
+ if (!name.length) {
+ setNameError('Name is required');
+ } else if (!isNameUnique(name)) {
+ setNameError('Name must be unique');
+ } else {
+ setNameError('');
+ }
+ };
+
+ const isValid = name.length && isNameUnique(name);
+
+ return (
+
+ onCreateClick({
+ name,
+ })
+ }
+ disabledPrimaryButton={!isValid}
+ onClose={() => {
+ setOpen(false);
+ }}
+ title='New token'
+ >
+
+
+ What is your new token name?
+
+ {
+ validateName(e.target.value);
+ setName(e.target.value);
+ }}
+ autoComplete='off'
+ />
+
+
+ );
+};
diff --git a/frontend/src/component/incomingWebhooks/IncomingWebhooksModal/IncomingWebhooksForm/IncomingWebhooksTokens/IncomingWebhooksTokensDialog.tsx b/frontend/src/component/incomingWebhooks/IncomingWebhooksModal/IncomingWebhooksForm/IncomingWebhooksTokens/IncomingWebhooksTokensDialog.tsx
new file mode 100644
index 000000000000..9e3b76ae9fa8
--- /dev/null
+++ b/frontend/src/component/incomingWebhooks/IncomingWebhooksModal/IncomingWebhooksForm/IncomingWebhooksTokens/IncomingWebhooksTokensDialog.tsx
@@ -0,0 +1,37 @@
+import { Alert, styled, Typography } from '@mui/material';
+import { UserToken } from 'component/admin/apiToken/ConfirmToken/UserToken/UserToken';
+import { Dialogue } from 'component/common/Dialogue/Dialogue';
+
+const StyledAlert = styled(Alert)(({ theme }) => ({
+ marginBottom: theme.spacing(3),
+}));
+
+interface IIncomingWebhooksTokensDialogProps {
+ open: boolean;
+ setOpen: React.Dispatch>;
+ token?: string;
+}
+
+export const IncomingWebhooksTokensDialog = ({
+ open,
+ setOpen,
+ token,
+}: IIncomingWebhooksTokensDialogProps) => (
+ {
+ if (!muiCloseReason) {
+ setOpen(false);
+ }
+ }}
+ title='Incoming webhook token created'
+ >
+
+ Make sure to copy your incoming webhook token now. You won't be able
+ to see it again!
+
+ Your token:
+
+
+);
diff --git a/frontend/src/component/incomingWebhooks/IncomingWebhooksModal/IncomingWebhooksForm/useIncomingWebhooksForm.ts b/frontend/src/component/incomingWebhooks/IncomingWebhooksModal/IncomingWebhooksForm/useIncomingWebhooksForm.ts
new file mode 100644
index 000000000000..337edc3f999d
--- /dev/null
+++ b/frontend/src/component/incomingWebhooks/IncomingWebhooksModal/IncomingWebhooksForm/useIncomingWebhooksForm.ts
@@ -0,0 +1,136 @@
+import { useIncomingWebhooks } from 'hooks/api/getters/useIncomingWebhooks/useIncomingWebhooks';
+import { IIncomingWebhook } from 'interfaces/incomingWebhook';
+import { useEffect, useState } from 'react';
+
+const INCOMING_WEBHOOK_NAME_REGEX = /^[A-Za-z0-9\-_]*$/;
+
+enum ErrorField {
+ NAME = 'name',
+ TOKEN_NAME = 'tokenName',
+}
+
+const DEFAULT_INCOMING_WEBHOOKS_FORM_ERRORS = {
+ [ErrorField.NAME]: undefined,
+ [ErrorField.TOKEN_NAME]: undefined,
+};
+
+export type IncomingWebhooksFormErrors = Record;
+
+export enum TokenGeneration {
+ LATER = 'later',
+ NOW = 'now',
+}
+
+export const useIncomingWebhooksForm = (incomingWebhook?: IIncomingWebhook) => {
+ const { incomingWebhooks } = useIncomingWebhooks();
+
+ const [enabled, setEnabled] = useState(false);
+ const [name, setName] = useState('');
+ const [description, setDescription] = useState('');
+ const [tokenGeneration, setTokenGeneration] = useState(
+ TokenGeneration.LATER,
+ );
+ const [tokenName, setTokenName] = useState('');
+
+ const reloadForm = () => {
+ setEnabled(incomingWebhook?.enabled ?? true);
+ setName(incomingWebhook?.name || '');
+ setDescription(incomingWebhook?.description || '');
+ setTokenGeneration(TokenGeneration.LATER);
+ setTokenName('');
+ setValidated(false);
+ setErrors(DEFAULT_INCOMING_WEBHOOKS_FORM_ERRORS);
+ };
+
+ useEffect(() => {
+ reloadForm();
+ }, [incomingWebhook]);
+
+ const [errors, setErrors] = useState(
+ DEFAULT_INCOMING_WEBHOOKS_FORM_ERRORS,
+ );
+ const [validated, setValidated] = useState(false);
+
+ const clearError = (field: ErrorField) => {
+ setErrors((errors) => ({ ...errors, [field]: undefined }));
+ };
+
+ const setError = (field: ErrorField, error: string) => {
+ setErrors((errors) => ({ ...errors, [field]: error }));
+ };
+
+ const isEmpty = (value: string) => !value.length;
+
+ const isNameNotUnique = (value: string) =>
+ incomingWebhooks?.some(
+ ({ id, name }) => id !== incomingWebhook?.id && name === value,
+ );
+
+ const isNameInvalid = (value: string) =>
+ !INCOMING_WEBHOOK_NAME_REGEX.test(value);
+
+ const validateName = (name: string) => {
+ if (isEmpty(name)) {
+ setError(ErrorField.NAME, 'Name is required.');
+ return false;
+ }
+
+ if (isNameNotUnique(name)) {
+ setError(ErrorField.NAME, 'Name must be unique.');
+ return false;
+ }
+
+ if (isNameInvalid(name)) {
+ setError(
+ ErrorField.NAME,
+ 'Name must only contain alphanumeric characters, dashes and underscores.',
+ );
+ return false;
+ }
+
+ clearError(ErrorField.NAME);
+ return true;
+ };
+
+ const validateTokenName = (
+ tokenGeneration: TokenGeneration,
+ tokenName: string,
+ ) => {
+ if (tokenGeneration === TokenGeneration.NOW && isEmpty(tokenName)) {
+ setError(ErrorField.TOKEN_NAME, 'Token name is required.');
+ return false;
+ }
+
+ clearError(ErrorField.TOKEN_NAME);
+ return true;
+ };
+
+ const validate = () => {
+ const validName = validateName(name);
+ const validTokenName = validateTokenName(tokenGeneration, tokenName);
+
+ setValidated(true);
+
+ return validName && validTokenName;
+ };
+
+ return {
+ enabled,
+ setEnabled,
+ name,
+ setName,
+ description,
+ setDescription,
+ tokenGeneration,
+ setTokenGeneration,
+ tokenName,
+ setTokenName,
+ errors,
+ setErrors,
+ validated,
+ validateName,
+ validateTokenName,
+ validate,
+ reloadForm,
+ };
+};
diff --git a/frontend/src/component/incomingWebhooks/IncomingWebhooksModal/IncomingWebhooksModal.tsx b/frontend/src/component/incomingWebhooks/IncomingWebhooksModal/IncomingWebhooksModal.tsx
new file mode 100644
index 000000000000..4301b7300b73
--- /dev/null
+++ b/frontend/src/component/incomingWebhooks/IncomingWebhooksModal/IncomingWebhooksModal.tsx
@@ -0,0 +1,184 @@
+import { FormEvent, useEffect } from 'react';
+import { Button, styled } from '@mui/material';
+import { SidebarModal } from 'component/common/SidebarModal/SidebarModal';
+import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig';
+import FormTemplate from 'component/common/FormTemplate/FormTemplate';
+import useToast from 'hooks/useToast';
+import { formatUnknownError } from 'utils/formatUnknownError';
+import { IIncomingWebhook } from 'interfaces/incomingWebhook';
+import { useIncomingWebhooks } from 'hooks/api/getters/useIncomingWebhooks/useIncomingWebhooks';
+import {
+ IncomingWebhookPayload,
+ useIncomingWebhooksApi,
+} from 'hooks/api/actions/useIncomingWebhooksApi/useIncomingWebhooksApi';
+import { useIncomingWebhookTokensApi } from 'hooks/api/actions/useIncomingWebhookTokensApi/useIncomingWebhookTokensApi';
+import { IncomingWebhooksForm } from './IncomingWebhooksForm/IncomingWebhooksForm';
+import {
+ TokenGeneration,
+ useIncomingWebhooksForm,
+} from './IncomingWebhooksForm/useIncomingWebhooksForm';
+
+const StyledForm = styled('form')(() => ({
+ display: 'flex',
+ flexDirection: 'column',
+ height: '100%',
+}));
+
+const StyledButtonContainer = styled('div')(({ theme }) => ({
+ marginTop: 'auto',
+ display: 'flex',
+ justifyContent: 'flex-end',
+ paddingTop: theme.spacing(4),
+}));
+
+const StyledCancelButton = styled(Button)(({ theme }) => ({
+ marginLeft: theme.spacing(3),
+}));
+
+interface IIncomingWebhooksModalProps {
+ incomingWebhook?: IIncomingWebhook;
+ open: boolean;
+ setOpen: React.Dispatch>;
+ newToken: (token: string) => void;
+}
+
+export const IncomingWebhooksModal = ({
+ incomingWebhook,
+ open,
+ setOpen,
+ newToken,
+}: IIncomingWebhooksModalProps) => {
+ const { refetch } = useIncomingWebhooks();
+ const { addIncomingWebhook, updateIncomingWebhook, loading } =
+ useIncomingWebhooksApi();
+ const { addIncomingWebhookToken } = useIncomingWebhookTokensApi();
+ const { setToastData, setToastApiError } = useToast();
+ const { uiConfig } = useUiConfig();
+
+ const {
+ enabled,
+ setEnabled,
+ name,
+ setName,
+ description,
+ setDescription,
+ tokenGeneration,
+ setTokenGeneration,
+ tokenName,
+ setTokenName,
+ errors,
+ validateName,
+ validateTokenName,
+ validate,
+ validated,
+ reloadForm,
+ } = useIncomingWebhooksForm(incomingWebhook);
+
+ useEffect(() => {
+ reloadForm();
+ }, [open]);
+
+ const editing = incomingWebhook !== undefined;
+ const title = `${editing ? 'Edit' : 'New'} incoming webhook`;
+
+ const payload: IncomingWebhookPayload = {
+ enabled,
+ name,
+ description,
+ };
+
+ const formatApiCode = () => `curl --location --request ${
+ editing ? 'PUT' : 'POST'
+ } '${uiConfig.unleashUrl}/api/admin/incoming-webhooks${
+ editing ? `/${incomingWebhook.id}` : ''
+ }' \\
+ --header 'Authorization: INSERT_API_KEY' \\
+ --header 'Content-Type: application/json' \\
+ --data-raw '${JSON.stringify(payload, undefined, 2)}'`;
+
+ const onSubmit = async (e: FormEvent) => {
+ e.preventDefault();
+
+ if (!validate()) return;
+
+ try {
+ if (editing) {
+ await updateIncomingWebhook(incomingWebhook.id, payload);
+ } else {
+ const { id } = await addIncomingWebhook(payload);
+ if (tokenGeneration === TokenGeneration.NOW) {
+ const { token } = await addIncomingWebhookToken(id, {
+ name: tokenName,
+ });
+ newToken(token);
+ }
+ }
+ setToastData({
+ title: `Incoming webhook ${
+ editing ? 'updated' : 'added'
+ } successfully`,
+ type: 'success',
+ });
+ refetch();
+ setOpen(false);
+ } catch (error: unknown) {
+ setToastApiError(formatUnknownError(error));
+ }
+ };
+
+ return (
+ {
+ setOpen(false);
+ }}
+ label={title}
+ >
+
+
+
+
+
+ {
+ setOpen(false);
+ }}
+ >
+ Cancel
+
+
+
+
+
+ );
+};
diff --git a/frontend/src/component/incomingWebhooks/IncomingWebhooksTable/IncomingWebhooksTable.tsx b/frontend/src/component/incomingWebhooks/IncomingWebhooksTable/IncomingWebhooksTable.tsx
index 0396fb7e365c..b59f9034130c 100644
--- a/frontend/src/component/incomingWebhooks/IncomingWebhooksTable/IncomingWebhooksTable.tsx
+++ b/frontend/src/component/incomingWebhooks/IncomingWebhooksTable/IncomingWebhooksTable.tsx
@@ -20,7 +20,8 @@ import { HighlightCell } from 'component/common/Table/cells/HighlightCell/Highli
import copy from 'copy-to-clipboard';
import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig';
import { IncomingWebhookTokensCell } from './IncomingWebhooksTokensCell';
-// import { IncomingWebhooksModal } from '../IncomingWebhooksModal/IncomingWebhooksModal';
+import { IncomingWebhooksModal } from '../IncomingWebhooksModal/IncomingWebhooksModal';
+import { IncomingWebhooksTokensDialog } from '../IncomingWebhooksModal/IncomingWebhooksForm/IncomingWebhooksTokens/IncomingWebhooksTokensDialog';
interface IIncomingWebhooksTableProps {
modalOpen: boolean;
@@ -40,10 +41,12 @@ export const IncomingWebhooksTable = ({
const { setToastData, setToastApiError } = useToast();
const { uiConfig } = useUiConfig();
- const { incomingWebhooks, refetch, loading } = useIncomingWebhooks();
+ const { incomingWebhooks, refetch } = useIncomingWebhooks();
const { toggleIncomingWebhook, removeIncomingWebhook } =
useIncomingWebhooksApi();
+ const [tokenDialog, setTokenDialog] = useState(false);
+ const [newToken, setNewToken] = useState('');
const [deleteOpen, setDeleteOpen] = useState(false);
const onToggleIncomingWebhook = async (
@@ -123,7 +126,7 @@ export const IncomingWebhooksTable = ({
/>
),
searchable: true,
- maxWidth: 100,
+ maxWidth: 120,
},
{
Header: 'Created',
@@ -233,11 +236,20 @@ export const IncomingWebhooksTable = ({
}
/>
- {/* */}
+ newToken={(token: string) => {
+ setNewToken(token);
+ setTokenDialog(true);
+ }}
+ />
+
= ({
}
diff --git a/frontend/src/hooks/api/actions/useIncomingWebhookTokensApi/useIncomingWebhookTokensApi.ts b/frontend/src/hooks/api/actions/useIncomingWebhookTokensApi/useIncomingWebhookTokensApi.ts
index 1e8b6ad179f4..ccf384574af1 100644
--- a/frontend/src/hooks/api/actions/useIncomingWebhookTokensApi/useIncomingWebhookTokensApi.ts
+++ b/frontend/src/hooks/api/actions/useIncomingWebhookTokensApi/useIncomingWebhookTokensApi.ts
@@ -3,12 +3,12 @@ import useAPI from '../useApi/useApi';
const ENDPOINT = 'api/admin/incoming-webhooks';
-export type AddOrUpdateIncomingWebhookToken = Omit<
+export type IncomingWebhookTokenPayload = Omit<
IIncomingWebhookToken,
'id' | 'incomingWebhookId' | 'createdAt' | 'createdByUserId'
>;
-export type IncomingWebhookTokenWithTokenSecret = IIncomingWebhookToken & {
+type IncomingWebhookTokenWithTokenSecret = IIncomingWebhookToken & {
token: string;
};
@@ -19,7 +19,7 @@ export const useIncomingWebhookTokensApi = () => {
const addIncomingWebhookToken = async (
incomingWebhookId: number,
- incomingWebhookToken: AddOrUpdateIncomingWebhookToken,
+ incomingWebhookToken: IncomingWebhookTokenPayload,
): Promise => {
const requestId = 'addIncomingWebhookToken';
const req = createRequest(
@@ -38,7 +38,7 @@ export const useIncomingWebhookTokensApi = () => {
const updateIncomingWebhookToken = async (
incomingWebhookId: number,
incomingWebhookTokenId: number,
- incomingWebhookToken: AddOrUpdateIncomingWebhookToken,
+ incomingWebhookToken: IncomingWebhookTokenPayload,
) => {
const requestId = 'updateIncomingWebhookToken';
const req = createRequest(
diff --git a/frontend/src/hooks/api/actions/useIncomingWebhooksApi/useIncomingWebhooksApi.ts b/frontend/src/hooks/api/actions/useIncomingWebhooksApi/useIncomingWebhooksApi.ts
index ce1f9a5b394b..de5773f7a344 100644
--- a/frontend/src/hooks/api/actions/useIncomingWebhooksApi/useIncomingWebhooksApi.ts
+++ b/frontend/src/hooks/api/actions/useIncomingWebhooksApi/useIncomingWebhooksApi.ts
@@ -3,9 +3,9 @@ import useAPI from '../useApi/useApi';
const ENDPOINT = 'api/admin/incoming-webhooks';
-export type AddOrUpdateIncomingWebhook = Omit<
+export type IncomingWebhookPayload = Omit<
IIncomingWebhook,
- 'id' | 'createdAt' | 'createdByUserId'
+ 'id' | 'createdAt' | 'createdByUserId' | 'tokens'
>;
export const useIncomingWebhooksApi = () => {
@@ -14,7 +14,7 @@ export const useIncomingWebhooksApi = () => {
});
const addIncomingWebhook = async (
- incomingWebhook: AddOrUpdateIncomingWebhook,
+ incomingWebhook: IncomingWebhookPayload,
) => {
const requestId = 'addIncomingWebhook';
const req = createRequest(
@@ -32,7 +32,7 @@ export const useIncomingWebhooksApi = () => {
const updateIncomingWebhook = async (
incomingWebhookId: number,
- incomingWebhook: AddOrUpdateIncomingWebhook,
+ incomingWebhook: IncomingWebhookPayload,
) => {
const requestId = 'updateIncomingWebhook';
const req = createRequest(
diff --git a/frontend/src/hooks/api/getters/useIncomingWebhookTokens/useIncomingWebhookTokens.ts b/frontend/src/hooks/api/getters/useIncomingWebhookTokens/useIncomingWebhookTokens.ts
index c9283c18d1e3..cf1042cd1e28 100644
--- a/frontend/src/hooks/api/getters/useIncomingWebhookTokens/useIncomingWebhookTokens.ts
+++ b/frontend/src/hooks/api/getters/useIncomingWebhookTokens/useIncomingWebhookTokens.ts
@@ -4,14 +4,16 @@ import handleErrorResponses from '../httpErrorResponseHandler';
import { useConditionalSWR } from '../useConditionalSWR/useConditionalSWR';
import useUiConfig from '../useUiConfig/useUiConfig';
import { IIncomingWebhookToken } from 'interfaces/incomingWebhook';
+import { useUiFlag } from 'hooks/useUiFlag';
const ENDPOINT = 'api/admin/incoming-webhooks';
export const useIncomingWebhookTokens = (incomingWebhookId: number) => {
const { isEnterprise } = useUiConfig();
+ const incomingWebhooksEnabled = useUiFlag('incomingWebhooks');
const { data, error, mutate } = useConditionalSWR(
- isEnterprise(),
+ isEnterprise() && incomingWebhooksEnabled,
{ incomingWebhookTokens: [] },
formatApiPath(`${ENDPOINT}/${incomingWebhookId}/tokens`),
fetcher,
@@ -19,7 +21,7 @@ export const useIncomingWebhookTokens = (incomingWebhookId: number) => {
return useMemo(
() => ({
- incomingWebhookTokens: (data?.incomingWebhooks ??
+ incomingWebhookTokens: (data?.incomingWebhookTokens ??
[]) as IIncomingWebhookToken[],
loading: !error && !data,
refetch: () => mutate(),