diff --git a/admin-ui/app/components/SVG/menu/ShortCodesIcon.tsx b/admin-ui/app/components/SVG/menu/ShortCodesIcon.tsx index 270e7204e3..5c32edcdd1 100644 --- a/admin-ui/app/components/SVG/menu/ShortCodesIcon.tsx +++ b/admin-ui/app/components/SVG/menu/ShortCodesIcon.tsx @@ -1,11 +1,11 @@ import React from 'react' interface ShortCodesIconProps { - className: string - style: React.CSSProperties + className?: string + style?: React.CSSProperties } -const ShortCodesIcon: React.FC = ({ className, style }) => { +const ShortCodesIcon: React.FC = ({ className, style = {} }) => { return ( = ({ className, style }) => viewBox="0 0 24 24" strokeWidth="1.5" stroke="currentColor" - style={{ width: '18px' }} + className={className} + style={{ width: '18px', ...style }} > } diff --git a/admin-ui/app/locales/en/translation.json b/admin-ui/app/locales/en/translation.json index 9219319de4..5202c636d0 100644 --- a/admin-ui/app/locales/en/translation.json +++ b/admin-ui/app/locales/en/translation.json @@ -445,6 +445,7 @@ "status": "Status", "scopes": "Scopes", "sort_by": "Sort By", + "results_per_page": "Results per page", "smtp_host": "SMTP Host", "smtp_port": "SMTP Port", "trust_host": "Trust Server", @@ -742,6 +743,9 @@ "webhook_execution_information": "Webhook Execution Information", "webhook_dialog_dec": "As part of the submission process, the system will automatically trigger the following associated webhooks to perform additional processing tasks.", "add_webhook": "Add Webhook", + "webhook_form_description": "Configure webhook to receive notifications when specific events occur.", + "no_webhooks_found": "No webhooks found", + "create_first_webhook": "Create your first webhook to get started", "permission_name_error": "Please ensure the permission name is at least 5 characters long.", "role_name_error": "Please ensure the role name is at least 5 characters long.", "error_message": "Error Message", @@ -755,6 +759,7 @@ "session_timeout_required_error": "Session timeout is required", "additional_parameters_required": "Both key and value are required for each custom parameter.", "invalid_json_error": "Invalid JSON value.", + "invalid_url_error": "Invalid URL or URL not allowed.", "add_identity_provider": "Add Identity Provider", "view_idp_details": "View IDP Details", "edit_identity_provider": "Edit Identity Provider", diff --git a/admin-ui/app/locales/es/translation.json b/admin-ui/app/locales/es/translation.json index 6012655411..ec6a1509a0 100644 --- a/admin-ui/app/locales/es/translation.json +++ b/admin-ui/app/locales/es/translation.json @@ -445,6 +445,7 @@ "status": "Estado", "scopes": "Ámbitos", "sort_by": "Ordenar Por", + "results_per_page": "Resultados por página", "smtp_host": "Host SMTP", "smtp_port": "Puerto SMTP", "trust_host": "Servidor de Confianza", @@ -742,6 +743,9 @@ "webhook_execution_information": "Información de Ejecución del Webhook", "webhook_dialog_dec": "Como parte del proceso de envío, el sistema activará automáticamente los siguientes webhooks asociados para realizar tareas de procesamiento adicionales.", "add_webhook": "Agregar Webhook", + "webhook_form_description": "Configure el webhook para recibir notificaciones cuando ocurran eventos específicos.", + "no_webhooks_found": "No se encontraron webhooks", + "create_first_webhook": "Cree su primer webhook para comenzar", "permission_name_error": "Asegúrese de que el nombre del permiso tenga al menos 5 caracteres.", "role_name_error": "Asegúrese de que el nombre del rol tenga al menos 5 caracteres.", "error_message": "Mensaje de Error", @@ -755,6 +759,7 @@ "session_timeout_required_error": "El tiempo de espera de sesión es obligatorio", "additional_parameters_required": "Both key and value are required for each custom parameter.", "invalid_json_error": "Valor JSON inválido.", + "invalid_url_error": "URL inválida o URL no permitida.", "add_identity_provider": "Agregar Proveedor de Identidad", "view_idp_details": "Ver Detalles del IdP", "edit_identity_provider": "Editar Proveedor de Identidad", diff --git a/admin-ui/app/locales/fr/translation.json b/admin-ui/app/locales/fr/translation.json index 038ee68436..38c95e2e67 100644 --- a/admin-ui/app/locales/fr/translation.json +++ b/admin-ui/app/locales/fr/translation.json @@ -364,6 +364,7 @@ "requirePar": "Requérir PAR", "scopes": "Portées", "sort_by": "Trier Par", + "results_per_page": "Résultats par page", "activate": "Activer", "active": "Active", "application_type": "Type de demande", @@ -678,6 +679,9 @@ "view_trust_relationshi_details": "Voir les détails de la relation de confiance", "add_trust_relationship": "Ajouter une relation de confiance", "add_webhook": "Ajouter un Webhook", + "webhook_form_description": "Configurez le webhook pour recevoir des notifications lorsque des événements spécifiques se produisent.", + "no_webhooks_found": "Aucun webhook trouvé", + "create_first_webhook": "Créez votre premier webhook pour commencer", "no_spaces": "ne doit pas contenir d'espaces", "permission_name_error": "Veuillez vous assurer que le nom de la permission comporte au moins 5 caractères.", "role_name_error": "Veuillez vous assurer que le nom du rôle comporte au moins 5 caractères.", @@ -723,6 +727,7 @@ "url_error": "Veuillez entrer une URL.", "missing_required_permission": "Permission requise manquante", "invalid_json_error": "Valeur JSON invalide.", + "invalid_url_error": "URL invalide ou URL non autorisée.", "action_commit_question": "Journal d'audit : vous souhaitez appliquer les modifications apportées sur cette page ?", "licenseAuditLog": "Voulez-vous vraiment réinitialiser la licence existante ?", "action_deletion_question": "Voulez-vous vraiment supprimer cet élément ?", diff --git a/admin-ui/app/locales/pt/translation.json b/admin-ui/app/locales/pt/translation.json index 85ec708b38..e155e712e8 100644 --- a/admin-ui/app/locales/pt/translation.json +++ b/admin-ui/app/locales/pt/translation.json @@ -331,6 +331,7 @@ "redirectUrisRegex": "Expressão regular de redirecionamento", "scopes": "Escopos", "sort_by": "Ordenar Por", + "results_per_page": "Resultados por página", "requestUris": "URIs de solicitação", "defaultAcrValues": "ACR padrão", "authorizedAcrValues": "ACRs autorizados", @@ -699,11 +700,15 @@ "select_message_provider_type": "Por favor, selecione o tipo de provedor de mensagens", "new_role": "Novo papel", "add_webhook": "Adicionar Webhook", + "webhook_form_description": "Configure o webhook para receber notificações quando eventos específicos ocorrerem.", + "no_webhooks_found": "Nenhum webhook encontrado", + "create_first_webhook": "Crie seu primeiro webhook para começar", "http_method_error": "Por favor, selecione um método HTTP.", "display_name_error": "Por favor, insira um nome de exibição.", "url_error": "Por favor, insira uma URL.", "missing_required_permission": "Permissão necessária ausente", "invalid_json_error": "Valor JSON inválido.", + "invalid_url_error": "URL inválido ou URL não permitido.", "license_api_not_enabled": "A API de licença não está habilitada para este aplicativo.", "adding_new_permission": "Adicionando nova permissão", "add_role": "Adicionar função", diff --git a/admin-ui/plugins/admin/components/Webhook/ShortcodePopover.js b/admin-ui/plugins/admin/components/Webhook/ShortcodePopover.tsx similarity index 54% rename from admin-ui/plugins/admin/components/Webhook/ShortcodePopover.js rename to admin-ui/plugins/admin/components/Webhook/ShortcodePopover.tsx index 923a630a86..ed25cafe83 100644 --- a/admin-ui/plugins/admin/components/Webhook/ShortcodePopover.js +++ b/admin-ui/plugins/admin/components/Webhook/ShortcodePopover.tsx @@ -1,24 +1,54 @@ -import * as React from 'react' +import React, { useState, memo } from 'react' import Popover from '@mui/material/Popover' import Typography from '@mui/material/Typography' import Button from '@mui/material/Button' import Box from '@mui/material/Box' import Divider from '@mui/material/Divider' -import { List, ListItemButton, ListItemText } from '@mui/material' -import { useTranslation } from 'react-i18next' +import List from '@mui/material/List' +import ListItemButton from '@mui/material/ListItemButton' +import ListItemText from '@mui/material/ListItemText' import { HelpOutline } from '@mui/icons-material' import { Tooltip as ReactTooltip } from 'react-tooltip' -import PropTypes from 'prop-types' +import { useTranslation } from 'react-i18next' import applicationstyle from 'Routes/Apps/Gluu/styles/applicationstyle' -import ShortCodesIcon from 'Components/SVG/menu/ShortCodesIcon' +import { ShortCodesIcon } from '@/components/SVG' +import type { ShortcodePopoverProps, ShortcodeLabelProps } from './types' + +const Label: React.FC = ({ doc_category, doc_entry, label }) => { + const { t, i18n } = useTranslation() -export default function ShortcodePopover({ + return ( + + {t(label)} + {doc_category && i18n.exists(doc_category) && ( + <> + + {t(doc_category)} + + + + )} + + ) +} + +const ShortcodePopover: React.FC = ({ codes, buttonWrapperStyles = {}, handleSelectShortcode, -}) { - const [anchorEl, setAnchorEl] = React.useState(null) - const handleClick = (event) => { +}) => { + const [anchorEl, setAnchorEl] = useState(null) + + const handleClick = (event: React.MouseEvent) => { setAnchorEl(event.currentTarget) } @@ -27,10 +57,15 @@ export default function ShortcodePopover({ } const open = Boolean(anchorEl) - const id = open ? 'simple-popover' : undefined + const id = open ? 'shortcode-popover' : undefined return ( -
+
@@ -47,7 +82,6 @@ export default function ShortcodePopover({ {codes?.length ? ( - {codes?.map((code, index) => { - return ( - - handleSelectShortcode(code.key)} - component="button" - sx={{ width: '100%' }} - > - - } - /> - - {index + 1 !== codes?.length && } - - ) - })} + {codes.map((code, index) => ( + + handleSelectShortcode(code.key)} + component="button" + sx={{ width: '100%' }} + > + + } + /> + + {index + 1 !== codes.length && } + + ))} ) : ( No shortcodes found! @@ -88,44 +120,4 @@ export default function ShortcodePopover({ ) } -const Label = ({ doc_category, doc_entry, label }) => { - const { t, i18n } = useTranslation() - - return ( - - {t(label)} - {doc_category && i18n.exists(doc_category) && ( - <> - - {t(doc_category)} - - - - )} - - ) -} - -// Adding prop validation -Label.propTypes = { - doc_category: PropTypes.string, - doc_entry: PropTypes.string, - label: PropTypes.string, -} - -ShortcodePopover.propTypes = { - codes: PropTypes.array, - buttonWrapperStyles: PropTypes.any, - handleSelectShortcode: PropTypes.func, -} +export default memo(ShortcodePopover) diff --git a/admin-ui/plugins/admin/components/Webhook/WebhookAddPage.js b/admin-ui/plugins/admin/components/Webhook/WebhookAddPage.js deleted file mode 100644 index 3ad4d59a47..0000000000 --- a/admin-ui/plugins/admin/components/Webhook/WebhookAddPage.js +++ /dev/null @@ -1,20 +0,0 @@ -import React from 'react' -import { Card } from 'Components' -import applicationStyle from 'Routes/Apps/Gluu/styles/applicationstyle' -import WebhookForm from './WebhookForm' -import GluuLoader from 'Routes/Apps/Gluu/GluuLoader' -import { useSelector } from 'react-redux' - -const WebhookAddPage = () => { - const loading = useSelector((state) => state.webhookReducer.loading) - - return ( - - - - - - ) -} - -export default WebhookAddPage diff --git a/admin-ui/plugins/admin/components/Webhook/WebhookAddPage.tsx b/admin-ui/plugins/admin/components/Webhook/WebhookAddPage.tsx new file mode 100644 index 0000000000..1c14f47fea --- /dev/null +++ b/admin-ui/plugins/admin/components/Webhook/WebhookAddPage.tsx @@ -0,0 +1,14 @@ +import React, { memo } from 'react' +import { Card } from 'Components' +import applicationStyle from 'Routes/Apps/Gluu/styles/applicationstyle' +import WebhookForm from './WebhookForm' + +const WebhookAddPage: React.FC = () => { + return ( + + + + ) +} + +export default memo(WebhookAddPage) diff --git a/admin-ui/plugins/admin/components/Webhook/WebhookEditPage.js b/admin-ui/plugins/admin/components/Webhook/WebhookEditPage.js deleted file mode 100644 index ce8ae155b8..0000000000 --- a/admin-ui/plugins/admin/components/Webhook/WebhookEditPage.js +++ /dev/null @@ -1,33 +0,0 @@ -import React, { useEffect } from 'react' -import { Card } from 'Components' -import applicationStyle from 'Routes/Apps/Gluu/styles/applicationstyle' -import WebhookForm from './WebhookForm' -import GluuLoader from 'Routes/Apps/Gluu/GluuLoader' -import { useDispatch, useSelector } from 'react-redux' -import { - getFeaturesByWebhookId, - getFeaturesByWebhookIdResponse, -} from 'Plugins/admin/redux/features/WebhookSlice' -import { useParams } from 'react-router' - -const WebhookEditPage = () => { - const { id } = useParams() - const dispatch = useDispatch() - const { loading, loadingWebhookFeatures } = useSelector((state) => state.webhookReducer) - - useEffect(() => { - if (id) dispatch(getFeaturesByWebhookId(id)) - - return function cleanup() { - dispatch(getFeaturesByWebhookIdResponse([])) - } - }, []) - - return ( - - {!loadingWebhookFeatures && } - - ) -} - -export default WebhookEditPage diff --git a/admin-ui/plugins/admin/components/Webhook/WebhookEditPage.tsx b/admin-ui/plugins/admin/components/Webhook/WebhookEditPage.tsx new file mode 100644 index 0000000000..918d0e7276 --- /dev/null +++ b/admin-ui/plugins/admin/components/Webhook/WebhookEditPage.tsx @@ -0,0 +1,14 @@ +import React, { memo } from 'react' +import { Card } from 'Components' +import applicationStyle from 'Routes/Apps/Gluu/styles/applicationstyle' +import WebhookForm from './WebhookForm' + +const WebhookEditPage: React.FC = () => { + return ( + + + + ) +} + +export default memo(WebhookEditPage) diff --git a/admin-ui/plugins/admin/components/Webhook/WebhookForm.js b/admin-ui/plugins/admin/components/Webhook/WebhookForm.js deleted file mode 100644 index ce803c2a6c..0000000000 --- a/admin-ui/plugins/admin/components/Webhook/WebhookForm.js +++ /dev/null @@ -1,427 +0,0 @@ -import React, { Suspense, lazy, useCallback, useState, useEffect, useMemo } from 'react' -import { Col, Form, Row, FormGroup } from 'Components' -import GluuInputRow from 'Routes/Apps/Gluu/GluuInputRow' -import GluuSelectRow from 'Routes/Apps/Gluu/GluuSelectRow' -import { useFormik } from 'formik' -import GluuFormFooter from 'Routes/Apps/Gluu/GluuFormFooter' -import GluuCommitDialog from 'Routes/Apps/Gluu/GluuCommitDialog' -import { useTranslation } from 'react-i18next' -import { useDispatch, useSelector } from 'react-redux' -import GluuSuspenseLoader from 'Routes/Apps/Gluu/GluuSuspenseLoader' -import { - createWebhook, - updateWebhook, - resetFlags, - getFeatures, -} from 'Plugins/admin/redux/features/WebhookSlice' -const GluuInputEditor = lazy(() => import('Routes/Apps/Gluu/GluuInputEditor')) -import { buildPayload } from 'Utils/PermChecker' -import { useParams } from 'react-router' -import GluuLabel from 'Routes/Apps/Gluu/GluuLabel' -import Toggle from 'react-toggle' -import { WEBHOOK } from 'Utils/ApiResources' -import GluuTypeAhead from 'Routes/Apps/Gluu/GluuTypeAhead' -import GluuProperties from 'Routes/Apps/Gluu/GluuProperties' -import ShortcodePopover from './ShortcodePopover' -import shortCodes from 'Plugins/admin/helper/shortCodes.json' -import { isValid } from './WebhookURLChecker' -import isEqual from 'lodash/isEqual' -import { getWebhookValidationSchema } from 'Plugins/admin/helper/validations/webhookValidation' -import { buildWebhookInitialValues } from 'Plugins/admin/helper/webhook' -import { useAppNavigation, ROUTES } from '@/helpers/navigation' - -const WebhookForm = () => { - const { id } = useParams() - const dispatch = useDispatch() - const { t } = useTranslation() - const { navigateBack } = useAppNavigation() - - const { - selectedWebhook, - features, - webhookFeatures, - loadingFeatures, - saveOperationFlag, - errorInSaveOperationFlag, - } = useSelector((state) => state.webhookReducer) - - const initialSelectedFeatures = useMemo(() => { - if (Array.isArray(webhookFeatures) && webhookFeatures.length > 0) { - return [webhookFeatures[0]] - } - return [] - }, [webhookFeatures]) - const initialFormValues = useMemo( - () => buildWebhookInitialValues(selectedWebhook), - [selectedWebhook], - ) - - const formik = useFormik({ - initialValues: initialFormValues, - enableReinitialize: true, - validationSchema: getWebhookValidationSchema(t), - onSubmit: (values, formikHelpers) => { - const isInvalid = validatePayload(values, formikHelpers.setFieldError) - if (isInvalid) return - openCommitDialog() - }, - }) - - const [cursorPosition, setCursorPosition] = useState({ - url: 0, - httpRequestBody: 0, - }) - const [showCommitDialog, setShowCommitDialog] = useState(false) - const [selectedFeatures, setSelectedFeatures] = useState(initialSelectedFeatures) - const [baselineSelectedFeatures, setBaselineSelectedFeatures] = useState(initialSelectedFeatures) - - useEffect(() => { - setSelectedFeatures(initialSelectedFeatures) - setBaselineSelectedFeatures(initialSelectedFeatures) - }, [initialSelectedFeatures]) - - const userAction = useMemo(() => ({}), []) - const openCommitDialog = useCallback(() => setShowCommitDialog(true), []) - const closeCommitDialog = useCallback(() => setShowCommitDialog(false), []) - - const validatePayload = useCallback( - (values, setFieldError) => { - let hasError = false - if (values.httpRequestBody) { - try { - JSON.parse(values.httpRequestBody) - } catch (error) { - hasError = true - setFieldError('httpRequestBody', t('messages.invalid_json_error')) - } - } - if (!isValid(values.url)) { - hasError = true - setFieldError('url', 'Invalid url or url not allowed.') - } - - return hasError - }, - [t], - ) - - const submitForm = useCallback( - (userMessage) => { - closeCommitDialog() - - const payload = { - ...formik.values, - httpHeaders: - formik.values.httpHeaders?.map((header) => ({ - key: header.key || header.source, - value: header.value || header.destination, - })) || [], - auiFeatureIds: selectedFeatures?.map((feature) => feature.auiFeatureId) || [], - } - - if (formik.values.httpMethod !== 'GET' && formik.values.httpMethod !== 'DELETE') { - payload['httpRequestBody'] = JSON.parse(formik.values.httpRequestBody) - } else { - delete payload.httpRequestBody - } - - if (id) { - payload['inum'] = selectedWebhook.inum - payload['dn'] = selectedWebhook.dn - payload['baseDn'] = selectedWebhook.baseDn - } - - buildPayload(userAction, userMessage, payload) - if (id) { - dispatch(updateWebhook({ action: userAction })) - } else { - dispatch(createWebhook({ action: userAction })) - } - - formik.resetForm({ values: formik.values }) - setBaselineSelectedFeatures([...selectedFeatures]) - }, - [closeCommitDialog, formik, selectedFeatures, id, selectedWebhook, userAction, dispatch], - ) - - useEffect(() => { - if (!features?.length) dispatch(getFeatures()) // cache features response using redux store - if (saveOperationFlag && !errorInSaveOperationFlag) { - navigateBack(ROUTES.WEBHOOK_LIST) - dispatch(resetFlags()) - } - - return function cleanup() { - dispatch(resetFlags()) - } - }, [saveOperationFlag, errorInSaveOperationFlag, navigateBack, dispatch]) - - const featureShortcodes = selectedFeatures?.[0]?.auiFeatureId - ? shortCodes?.[selectedFeatures?.[0]?.auiFeatureId]?.fields || [] - : [] - - const handleCancel = useCallback(() => { - formik.resetForm({ values: initialFormValues }) - setSelectedFeatures([...baselineSelectedFeatures]) - }, [formik, baselineSelectedFeatures, initialFormValues]) - - const isFeatureSelectionChanged = useMemo( - () => !isEqual(selectedFeatures, baselineSelectedFeatures), - [selectedFeatures, baselineSelectedFeatures], - ) - - const isFormChanged = formik.dirty || isFeatureSelectionChanged - const handleBack = useCallback(() => { - navigateBack(ROUTES.WEBHOOK_LIST) - }, [navigateBack]) - - const handleSelectShortcode = (code, name, withString = false) => { - const _code = withString ? '"${' + `${code}` + '}"' : '${' + `${code}` + '}' - const currentPosition = cursorPosition[name] - let value = formik.values[name] || '' - if (currentPosition >= 0 && value) { - const str = formik.values[name] - value = str.slice(0, currentPosition) + _code + str.slice(currentPosition) - } else if (value) { - value += _code - } else { - value = _code - } - - setCursorPosition((prevState) => ({ - ...prevState, - [name]: currentPosition + _code.length, - })) - formik.setFieldValue(name, value) - } - - return ( - <> -
- - {id ? ( - - ) : null} - - { - setSelectedFeatures(options && options.length > 0 ? [options[0]] : []) - }} - lsize={4} - doc_category={WEBHOOK} - doc_entry="aui_feature_ids" - rsize={8} - allowNew={false} - isLoading={loadingFeatures} - multiple={false} - hideHelperMessage - /> - { - const currentPosition = event.target.selectionStart - setCursorPosition((prevState) => ({ - ...prevState, - url: currentPosition, - })) - }} - onFocus={(event) => { - setTimeout(() => { - const currentPosition = event.target.selectionStart - setCursorPosition((prevState) => ({ - ...prevState, - url: currentPosition, - })) - }, 0) - }} - doc_entry="url" - name="url" - errorMessage={formik.errors.url} - showError={formik.errors.url && formik.touched.url} - shortcode={ - handleSelectShortcode(code, 'url')} - /> - } - /> - - - - - - - - - - - - {formik.values.httpMethod && - formik.values.httpMethod !== 'GET' && - formik.values.httpMethod !== 'DELETE' && ( - }> - { - setTimeout(() => { - const cursorPos = value.cursor - const lines = value.cursor?.document?.$lines - let index = 0 - for (let i = 0; i < cursorPos.row; i++) { - index += lines[i].length + 1 // +1 for the newline character - } - index += cursorPos.column - setCursorPosition((prevState) => ({ - ...prevState, - httpRequestBody: index, - })) - }, 0) - }} - theme="xcode" - doc_category={WEBHOOK} - doc_entry="http_request_body" - formik={formik} - value={formik.values?.httpRequestBody} - errorMessage={formik.errors.httpRequestBody} - showError={formik.errors.httpRequestBody && formik.touched.httpRequestBody} - placeholder="" - shortcode={ - - handleSelectShortcode(code, 'httpRequestBody', true) - } - /> - } - /> - - )} - - - - - - - - - - - - - - - - - - ) -} - -export default WebhookForm diff --git a/admin-ui/plugins/admin/components/Webhook/WebhookForm.tsx b/admin-ui/plugins/admin/components/Webhook/WebhookForm.tsx new file mode 100644 index 0000000000..631e7daa9c --- /dev/null +++ b/admin-ui/plugins/admin/components/Webhook/WebhookForm.tsx @@ -0,0 +1,530 @@ +import React, { Suspense, lazy, useCallback, useState, useEffect, useMemo, memo } from 'react' +import { Col, Form, Row, FormGroup, Card, CardBody } from 'Components' +import GluuInputRow from 'Routes/Apps/Gluu/GluuInputRow' +import GluuSelectRow from 'Routes/Apps/Gluu/GluuSelectRow' +import { useFormik } from 'formik' +import GluuFormFooter from 'Routes/Apps/Gluu/GluuFormFooter' +import GluuCommitDialog from 'Routes/Apps/Gluu/GluuCommitDialog' +import { useTranslation } from 'react-i18next' +import { useDispatch, useSelector } from 'react-redux' +import GluuSuspenseLoader from 'Routes/Apps/Gluu/GluuSuspenseLoader' +import { resetFlags } from 'Plugins/admin/redux/features/WebhookSlice' +import GluuLabel from 'Routes/Apps/Gluu/GluuLabel' +import Toggle from 'react-toggle' +import { WEBHOOK } from 'Utils/ApiResources' +import GluuTypeAhead from 'Routes/Apps/Gluu/GluuTypeAhead' +import GluuProperties from 'Routes/Apps/Gluu/GluuProperties' +import ShortcodePopover from './ShortcodePopover' +import shortCodes from 'Plugins/admin/helper/shortCodes.json' +import { isValid } from './WebhookURLChecker' +import isEqual from 'lodash/isEqual' +import { getWebhookValidationSchema } from 'Plugins/admin/helper/validations/webhookValidation' +import { buildWebhookInitialValues } from 'Plugins/admin/helper/webhook' +import { useAppNavigation, ROUTES } from '@/helpers/navigation' +import { useParams } from 'react-router' +import { useGetAllFeatures, useGetFeaturesByWebhookId } from 'JansConfigApi' +import { useCreateWebhookWithAudit, useUpdateWebhookWithAudit } from './hooks' +import Chip from '@mui/material/Chip' +import Box from '@mui/material/Box' +import Alert from '@mui/material/Alert' +import type { + WebhookFormValues, + CursorPosition, + AuiFeature, + ShortCodesConfig, + WebhookEntry, +} from './types' +import type { RootState } from 'Plugins/admin/redux/sagas/types/state' + +const GluuInputEditor = lazy(() => import('Routes/Apps/Gluu/GluuInputEditor')) + +const HTTP_METHODS = [ + { value: 'GET', label: 'GET' }, + { value: 'POST', label: 'POST' }, + { value: 'PUT', label: 'PUT' }, + { value: 'PATCH', label: 'PATCH' }, + { value: 'DELETE', label: 'DELETE' }, +] + +const WebhookForm: React.FC = () => { + const { id } = useParams<{ id?: string }>() + const dispatch = useDispatch() + const { t } = useTranslation() + const { navigateBack } = useAppNavigation() + + const selectedWebhook = useSelector((state: RootState) => state.webhookReducer.selectedWebhook) + + const { data: featuresData, isLoading: loadingFeatures } = useGetAllFeatures() + const features = useMemo(() => featuresData || [], [featuresData]) + + // Only fetch webhook features when editing an existing webhook (id is present) + // The 'skip' placeholder is never used as the query is disabled when id is falsy + const { data: webhookFeaturesData, isLoading: loadingWebhookFeatures } = + useGetFeaturesByWebhookId(id ?? 'skip', { + query: { enabled: Boolean(id) }, + }) + + const initialSelectedFeatures = useMemo(() => { + if (Array.isArray(webhookFeaturesData) && webhookFeaturesData.length > 0) { + return [webhookFeaturesData[0]] + } + return [] + }, [webhookFeaturesData]) + + const initialFormValues = useMemo( + () => buildWebhookInitialValues(selectedWebhook), + [selectedWebhook], + ) + + const { createWebhook, isLoading: isCreating } = useCreateWebhookWithAudit({ + onSuccess: () => { + navigateBack(ROUTES.WEBHOOK_LIST) + }, + }) + + const { updateWebhook, isLoading: isUpdating } = useUpdateWebhookWithAudit({ + onSuccess: () => { + navigateBack(ROUTES.WEBHOOK_LIST) + }, + }) + + const isLoading = isCreating || isUpdating + + const formik = useFormik({ + initialValues: initialFormValues, + enableReinitialize: true, + validationSchema: getWebhookValidationSchema(t), + onSubmit: (values, formikHelpers) => { + const isInvalid = validatePayload(values, formikHelpers.setFieldError) + if (isInvalid) return + openCommitDialog() + }, + }) + + const [cursorPosition, setCursorPosition] = useState({ + url: 0, + httpRequestBody: 0, + }) + const [showCommitDialog, setShowCommitDialog] = useState(false) + const [selectedFeatures, setSelectedFeatures] = useState(initialSelectedFeatures) + const [baselineSelectedFeatures, setBaselineSelectedFeatures] = + useState(initialSelectedFeatures) + + useEffect(() => { + setSelectedFeatures(initialSelectedFeatures) + setBaselineSelectedFeatures(initialSelectedFeatures) + }, [initialSelectedFeatures]) + + const openCommitDialog = useCallback(() => setShowCommitDialog(true), []) + const closeCommitDialog = useCallback(() => setShowCommitDialog(false), []) + + const validatePayload = useCallback( + (values: WebhookFormValues, setFieldError: (field: string, message: string) => void) => { + let hasError = false + if (values.httpRequestBody) { + try { + JSON.parse(values.httpRequestBody) + } catch { + hasError = true + setFieldError('httpRequestBody', t('messages.invalid_json_error')) + } + } + if (values.url && !isValid(values.url)) { + hasError = true + setFieldError('url', t('messages.invalid_url_error')) + } + return hasError + }, + [t], + ) + + const { + values: formikValues, + resetForm, + setFieldValue, + setFieldError, + dirty: formikDirty, + } = formik + + const submitForm = useCallback( + async (userMessage: string) => { + closeCommitDialog() + + const payload: WebhookEntry = { + displayName: formikValues.displayName, + url: formikValues.url, + httpMethod: formikValues.httpMethod, + description: formikValues.description, + jansEnabled: formikValues.jansEnabled, + httpHeaders: + formikValues.httpHeaders?.map((header) => ({ + key: header.key || header.source || '', + value: header.value || header.destination || '', + })) || [], + auiFeatureIds: + selectedFeatures?.map((feature) => feature.auiFeatureId).filter(Boolean) || [], + } + + if (formikValues.httpMethod !== 'GET' && formikValues.httpMethod !== 'DELETE') { + try { + payload.httpRequestBody = JSON.parse(formikValues.httpRequestBody) + } catch { + setFieldError('httpRequestBody', t('messages.invalid_json_error')) + return + } + } + + try { + if (id && selectedWebhook) { + payload.inum = selectedWebhook.inum + payload.dn = selectedWebhook.dn + payload.baseDn = selectedWebhook.baseDn + await updateWebhook(payload, userMessage) + } else { + await createWebhook(payload, userMessage) + } + + resetForm({ values: formikValues }) + setBaselineSelectedFeatures([...selectedFeatures]) + } catch (error) { + // Hooks already surface user-facing errors via toast; keep this for diagnostics + console.error('Failed to submit webhook form:', error) + } + }, + [ + closeCommitDialog, + formikValues, + resetForm, + setFieldError, + t, + selectedFeatures, + id, + selectedWebhook, + createWebhook, + updateWebhook, + ], + ) + + useEffect(() => { + return function cleanup() { + dispatch(resetFlags()) + } + }, [dispatch]) + + const typedShortCodes = shortCodes as ShortCodesConfig + const featureShortcodes = selectedFeatures?.[0]?.auiFeatureId + ? typedShortCodes?.[selectedFeatures[0].auiFeatureId]?.fields || [] + : [] + + const handleCancel = useCallback(() => { + resetForm({ values: initialFormValues }) + setSelectedFeatures([...baselineSelectedFeatures]) + }, [resetForm, baselineSelectedFeatures, initialFormValues]) + + const isFeatureSelectionChanged = useMemo( + () => !isEqual(selectedFeatures, baselineSelectedFeatures), + [selectedFeatures, baselineSelectedFeatures], + ) + + const isFormChanged = formikDirty || isFeatureSelectionChanged + + const handleBack = useCallback(() => { + navigateBack(ROUTES.WEBHOOK_LIST) + }, [navigateBack]) + + const handleSelectShortcode = ( + code: string, + name: 'url' | 'httpRequestBody', + withString = false, + ) => { + const _code = withString ? '"${' + code + '}"' : '${' + code + '}' + const currentPosition = cursorPosition[name] + let value = formikValues[name] || '' + if (currentPosition >= 0 && value) { + value = value.slice(0, currentPosition) + _code + value.slice(currentPosition) + } else if (value) { + value += _code + } else { + value = _code + } + + setCursorPosition((prevState) => ({ + ...prevState, + [name]: currentPosition + _code.length, + })) + setFieldValue(name, value) + } + + const showBodyEditor = + formikValues.httpMethod && + formikValues.httpMethod !== 'GET' && + formikValues.httpMethod !== 'DELETE' + + if (loadingWebhookFeatures && id) { + return + } + + return ( + + +
+ + + {t('messages.webhook_form_description', { + defaultValue: + 'Configure webhook to receive notifications when specific events occur.', + })} + + + + + {id && selectedWebhook?.inum && ( + + + + + + + )} + + + + []} + options={features as unknown as Record[]} + onChange={(options) => { + const typedOptions = options as unknown as AuiFeature[] + setSelectedFeatures( + typedOptions && typedOptions.length > 0 ? [typedOptions[0]] : [], + ) + }} + lsize={4} + doc_category={WEBHOOK} + doc_entry="aui_feature_ids" + rsize={8} + allowNew={false} + isLoading={loadingFeatures} + multiple={false} + hideHelperMessage + /> + + ) => { + const currentPosition = event.target.selectionStart || 0 + setCursorPosition((prevState) => ({ + ...prevState, + url: currentPosition, + })) + }} + onFocus={(event: React.FocusEvent) => { + setTimeout(() => { + const currentPosition = event.target.selectionStart || 0 + setCursorPosition((prevState) => ({ + ...prevState, + url: currentPosition, + })) + }, 0) + }} + doc_entry="url" + name="url" + errorMessage={formik.errors.url} + showError={!!(formik.errors.url && formik.touched.url)} + shortcode={ + handleSelectShortcode(code, 'url')} + /> + } + /> + + + + + + + + + + + + + {showBodyEditor && ( + }> + { + setTimeout(() => { + const cursorPos = value.cursor + const lines = cursorPos?.document?.$lines + let index = 0 + if (lines) { + for (let i = 0; i < cursorPos.row; i++) { + index += lines[i].length + 1 + } + } + index += cursorPos.column + setCursorPosition((prevState) => ({ + ...prevState, + httpRequestBody: index, + })) + }, 0) + }} + theme="xcode" + doc_category={WEBHOOK} + doc_entry="http_request_body" + formik={formik} + value={formikValues?.httpRequestBody} + errorMessage={formik.errors.httpRequestBody} + showError={!!(formik.errors.httpRequestBody && formik.touched.httpRequestBody)} + placeholder="" + shortcode={ + + handleSelectShortcode(code, 'httpRequestBody', true) + } + /> + } + /> + + )} + + + + + + + + + + + + + + + + + + + + +
+
+ ) +} + +export default memo(WebhookForm) diff --git a/admin-ui/plugins/admin/components/Webhook/WebhookListPage.js b/admin-ui/plugins/admin/components/Webhook/WebhookListPage.js deleted file mode 100644 index 74509d6694..0000000000 --- a/admin-ui/plugins/admin/components/Webhook/WebhookListPage.js +++ /dev/null @@ -1,296 +0,0 @@ -import React, { useEffect, useState, useContext, useCallback, useMemo } from 'react' -import MaterialTable from '@material-table/core' -import { DeleteOutlined } from '@mui/icons-material' -import { Paper, TablePagination } from '@mui/material' -import customColors from '@/customColors' -import { Card, CardBody } from 'Components' -import { useCedarling } from '@/cedarling' -import GluuViewWrapper from 'Routes/Apps/Gluu/GluuViewWrapper' -import applicationStyle from 'Routes/Apps/Gluu/styles/applicationstyle' -import GluuAdvancedSearch from 'Routes/Apps/Gluu/GluuAdvancedSearch' -import { buildPayload } from 'Utils/PermChecker' -import GluuCommitDialog from 'Routes/Apps/Gluu/GluuCommitDialog' -import GluuLoader from 'Routes/Apps/Gluu/GluuLoader' -import { useDispatch, useSelector } from 'react-redux' -import { useTranslation } from 'react-i18next' -import { ThemeContext } from 'Context/theme/themeContext' -import getThemeColor from 'Context/theme/config' -import { LIMIT_ID, PATTERN_ID } from 'Plugins/admin/common/Constants' -import SetTitle from 'Utils/SetTitle' -import { useAppNavigation, ROUTES } from '@/helpers/navigation' -import { - getWebhook, - deleteWebhook, - setSelectedWebhook, -} from 'Plugins/admin/redux/features/WebhookSlice' -import { ADMIN_UI_RESOURCES } from '@/cedarling/utility' -import { CEDAR_RESOURCE_SCOPES } from '@/cedarling/constants/resourceScopes' - -const WebhookListPage = () => { - const dispatch = useDispatch() - const { navigateToRoute } = useAppNavigation() - const { - hasCedarReadPermission, - hasCedarWritePermission, - hasCedarDeletePermission, - authorizeHelper, - } = useCedarling() - const webhookResourceId = useMemo(() => ADMIN_UI_RESOURCES.Webhooks, []) - const webhookScopes = useMemo(() => CEDAR_RESOURCE_SCOPES[webhookResourceId], [webhookResourceId]) - - const { t } = useTranslation() - const [pageNumber, setPageNumber] = useState(0) - const { totalItems, webhooks } = useSelector((state) => state.webhookReducer) - const loading = useSelector((state) => state.webhookReducer.loading) - const { permissions: cedarPermissions } = useSelector((state) => state.cedarPermissions) - const PaperContainer = useCallback((props) => , []) - - const theme = useContext(ThemeContext) - const themeColors = getThemeColor(theme.state.theme) - const bgThemeColor = { background: themeColors.background } - SetTitle(t('titles.webhooks')) - - const [modal, setModal] = useState(false) - const [deleteData, setDeleteData] = useState(null) - const toggle = () => setModal(!modal) - const submitForm = (userMessage) => { - const userAction = {} - toggle() - buildPayload(userAction, userMessage, deleteData) - dispatch(deleteWebhook({ action: userAction })) - } - - const [myActions, setMyActions] = useState([]) - const options = {} - - const [limit, setLimit] = useState(10) - const [pattern, setPattern] = useState(null) - - const canReadWebhooks = useMemo( - () => hasCedarReadPermission(webhookResourceId), - [hasCedarReadPermission, webhookResourceId], - ) - - useEffect(() => { - if (webhookScopes && webhookScopes.length > 0) { - authorizeHelper(webhookScopes) - } - }, [authorizeHelper, webhookScopes]) - - useEffect(() => { - if (canReadWebhooks) { - options['limit'] = 10 - dispatch(getWebhook({ action: options })) - } - }, [canReadWebhooks, dispatch]) - const canWriteWebhooks = useMemo( - () => hasCedarWritePermission(webhookResourceId), - [hasCedarWritePermission, webhookResourceId], - ) - const canDeleteWebhooks = useMemo( - () => hasCedarDeletePermission(webhookResourceId), - [hasCedarDeletePermission, webhookResourceId], - ) - - // Build actions only when permissions change - useEffect(() => { - const actions = [] - - if (canReadWebhooks) { - actions.push({ - icon: () => ( - - ), - tooltip: `${t('messages.advanced_search')}`, - iconProps: { color: 'primary', style: { borderColor: customColors.lightBlue } }, - isFreeAction: true, - onClick: () => {}, - }) - - actions.push({ - icon: 'refresh', - tooltip: `${t('messages.refresh')}`, - iconProps: { color: 'primary', style: { color: customColors.lightBlue } }, - isFreeAction: true, - onClick: () => { - setLimit(memoLimit) - setPattern(memoPattern) - dispatch(getWebhook({ action: { limit: memoLimit, pattern: memoPattern } })) - }, - }) - } - - if (canWriteWebhooks) { - actions.push({ - icon: 'add', - tooltip: `${t('messages.add_webhook')}`, - iconProps: { color: 'primary', style: { color: customColors.lightBlue } }, - isFreeAction: true, - onClick: navigateToAddPage, - }) - - actions.push((rowData) => ({ - icon: 'edit', - iconProps: { - color: 'primary', - id: 'editScope' + rowData.inum, - style: { color: customColors.darkGray }, - }, - onClick: (event, rowData) => navigateToEditPage(rowData), - disabled: !canWriteWebhooks, - })) - } - - if (canDeleteWebhooks) { - actions.push((rowData) => ({ - icon: () => , - iconProps: { - style: { color: customColors.darkGray }, - id: 'deleteClient' + rowData.inum, - }, - onClick: (event, rowData) => { - setDeleteData(rowData) - toggle() - }, - disabled: false, - })) - } - - setMyActions(actions) - }, [ - cedarPermissions, - limit, - pattern, - t, - navigateToAddPage, - navigateToEditPage, - canReadWebhooks, - canWriteWebhooks, - canDeleteWebhooks, - ]) - - let memoLimit = limit - let memoPattern = pattern - - const handleOptionsChange = useCallback((event) => { - if (event.target.name == 'limit') { - memoLimit = event.target.value - } else if (event.target.name == 'pattern') { - memoPattern = event.target.value - if (event.keyCode === 13) { - const newOptions = { - limit: limit, - pattern: memoPattern, - } - dispatch(getWebhook({ action: newOptions })) - } - } - }, []) - - const onPageChangeClick = (page) => { - const startCount = page * limit - options['startIndex'] = parseInt(startCount) - options['limit'] = limit - options['pattern'] = pattern - setPageNumber(page) - dispatch(getWebhook({ action: options })) - } - const onRowCountChangeClick = (count) => { - options['limit'] = count - options['pattern'] = pattern - setPageNumber(0) - setLimit(count) - dispatch(getWebhook({ action: options })) - } - - const PaginationWrapper = useCallback( - () => ( - { - onPageChangeClick(page) - }} - rowsPerPage={limit} - onRowsPerPageChange={(prop, count) => onRowCountChangeClick(count.props.value)} - /> - ), - [pageNumber, totalItems, onPageChangeClick, limit, onRowCountChangeClick], - ) - - const navigateToAddPage = useCallback(() => { - dispatch(setSelectedWebhook({})) - navigateToRoute(ROUTES.WEBHOOK_ADD) - }, [dispatch, navigateToRoute]) - - const navigateToEditPage = useCallback( - (data) => { - if (!data?.inum) return - dispatch(setSelectedWebhook(data)) - navigateToRoute(ROUTES.WEBHOOK_EDIT(data.inum)) - }, - [dispatch, navigateToRoute], - ) - - return ( - - - - - ( -
{rowData.url}
- ), - }, - { title: `${t('fields.http_method')}`, field: 'httpMethod' }, - { title: `${t('fields.enabled')}`, field: 'jansEnabled' }, - ]} - data={webhooks} - isLoading={loading} - title="" - actions={myActions} - options={{ - idSynonym: 'inum', - search: false, - searchFieldAlignment: 'left', - selection: false, - pageSize: limit, - rowStyle: (rowData) => ({ - backgroundColor: rowData.enabled ? customColors.logo : customColors.white, - }), - headerStyle: { - ...applicationStyle.tableHeaderStyle, - ...bgThemeColor, - }, - actionsColumnIndex: -1, - }} - /> -
-
- -
-
- ) -} - -export default WebhookListPage diff --git a/admin-ui/plugins/admin/components/Webhook/WebhookListPage.tsx b/admin-ui/plugins/admin/components/Webhook/WebhookListPage.tsx new file mode 100644 index 0000000000..13d268d5f8 --- /dev/null +++ b/admin-ui/plugins/admin/components/Webhook/WebhookListPage.tsx @@ -0,0 +1,457 @@ +import React, { useEffect, useState, useContext, useCallback, useMemo, memo } from 'react' +import MaterialTable from '@material-table/core' +import { DeleteOutlined, Edit, Refresh, Add } from '@mui/icons-material' +import { + Paper, + TablePagination, + Chip, + Skeleton, + Box, + Typography, + IconButton, + Tooltip, +} from '@mui/material' +import customColors from '@/customColors' +import { Card, CardBody } from 'Components' +import { useCedarling } from '@/cedarling' +import GluuViewWrapper from 'Routes/Apps/Gluu/GluuViewWrapper' +import applicationStyle from 'Routes/Apps/Gluu/styles/applicationstyle' +import GluuCommitDialog from 'Routes/Apps/Gluu/GluuCommitDialog' +import { useDispatch } from 'react-redux' +import { useTranslation } from 'react-i18next' +import { ThemeContext, ThemeContextType } from 'Context/theme/themeContext' +import getThemeColor from 'Context/theme/config' +import SetTitle from 'Utils/SetTitle' +import { useAppNavigation, ROUTES } from '@/helpers/navigation' +import { setSelectedWebhook } from 'Plugins/admin/redux/features/WebhookSlice' +import { ADMIN_UI_RESOURCES } from '@/cedarling/utility' +import { CEDAR_RESOURCE_SCOPES } from '@/cedarling/constants/resourceScopes' +import { useGetAllWebhooks } from 'JansConfigApi' +import { useDeleteWebhookWithAudit } from './hooks' +import WebhookSearch, { WebhookSortBy } from './WebhookSearch' +import type { WebhookEntry, TableAction } from './types' + +const EmptyState: React.FC = () => { + const { t } = useTranslation() + return ( + + + {t('messages.no_webhooks_found', { defaultValue: 'No webhooks found' })} + + + {t('messages.create_first_webhook', { + defaultValue: 'Create your first webhook to get started', + })} + + + ) +} + +const LoadingSkeleton: React.FC = () => ( + + {[1, 2, 3, 4, 5].map((i) => ( + + ))} + +) + +const getHttpMethodColor = ( + method: string, +): 'info' | 'success' | 'error' | 'warning' | 'default' => { + const colorMap: Record = { + GET: 'info', + POST: 'success', + PUT: 'warning', + PATCH: 'warning', + DELETE: 'error', + } + return colorMap[method] || 'default' +} + +const EditIcon: React.FC> = (props) => +const DeleteIcon: React.FC> = (props) => ( + +) +const PaperContainer = (props: React.ComponentProps) => ( + +) + +interface PaginationWrapperProps { + count: number + page: number + rowsPerPage: number + onPageChange: (page: number) => void + onRowsPerPageChange: (count: number) => void +} + +const PaginationWrapper: React.FC = ({ + count, + page, + rowsPerPage, + onPageChange, + onRowsPerPageChange, +}) => ( + onPageChange(p)} + rowsPerPage={rowsPerPage} + onRowsPerPageChange={(e) => onRowsPerPageChange(Number(e.target.value))} + /> +) + +const WebhookListPage: React.FC = () => { + const dispatch = useDispatch() + const { navigateToRoute } = useAppNavigation() + const { + hasCedarReadPermission, + hasCedarWritePermission, + hasCedarDeletePermission, + authorizeHelper, + } = useCedarling() + + const webhookResourceId = useMemo(() => ADMIN_UI_RESOURCES.Webhooks, []) + const webhookScopes = useMemo(() => CEDAR_RESOURCE_SCOPES[webhookResourceId], [webhookResourceId]) + + const { t } = useTranslation() + const [pageNumber, setPageNumber] = useState(0) + const [limit, setLimit] = useState(10) + const [pattern, setPattern] = useState('') + const [sortBy, setSortBy] = useState('displayName') + const [sortOrder, setSortOrder] = useState<'ascending' | 'descending'>('ascending') + const [modal, setModal] = useState(false) + const [deleteData, setDeleteData] = useState(null) + + const theme = useContext(ThemeContext) as ThemeContextType + const themeColors = getThemeColor(theme.state.theme) + const bgThemeColor = { background: themeColors.background } + SetTitle(t('titles.webhooks')) + + const canReadWebhooks = useMemo( + () => hasCedarReadPermission(webhookResourceId), + [hasCedarReadPermission, webhookResourceId], + ) + const canWriteWebhooks = useMemo( + () => hasCedarWritePermission(webhookResourceId), + [hasCedarWritePermission, webhookResourceId], + ) + const canDeleteWebhooks = useMemo( + () => hasCedarDeletePermission(webhookResourceId), + [hasCedarDeletePermission, webhookResourceId], + ) + + const { data, isLoading, refetch } = useGetAllWebhooks( + { + limit, + pattern: pattern || undefined, + startIndex: pageNumber * limit, + sortBy, + sortOrder, + }, + { + query: { + enabled: canReadWebhooks, + }, + }, + ) + + const webhooks = useMemo(() => (data?.entries || []) as unknown as WebhookEntry[], [data]) + const totalItems = useMemo(() => data?.totalEntriesCount || 0, [data]) + + // Clamp pageNumber when it becomes out of range (e.g., after deleting the last item on the last page) + useEffect(() => { + if (totalItems > 0 && pageNumber * limit >= totalItems) { + const lastPage = Math.max(0, Math.ceil(totalItems / limit) - 1) + setPageNumber(lastPage) + } + }, [totalItems, pageNumber, limit]) + + const { deleteWebhook, isLoading: isDeleting } = useDeleteWebhookWithAudit() + + useEffect(() => { + if (webhookScopes && webhookScopes.length > 0) { + authorizeHelper(webhookScopes) + } + }, [authorizeHelper, webhookScopes]) + + const toggle = useCallback(() => setModal((prev) => !prev), []) + + const submitForm = useCallback( + async (userMessage: string) => { + toggle() + if (deleteData?.inum) { + await deleteWebhook(deleteData.inum, userMessage) + } + }, + [toggle, deleteData, deleteWebhook], + ) + + const navigateToAddPage = useCallback(() => { + dispatch(setSelectedWebhook(null)) + navigateToRoute(ROUTES.WEBHOOK_ADD) + }, [dispatch, navigateToRoute]) + + const navigateToEditPage = useCallback( + (rowData: WebhookEntry) => { + if (!rowData?.inum) return + dispatch(setSelectedWebhook(rowData)) + navigateToRoute(ROUTES.WEBHOOK_EDIT(rowData.inum)) + }, + [dispatch, navigateToRoute], + ) + + const handlePatternChange = useCallback((newPattern: string) => { + setPattern(newPattern) + setPageNumber(0) + }, []) + + const handleSortByChange = useCallback((newSortBy: WebhookSortBy) => { + setSortBy(newSortBy) + setPageNumber(0) + }, []) + + const handleSortOrderChange = useCallback((newSortOrder: 'ascending' | 'descending') => { + setSortOrder(newSortOrder) + setPageNumber(0) + }, []) + + const handleLimitChange = useCallback((newLimit: number) => { + setLimit(newLimit) + setPageNumber(0) + }, []) + + const handleRefresh = useCallback(() => { + refetch() + }, [refetch]) + + const onPageChangeClick = useCallback((page: number) => { + setPageNumber(page) + }, []) + + const TablePaginationComponent = useMemo( + () => () => ( + + ), + [totalItems, pageNumber, limit, onPageChangeClick, handleLimitChange], + ) + + const rowActions = useMemo(() => { + const actions: ((rowData: WebhookEntry) => TableAction)[] = [] + + if (canWriteWebhooks) { + actions.push((rowData: WebhookEntry) => ({ + icon: EditIcon, + tooltip: t('actions.edit'), + iconProps: { + color: 'primary', + id: 'editWebhook' + rowData.inum, + style: { color: customColors.darkGray }, + }, + onClick: () => navigateToEditPage(rowData), + disabled: false, + })) + } + + if (canDeleteWebhooks) { + actions.push((rowData: WebhookEntry) => ({ + icon: DeleteIcon, + tooltip: t('actions.delete'), + iconProps: { + id: 'deleteWebhook' + rowData.inum, + }, + onClick: () => { + setDeleteData(rowData) + toggle() + }, + disabled: false, + })) + } + + return actions + }, [canWriteWebhooks, canDeleteWebhooks, t, navigateToEditPage, toggle]) + + const columns = useMemo( + () => [ + { + title: t('fields.name'), + field: 'displayName', + render: (rowData: WebhookEntry) => ( + + {rowData.displayName} + + ), + }, + { + title: t('fields.url'), + field: 'url', + width: '35%', + render: (rowData: WebhookEntry) => ( + + {rowData.url} + + ), + }, + { + title: t('fields.http_method'), + field: 'httpMethod', + render: (rowData: WebhookEntry) => ( + + ), + }, + { + title: t('fields.status'), + field: 'jansEnabled', + render: (rowData: WebhookEntry) => ( + + ), + }, + ], + [t, themeColors], + ) + + const renderTableContent = useCallback(() => { + if (isLoading || isDeleting) { + return + } + + if (!pattern && totalItems === 0) { + return + } + + return ( + ({ + backgroundColor: rowData?.jansEnabled + ? themeColors.lightBackground + : customColors.white, + }), + headerStyle: { + ...(applicationStyle.tableHeaderStyle as React.CSSProperties), + ...bgThemeColor, + }, + actionsColumnIndex: -1, + }} + /> + ) + }, [ + isLoading, + isDeleting, + webhooks, + pattern, + totalItems, + TablePaginationComponent, + columns, + rowActions, + limit, + themeColors, + bgThemeColor, + ]) + + return ( + + + + + + + {canReadWebhooks && ( + + + + + + )} + {canWriteWebhooks && ( + + + + + + )} + + + + {renderTableContent()} + + + + + ) +} + +export default memo(WebhookListPage) diff --git a/admin-ui/plugins/admin/components/Webhook/WebhookSearch.tsx b/admin-ui/plugins/admin/components/Webhook/WebhookSearch.tsx new file mode 100644 index 0000000000..4aa3863b02 --- /dev/null +++ b/admin-ui/plugins/admin/components/Webhook/WebhookSearch.tsx @@ -0,0 +1,149 @@ +import React, { memo, useCallback, useState, useEffect, KeyboardEvent } from 'react' +import { + Box, + TextField, + Select, + MenuItem, + IconButton, + FormControl, + InputLabel, + InputAdornment, + Tooltip, + SelectChangeEvent, +} from '@mui/material' +import { Search, ArrowUpward, ArrowDownward } from '@mui/icons-material' +import { useTranslation } from 'react-i18next' + +export type WebhookSortBy = 'displayName' | 'url' | 'httpMethod' | 'jansEnabled' + +interface WebhookSearchProps { + pattern: string + sortBy: WebhookSortBy + sortOrder: 'ascending' | 'descending' + limit: number + onPatternChange: (pattern: string) => void + onSortByChange: (sortBy: WebhookSortBy) => void + onSortOrderChange: (sortOrder: 'ascending' | 'descending') => void + onLimitChange: (limit: number) => void +} + +const SORT_BY_OPTIONS: { value: WebhookSortBy; labelKey: string }[] = [ + { value: 'displayName', labelKey: 'fields.name' }, + { value: 'url', labelKey: 'fields.url' }, + { value: 'httpMethod', labelKey: 'fields.http_method' }, + { value: 'jansEnabled', labelKey: 'fields.status' }, +] + +const LIMIT_OPTIONS = [5, 10, 25, 50] + +const WebhookSearch: React.FC = ({ + pattern, + sortBy, + sortOrder, + limit, + onPatternChange, + onSortByChange, + onSortOrderChange, + onLimitChange, +}) => { + const { t } = useTranslation() + const [inputValue, setInputValue] = useState(pattern) + + // Keep input in sync if pattern is reset externally (e.g., from URL/query params) + useEffect(() => { + setInputValue(pattern) + }, [pattern]) + + const handlePatternKeyDown = useCallback( + (event: KeyboardEvent) => { + if (event.key === 'Enter') { + onPatternChange(inputValue) + } + }, + [inputValue, onPatternChange], + ) + + const handleSortByChange = useCallback( + (event: SelectChangeEvent) => { + onSortByChange(event.target.value) + }, + [onSortByChange], + ) + + const handleSortOrderToggle = useCallback(() => { + onSortOrderChange(sortOrder === 'ascending' ? 'descending' : 'ascending') + }, [sortOrder, onSortOrderChange]) + + const handleLimitChange = useCallback( + (event: SelectChangeEvent) => { + onLimitChange(Number(event.target.value)) + }, + [onLimitChange], + ) + + return ( + + setInputValue(e.target.value)} + onKeyDown={handlePatternKeyDown} + InputProps={{ + startAdornment: ( + + + + ), + }} + sx={{ minWidth: 200 }} + /> + + + {t('fields.sort_by')} + + + + + + {sortOrder === 'ascending' ? : } + + + + + {t('fields.results_per_page')} + + + + ) +} + +export default memo(WebhookSearch) diff --git a/admin-ui/plugins/admin/components/Webhook/WebhookURLChecker.js b/admin-ui/plugins/admin/components/Webhook/WebhookURLChecker.js deleted file mode 100644 index 42cdd42054..0000000000 --- a/admin-ui/plugins/admin/components/Webhook/WebhookURLChecker.js +++ /dev/null @@ -1,33 +0,0 @@ -const NOT_ALLOWED = [ - 'http://', - 'ftp://', - 'file://', - 'telnet://', - 'smb://', - 'ssh://', - 'ldap://', - 'https://192.168', - 'https://127.0', - 'https://172', - 'https://localhost', -] -const PATTERN = /^(https?:\/\/)?([\w-]+\.)+[\w-]+(:\d+)?(\/[\w-.]*)*$/i - -export const isValid = (url) => { - if (url === undefined || url === null || !isAllowed(url)) { - return false - } else { - return PATTERN.test(url) - } -} - -const isAllowed = (url) => { - let result = true - for (const extention in NOT_ALLOWED) { - if (url.startsWith(NOT_ALLOWED[extention])) { - result = false - break - } - } - return result -} diff --git a/admin-ui/plugins/admin/components/Webhook/WebhookURLChecker.ts b/admin-ui/plugins/admin/components/Webhook/WebhookURLChecker.ts new file mode 100644 index 0000000000..c1ce6b5519 --- /dev/null +++ b/admin-ui/plugins/admin/components/Webhook/WebhookURLChecker.ts @@ -0,0 +1,99 @@ +const BLOCKED_SCHEMES = [ + 'http', + 'ftp', + 'file', + 'telnet', + 'smb', + 'ssh', + 'ldap', + 'gopher', + 'dict', + 'tftp', +] + +const isPrivateOrLocalhost = (hostname: string): boolean => { + if ( + hostname === 'localhost' || + hostname === '::1' || + hostname === '::' || + hostname === '0.0.0.0' || + hostname === '255.255.255.255' || + hostname.startsWith('0.') + ) { + return true + } + if ( + hostname.startsWith('127.') || + hostname.startsWith('10.') || + hostname.startsWith('192.168.') || + hostname.startsWith('169.254.') + ) { + return true + } + const cgnatMatch = hostname.match(/^100\.(\d+)\./) + if (cgnatMatch) { + const secondOctet = parseInt(cgnatMatch[1], 10) + if (secondOctet >= 64 && secondOctet <= 127) { + return true + } + } + const match = hostname.match(/^172\.(\d+)\./) + if (match) { + const secondOctet = parseInt(match[1], 10) + if (secondOctet >= 16 && secondOctet <= 31) { + return true + } + } + + if (hostname.includes(':')) { + if (hostname.startsWith('::ffff:')) { + return true + } + if (hostname.startsWith('fc') || hostname.startsWith('fd')) { + return true + } + if ( + hostname.startsWith('fe8') || + hostname.startsWith('fe9') || + hostname.startsWith('fea') || + hostname.startsWith('feb') + ) { + return true + } + if (hostname.startsWith('2001:db8:') || hostname.startsWith('ff')) { + return true + } + } + + return false +} + +const PATTERN = + /^https:\/\/(([\w-]+\.)+[\w-]+|\[[\da-fA-F:]+\])(:\d+)?(\/[^\s?#]*)?(\?[^\s#]*)?(#[^\s]*)?$/i + +const isAllowed = (url: string): boolean => { + try { + const parsed = new URL(url) + + if (BLOCKED_SCHEMES.includes(parsed.protocol.replace(':', ''))) { + return false + } + + if (isPrivateOrLocalhost(parsed.hostname)) { + return false + } + + return true + } catch { + return false + } +} + +export const isValid = (url: string | undefined | null): boolean => { + if (url === undefined || url === null || !isAllowed(url)) { + return false + } + return PATTERN.test(url) +} + +export { isAllowed } diff --git a/admin-ui/plugins/admin/components/Webhook/hooks/index.ts b/admin-ui/plugins/admin/components/Webhook/hooks/index.ts new file mode 100644 index 0000000000..26032ef05e --- /dev/null +++ b/admin-ui/plugins/admin/components/Webhook/hooks/index.ts @@ -0,0 +1,7 @@ +export { useWebhookAudit, CREATE, UPDATE, DELETION, FETCH } from './useWebhookAudit' +export { + useCreateWebhookWithAudit, + useUpdateWebhookWithAudit, + useDeleteWebhookWithAudit, +} from './useWebhookMutations' +export type { MutationCallbacks } from './useWebhookMutations' diff --git a/admin-ui/plugins/admin/components/Webhook/hooks/useWebhookAudit.ts b/admin-ui/plugins/admin/components/Webhook/hooks/useWebhookAudit.ts new file mode 100644 index 0000000000..341fb6bb75 --- /dev/null +++ b/admin-ui/plugins/admin/components/Webhook/hooks/useWebhookAudit.ts @@ -0,0 +1,58 @@ +import { useCallback } from 'react' +import { useSelector } from 'react-redux' +import { postUserAction } from 'Redux/api/backend-api' +import { addAdditionalData } from 'Utils/TokenController' +import { CREATE, UPDATE, DELETION, FETCH } from '@/audit/UserActionType' +import type { WebhookEntry } from '../types' + +interface AuthState { + token: { access_token: string } + config: { clientId: string } + location: { IPv4: string } + userinfo: { name: string; inum: string } | null +} + +interface RootState { + authReducer: AuthState +} + +type ActionType = typeof CREATE | typeof UPDATE | typeof DELETION | typeof FETCH + +export const useWebhookAudit = () => { + const token = useSelector((state: RootState) => state.authReducer.token.access_token) + const clientId = useSelector((state: RootState) => state.authReducer.config.clientId) + const ipAddress = useSelector((state: RootState) => state.authReducer.location.IPv4) + const userinfo = useSelector((state: RootState) => state.authReducer.userinfo) + + const initAudit = useCallback((): Record => { + return { + headers: { Authorization: `Bearer ${token}` }, + client_id: clientId, + ip_address: ipAddress, + status: 'success', + performedBy: { + user_inum: userinfo?.inum || '-', + userId: userinfo?.name || '-', + }, + } + }, [token, clientId, ipAddress, userinfo]) + + const logAction = useCallback( + async ( + actionType: ActionType, + resource: string, + payload: { action_message?: string; action_data?: WebhookEntry | { inum: string } }, + ) => { + const audit = initAudit() + addAdditionalData(audit, actionType, resource, { + action: payload as { action_message?: string; action_data?: Record }, + }) + await postUserAction(audit) + }, + [initAudit], + ) + + return { initAudit, logAction } +} + +export { CREATE, UPDATE, DELETION, FETCH } diff --git a/admin-ui/plugins/admin/components/Webhook/hooks/useWebhookMutations.ts b/admin-ui/plugins/admin/components/Webhook/hooks/useWebhookMutations.ts new file mode 100644 index 0000000000..d99b26a69f --- /dev/null +++ b/admin-ui/plugins/admin/components/Webhook/hooks/useWebhookMutations.ts @@ -0,0 +1,140 @@ +import { useCallback, useRef } from 'react' +import { useDispatch } from 'react-redux' +import { useQueryClient } from '@tanstack/react-query' +import { + usePostWebhook, + usePutWebhook, + useDeleteWebhookByInum, + getGetAllWebhooksQueryKey, +} from 'JansConfigApi' +import { updateToast } from 'Redux/features/toastSlice' +import { useWebhookAudit, CREATE, UPDATE, DELETION } from './useWebhookAudit' +import type { WebhookEntry } from '../types' + +export interface MutationCallbacks { + onSuccess?: () => void + onError?: (error: unknown) => void +} + +/** + * Extracts error message from webhook API responses. + * Note: The webhook API returns `responseMessage` in error responses, which differs from + * other APIs that use `message` (see plugins/schema/utils/errorHandler.ts). + * This is intentional and matches the Jans Config API response structure for webhook endpoints. + */ +const extractErrorMessage = (error: unknown, fallback: string): string => + (error as { response?: { data?: { responseMessage?: string } } })?.response?.data + ?.responseMessage || + (error as Error)?.message || + fallback + +export const useCreateWebhookWithAudit = (callbacks?: MutationCallbacks) => { + const dispatch = useDispatch() + const queryClient = useQueryClient() + const { logAction } = useWebhookAudit() + const mutation = usePostWebhook() + const callbacksRef = useRef(callbacks) + callbacksRef.current = callbacks + + const createWebhook = useCallback( + async (data: WebhookEntry, userMessage?: string) => { + try { + const result = await mutation.mutateAsync({ data }) + logAction(CREATE, 'webhook', { + action_message: userMessage, + action_data: data, + }).catch((auditError) => console.error('Audit logging failed:', auditError)) + dispatch(updateToast(true, 'success', 'Webhook created successfully')) + queryClient.invalidateQueries({ queryKey: getGetAllWebhooksQueryKey() }) + callbacksRef.current?.onSuccess?.() + return result + } catch (error: unknown) { + dispatch(updateToast(true, 'error', extractErrorMessage(error, 'Failed to create webhook'))) + callbacksRef.current?.onError?.(error) + throw error + } + }, + [mutation, logAction, dispatch, queryClient], + ) + + return { + createWebhook, + isLoading: mutation.isPending, + isError: mutation.isError, + error: mutation.error, + } +} + +export const useUpdateWebhookWithAudit = (callbacks?: MutationCallbacks) => { + const dispatch = useDispatch() + const queryClient = useQueryClient() + const { logAction } = useWebhookAudit() + const mutation = usePutWebhook() + const callbacksRef = useRef(callbacks) + callbacksRef.current = callbacks + + const updateWebhook = useCallback( + async (data: WebhookEntry, userMessage?: string) => { + try { + const result = await mutation.mutateAsync({ data }) + logAction(UPDATE, 'webhook', { + action_message: userMessage, + action_data: data, + }).catch((auditError) => console.error('Audit logging failed:', auditError)) + dispatch(updateToast(true, 'success', 'Webhook updated successfully')) + queryClient.invalidateQueries({ queryKey: getGetAllWebhooksQueryKey() }) + callbacksRef.current?.onSuccess?.() + return result + } catch (error: unknown) { + dispatch(updateToast(true, 'error', extractErrorMessage(error, 'Failed to update webhook'))) + callbacksRef.current?.onError?.(error) + throw error + } + }, + [mutation, logAction, dispatch, queryClient], + ) + + return { + updateWebhook, + isLoading: mutation.isPending, + isError: mutation.isError, + error: mutation.error, + } +} + +export const useDeleteWebhookWithAudit = (callbacks?: MutationCallbacks) => { + const dispatch = useDispatch() + const queryClient = useQueryClient() + const { logAction } = useWebhookAudit() + const mutation = useDeleteWebhookByInum() + const callbacksRef = useRef(callbacks) + callbacksRef.current = callbacks + + const deleteWebhook = useCallback( + async (inum: string, userMessage?: string) => { + try { + const result = await mutation.mutateAsync({ webhookId: inum }) + logAction(DELETION, 'webhook', { + action_message: userMessage, + action_data: { inum }, + }).catch((auditError) => console.error('Audit logging failed:', auditError)) + dispatch(updateToast(true, 'success', 'Webhook deleted successfully')) + queryClient.invalidateQueries({ queryKey: getGetAllWebhooksQueryKey() }) + callbacksRef.current?.onSuccess?.() + return result + } catch (error: unknown) { + dispatch(updateToast(true, 'error', extractErrorMessage(error, 'Failed to delete webhook'))) + callbacksRef.current?.onError?.(error) + throw error + } + }, + [mutation, logAction, dispatch, queryClient], + ) + + return { + deleteWebhook, + isLoading: mutation.isPending, + isError: mutation.isError, + error: mutation.error, + } +} diff --git a/admin-ui/plugins/admin/components/Webhook/index.ts b/admin-ui/plugins/admin/components/Webhook/index.ts new file mode 100644 index 0000000000..11aebc0555 --- /dev/null +++ b/admin-ui/plugins/admin/components/Webhook/index.ts @@ -0,0 +1,9 @@ +export { default as WebhookListPage } from './WebhookListPage' +export { default as WebhookAddPage } from './WebhookAddPage' +export { default as WebhookEditPage } from './WebhookEditPage' +export { default as WebhookForm } from './WebhookForm' +export { default as WebhookSearch } from './WebhookSearch' +export { default as ShortcodePopover } from './ShortcodePopover' +export { isValid, isAllowed } from './WebhookURLChecker' +export * from './types' +export * from './hooks' diff --git a/admin-ui/plugins/admin/components/Webhook/types/WebhookTypes.ts b/admin-ui/plugins/admin/components/Webhook/types/WebhookTypes.ts new file mode 100644 index 0000000000..8995c1f055 --- /dev/null +++ b/admin-ui/plugins/admin/components/Webhook/types/WebhookTypes.ts @@ -0,0 +1,109 @@ +import type { CSSProperties, ReactNode } from 'react' +import type { FormikProps } from 'formik' +import type { WebhookEntry, AuiFeature, GetAllWebhooksParams, KeyValuePair } from 'JansConfigApi' + +export type { WebhookEntry, AuiFeature, GetAllWebhooksParams, KeyValuePair } + +export interface WebhookFormValues { + displayName: string + url: string + httpMethod: string + description: string + httpHeaders: HttpHeader[] + httpRequestBody: string + jansEnabled: boolean +} + +export interface HttpHeader { + key?: string + value?: string + source?: string + destination?: string +} + +export interface WebhookFormProps { + webhook?: WebhookEntry | null + webhookFeatures?: AuiFeature[] + isEdit?: boolean +} + +export interface ShortcodeField { + key: string + label: string + description?: string +} + +export interface ShortcodePopoverProps { + codes: ShortcodeField[] + buttonWrapperStyles?: CSSProperties + handleSelectShortcode: (code: string) => void +} + +export interface ShortcodeLabelProps { + doc_category?: string + doc_entry: string + label: string +} + +export interface WebhookListState { + pageNumber: number + limit: number + pattern: string +} + +export interface DeleteModalState { + isOpen: boolean + webhook: WebhookEntry | null +} + +export interface CursorPosition { + url: number + httpRequestBody: number +} + +export interface TriggerPayload { + feature?: string | null + payload?: unknown + createdFeatureValue?: unknown +} + +export interface WebhookActionPayload { + action?: { + action_message?: string + action_data?: WebhookEntry | { inum: string } + limit?: number + pattern?: string + startIndex?: number + } +} + +export interface PagedWebhookResult { + entries?: WebhookEntry[] + totalEntriesCount?: number + entriesCount?: number +} + +export interface TableAction { + icon: string | (() => ReactNode) + tooltip: string + iconProps?: Record + isFreeAction?: boolean + onClick: (event: React.MouseEvent, rowData?: WebhookEntry | WebhookEntry[]) => void + disabled?: boolean +} + +export type WebhookFormikInstance = FormikProps + +export interface SearchEvent { + target: { + name: string + value: string | number + } + keyCode?: number +} + +export interface ShortCodesConfig { + [featureId: string]: { + fields: ShortcodeField[] + } +} diff --git a/admin-ui/plugins/admin/components/Webhook/types/index.ts b/admin-ui/plugins/admin/components/Webhook/types/index.ts new file mode 100644 index 0000000000..fd34ef3b03 --- /dev/null +++ b/admin-ui/plugins/admin/components/Webhook/types/index.ts @@ -0,0 +1 @@ +export * from './WebhookTypes' diff --git a/admin-ui/plugins/admin/redux/features/WebhookSlice.js b/admin-ui/plugins/admin/redux/features/WebhookSlice.js.bak similarity index 100% rename from admin-ui/plugins/admin/redux/features/WebhookSlice.js rename to admin-ui/plugins/admin/redux/features/WebhookSlice.js.bak diff --git a/admin-ui/plugins/admin/redux/features/WebhookSlice.ts b/admin-ui/plugins/admin/redux/features/WebhookSlice.ts new file mode 100644 index 0000000000..8deb17f84f --- /dev/null +++ b/admin-ui/plugins/admin/redux/features/WebhookSlice.ts @@ -0,0 +1,195 @@ +import reducerRegistry from 'Redux/reducers/ReducerRegistry' +import { createSlice, PayloadAction } from '@reduxjs/toolkit' +import type { WebhookEntry, AuiFeature } from 'JansConfigApi' +import type { TriggerPayload, WebhookActionPayload } from 'Plugins/admin/components/Webhook/types' + +export interface WebhookSliceState { + webhooks: WebhookEntry[] + loading: boolean + saveOperationFlag: boolean + errorInSaveOperationFlag: boolean + totalItems: number + entriesCount: number + selectedWebhook: WebhookEntry | null + loadingFeatures: boolean + features: AuiFeature[] + webhookFeatures: AuiFeature[] + loadingWebhookFeatures: boolean + loadingWebhooks: boolean + featureWebhooks: WebhookEntry[] + webhookModal: boolean + triggerWebhookInProgress: boolean + triggerWebhookMessage: string + webhookTriggerErrors: unknown[] + triggerPayload: TriggerPayload + featureToTrigger: string + showErrorModal: boolean +} + +interface WebhookResponsePayload { + data?: { + entries?: WebhookEntry[] + totalEntriesCount?: number + entriesCount?: number + } | null +} + +const initialState: WebhookSliceState = { + webhooks: [], + loading: false, + saveOperationFlag: false, + errorInSaveOperationFlag: false, + totalItems: 0, + entriesCount: 0, + selectedWebhook: null, + loadingFeatures: false, + features: [], + webhookFeatures: [], + loadingWebhookFeatures: false, + loadingWebhooks: false, + featureWebhooks: [], + webhookModal: false, + triggerWebhookInProgress: false, + triggerWebhookMessage: '', + webhookTriggerErrors: [], + triggerPayload: { + feature: null, + payload: null, + }, + featureToTrigger: '', + showErrorModal: false, +} + +const webhookSlice = createSlice({ + name: 'webhook', + initialState, + reducers: { + getWebhook: (state, _action: PayloadAction) => { + state.loading = true + }, + getWebhookResponse: (state, action: PayloadAction) => { + state.loading = false + if (action.payload?.data) { + state.webhooks = action.payload.data?.entries || [] + state.totalItems = action.payload.data.totalEntriesCount || 0 + state.entriesCount = action.payload.data.entriesCount || 0 + } + }, + createWebhook: (state, _action: PayloadAction) => { + state.loading = true + state.saveOperationFlag = false + state.errorInSaveOperationFlag = false + }, + createWebhookResponse: (state, action: PayloadAction<{ data?: unknown }>) => { + state.loading = false + state.saveOperationFlag = true + if (action.payload?.data) { + state.errorInSaveOperationFlag = false + } else { + state.errorInSaveOperationFlag = true + } + }, + deleteWebhook: (state, _action: PayloadAction) => { + state.loading = true + }, + deleteWebhookResponse: (state) => { + state.loading = false + }, + setSelectedWebhook: (state, action: PayloadAction) => { + state.selectedWebhook = action.payload + }, + updateWebhook: (state, _action: PayloadAction) => { + state.loading = true + state.saveOperationFlag = false + state.errorInSaveOperationFlag = false + }, + updateWebhookResponse: (state, action: PayloadAction<{ data?: unknown }>) => { + state.saveOperationFlag = true + state.loading = false + if (action.payload?.data) { + state.errorInSaveOperationFlag = false + } else { + state.errorInSaveOperationFlag = true + } + }, + resetFlags: (state) => { + state.saveOperationFlag = false + state.errorInSaveOperationFlag = false + }, + getFeatures: (state) => { + state.loadingFeatures = true + }, + getFeaturesResponse: (state, action: PayloadAction) => { + state.loadingFeatures = false + state.features = action.payload + }, + getFeaturesByWebhookId: (state, _action: PayloadAction) => { + state.loadingWebhookFeatures = true + }, + getFeaturesByWebhookIdResponse: (state, action: PayloadAction) => { + state.loadingWebhookFeatures = false + state.webhookFeatures = action.payload + }, + getWebhooksByFeatureId: (state, _action: PayloadAction) => { + state.loadingWebhooks = true + }, + getWebhooksByFeatureIdResponse: (state, action: PayloadAction) => { + state.featureWebhooks = action.payload + state.loadingWebhooks = false + }, + setWebhookModal: (state, action: PayloadAction) => { + state.webhookModal = action.payload + }, + triggerWebhook: (state, action: PayloadAction) => { + state.triggerWebhookInProgress = true + state.triggerPayload = action.payload + }, + setTriggerWebhookResponse: (state, action: PayloadAction) => { + state.triggerWebhookInProgress = false + state.triggerWebhookMessage = action.payload + }, + setWebhookTriggerErrors: (state, action: PayloadAction) => { + state.webhookTriggerErrors = action.payload + }, + setTriggerPayload: (state, action: PayloadAction) => { + state.triggerPayload = action.payload + }, + setFeatureToTrigger: (state, action: PayloadAction) => { + state.featureToTrigger = action.payload + }, + setShowErrorModal: (state, action: PayloadAction) => { + state.showErrorModal = action.payload + }, + }, +}) + +export const { + getWebhook, + getWebhookResponse, + createWebhook, + createWebhookResponse, + deleteWebhook, + deleteWebhookResponse, + setSelectedWebhook, + updateWebhook, + updateWebhookResponse, + resetFlags, + getFeaturesResponse, + getFeatures, + getFeaturesByWebhookId, + getFeaturesByWebhookIdResponse, + getWebhooksByFeatureId, + getWebhooksByFeatureIdResponse, + setWebhookModal, + triggerWebhook, + setTriggerWebhookResponse, + setWebhookTriggerErrors, + setTriggerPayload, + setFeatureToTrigger, + setShowErrorModal, +} = webhookSlice.actions + +export const { actions, reducer } = webhookSlice +export default reducer + +reducerRegistry.register('webhookReducer', reducer) diff --git a/admin-ui/plugins/admin/redux/sagas/types/state.ts b/admin-ui/plugins/admin/redux/sagas/types/state.ts index c3c478762b..569660fd6d 100644 --- a/admin-ui/plugins/admin/redux/sagas/types/state.ts +++ b/admin-ui/plugins/admin/redux/sagas/types/state.ts @@ -1,6 +1,6 @@ import { CustomScriptItem } from '../../features/types/customScript' import { ScriptType } from '../../features/types/customScript' -import { Webhook } from './webhook' +import type { WebhookSliceState } from '../../features/WebhookSlice' export interface RootState { authReducer: { @@ -23,8 +23,5 @@ export interface RootState { loadingScriptTypes: boolean item?: CustomScriptItem } - webhookReducer: { - featureToTrigger: string - featureWebhooks: Webhook[] - } + webhookReducer: WebhookSliceState } diff --git a/admin-ui/plugins/auth-server/components/Scopes/ScopeAddPage.tsx b/admin-ui/plugins/auth-server/components/Scopes/ScopeAddPage.tsx index a7e16fdec4..9a232b504b 100644 --- a/admin-ui/plugins/auth-server/components/Scopes/ScopeAddPage.tsx +++ b/admin-ui/plugins/auth-server/components/Scopes/ScopeAddPage.tsx @@ -8,6 +8,7 @@ import { getAttributes, getScripts } from 'Redux/features/initSlice' import { buildPayload } from 'Utils/PermChecker' import GluuAlert from 'Routes/Apps/Gluu/GluuAlert' import { updateToast } from 'Redux/features/toastSlice' +import { triggerWebhook } from 'Plugins/admin/redux/features/WebhookSlice' import { useTranslation } from 'react-i18next' import applicationStyle from 'Routes/Apps/Gluu/styles/applicationstyle' import { usePostOauthScopes, getGetOauthScopesQueryKey } from 'JansConfigApi' @@ -77,6 +78,7 @@ const ScopeAddPage: React.FC = () => { : t('messages.scope_created_successfully') dispatch(updateToast(true, 'success', successMessage)) + dispatch(triggerWebhook({ createdFeatureValue: response })) try { await logScopeCreation(parsedData as Scope, message, modifiedFields) diff --git a/admin-ui/plugins/auth-server/components/Scopes/ScopeEditPage.tsx b/admin-ui/plugins/auth-server/components/Scopes/ScopeEditPage.tsx index 4529c5e1f6..04e0ae522a 100644 --- a/admin-ui/plugins/auth-server/components/Scopes/ScopeEditPage.tsx +++ b/admin-ui/plugins/auth-server/components/Scopes/ScopeEditPage.tsx @@ -9,6 +9,7 @@ import { getAttributes, getScripts } from 'Redux/features/initSlice' import { buildPayload } from 'Utils/PermChecker' import GluuAlert from 'Routes/Apps/Gluu/GluuAlert' import { updateToast } from 'Redux/features/toastSlice' +import { triggerWebhook } from 'Plugins/admin/redux/features/WebhookSlice' import { useTranslation } from 'react-i18next' import applicationStyle from 'Routes/Apps/Gluu/styles/applicationstyle' import { @@ -138,6 +139,7 @@ const ScopeEditPage: React.FC = () => { : t('messages.scope_updated_successfully') dispatch(updateToast(true, 'success', successMessage)) + dispatch(triggerWebhook({ createdFeatureValue: response })) try { await logScopeUpdate(parsedData as Scope, message, modifiedFields) diff --git a/admin-ui/plugins/auth-server/components/Scopes/ScopeListPage.tsx b/admin-ui/plugins/auth-server/components/Scopes/ScopeListPage.tsx index 1f18f228ee..c3a11e9ba9 100644 --- a/admin-ui/plugins/auth-server/components/Scopes/ScopeListPage.tsx +++ b/admin-ui/plugins/auth-server/components/Scopes/ScopeListPage.tsx @@ -36,6 +36,7 @@ import { adminUiFeatures } from 'Plugins/admin/helper/utils' import customColors from '@/customColors' import { useQueryClient } from '@tanstack/react-query' import { updateToast } from 'Redux/features/toastSlice' +import { triggerWebhook } from 'Plugins/admin/redux/features/WebhookSlice' import { useGetOauthScopes, useDeleteOauthScopesByInum, @@ -338,13 +339,14 @@ const ScopeListPage: React.FC = () => { try { await deleteScope.mutateAsync({ inum: item.inum }) + dispatch(triggerWebhook({ createdFeatureValue: item })) await logScopeDeletion(item, message) toggle() } catch (error) { console.error('Error deleting scope:', error) } }, - [item, deleteScope, logScopeDeletion, toggle], + [item, deleteScope, logScopeDeletion, toggle, dispatch], ) const onPageChangeClick = useCallback(