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(),