diff --git a/admin-ui/app/context/WebhookDialogContext.tsx b/admin-ui/app/context/WebhookDialogContext.tsx new file mode 100644 index 0000000000..5035aeae8b --- /dev/null +++ b/admin-ui/app/context/WebhookDialogContext.tsx @@ -0,0 +1,58 @@ +/** + * Webhook Dialog Context + * Manages state for webhook trigger dialogs and error displays + * Replaces the deleted webhookReducer from Redux + */ + +import React, { createContext, useContext } from 'react' +import type { WebhookEntry } from 'JansConfigApi' + +export interface WebhookTriggerError { + success: boolean + responseMessage: string + responseObject: { + webhookId?: string + webhookName?: string + inum?: string + } +} + +export interface WebhookDialogState { + showErrorModal: boolean + webhookModal: boolean + triggerWebhookMessage: string + webhookTriggerErrors: WebhookTriggerError[] + triggerWebhookInProgress: boolean + loadingWebhooks: boolean + featureWebhooks: WebhookEntry[] + featureToTrigger: string +} + +export interface WebhookDialogActions { + setShowErrorModal: (show: boolean) => void + setWebhookModal: (show: boolean) => void + setTriggerWebhookResponse: (message: string) => void + setWebhookTriggerErrors: (errors: WebhookTriggerError[]) => void + setFeatureToTrigger: (feature: string) => void + setFeatureWebhooks: (webhooks: WebhookEntry[]) => void + setLoadingWebhooks: (loading: boolean) => void + setTriggerWebhookInProgress: (inProgress: boolean) => void + resetWebhookDialog: () => void +} + +export interface WebhookDialogContextValue { + state: WebhookDialogState + actions: WebhookDialogActions +} + +const WebhookDialogContext = createContext(undefined) + +export const useWebhookDialog = (): WebhookDialogContextValue => { + const context = useContext(WebhookDialogContext) + if (!context) { + throw new Error('useWebhookDialog must be used within a WebhookDialogProvider') + } + return context +} + +export default WebhookDialogContext diff --git a/admin-ui/app/context/WebhookDialogProvider.tsx b/admin-ui/app/context/WebhookDialogProvider.tsx new file mode 100644 index 0000000000..9cf9186497 --- /dev/null +++ b/admin-ui/app/context/WebhookDialogProvider.tsx @@ -0,0 +1,83 @@ +/** + * Webhook Dialog Provider + * Provides webhook dialog state and actions to the application + */ + +import React, { useState, useMemo, useCallback } from 'react' +import WebhookDialogContext, { + type WebhookDialogState, + type WebhookDialogActions, + type WebhookDialogContextValue, +} from './WebhookDialogContext' +import type { WebhookEntry } from 'JansConfigApi' +import type { WebhookTriggerError } from './WebhookDialogContext' + +const initialState: WebhookDialogState = { + showErrorModal: false, + webhookModal: false, + triggerWebhookMessage: '', + webhookTriggerErrors: [], + triggerWebhookInProgress: false, + loadingWebhooks: false, + featureWebhooks: [], + featureToTrigger: '', +} + +export const WebhookDialogProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => { + const [state, setState] = useState(initialState) + + const actions: WebhookDialogActions = useMemo( + () => ({ + setShowErrorModal: (show: boolean) => { + setState((prev) => ({ ...prev, showErrorModal: show })) + }, + + setWebhookModal: (show: boolean) => { + setState((prev) => ({ ...prev, webhookModal: show })) + }, + + setTriggerWebhookResponse: (message: string) => { + setState((prev) => ({ ...prev, triggerWebhookMessage: message })) + }, + + setWebhookTriggerErrors: (errors: WebhookTriggerError[]) => { + setState((prev) => ({ ...prev, webhookTriggerErrors: errors })) + }, + + setFeatureToTrigger: (feature: string) => { + setState((prev) => ({ ...prev, featureToTrigger: feature })) + }, + + setFeatureWebhooks: (webhooks: WebhookEntry[]) => { + setState((prev) => ({ ...prev, featureWebhooks: webhooks })) + }, + + setLoadingWebhooks: (loading: boolean) => { + setState((prev) => ({ ...prev, loadingWebhooks: loading })) + }, + + setTriggerWebhookInProgress: (inProgress: boolean) => { + setState((prev) => ({ ...prev, triggerWebhookInProgress: inProgress })) + }, + + resetWebhookDialog: () => { + setState(initialState) + }, + }), + [], + ) + + const contextValue: WebhookDialogContextValue = useMemo( + () => ({ + state, + actions, + }), + [state, actions], + ) + + return ( + {children} + ) +} + +export default WebhookDialogProvider diff --git a/admin-ui/app/context/theme/themeContext.tsx b/admin-ui/app/context/theme/themeContext.tsx index 2d040f100f..42b6649aa5 100644 --- a/admin-ui/app/context/theme/themeContext.tsx +++ b/admin-ui/app/context/theme/themeContext.tsx @@ -1,17 +1,14 @@ import React, { createContext, useReducer, Dispatch, ReactNode } from 'react' -// Define the shape of the theme state type ThemeState = { theme: string } -// Define the shape of the actions for the reducer type ThemeAction = { type: string } -// Define the context value type -interface ThemeContextType { +export interface ThemeContextType { state: ThemeState dispatch: Dispatch } diff --git a/admin-ui/app/index.tsx b/admin-ui/app/index.tsx index 7feb6017ea..406fa131bf 100644 --- a/admin-ui/app/index.tsx +++ b/admin-ui/app/index.tsx @@ -5,6 +5,7 @@ import i18n from './i18n' import { ThemeProvider } from 'Context/theme/themeContext' import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { ReactQueryDevtools } from '@tanstack/react-query-devtools' +import { WebhookDialogProvider } from './context/WebhookDialogProvider' import './styles/index.css' import 'bootstrap/dist/css/bootstrap.css' @@ -27,7 +28,9 @@ root.render( - + + + diff --git a/admin-ui/app/locales/en/translation.json b/admin-ui/app/locales/en/translation.json index b136b456b6..8fc4c9750b 100644 --- a/admin-ui/app/locales/en/translation.json +++ b/admin-ui/app/locales/en/translation.json @@ -731,6 +731,10 @@ "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_trigger_success": "All webhooks triggered successfully.", + "webhook_trigger_error": "Something went wrong while triggering webhook.", + "webhook_trigger_failed": "Failed to trigger webhook.", + "webhook_trigger_invalid_data": "Cannot trigger webhook: invalid data provided.", "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", @@ -739,6 +743,7 @@ "request_body_error": "HTTP Request Body is required for selected HTTP Method", "display_name_error": "Please enter display name.", "url_error": "Please enter URL.", + "aui_feature_ids_error": "At least one Admin UI Feature must be selected.", "session_timeout_error": "Session timeout must be at least 1 minute", "session_timeout_required_error": "Session timeout is required", "invalid_json_error": "Invalid JSON value.", @@ -825,7 +830,13 @@ "user_updated_successfully": "User updated successfully", "user_deleted_successfully": "User deleted successfully", "password_changed_successfully": "Password changed successfully", - "device_deleted_successfully": "Device deleted successfully" + "device_deleted_successfully": "Device deleted successfully", + "bad_request": "Bad request", + "unauthorized": "Unauthorized", + "forbidden": "Forbidden", + "not_found": "Not found", + "conflict": "Conflict", + "server_error": "Server error" }, "errors": { "attribute_create_failed": "Error creating attribute", @@ -875,6 +886,7 @@ "enter_property_key": "Enter the property key", "enter_property_value": "Enter the property value", "enter_header_key": "Enter header key", + "enter_header_value": "Enter header value", "enter_key_value": "Enter key value", "enter_source_value": "Enter the source value", "enter_destination_value": "Enter the destination value", diff --git a/admin-ui/app/locales/es/translation.json b/admin-ui/app/locales/es/translation.json index 823c30e705..7bdf90af56 100644 --- a/admin-ui/app/locales/es/translation.json +++ b/admin-ui/app/locales/es/translation.json @@ -731,6 +731,10 @@ "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_trigger_success": "Todos los webhooks se activaron correctamente.", + "webhook_trigger_error": "Algo salió mal al activar el webhook.", + "webhook_trigger_failed": "No se pudo activar el webhook.", + "webhook_trigger_invalid_data": "No se puede activar el webhook: datos no válidos proporcionados.", "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", @@ -739,6 +743,7 @@ "request_body_error": "El cuerpo de la solicitud HTTP es obligatorio para el método HTTP seleccionado", "display_name_error": "Ingrese el nombre para mostrar.", "url_error": "Ingrese la URL.", + "aui_feature_ids_error": "Debe seleccionarse al menos una Función de Interfaz de Administración.", "session_timeout_error": "El tiempo de espera de sesión debe ser de al menos 1 minuto", "session_timeout_required_error": "El tiempo de espera de sesión es obligatorio", "invalid_json_error": "Valor JSON inválido.", @@ -824,7 +829,13 @@ "user_updated_successfully": "Usuario actualizado exitosamente", "user_deleted_successfully": "Usuario eliminado exitosamente", "password_changed_successfully": "Contraseña cambiada exitosamente", - "device_deleted_successfully": "Dispositivo eliminado exitosamente" + "device_deleted_successfully": "Dispositivo eliminado exitosamente", + "bad_request": "Solicitud incorrecta", + "unauthorized": "No autorizado", + "forbidden": "Prohibido", + "not_found": "No encontrado", + "conflict": "Conflicto", + "server_error": "Error del servidor" }, "errors": { "attribute_create_failed": "Error al crear el atributo", @@ -868,6 +879,7 @@ "enter_property_key": "Introduce la clave de la propiedad", "enter_property_value": "Introduce el valor de la propiedad", "enter_header_key": "Introduce la clave de cabecera", + "enter_header_value": "Introduce el valor de cabecera", "enter_key_value": "Introduce el valor de la clave", "enter_source_value": "Introduce el valor de origen", "enter_destination_value": "Introduce el valor de destino", diff --git a/admin-ui/app/locales/fr/translation.json b/admin-ui/app/locales/fr/translation.json index 487ab227bb..bd3c8c5f90 100644 --- a/admin-ui/app/locales/fr/translation.json +++ b/admin-ui/app/locales/fr/translation.json @@ -667,6 +667,10 @@ "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_trigger_success": "Tous les webhooks ont été déclenchés avec succès.", + "webhook_trigger_error": "Une erreur s'est produite lors du déclenchement du webhook.", + "webhook_trigger_failed": "Échec du déclenchement du webhook.", + "webhook_trigger_invalid_data": "Impossible de déclencher le webhook : données non valides fournies.", "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.", @@ -708,6 +712,7 @@ "http_method_error": "Veuillez sélectionner une méthode HTTP.", "display_name_error": "Veuillez entrer un nom d'affichage.", "url_error": "Veuillez entrer une URL.", + "aui_feature_ids_error": "Au moins une Fonctionnalité de l'Interface d'Administration doit être sélectionnée.", "invalid_json_error": "Valeur JSON invalide.", "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 ?", @@ -762,7 +767,13 @@ "user_updated_successfully": "Utilisateur mis à jour avec succès", "user_deleted_successfully": "Utilisateur supprimé avec succès", "password_changed_successfully": "Mot de passe modifié avec succès", - "device_deleted_successfully": "Appareil supprimé avec succès" + "device_deleted_successfully": "Appareil supprimé avec succès", + "bad_request": "Mauvaise requête", + "unauthorized": "Non autorisé", + "forbidden": "Interdit", + "not_found": "Non trouvé", + "conflict": "Conflit", + "server_error": "Erreur du serveur" }, "errors": { "attribute_create_failed": "Erreur lors de la création de l'attribut", @@ -807,6 +818,7 @@ "description": "Entrer la description", "display_name": "Entrez le nom d'affichage", "enter_header_key": "Entrer la clé d'en-tête", + "enter_header_value": "Entrer la valeur d'en-tête", "enter_key_value": "Entrer la valeur de la clé", "enable_ssl_communication": "Activer la communication SSL entre le serveur d'authentification et le serveur LDAP", "enter_property_key": "Entrez la clé de propriété", diff --git a/admin-ui/app/locales/pt/translation.json b/admin-ui/app/locales/pt/translation.json index f0ef04051d..b498c4b175 100644 --- a/admin-ui/app/locales/pt/translation.json +++ b/admin-ui/app/locales/pt/translation.json @@ -687,9 +687,14 @@ "select_message_provider_type": "Por favor, selecione o tipo de provedor de mensagens", "new_role": "Novo papel", "add_webhook": "Adicionar Webhook", + "webhook_trigger_success": "Todos os webhooks foram acionados com sucesso.", + "webhook_trigger_error": "Algo deu errado ao acionar o webhook.", + "webhook_trigger_failed": "Falha ao acionar o webhook.", + "webhook_trigger_invalid_data": "Não é possível acionar o webhook: dados inválidos fornecidos.", "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.", + "aui_feature_ids_error": "Pelo menos um Recurso da Interface de Administração deve ser selecionado.", "invalid_json_error": "Valor JSON inválido.", "license_api_not_enabled": "A API de licença não está habilitada para este aplicativo.", "adding_new_permission": "Adicionando nova permissão", @@ -757,7 +762,13 @@ "user_updated_successfully": "Usuário atualizado com sucesso", "user_deleted_successfully": "Usuário excluído com sucesso", "password_changed_successfully": "Senha alterada com sucesso", - "device_deleted_successfully": "Dispositivo excluído com sucesso" + "device_deleted_successfully": "Dispositivo excluído com sucesso", + "bad_request": "Solicitação inválida", + "unauthorized": "Não autorizado", + "forbidden": "Proibido", + "not_found": "Não encontrado", + "conflict": "Conflito", + "server_error": "Erro do servidor" }, "errors": { "attribute_create_failed": "Erro ao criar atributo", @@ -797,6 +808,7 @@ "client_description": "Insira a descrição do cliente", "client_name": "Insira o nome do cliente", "enter_header_key": "Digite a chave do cabeçalho", + "enter_header_value": "Digite o valor do cabeçalho", "enter_key_value": "Digite o valor da chave", "request_body_error": "O corpo da solicitação HTTP é obrigatório para o método HTTP selecionado", "description": "Digite a descrição", diff --git a/admin-ui/app/routes/Apps/Gluu/GluuCommitDialog.tsx b/admin-ui/app/routes/Apps/Gluu/GluuCommitDialog.tsx index 5b684e70dd..ce8e69fa1a 100644 --- a/admin-ui/app/routes/Apps/Gluu/GluuCommitDialog.tsx +++ b/admin-ui/app/routes/Apps/Gluu/GluuCommitDialog.tsx @@ -16,11 +16,11 @@ import { ThemeContext } from 'Context/theme/themeContext' import PropTypes from 'prop-types' import KeyboardArrowDownIcon from '@mui/icons-material/KeyboardArrowDown' import KeyboardArrowUpIcon from '@mui/icons-material/KeyboardArrowUp' -import { useSelector } from 'react-redux' import useWebhookDialogAction from 'Utils/hooks/useWebhookDialogAction' import { WEBHOOK_READ } from 'Utils/PermChecker' import { useCedarling } from '@/cedarling' import customColors from '@/customColors' +import { useWebhookDialog } from '@/context/WebhookDialogContext' const USER_MESSAGE = 'user_action_message' @@ -44,10 +44,10 @@ const GluuCommitDialog = ({ const [active, setActive] = useState(false) const [isOpen, setIsOpen] = useState(null) const [userMessage, setUserMessage] = useState('') - const { loadingWebhooks, webhookModal } = useSelector((state: any) => state.webhookReducer) + const { state: webhookState } = useWebhookDialog() + const { loadingWebhooks, webhookModal } = webhookState const { webhookTriggerModal, onCloseModal } = useWebhookDialogAction({ feature, - modal, }) const prevModalRef = useRef(false) diff --git a/admin-ui/app/routes/Apps/Gluu/GluuDialog.tsx b/admin-ui/app/routes/Apps/Gluu/GluuDialog.tsx index 9a1126771e..c28315d40c 100644 --- a/admin-ui/app/routes/Apps/Gluu/GluuDialog.tsx +++ b/admin-ui/app/routes/Apps/Gluu/GluuDialog.tsx @@ -12,11 +12,11 @@ import { import { useTranslation } from 'react-i18next' import applicationStyle from 'Routes/Apps/Gluu/styles/applicationstyle' import { ThemeContext } from 'Context/theme/themeContext' -import { useSelector } from 'react-redux' import useWebhookDialogAction from 'Utils/hooks/useWebhookDialogAction' import { WEBHOOK_READ } from 'Utils/PermChecker' import { useCedarling } from '@/cedarling' import customColors from '@/customColors' +import { useWebhookDialog } from '@/context/WebhookDialogContext' const GluuDialog = ({ row, handler, modal, onAccept, subject, name, feature }: any) => { const [active, setActive] = useState(false) @@ -24,13 +24,13 @@ const GluuDialog = ({ row, handler, modal, onAccept, subject, name, feature }: a const { hasCedarPermission } = useCedarling() const [userMessage, setUserMessage] = useState('') - const { loadingWebhooks, webhookModal } = useSelector((state: any) => state.webhookReducer) + const { state: webhookState } = useWebhookDialog() + const { loadingWebhooks, webhookModal } = webhookState const theme: any = useContext(ThemeContext) const selectedTheme = theme.state.theme const { webhookTriggerModal, onCloseModal } = useWebhookDialogAction({ feature, - modal, }) useEffect(() => { @@ -41,7 +41,6 @@ const GluuDialog = ({ row, handler, modal, onAccept, subject, name, feature }: a } }, [userMessage]) - // Reset user message when modal opens with a new item useEffect(() => { if (modal) { setUserMessage('') diff --git a/admin-ui/app/routes/Apps/Gluu/GluuWebhookErrorDialog.tsx b/admin-ui/app/routes/Apps/Gluu/GluuWebhookErrorDialog.tsx index 343d56a881..97fe9814eb 100644 --- a/admin-ui/app/routes/Apps/Gluu/GluuWebhookErrorDialog.tsx +++ b/admin-ui/app/routes/Apps/Gluu/GluuWebhookErrorDialog.tsx @@ -1,33 +1,35 @@ import { useContext } from 'react' import { useTranslation } from 'react-i18next' -import { useDispatch, useSelector } from 'react-redux' import { WEBHOOK_READ } from 'Utils/PermChecker' import { Button, Modal, ModalHeader, ModalBody, ModalFooter } from 'reactstrap' -import { - setShowErrorModal, - setWebhookTriggerErrors, - setTriggerWebhookResponse, -} from 'Plugins/admin/redux/features/WebhookSlice' import { Box } from '@mui/material' import applicationStyle from 'Routes/Apps/Gluu/styles/applicationstyle' import { ThemeContext } from 'Context/theme/themeContext' import { useCedarling } from '@/cedarling' import customColors from '@/customColors' +import { useWebhookDialog } from '@/context/WebhookDialogContext' +import type { WebhookTriggerError } from '@/context/WebhookDialogContext' + +interface ThemeState { + state: { + theme: string + } +} const GluuWebhookErrorDialog = () => { const { t } = useTranslation() - const dispatch = useDispatch() + const { state, actions } = useWebhookDialog() const { triggerWebhookMessage, webhookTriggerErrors, triggerWebhookInProgress, showErrorModal } = - useSelector((state: any) => state.webhookReducer) + state const { hasCedarPermission } = useCedarling() - const theme: any = useContext(ThemeContext) + const theme = useContext(ThemeContext) as ThemeState const selectedTheme = theme.state.theme const closeModal = () => { - dispatch(setShowErrorModal(!showErrorModal)) - dispatch(setWebhookTriggerErrors([])) - dispatch(setTriggerWebhookResponse('Something went wrong while triggering webhook.')) + actions.setShowErrorModal(!showErrorModal) + actions.setWebhookTriggerErrors([]) + actions.setTriggerWebhookResponse('Something went wrong while triggering webhook.') } return ( @@ -57,7 +59,7 @@ const GluuWebhookErrorDialog = () => { ) : null} {webhookTriggerErrors.length ? (
    - {webhookTriggerErrors.map((item: any) => ( + {webhookTriggerErrors.map((item: WebhookTriggerError) => (
  • { - const dispatch = useDispatch() - const { hasCedarPermission, authorize } = useCedarling() +interface UseWebhookDialogActionProps { + feature?: string +} +const useWebhookDialogAction = ({ feature }: UseWebhookDialogActionProps) => { + const { hasCedarPermission, authorize } = useCedarling() const { t } = useTranslation() - - const theme = useContext(ThemeContext) + const theme = useContext(ThemeContext) as ThemeContextType const selectedTheme = theme.state.theme - const { featureWebhooks, loadingWebhooks, webhookModal, triggerWebhookInProgress } = useSelector( - (state) => state.webhookReducer, - ) - - const enabledFeatureWebhooks = featureWebhooks.filter((item) => item.jansEnabled) + const { state, actions } = useWebhookDialog() + const { featureWebhooks, loadingWebhooks, webhookModal, triggerWebhookInProgress } = state - const onCloseModal = useCallback(() => { - dispatch(setWebhookModal(enabledFeatureWebhooks?.length > 0)) - dispatch(setWebhookTriggerErrors([])) - dispatch(setTriggerWebhookResponse('')) - dispatch(setFeatureToTrigger('')) - }, [dispatch, enabledFeatureWebhooks]) + const shouldFetchWebhooks = Boolean(feature) && hasCedarPermission(WEBHOOK_READ) + const { data: webhooksData, isLoading: isFetchingWebhooks } = useGetWebhooksByFeatureId( + feature ?? '', + { + query: { + enabled: shouldFetchWebhooks, + }, + }, + ) useEffect(() => { authorize([WEBHOOK_READ]).catch(console.error) }, [authorize]) - const { permissions: cedarPermissions } = useSelector( - (state: RootStateOfRedux) => state.cedarPermissions, - ) useEffect(() => { - if (hasCedarPermission(WEBHOOK_READ)) { - if (modal) { - if (feature) { - dispatch(getWebhooksByFeatureId(feature)) - } else { - dispatch(getWebhooksByFeatureIdResponse([])) - } - } + if (webhooksData) { + const webhooks = Array.isArray(webhooksData) ? webhooksData : [] + actions.setFeatureWebhooks(webhooks as WebhookEntry[]) + } else { + actions.setFeatureWebhooks([]) } - }, [cedarPermissions, modal]) + }, [webhooksData, actions]) useEffect(() => { - dispatch(setWebhookModal(enabledFeatureWebhooks?.length > 0)) - }, [featureWebhooks?.length]) + actions.setLoadingWebhooks(isFetchingWebhooks) + }, [isFetchingWebhooks, actions]) - const handleAcceptWebhookTrigger = () => { - dispatch(setWebhookModal(false)) - dispatch(setFeatureToTrigger(feature)) - } + const enabledFeatureWebhooks = useMemo( + () => featureWebhooks.filter((item) => item.jansEnabled), + [featureWebhooks], + ) - const webhookTriggerModal = ({ closeModal }) => { + const hasInitializedModal = useRef(false) + + useEffect(() => { + hasInitializedModal.current = false + }, [feature]) + + useEffect(() => { + const enabledCount = enabledFeatureWebhooks.length + if (featureWebhooks.length > 0 && !hasInitializedModal.current) { + actions.setWebhookModal(enabledCount > 0) + hasInitializedModal.current = true + } + }, [featureWebhooks, actions, enabledFeatureWebhooks]) + + const onCloseModal = useCallback(() => { + actions.setWebhookModal(false) + actions.setWebhookTriggerErrors([]) + actions.setTriggerWebhookResponse('') + actions.setFeatureToTrigger('') + }, [actions]) + + const handleAcceptWebhookTrigger = useCallback(() => { + actions.setWebhookModal(false) + actions.setFeatureToTrigger(feature || '') + }, [actions, feature]) + + const webhookTriggerModal = ({ closeModal }: { closeModal: () => void }) => { const closeWebhookTriggerModal = () => { closeModal() - dispatch(setFeatureToTrigger('')) + actions.setFeatureToTrigger('') } + return ( { toggle={() => { if (!loadingWebhooks) { closeModal() - dispatch(setFeatureToTrigger('')) + actions.setFeatureToTrigger('') } }} className="modal-outline-primary" @@ -100,7 +114,6 @@ const useWebhookDialogAction = ({ feature, modal }) => { onClick={closeWebhookTriggerModal} onKeyDown={() => {}} style={{ color: customColors.logo }} - code className="fa fa-2x fa-info fa-fw modal-icon mb-3" role="img" aria-hidden="true" @@ -112,7 +125,7 @@ const useWebhookDialogAction = ({ feature, modal }) => { {!loadingWebhooks ? ( <> - +

    {t('messages.webhook_dialog_dec')}

    {enabledFeatureWebhooks?.length ? ( @@ -177,7 +190,3 @@ const useWebhookDialogAction = ({ feature, modal }) => { } export default useWebhookDialogAction -useWebhookDialogAction.propTypes = { - feature: PropTypes.string, - modal: PropTypes.bool, -} diff --git a/admin-ui/plugins/admin/components/Webhook/ShortcodePopover.js b/admin-ui/plugins/admin/components/Webhook/ShortcodePopover.tsx similarity index 76% rename from admin-ui/plugins/admin/components/Webhook/ShortcodePopover.js rename to admin-ui/plugins/admin/components/Webhook/ShortcodePopover.tsx index 923a630a86..7c49ad2a18 100644 --- a/admin-ui/plugins/admin/components/Webhook/ShortcodePopover.js +++ b/admin-ui/plugins/admin/components/Webhook/ShortcodePopover.tsx @@ -1,4 +1,4 @@ -import * as React from 'react' +import React from 'react' import Popover from '@mui/material/Popover' import Typography from '@mui/material/Typography' import Button from '@mui/material/Button' @@ -8,21 +8,22 @@ import { List, ListItemButton, ListItemText } from '@mui/material' import { useTranslation } from 'react-i18next' import { HelpOutline } from '@mui/icons-material' import { Tooltip as ReactTooltip } from 'react-tooltip' -import PropTypes from 'prop-types' import applicationstyle from 'Routes/Apps/Gluu/styles/applicationstyle' -import ShortCodesIcon from 'Components/SVG/menu/ShortCodesIcon' +import ShortCodesIcon from '@/components/SVG/menu/ShortCodesIcon' +import type { ShortcodePopoverProps, ShortcodeField } from './types' export default function ShortcodePopover({ codes, buttonWrapperStyles = {}, handleSelectShortcode, -}) { - const [anchorEl, setAnchorEl] = React.useState(null) - const handleClick = (event) => { +}: ShortcodePopoverProps): JSX.Element { + const [anchorEl, setAnchorEl] = React.useState(null) + + const handleClick = (event: React.MouseEvent): void => { setAnchorEl(event.currentTarget) } - const handleClose = () => { + const handleClose = (): void => { setAnchorEl(null) } @@ -30,9 +31,14 @@ export default function ShortcodePopover({ const id = open ? 'simple-popover' : undefined return ( -
    +
    {codes?.length ? ( - {codes?.map((code, index) => { + {codes.map((code, index) => { return ( handleSelectShortcode(code.key)} + onClick={() => handleSelectShortcode(code.key, code.label)} component="button" sx={{ width: '100%' }} > @@ -74,7 +79,7 @@ export default function ShortcodePopover({ } /> - {index + 1 !== codes?.length && } + {index + 1 !== codes.length && } ) })} @@ -88,7 +93,13 @@ export default function ShortcodePopover({ ) } -const Label = ({ doc_category, doc_entry, label }) => { +interface LabelProps { + doc_category?: string + doc_entry: string + label: string +} + +const Label = ({ doc_category, doc_entry, label }: LabelProps): JSX.Element => { const { t, i18n } = useTranslation() return ( @@ -97,7 +108,6 @@ const Label = ({ doc_category, doc_entry, 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, -} 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..1876f4cfa7 --- /dev/null +++ b/admin-ui/plugins/admin/components/Webhook/WebhookAddPage.tsx @@ -0,0 +1,169 @@ +import React, { useRef } 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 { useNavigate } from 'react-router' +import { useSelector, useDispatch } from 'react-redux' +import { updateToast } from 'Redux/features/toastSlice' +import { usePostWebhook, useGetAllFeatures, getGetAllWebhooksQueryKey } from 'JansConfigApi' +import type { WebhookEntry, WebhookFormValues } from './types' +import { postUserAction } from 'Redux/api/backend-api' +import { addAdditionalData } from 'Utils/TokenController' +import { CREATE } from '@/audit/UserActionType' +import { useQueryClient } from '@tanstack/react-query' +import { useTranslation } from 'react-i18next' + +interface RootState { + authReducer: { + config: { clientId: string } + location: { IPv4: string } + userinfo: { name: string; inum: string } + token: { access_token: string } + } +} + +const ALLOWED_HTTP_METHODS = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'] as const + +const getErrorMessage = ( + error: Error, + defaultMessage: string, + t: (key: string) => string, +): string => { + const errorWithResponse = error as Error & { + response?: { body?: { responseMessage?: string }; status?: number } + } + + const status = errorWithResponse.response?.status + const responseMessage = errorWithResponse.response?.body?.responseMessage + + if (status === 400) { + return responseMessage || t('messages.bad_request') + } else if (status === 401) { + return t('messages.unauthorized') + } else if (status === 403) { + return t('messages.forbidden') + } else if (status === 404) { + return t('messages.not_found') + } else if (status === 409) { + return responseMessage || t('messages.conflict') + } else if (status && status >= 500) { + return t('messages.server_error') + } + + return responseMessage || error.message || defaultMessage +} + +const WebhookAddPage: React.FC = () => { + const navigate = useNavigate() + const dispatch = useDispatch() + const queryClient = useQueryClient() + const { t } = useTranslation() + const userMessageRef = useRef('') + + const authData = useSelector((state: RootState) => ({ + clientId: state.authReducer.config.clientId, + ipAddress: state.authReducer.location.IPv4, + userinfo: state.authReducer.userinfo, + token: state.authReducer.token.access_token, + })) + + const { data: featuresData, isLoading: loadingFeatures } = useGetAllFeatures() + + const features = Array.isArray(featuresData) ? featuresData : [] + + const createWebhookMutation = usePostWebhook({ + mutation: { + onSuccess: async (data, variables) => { + const audit = { + headers: { + Authorization: `Bearer ${authData.token}`, + }, + client_id: authData.clientId, + ip_address: authData.ipAddress, + status: 'success', + performedBy: { + user_inum: authData.userinfo.inum, + userId: authData.userinfo.name, + }, + } + + const payload = { + action: { + action_message: userMessageRef.current, + action_data: variables.data as unknown as Record, + }, + } + + addAdditionalData(audit, CREATE, 'webhook', payload) + + try { + await postUserAction(audit) + } catch (auditError) { + console.error('Audit logging failed:', auditError) + } + + dispatch(updateToast(true, 'success')) + + await queryClient.invalidateQueries({ queryKey: getGetAllWebhooksQueryKey() }) + + navigate('/adm/webhook') + }, + onError: (error: Error) => { + const errorMessage = getErrorMessage(error, 'Failed to create webhook', t) + dispatch(updateToast(true, 'error', errorMessage)) + }, + }, + }) + + const isValidHttpMethod = (method: string): method is (typeof ALLOWED_HTTP_METHODS)[number] => { + return ALLOWED_HTTP_METHODS.includes(method as (typeof ALLOWED_HTTP_METHODS)[number]) + } + + const handleSubmit = (values: WebhookFormValues, userMessage: string): void => { + userMessageRef.current = userMessage + + if (!isValidHttpMethod(values.httpMethod)) { + dispatch(updateToast(true, 'error', 'Invalid HTTP method')) + return + } + + const webhookPayload: WebhookEntry = { + displayName: values.displayName, + url: values.url, + httpMethod: values.httpMethod, + jansEnabled: values.jansEnabled, + description: values.description, + httpHeaders: values.httpHeaders, + auiFeatureIds: values.auiFeatureIds, + } + + if (values.httpRequestBody && values.httpMethod !== 'GET' && values.httpMethod !== 'DELETE') { + try { + webhookPayload.httpRequestBody = JSON.parse(values.httpRequestBody) + } catch (error) { + dispatch(updateToast(true, 'error', 'Invalid JSON in request body')) + return + } + } + + createWebhookMutation.mutate({ + data: webhookPayload, + }) + } + + return ( + + + + + + ) +} + +export default 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..9bed12a9cb --- /dev/null +++ b/admin-ui/plugins/admin/components/Webhook/WebhookEditPage.tsx @@ -0,0 +1,255 @@ +import React, { useEffect, useRef, useState, useMemo } 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 { useNavigate, useParams, useLocation } from 'react-router' +import { useSelector, useDispatch } from 'react-redux' +import { updateToast } from 'Redux/features/toastSlice' +import { + usePutWebhook, + useGetAllFeatures, + useGetFeaturesByWebhookId, + useGetAllWebhooks, + getGetAllWebhooksQueryKey, +} from 'JansConfigApi' +import type { WebhookEntry, WebhookFormValues } from './types' +import { postUserAction } from 'Redux/api/backend-api' +import { addAdditionalData } from 'Utils/TokenController' +import { UPDATE } from '@/audit/UserActionType' +import { useQueryClient } from '@tanstack/react-query' +import { useTranslation } from 'react-i18next' + +interface RootState { + authReducer: { + config: { clientId: string } + location: { IPv4: string } + userinfo: { name: string; inum: string } + token: { access_token: string } + } +} + +interface LocationState { + webhook?: WebhookEntry +} + +const ALLOWED_HTTP_METHODS = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'] as const + +const getErrorMessage = ( + error: Error, + defaultMessage: string, + t: (key: string) => string, +): string => { + const errorWithResponse = error as Error & { + response?: { body?: { responseMessage?: string }; status?: number } + } + + const status = errorWithResponse.response?.status + const responseMessage = errorWithResponse.response?.body?.responseMessage + + if (status === 400) { + return responseMessage || t('messages.bad_request') + } else if (status === 401) { + return t('messages.unauthorized') + } else if (status === 403) { + return t('messages.forbidden') + } else if (status === 404) { + return t('messages.not_found') + } else if (status === 409) { + return responseMessage || t('messages.conflict') + } else if (status && status >= 500) { + return t('messages.server_error') + } + + return responseMessage || error.message || defaultMessage +} + +const WebhookEditPage: React.FC = () => { + const { id } = useParams<{ id: string }>() + const navigate = useNavigate() + const dispatch = useDispatch() + const queryClient = useQueryClient() + const { t } = useTranslation() + const location = useLocation() + const locationState = location.state as LocationState + const userMessageRef = useRef('') + const [isInitialized, setIsInitialized] = useState(false) + + const authData = useSelector((state: RootState) => ({ + clientId: state.authReducer.config.clientId, + ipAddress: state.authReducer.location.IPv4, + userinfo: state.authReducer.userinfo, + token: state.authReducer.token.access_token, + })) + + const { data: allWebhooksData, isLoading: loadingWebhook } = useGetAllWebhooks({ + query: { + enabled: !!id && !locationState?.webhook, + }, + }) + + const webhooksList = allWebhooksData?.entries || [] + const fetchedWebhook = webhooksList.find((w) => w.inum === id) + + const { data: featuresData, isLoading: loadingFeatures } = useGetAllFeatures() + + const { data: webhookFeaturesData, isLoading: loadingWebhookFeatures } = + useGetFeaturesByWebhookId(id || '', { + query: { + enabled: !!id, + }, + }) + + const features = Array.isArray(featuresData) ? featuresData : [] + + const webhook = useMemo(() => { + const baseWebhook = locationState?.webhook || fetchedWebhook + + if (!baseWebhook) { + return undefined + } + + if (loadingWebhookFeatures) { + return undefined + } + + if (webhookFeaturesData) { + return { + ...baseWebhook, + auiFeatureIds: Array.isArray(webhookFeaturesData) + ? webhookFeaturesData.map((f) => f.auiFeatureId || '') + : [], + } + } + + return baseWebhook + }, [locationState?.webhook, fetchedWebhook, webhookFeaturesData, loadingWebhookFeatures]) + + const updateWebhookMutation = usePutWebhook({ + mutation: { + onSuccess: async (data, variables) => { + const audit = { + headers: { + Authorization: `Bearer ${authData.token}`, + }, + client_id: authData.clientId, + ip_address: authData.ipAddress, + status: 'success', + performedBy: { + user_inum: authData.userinfo.inum, + userId: authData.userinfo.name, + }, + } + + const payload = { + action: { + action_message: userMessageRef.current, + action_data: variables.data as unknown as Record, + }, + } + + addAdditionalData(audit, UPDATE, 'webhook', payload) + + try { + await postUserAction(audit) + } catch (auditError) { + console.error('Audit logging failed:', auditError) + } + + dispatch(updateToast(true, 'success')) + + await queryClient.invalidateQueries({ queryKey: getGetAllWebhooksQueryKey() }) + + navigate('/adm/webhook') + }, + onError: (error: Error) => { + const errorMessage = getErrorMessage(error, 'Failed to update webhook', t) + dispatch(updateToast(true, 'error', errorMessage)) + }, + }, + }) + + const isValidHttpMethod = (method: string): method is (typeof ALLOWED_HTTP_METHODS)[number] => { + return ALLOWED_HTTP_METHODS.includes(method as (typeof ALLOWED_HTTP_METHODS)[number]) + } + + const handleSubmit = (values: WebhookFormValues, userMessage: string): void => { + userMessageRef.current = userMessage + + if (!webhook) { + dispatch(updateToast(true, 'error', 'Webhook data not found')) + return + } + + if (!isValidHttpMethod(values.httpMethod)) { + dispatch(updateToast(true, 'error', 'Invalid HTTP method')) + return + } + + const webhookPayload: WebhookEntry = { + inum: webhook.inum, + dn: webhook.dn, + baseDn: webhook.baseDn, + displayName: values.displayName, + url: values.url, + httpMethod: values.httpMethod, + jansEnabled: values.jansEnabled, + description: values.description, + httpHeaders: values.httpHeaders, + auiFeatureIds: values.auiFeatureIds, + } + + if (values.httpRequestBody && values.httpMethod !== 'GET' && values.httpMethod !== 'DELETE') { + try { + webhookPayload.httpRequestBody = JSON.parse(values.httpRequestBody) + } catch (error) { + dispatch(updateToast(true, 'error', 'Invalid JSON in request body')) + return + } + } + + updateWebhookMutation.mutate({ + data: webhookPayload, + }) + } + + useEffect(() => { + if (loadingWebhook || loadingWebhookFeatures) return + + setIsInitialized(true) + + if (!webhook) { + dispatch( + updateToast(true, 'error', 'Webhook data not found. Please select a webhook to edit.'), + ) + navigate('/adm/webhook') + } + }, [webhook, loadingWebhook, loadingWebhookFeatures, navigate, dispatch]) + + if (!isInitialized || !webhook) { + return + } + + return ( + + + + + + ) +} + +export default WebhookEditPage diff --git a/admin-ui/plugins/admin/components/Webhook/WebhookForm.js b/admin-ui/plugins/admin/components/Webhook/WebhookForm.tsx similarity index 60% rename from admin-ui/plugins/admin/components/Webhook/WebhookForm.js rename to admin-ui/plugins/admin/components/Webhook/WebhookForm.tsx index d5af0ae017..5f736c94be 100644 --- a/admin-ui/plugins/admin/components/Webhook/WebhookForm.js +++ b/admin-ui/plugins/admin/components/Webhook/WebhookForm.tsx @@ -1,4 +1,4 @@ -import React, { Suspense, lazy, useCallback, useState, useEffect } from 'react' +import React, { Suspense, lazy, useCallback, useState, useEffect, useRef, useMemo } from 'react' import { Col, Form, Row, FormGroup } from 'Components' import GluuInputRow from 'Routes/Apps/Gluu/GluuInputRow' import GluuSelectRow from 'Routes/Apps/Gluu/GluuSelectRow' @@ -6,18 +6,9 @@ import { useFormik } from 'formik' import GluuCommitFooter from 'Routes/Apps/Gluu/GluuCommitFooter' import GluuCommitDialog from 'Routes/Apps/Gluu/GluuCommitDialog' import { useTranslation } from 'react-i18next' -import { useDispatch, useSelector } from 'react-redux' import * as Yup from 'yup' 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 { useNavigate, useParams } from 'react-router' import GluuLabel from 'Routes/Apps/Gluu/GluuLabel' import Toggle from 'react-toggle' import { WEBHOOK } from 'Utils/ApiResources' @@ -26,28 +17,68 @@ import GluuProperties from 'Routes/Apps/Gluu/GluuProperties' import ShortcodePopover from './ShortcodePopover' import shortCodes from 'Plugins/admin/helper/shortCodes.json' import { isValid } from './WebhookURLChecker' +import type { + WebhookFormValues, + WebhookFormProps, + CursorPosition, + FeatureShortcodes, + AuiFeature, + KeyValuePair, + WebhookEntry, +} from './types' -const WebhookForm = () => { - const { id } = useParams() - const userAction = {} - const { selectedWebhook, features, webhookFeatures, loadingFeatures } = useSelector( - (state) => state.webhookReducer, - ) - const [selectedFeatures, setSelectedFeatures] = useState(webhookFeatures || {}) - const [cursorPosition, setCursorPosition] = useState({ +const JSON_INDENT_SPACES = 2 + +const WebhookForm: React.FC = ({ + item, + features, + loadingFeatures, + onSubmit, + isEdit = false, +}) => { + const [selectedFeatures, setSelectedFeatures] = useState([]) + const [cursorPosition, setCursorPosition] = useState({ url: 0, httpRequestBody: 0, }) const { t } = useTranslation() - const navigate = useNavigate() - const saveOperationFlag = useSelector((state) => state.webhookReducer.saveOperationFlag) - const errorInSaveOperationFlag = useSelector( - (state) => state.webhookReducer.errorInSaveOperationFlag, - ) - const dispatch = useDispatch() const [modal, setModal] = useState(false) - const validatePayload = (values) => { + const urlFocusTimeoutRef = useRef() + const editorCursorTimeoutRef = useRef() + + useEffect(() => { + return () => { + if (urlFocusTimeoutRef.current) clearTimeout(urlFocusTimeoutRef.current) + if (editorCursorTimeoutRef.current) clearTimeout(editorCursorTimeoutRef.current) + } + }, []) + + useEffect(() => { + if (loadingFeatures) return + if (!features.length) return + + const itemFeatureIds = item?.auiFeatureIds || [] + const selected = itemFeatureIds.length + ? features.filter((feature) => itemFeatureIds.includes(feature.auiFeatureId ?? '')) + : [] + + setSelectedFeatures(selected) + + if (itemFeatureIds.length) { + const missingCount = itemFeatureIds.length - selected.length + if (missingCount > 0) { + console.warn(`${missingCount} feature(s) not found in available features`) + } + } + }, [loadingFeatures, features, item]) + + useEffect(() => { + const featureIds = selectedFeatures.map((feature) => feature.auiFeatureId || '') + formik.setFieldValue('auiFeatureIds', featureIds, false) + }, [selectedFeatures]) + + const validatePayload = (values: WebhookFormValues): boolean => { let faulty = false if (values.httpRequestBody) { try { @@ -65,21 +96,31 @@ const WebhookForm = () => { return faulty } - const getHttpHeaders = () => { - return selectedWebhook?.httpHeaders || [] + const getHttpHeaders = (): KeyValuePair[] => { + return item?.httpHeaders || [] } - const formik = useFormik({ + const initialRequestBody = useMemo(() => { + return item?.httpRequestBody + ? JSON.stringify(item.httpRequestBody, null, JSON_INDENT_SPACES) + : '' + }, [item?.httpRequestBody]) + + const requiresRequestBody = (httpMethod: string): boolean => { + return httpMethod !== 'GET' && httpMethod !== 'DELETE' + } + + const formik = useFormik({ + enableReinitialize: true, initialValues: { - httpRequestBody: selectedWebhook?.httpRequestBody - ? JSON.stringify(selectedWebhook.httpRequestBody, null, 2) - : '', - httpMethod: selectedWebhook?.httpMethod || '', - url: selectedWebhook?.url || '', - displayName: selectedWebhook?.displayName || '', + httpRequestBody: initialRequestBody, + httpMethod: item?.httpMethod || '', + url: item?.url || '', + displayName: item?.displayName || '', httpHeaders: getHttpHeaders(), - jansEnabled: selectedWebhook?.jansEnabled || false, - description: selectedWebhook?.description || '', + jansEnabled: item?.jansEnabled || false, + description: item?.description || '', + auiFeatureIds: item?.auiFeatureIds || [], }, onSubmit: (values) => { const faulty = validatePayload(values) @@ -94,88 +135,64 @@ const WebhookForm = () => { .required(t('messages.display_name_error')) .matches(/^\S*$/, `${t('fields.webhook_name')} ${t('messages.no_spaces')}`), url: Yup.string().required(t('messages.url_error')), + auiFeatureIds: Yup.array() + .min(1, t('messages.aui_feature_ids_error')) + .required(t('messages.aui_feature_ids_error')), httpRequestBody: Yup.string().when('httpMethod', { - is: (value) => { - return !(value === 'GET' || value === 'DELETE') - }, + is: requiresRequestBody, then: () => Yup.string().required(t('messages.request_body_error')), }), }), }) - const toggle = () => { - setModal(!modal) - } + const toggle = useCallback(() => { + setModal((prev) => !prev) + }, []) const submitForm = useCallback( - (userMessage) => { - toggle() + (userMessage: string): void => { + setModal(false) - const httpHeaders = formik.values.httpHeaders?.map((header) => { - return { - key: header.key || header.source, - value: header.value || header.destination, - } - }) + const validHeaders = (formik.values.httpHeaders || []) + .map((header) => ({ + key: header.key?.trim() || '', + value: header.value?.trim() || '', + })) + .filter((header) => header.key && header.value) - const payload = { + const payload: WebhookFormValues = { ...formik.values, - httpHeaders: httpHeaders || [], - auiFeatureIds: selectedFeatures?.map((feature) => feature.auiFeatureId) || [], + httpHeaders: validHeaders, + auiFeatureIds: selectedFeatures?.map((feature) => feature.auiFeatureId || '') || [], } - if (formik.values.httpMethod !== 'GET' && formik.values.httpMethod !== 'DELETE') { - payload['httpRequestBody'] = JSON.parse(formik.values.httpRequestBody) - } else { + if (!requiresRequestBody(formik.values.httpMethod)) { 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 })) - } + onSubmit(payload, userMessage) }, - [formik], + [formik, selectedFeatures, onSubmit], ) - useEffect(() => { - if (!features?.length) dispatch(getFeatures()) // cache features response using redux store - if (saveOperationFlag && !errorInSaveOperationFlag) navigate('/adm/webhook') - - return function cleanup() { - dispatch(resetFlags()) - } - }, [saveOperationFlag, errorInSaveOperationFlag]) - - function getPropertiesConfig(entry, key) { - if (entry[key] && Array.isArray(entry[key])) { - return entry[key].map((e) => ({ - source: e.key, - destination: e.value, - })) - } else { - return [] - } - } + const featureShortcodes = useMemo(() => { + const firstFeature = selectedFeatures?.[0] + if (!firstFeature?.auiFeatureId) return [] - const featureShortcodes = selectedFeatures?.[0]?.auiFeatureId - ? shortCodes?.[selectedFeatures?.[0]?.auiFeatureId]?.fields || [] - : [] + const typedShortCodes = shortCodes as FeatureShortcodes + return typedShortCodes[firstFeature.auiFeatureId]?.fields || [] + }, [selectedFeatures]) - const handleSelectShortcode = (code, name, withString = false) => { + const handleSelectShortcode = ( + code: string, + name: keyof Pick, + withString = false, + ): void => { const _code = withString ? '"${' + `${code}` + '}"' : '${' + `${code}` + '}' const currentPosition = cursorPosition[name] let value = formik.values[name] || '' if (currentPosition >= 0 && value) { - const str = formik.values[name] + const str = formik.values[name] || '' value = str.slice(0, currentPosition) + _code + str.slice(currentPosition) } else if (value) { value += _code @@ -194,11 +211,11 @@ const WebhookForm = () => { <>
    - {id ? ( + {isEdit ? ( { name="displayName" doc_category={WEBHOOK} errorMessage={formik.errors.displayName} - showError={formik.errors.displayName && formik.touched.displayName} + showError={!!(formik.errors.displayName && formik.touched.displayName)} /> { - setSelectedFeatures(options || []) + value={selectedFeatures as unknown as Record[]} + options={features as unknown as Record[]} + onChange={(options: unknown) => { + const auiFeatures = options as AuiFeature[] + setSelectedFeatures( + Array.isArray(auiFeatures) ? auiFeatures : auiFeatures ? [auiFeatures] : [], + ) + formik.setFieldTouched('auiFeatureIds', true) }} lsize={4} doc_category={WEBHOOK} @@ -237,6 +258,9 @@ const WebhookForm = () => { isLoading={loadingFeatures} multiple={false} hideHelperMessage + required + errorMessage={formik.errors.auiFeatureIds} + showError={!!(formik.errors.auiFeatureIds && formik.touched.auiFeatureIds)} /> { rsize={8} required doc_category={WEBHOOK} - handleChange={(event) => { - const currentPosition = event.target.selectionStart + handleChange={(event: React.ChangeEvent) => { + const currentPosition = event.target.selectionStart || 0 setCursorPosition((prevState) => ({ ...prevState, url: currentPosition, })) }} - onFocus={(event) => { - setTimeout(() => { - const currentPosition = event.target.selectionStart + onFocus={(event: React.FocusEvent) => { + if (urlFocusTimeoutRef.current) clearTimeout(urlFocusTimeoutRef.current) + + urlFocusTimeoutRef.current = setTimeout(() => { + const currentPosition = event.target.selectionStart || 0 setCursorPosition((prevState) => ({ ...prevState, url: currentPosition, @@ -265,7 +291,7 @@ const WebhookForm = () => { doc_entry="url" name="url" errorMessage={formik.errors.url} - showError={formik.errors.url && formik.touched.url} + showError={!!(formik.errors.url && formik.touched.url)} shortcode={ { rsize={8} required errorMessage={formik.errors.httpMethod} - showError={formik.errors.httpMethod && formik.touched.httpMethod} + showError={!!(formik.errors.httpMethod && formik.touched.httpMethod)} name="httpMethod" /> @@ -317,14 +343,11 @@ const WebhookForm = () => { compName="httpHeaders" isInputLables={true} formik={formik} - multiProperties - inputSm={10} - destinationPlaceholder={'placeholders.enter_key_value'} - sourcePlaceholder={'placeholders.enter_header_key'} - options={getPropertiesConfig(selectedWebhook, 'httpHeaders')} - isKeys={false} + valuePlaceholder={'placeholders.enter_header_value'} + keyPlaceholder={'placeholders.enter_header_key'} + options={formik.values.httpHeaders || []} buttonText="actions.add_header" - showError={formik.errors.httpHeaders && formik.touched.httpHeaders} + showError={!!(formik.errors.httpHeaders && formik.touched.httpHeaders)} errorMessage={formik.errors.httpHeaders} /> @@ -341,13 +364,23 @@ const WebhookForm = () => { lsize={4} required rsize={8} - onCursorChange={(value) => { - setTimeout(() => { + onCursorChange={(value: { + cursor: { + row: number + column: number + document?: { $lines: string[] } + } + }) => { + if (editorCursorTimeoutRef.current) clearTimeout(editorCursorTimeoutRef.current) + + editorCursorTimeoutRef.current = 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 + if (lines) { + for (let i = 0; i < cursorPos.row; i++) { + index += lines[i].length + 1 + } } index += cursorPos.column setCursorPosition((prevState) => ({ @@ -362,7 +395,7 @@ const WebhookForm = () => { formik={formik} value={formik.values?.httpRequestBody} errorMessage={formik.errors.httpRequestBody} - showError={formik.errors.httpRequestBody && formik.touched.httpRequestBody} + showError={!!(formik.errors.httpRequestBody && formik.touched.httpRequestBody)} placeholder="" shortcode={ { name="jansEnabled" onChange={formik.handleChange} defaultChecked={formik.values.jansEnabled} + aria-label={t('options.enabled')} /> 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 71531bac98..0000000000 --- a/admin-ui/plugins/admin/components/Webhook/WebhookListPage.js +++ /dev/null @@ -1,266 +0,0 @@ -import React, { useEffect, useState, useContext, useCallback } 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 { WEBHOOK_WRITE, WEBHOOK_READ, WEBHOOK_DELETE, 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 { useNavigate } from 'react-router' -import { - getWebhook, - deleteWebhook, - setSelectedWebhook, -} from 'Plugins/admin/redux/features/WebhookSlice' - -const WebhookListPage = () => { - const dispatch = useDispatch() - const navigate = useNavigate() - const { hasCedarPermission, authorize } = useCedarling() - 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) - - // Initialize Cedar permissions - useEffect(() => { - const initPermissions = async () => { - const permissions = [WEBHOOK_READ, WEBHOOK_WRITE, WEBHOOK_DELETE] - for (const permission of permissions) { - await authorize([permission]) - } - } - initPermissions() - options['limit'] = 10 - dispatch(getWebhook({ action: options })) - }, []) - - // Build actions only when permissions change - useEffect(() => { - const actions = [] - - const canRead = hasCedarPermission(WEBHOOK_READ) - const canWrite = hasCedarPermission(WEBHOOK_WRITE) - const canDelete = hasCedarPermission(WEBHOOK_DELETE) - - if (canRead) { - 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 (canWrite) { - 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: !canWrite, - })) - } - - if (canDelete) { - 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]) - - 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({})) - navigate('/adm/webhook/add') - }, [dispatch, navigate]) - - const navigateToEditPage = useCallback( - (data) => { - dispatch(setSelectedWebhook(data)) - navigate(`/adm/webhook/edit/${data.inum}`) - }, - [dispatch, navigate], - ) - - 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..7780fe6529 --- /dev/null +++ b/admin-ui/plugins/admin/components/Webhook/WebhookListPage.tsx @@ -0,0 +1,497 @@ +import React, { useEffect, useState, useContext, useCallback, useMemo, useRef } from 'react' +import MaterialTable from '@material-table/core' +import { + DeleteOutlined, + Search as SearchIcon, + Refresh as RefreshIcon, + SwapVert as SwapVertIcon, + Clear as ClearIcon, +} from '@mui/icons-material' +import { + Paper, + TablePagination, + Box, + TextField, + IconButton, + InputAdornment, + MenuItem, + Button, +} 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 { WEBHOOK_WRITE, WEBHOOK_READ, WEBHOOK_DELETE, buildPayload } from 'Utils/PermChecker' +import GluuCommitDialog from 'Routes/Apps/Gluu/GluuCommitDialog' +import GluuLoader from 'Routes/Apps/Gluu/GluuLoader' +import { useSelector, useDispatch } from 'react-redux' +import { useTranslation } from 'react-i18next' +import { ThemeContext } from 'Context/theme/themeContext' +import getThemeColor from 'Context/theme/config' +import SetTitle from 'Utils/SetTitle' +import { useNavigate } from 'react-router' +import { useGetAllWebhooks, useDeleteWebhookByInum, getGetAllWebhooksQueryKey } from 'JansConfigApi' +import type { WebhookEntry, TableAction, WebhookTableColumn, PagedResult } from './types' +import { postUserAction } from 'Redux/api/backend-api' +import { addAdditionalData } from 'Utils/TokenController' +import { DELETION } from '@/audit/UserActionType' +import { useQueryClient } from '@tanstack/react-query' +import { updateToast } from 'Redux/features/toastSlice' + +interface RootState { + authReducer: { + config: { clientId: string } + location: { IPv4: string } + userinfo: { name: string; inum: string } + token: { access_token: string } + } + cedarPermissions: { + permissions: Record + } +} + +interface ThemeState { + state: { + theme: string + } +} + +const DEFAULT_PAGE_SIZE = 10 +const MAX_PAGE_SIZE = 100 +const MIN_PAGE_SIZE = 1 + +const getErrorMessage = ( + error: Error, + defaultMessage: string, + t: (key: string) => string, +): string => { + const errorWithResponse = error as Error & { + response?: { body?: { responseMessage?: string }; status?: number } + } + + const status = errorWithResponse.response?.status + const responseMessage = errorWithResponse.response?.body?.responseMessage + + if (status === 400) { + return responseMessage || t('messages.bad_request') + } else if (status === 401) { + return t('messages.unauthorized') + } else if (status === 403) { + return t('messages.forbidden') + } else if (status === 404) { + return t('messages.not_found') + } else if (status === 409) { + return responseMessage || t('messages.conflict') + } else if (status && status >= 500) { + return t('messages.server_error') + } + + return responseMessage || error.message || defaultMessage +} + +const WebhookListPage: React.FC = () => { + const dispatch = useDispatch() + const navigate = useNavigate() + const queryClient = useQueryClient() + const { hasCedarPermission, authorize } = useCedarling() + const { t } = useTranslation() + const [pageNumber, setPageNumber] = useState(0) + const { permissions: cedarPermissions } = useSelector( + (state: RootState) => state.cedarPermissions, + ) + + const PaperContainer = useCallback( + (props: React.ComponentProps) => , + [], + ) + + const theme = useContext(ThemeContext) as ThemeState + const themeColors = getThemeColor(theme.state.theme) + const bgThemeColor = { background: themeColors.background } + SetTitle(t('titles.webhooks')) + + const authData = useSelector((state: RootState) => ({ + clientId: state.authReducer.config.clientId, + ipAddress: state.authReducer.location.IPv4, + userinfo: state.authReducer.userinfo, + token: state.authReducer.token.access_token, + })) + + const [modal, setModal] = useState(false) + const [deleteData, setDeleteData] = useState(null) + const toggle = (): void => setModal((prev) => !prev) + const userMessageRef = useRef('') + + const [limit, setLimit] = useState(DEFAULT_PAGE_SIZE) + const [pattern, setPattern] = useState(null) + const [sortBy, setSortBy] = useState(undefined) + const [sortOrder, setSortOrder] = useState<'ascending' | 'descending'>('ascending') + + const { + data: webhooksResponse, + isLoading, + refetch, + } = useGetAllWebhooks({ + limit, + pattern: pattern || undefined, + startIndex: pageNumber * limit, + sortBy: sortBy || undefined, + sortOrder: sortBy ? sortOrder : undefined, + }) + + const webhooksData = webhooksResponse as PagedResult | undefined + const webhooks = (webhooksData?.entries as unknown as WebhookEntry[]) || [] + const totalItems = webhooksData?.totalEntriesCount || 0 + + const deleteWebhookMutation = useDeleteWebhookByInum({ + mutation: { + onSuccess: async (data, variables) => { + const audit = { + headers: { + Authorization: `Bearer ${authData.token}`, + }, + client_id: authData.clientId, + ip_address: authData.ipAddress, + status: 'success', + performedBy: { + user_inum: authData.userinfo.inum, + userId: authData.userinfo.name, + }, + } + + const payload = { + action: { + action_message: userMessageRef.current, + action_data: { inum: variables.webhookId }, + }, + } + + addAdditionalData(audit, DELETION, 'webhook', payload) + + try { + await postUserAction(audit) + } catch (auditError) { + console.error('Audit logging failed:', auditError) + } + + dispatch(updateToast(true, 'success')) + + await queryClient.invalidateQueries({ queryKey: getGetAllWebhooksQueryKey() }) + }, + onError: (error: Error) => { + const errorMessage = getErrorMessage(error, 'Failed to delete webhook', t) + dispatch(updateToast(true, 'error', errorMessage)) + }, + }, + }) + + const submitForm = (userMessage: string): void => { + if (!deleteData?.inum) return + + userMessageRef.current = userMessage + + toggle() + deleteWebhookMutation.mutate({ webhookId: deleteData.inum }) + } + + useEffect(() => { + const initPermissions = async (): Promise => { + const permissions = [WEBHOOK_READ, WEBHOOK_WRITE, WEBHOOK_DELETE] + for (const permission of permissions) { + await authorize([permission]) + } + } + initPermissions() + }, [authorize]) + + const handlePatternChange = useCallback((event: React.ChangeEvent) => { + setPattern(event.target.value) + }, []) + + const handlePatternKeyDown = useCallback( + (event: React.KeyboardEvent) => { + if (event.key === 'Enter') { + refetch() + } + }, + [refetch], + ) + + const handleSortByChange = useCallback((event: React.ChangeEvent) => { + const value = event.target.value + setSortBy(value === 'none' ? undefined : value) + setPageNumber(0) + }, []) + + const handleSortOrderToggle = useCallback(() => { + setSortOrder((prevOrder) => (prevOrder === 'ascending' ? 'descending' : 'ascending')) + }, []) + + const handleClearFilters = useCallback(() => { + setPattern(null) + setSortBy(undefined) + setSortOrder('ascending') + setPageNumber(0) + }, []) + + const onPageChangeClick = (page: number): void => { + const maxPage = Math.max(0, Math.ceil(totalItems / limit) - 1) + const validPage = Math.min(Math.max(0, page), maxPage) + setPageNumber(validPage) + } + + const onRowCountChangeClick = (count: number): void => { + const validCount = Math.max(MIN_PAGE_SIZE, Math.min(count, MAX_PAGE_SIZE)) + setPageNumber(0) + setLimit(validCount) + } + + const PaginationWrapper = useCallback( + () => ( + { + onPageChangeClick(page) + }} + rowsPerPage={limit} + onRowsPerPageChange={(event) => onRowCountChangeClick(Number(event.target.value))} + /> + ), + [pageNumber, totalItems, limit], + ) + + const navigateToAddPage = useCallback(() => { + navigate('/adm/webhook/add') + }, [navigate]) + + const navigateToEditPage = useCallback( + (webhook: WebhookEntry) => { + navigate(`/adm/webhook/edit/${webhook.inum}`, { state: { webhook } }) + }, + [navigate], + ) + + const myActions = useMemo TableAction)>>(() => { + const actions: Array TableAction)> = [] + + const canWrite = hasCedarPermission(WEBHOOK_WRITE) + const canDelete = hasCedarPermission(WEBHOOK_DELETE) + + if (canWrite) { + actions.push({ + icon: 'add', + tooltip: `${t('messages.add_webhook')}`, + iconProps: { color: 'primary', style: { color: customColors.lightBlue } }, + isFreeAction: true, + onClick: navigateToAddPage, + }) + + actions.push((rowData: WebhookEntry) => ({ + icon: 'edit', + tooltip: `${t('messages.edit')}`, + iconProps: { + color: 'primary', + id: 'editWebhook' + rowData.inum, + style: { color: customColors.darkGray }, + }, + onClick: (_: React.MouseEvent, rowData: WebhookEntry | WebhookEntry[]) => { + if (!Array.isArray(rowData)) { + navigateToEditPage(rowData) + } + }, + disabled: !canWrite, + })) + } + + if (canDelete) { + actions.push((rowData: WebhookEntry) => ({ + icon: () => , + tooltip: `${t('messages.delete')}`, + iconProps: { + style: { color: customColors.darkGray }, + id: 'deleteWebhook' + rowData.inum, + }, + onClick: (_: React.MouseEvent, rowData: WebhookEntry | WebhookEntry[]) => { + if (!Array.isArray(rowData)) { + setDeleteData(rowData) + toggle() + } + }, + disabled: false, + })) + } + + return actions + }, [cedarPermissions, t, navigateToAddPage, navigateToEditPage, hasCedarPermission]) + + const columns: WebhookTableColumn[] = useMemo( + () => [ + { + title: `${t('fields.name')}`, + field: 'displayName', + }, + { + title: `${t('fields.url')}`, + field: 'url', + width: '40%', + render: (rowData: WebhookEntry) => ( +
    {rowData.url}
    + ), + }, + { title: `${t('fields.http_method')}`, field: 'httpMethod' }, + { title: `${t('fields.enabled')}`, field: 'jansEnabled' }, + ], + [t], + ) + + return ( + + + + + + + + + + + ), + }} + /> + + + {t('options.none')} + {t('fields.displayname')} + {t('fields.url')} + {t('fields.http_method')} + {t('fields.enabled')} + + + {sortBy && sortBy !== 'none' && ( + + + + )} + + + + + { + setPageNumber(0) + refetch() + }} + size="small" + sx={{ + 'color': customColors.lightBlue, + '&:hover': { + backgroundColor: 'rgba(0, 123, 255, 0.08)', + }, + }} + title={t('messages.refresh')} + aria-label={t('messages.refresh')} + > + + + + + + ({ + backgroundColor: rowData.jansEnabled ? customColors.logo : customColors.white, + }), + headerStyle: { + ...(applicationStyle.tableHeaderStyle as React.CSSProperties), + ...bgThemeColor, + }, + actionsColumnIndex: -1, + }} + /> + + + + + + ) +} + +export default WebhookListPage 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 b0d045667c..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 (let 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..69f3f57a61 --- /dev/null +++ b/admin-ui/plugins/admin/components/Webhook/WebhookURLChecker.ts @@ -0,0 +1,106 @@ +const BLOCKED_PROTOCOLS: string[] = [ + 'http://', + 'ftp://', + 'file://', + 'telnet://', + 'smb://', + 'ssh://', + 'ldap://', + 'gopher://', + 'dict://', + 'data://', + 'javascript:', +] + +const PRIVATE_IP_PATTERNS: RegExp[] = [ + /^https?:\/\/(10\.\d{1,3}\.\d{1,3}\.\d{1,3})/i, + /^https?:\/\/(192\.168\.\d{1,3}\.\d{1,3})/i, + /^https?:\/\/(172\.(1[6-9]|2[0-9]|3[0-1])\.\d{1,3}\.\d{1,3})/i, + /^https?:\/\/(127\.\d{1,3}\.\d{1,3}\.\d{1,3})/i, + /^https?:\/\/(localhost|0\.0\.0\.0)/i, + /^https?:\/\/\[::1\]/i, + /^https?:\/\/\[fe80:/i, + /^https?:\/\/\[fc00:/i, + /^https?:\/\/\[fd00:/i, +] + +const DANGEROUS_PORTS: number[] = [ + 22, 23, 25, 110, 143, 445, 3306, 5432, 6379, 27017, 3389, 5900, 21, 69, 514, 873, +] + +export const isValid = (url: string | undefined | null): boolean => { + if (!url) return false + + const normalizedUrl = url.trim() + if (!normalizedUrl) return false + + const lowerUrl = normalizedUrl.toLowerCase() + + if (!hasValidProtocol(lowerUrl)) return false + + if (hasBlockedProtocol(lowerUrl)) return false + + if (hasPrivateIP(normalizedUrl)) return false + + if (hasCredentials(normalizedUrl)) return false + + try { + const urlObj = new URL(normalizedUrl) + + if (urlObj.protocol !== 'https:') return false + + if (hasDangerousPort(urlObj)) return false + + if (isIPAddress(urlObj.hostname)) return false + + if (!hasValidHostname(urlObj.hostname)) return false + + return true + } catch { + return false + } +} + +const hasValidProtocol = (url: string): boolean => { + return url.startsWith('https://') +} + +const hasBlockedProtocol = (url: string): boolean => { + return BLOCKED_PROTOCOLS.some((protocol) => url.startsWith(protocol)) +} + +const hasPrivateIP = (url: string): boolean => { + return PRIVATE_IP_PATTERNS.some((pattern) => pattern.test(url)) +} + +const hasCredentials = (url: string): boolean => { + try { + const urlWithoutProtocol = url.split('//')[1] || '' + const hostPart = urlWithoutProtocol.split('/')[0] || '' + return hostPart.includes('@') + } catch { + return false + } +} + +const hasDangerousPort = (urlObj: URL): boolean => { + if (!urlObj.port) return false + + const port = parseInt(urlObj.port, 10) + return DANGEROUS_PORTS.includes(port) +} + +const isIPAddress = (hostname: string): boolean => { + const ipv4Pattern = /^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$/ + const ipv6Pattern = /^\[?[0-9a-f:]+\]?$/i + return ipv4Pattern.test(hostname) || ipv6Pattern.test(hostname) +} + +const hasValidHostname = (hostname: string): boolean => { + if (!hostname || hostname.length > 253) return false + + if (hostname === 'localhost' || hostname === '0.0.0.0') return false + + const domainPattern = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)*$/i + return domainPattern.test(hostname) +} 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..876c2af392 --- /dev/null +++ b/admin-ui/plugins/admin/components/Webhook/types/index.ts @@ -0,0 +1,87 @@ +import type { CSSProperties, ReactNode, MouseEvent } from 'react' + +import type { + WebhookEntry, + WebhookEntryHttpRequestBody, + KeyValuePair, + AuiFeature, + GetAllWebhooksParams, + PagedResult, +} from 'JansConfigApi' + +export type { + WebhookEntry, + WebhookEntryHttpRequestBody, + KeyValuePair, + AuiFeature, + GetAllWebhooksParams, + PagedResult, +} + +export interface WebhookFormValues { + httpRequestBody?: string + httpMethod: string + url: string + displayName: string + httpHeaders: Array<{ key?: string; value?: string }> + jansEnabled: boolean + description?: string + auiFeatureIds: string[] +} + +export interface CursorPosition { + url: number + httpRequestBody: number +} + +export interface ShortcodeField { + key: string + label: string + description?: string +} + +export interface FeatureShortcodes { + [featureId: string]: { + fields: ShortcodeField[] + } +} + +export interface ShortcodePopoverProps { + codes?: ShortcodeField[] + buttonWrapperStyles?: CSSProperties + handleSelectShortcode: (code: string, name: string, withString?: boolean) => void +} + +export interface WebhookFormProps { + item?: WebhookEntry + features: AuiFeature[] + loadingFeatures: boolean + onSubmit: (values: WebhookFormValues, userMessage: string) => void + isEdit?: boolean +} + +export type HttpMethod = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE' + +export type AuditAction = 'CREATE' | 'UPDATE' | 'DELETE' + +export interface TableAction { + icon: string | (() => string | ReactNode) + tooltip: string + onClick: (event: MouseEvent, rowData: WebhookEntry | WebhookEntry[]) => void + disabled?: boolean + isFreeAction?: boolean + iconProps?: { + color?: string + style?: CSSProperties + id?: string + } +} + +export interface WebhookTableColumn { + title: string + field?: string + width?: string + render?: (rowData: WebhookEntry) => ReactNode + headerStyle?: CSSProperties + cellStyle?: CSSProperties +} diff --git a/admin-ui/plugins/admin/constants/webhookFeatures.ts b/admin-ui/plugins/admin/constants/webhookFeatures.ts new file mode 100644 index 0000000000..e9b2c56120 --- /dev/null +++ b/admin-ui/plugins/admin/constants/webhookFeatures.ts @@ -0,0 +1,13 @@ +export const WEBHOOK_FEATURE_IDS = { + OIDC_CLIENTS_WRITE: 'oidc_clients_write', + OIDC_CLIENTS_DELETE: 'oidc_clients_delete', + SCOPES_WRITE: 'scopes_write', + SCOPES_DELETE: 'scopes_delete', + CUSTOM_SCRIPT_WRITE: 'custom_script_write', + CUSTOM_SCRIPT_DELETE: 'custom_script_delete', + SAML_CONFIGURATION_WRITE: 'saml_configuration_write', + SAML_IDP_WRITE: 'saml_idp_write', + USERS_EDIT: 'users_edit', +} as const + +export type WebhookFeatureId = (typeof WEBHOOK_FEATURE_IDS)[keyof typeof WEBHOOK_FEATURE_IDS] diff --git a/admin-ui/plugins/admin/constants/webhookTypes.ts b/admin-ui/plugins/admin/constants/webhookTypes.ts new file mode 100644 index 0000000000..fa8cdbdef2 --- /dev/null +++ b/admin-ui/plugins/admin/constants/webhookTypes.ts @@ -0,0 +1,15 @@ +export interface WebhookResponseObject { + webhookId?: string + webhookName?: string + inum?: string +} + +export interface WebhookTriggerResponseItem { + success: boolean + responseMessage: string + responseObject: WebhookResponseObject +} + +export interface WebhookTriggerResponse { + body: WebhookTriggerResponseItem[] +} diff --git a/admin-ui/plugins/admin/helper/webhookUtils.ts b/admin-ui/plugins/admin/helper/webhookUtils.ts new file mode 100644 index 0000000000..58ff94df40 --- /dev/null +++ b/admin-ui/plugins/admin/helper/webhookUtils.ts @@ -0,0 +1,75 @@ +import cloneDeep from 'lodash/cloneDeep' +import type { WebhookEntry, ShortCodeRequest } from 'JansConfigApi' + +const getNestedValue = (obj: Record, path: string): any => { + return path.split('.').reduce((acc, part) => (acc != null ? acc[part] : undefined), obj) +} + +export const webhookOutputObject = ( + enabledFeatureWebhooks: WebhookEntry[], + createdFeatureValue: Record, +): ShortCodeRequest[] => { + return enabledFeatureWebhooks.map((originalWebhook) => { + const webhook = cloneDeep(originalWebhook) + const url = webhook.url || '' + const shortcodeValueMap: Record = {} + + url.match(/\{([^{}]+?)\}/g)?.forEach((placeholder) => { + const key = placeholder.slice(1, -1) + const value = key?.includes('.') + ? getNestedValue(createdFeatureValue, key) + : createdFeatureValue[key] + + if (value !== undefined) { + if (typeof value === 'object' && value !== null) { + console.warn( + `Placeholder {${key}} resolved to a complex value; using JSON.stringify. Consider using a primitive field.`, + ) + } + const stringValue = + typeof value === 'object' && value !== null ? JSON.stringify(value) : String(value) + shortcodeValueMap[key] = value + webhook.url = webhook.url?.replaceAll(`{${key}}`, stringValue) + } + }) + + if ( + webhook.httpRequestBody && + typeof webhook.httpRequestBody === 'object' && + !Array.isArray(webhook.httpRequestBody) + ) { + const requestBody = webhook.httpRequestBody as Record + Object.entries(requestBody).forEach(([key, templateValue]) => { + if (typeof templateValue === 'string' && templateValue.includes('{')) { + const matches = templateValue.match(/\{([^{}]+?)\}/g) + let updatedValue = templateValue + matches?.forEach((placeholder: string) => { + const placeholderKey = placeholder.slice(1, -1) + const value = placeholderKey.includes('.') + ? getNestedValue(createdFeatureValue, placeholderKey) + : createdFeatureValue[placeholderKey] + if (value !== undefined) { + if (typeof value === 'object' && value !== null) { + console.warn( + `Placeholder {${placeholderKey}} resolved to a complex value; using JSON.stringify. Consider using a primitive field.`, + ) + } + const stringValue = + typeof value === 'object' && value !== null ? JSON.stringify(value) : String(value) + updatedValue = updatedValue.replaceAll(`{${placeholderKey}}`, stringValue) + shortcodeValueMap[placeholderKey] = value + } + }) + if (webhook.httpRequestBody) { + ;(webhook.httpRequestBody as Record)[key] = updatedValue + } + } + }) + } + + return { + webhookId: webhook.inum || '', + shortcodeValueMap, + } + }) +} diff --git a/admin-ui/plugins/admin/hooks/useScriptWebhook.ts b/admin-ui/plugins/admin/hooks/useScriptWebhook.ts new file mode 100644 index 0000000000..09247fdebc --- /dev/null +++ b/admin-ui/plugins/admin/hooks/useScriptWebhook.ts @@ -0,0 +1,21 @@ +import { useCallback } from 'react' +import { useWebhookTrigger } from './useWebhookTrigger' +import { WEBHOOK_FEATURE_IDS } from '../constants/webhookFeatures' + +export function useScriptWebhook() { + const trigger = useWebhookTrigger() + + const triggerScriptWebhook = useCallback( + ( + script: Record, + featureId: + | typeof WEBHOOK_FEATURE_IDS.CUSTOM_SCRIPT_WRITE + | typeof WEBHOOK_FEATURE_IDS.CUSTOM_SCRIPT_DELETE, + ): void => { + trigger(script, featureId) + }, + [trigger], + ) + + return { triggerScriptWebhook } +} diff --git a/admin-ui/plugins/admin/hooks/useWebhookTrigger.ts b/admin-ui/plugins/admin/hooks/useWebhookTrigger.ts new file mode 100644 index 0000000000..cee8630eb3 --- /dev/null +++ b/admin-ui/plugins/admin/hooks/useWebhookTrigger.ts @@ -0,0 +1,108 @@ +import { useCallback } from 'react' +import { useTriggerWebhook, getWebhooksByFeatureId } from 'JansConfigApi' +import type { WebhookEntry } from 'JansConfigApi' +import { useWebhookDialog } from '@/context/WebhookDialogContext' +import { useDispatch } from 'react-redux' +import { updateToast } from 'Redux/features/toastSlice' +import { useTranslation } from 'react-i18next' +import type { WebhookTriggerResponse } from '../constants/webhookTypes' +import type { ApiError } from '../redux/sagas/types/webhook' +import { webhookOutputObject } from '../helper/webhookUtils' + +export function useWebhookTrigger() { + const { t } = useTranslation() + const dispatch = useDispatch() + const { actions } = useWebhookDialog() + + const triggerWebhookMutation = useTriggerWebhook({ + mutation: { + onSuccess: (data: any) => { + // Cast to WebhookTriggerResponse since the OpenAPI spec doesn't match the actual response + const response = data as WebhookTriggerResponse + const { body = [] } = response + const allSucceeded = body.every(({ success }) => success) + if (allSucceeded) { + actions.setWebhookTriggerErrors([]) + actions.setShowErrorModal(false) + actions.setWebhookModal(false) + actions.setTriggerWebhookResponse('') + dispatch(updateToast(true, 'success', t('messages.webhook_trigger_success'))) + } else { + const errors = body.filter(({ success }) => !success) + actions.setWebhookTriggerErrors(errors) + actions.setTriggerWebhookResponse(t('messages.webhook_trigger_error')) + dispatch(updateToast(true, 'error', t('messages.webhook_trigger_error'))) + actions.setShowErrorModal(true) + } + }, + onError: (error: ApiError) => { + actions.setTriggerWebhookResponse(t('messages.webhook_trigger_failed')) + dispatch( + updateToast( + true, + 'error', + error.response?.body?.responseMessage || + error.message || + t('messages.webhook_trigger_failed'), + ), + ) + actions.setShowErrorModal(true) + }, + }, + }) + + const trigger = useCallback( + async (item: Record, featureId: string): Promise => { + try { + actions.setTriggerWebhookInProgress(true) + actions.setFeatureToTrigger(featureId) + + const webhooksResponse = await getWebhooksByFeatureId(featureId) + // The API response structure is { data: { body: WebhookEntry[] } } + // Cast to any because the OpenAPI spec doesn't match the actual response + const responseData = (webhooksResponse as any)?.data + const featureWebhooks: WebhookEntry[] = Array.isArray(responseData?.body) + ? responseData.body + : [] + + const enabledFeatureWebhooks = featureWebhooks.filter( + (webhook) => webhook.jansEnabled === true, + ) + + if (!enabledFeatureWebhooks.length) { + console.warn(`No enabled webhooks found for feature: ${featureId}`) + actions.setTriggerWebhookInProgress(false) + actions.setFeatureToTrigger('') + return + } + + const shortCodeRequests = webhookOutputObject(enabledFeatureWebhooks, item) + + const requestData = { + shortCodeRequest: shortCodeRequests, + } as any + + triggerWebhookMutation.mutate( + { + featureId, + data: requestData, + }, + { + onSettled: () => { + actions.setTriggerWebhookInProgress(false) + actions.setFeatureToTrigger('') + }, + }, + ) + } catch (error) { + console.error('Error fetching webhooks:', error) + actions.setTriggerWebhookInProgress(false) + actions.setFeatureToTrigger('') + dispatch(updateToast(true, 'error', t('messages.webhook_fetch_failed'))) + } + }, + [dispatch, t, actions, triggerWebhookMutation], + ) + + return trigger +} diff --git a/admin-ui/plugins/admin/plugin-metadata.ts b/admin-ui/plugins/admin/plugin-metadata.ts index 3092cd2fe4..6dd989d45f 100644 --- a/admin-ui/plugins/admin/plugin-metadata.ts +++ b/admin-ui/plugins/admin/plugin-metadata.ts @@ -11,14 +11,12 @@ import AuditListPage from '../admin/components/Audit/AuditListPage' import apiRoleSaga from './redux/sagas/ApiRoleSaga' import apiPermissionSaga from './redux/sagas/ApiPermissionSaga' import mappingSaga from './redux/sagas/MappingSaga' -import webhookSaga from './redux/sagas/WebhookSaga' import assetSaga from './redux/sagas/AssetSaga' import auditSaga from '../admin/redux/sagas/AuditSaga' import { reducer as apiRoleReducer } from 'Plugins/admin/redux/features/apiRoleSlice' import { reducer as apiPermissionReducer } from 'Plugins/admin/redux/features/apiPermissionSlice' import { reducer as mappingReducer } from 'Plugins/admin/redux/features/mappingSlice' -import webhookReducer from 'Plugins/admin/redux/features/WebhookSlice' import { reducer as assetReducer } from 'Plugins/admin/redux/features/AssetSlice' import auditReducer from '../admin/redux/features/auditSlice' @@ -200,17 +198,9 @@ const pluginMetadata = { { name: 'apiPermissionReducer', reducer: apiPermissionReducer }, { name: 'mappingReducer', reducer: mappingReducer }, { name: 'auditReducer', reducer: auditReducer }, - { name: 'webhookReducer', reducer: webhookReducer }, { name: 'assetReducer', reducer: assetReducer }, ], - sagas: [ - apiRoleSaga(), - auditSaga(), - apiPermissionSaga(), - mappingSaga(), - webhookSaga(), - assetSaga(), - ], + sagas: [apiRoleSaga(), auditSaga(), apiPermissionSaga(), mappingSaga(), assetSaga()], } export default pluginMetadata diff --git a/admin-ui/plugins/admin/redux/api/WebhookApi.js b/admin-ui/plugins/admin/redux/api/WebhookApi.js deleted file mode 100644 index fd786ec01c..0000000000 --- a/admin-ui/plugins/admin/redux/api/WebhookApi.js +++ /dev/null @@ -1,80 +0,0 @@ -import { handleResponse } from 'Utils/ApiUtils' - -export default class MappingApi { - constructor(api) { - this.api = api - } - - getAllWebhooks = (opts) => { - return new Promise((resolve, reject) => { - this.api.getAllWebhooks(opts, (error, data) => { - handleResponse(error, reject, resolve, data) - }) - }) - } - - createWebhook = (body) => { - const options = { - webhookEntry: body, - } - return new Promise((resolve, reject) => { - this.api.postWebhook(options, (error, data) => { - handleResponse(error, reject, resolve, data) - }) - }) - } - - deleteWebhookByInum = (id) => { - return new Promise((resolve, reject) => { - this.api.deleteWebhookByInum(id, (error, data) => { - handleResponse(error, reject, resolve, data) - }) - }) - } - - updateWebhook = (body) => { - const options = { - webhookEntry: body, - } - return new Promise((resolve, reject) => { - this.api.putWebhook(options, (error, data) => { - handleResponse(error, reject, resolve, data) - }) - }) - } - - getAllFeatures = () => { - return new Promise((resolve, reject) => { - this.api.getAllFeatures((error, _data, response) => { - handleResponse(error, reject, resolve, response) - }) - }) - } - - getFeaturesByWebhookId = (webhookId) => { - return new Promise((resolve, reject) => { - this.api.getFeaturesByWebhookId(webhookId, (error, _data, response) => { - handleResponse(error, reject, resolve, response) - }) - }) - } - - getWebhooksByFeatureId = (featureId) => { - return new Promise((resolve, reject) => { - this.api.getWebhooksByFeatureId(featureId, (error, _data, response) => { - handleResponse(error, reject, resolve, response) - }) - }) - } - - triggerWebhook = (payload) => { - const shortCodeRequest = { - shortCodeRequest: payload.outputObject, - } - return new Promise((resolve, reject) => { - this.api.triggerWebhook(payload.feature, shortCodeRequest, (error, _data, response) => { - handleResponse(error, reject, resolve, response) - }) - }) - } -} diff --git a/admin-ui/plugins/admin/redux/features/WebhookSlice.js b/admin-ui/plugins/admin/redux/features/WebhookSlice.js deleted file mode 100644 index 0483b7021a..0000000000 --- a/admin-ui/plugins/admin/redux/features/WebhookSlice.js +++ /dev/null @@ -1,160 +0,0 @@ -import reducerRegistry from 'Redux/reducers/ReducerRegistry' -import { createSlice } from '@reduxjs/toolkit' - -const initialState = { - webhooks: [], - loading: false, - saveOperationFlag: false, - errorInSaveOperationFlag: false, - totalItems: 0, - entriesCount: 0, - selectedWebhook: {}, - 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) => { - state.loading = true - }, - getWebhookResponse: (state, action) => { - state.loading = false - if (action.payload?.data) { - state.webhooks = action.payload.data?.entries || [] - state.totalItems = action.payload.data.totalEntriesCount - state.entriesCount = action.payload.data.entriesCount - } - }, - createWebhook: (state) => { - state.loading = true - state.saveOperationFlag = false - state.errorInSaveOperationFlag = false - }, - createWebhookResponse: (state, action) => { - state.loading = false - state.saveOperationFlag = true - if (action.payload?.data) { - state.errorInSaveOperationFlag = false - } else { - state.errorInSaveOperationFlag = true - } - }, - deleteWebhook: (state) => { - state.loading = true - }, - deleteWebhookResponse: (state) => { - state.loading = false - }, - setSelectedWebhook: (state, action) => { - state.selectedWebhook = action.payload - }, - updateWebhook: (state) => { - state.loading = true - state.saveOperationFlag = false - state.errorInSaveOperationFlag = false - }, - updateWebhookResponse: (state, action) => { - 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) => { - state.loadingFeatures = false - state.features = action.payload - }, - getFeaturesByWebhookId: (state) => { - state.loadingWebhookFeatures = true - }, - getFeaturesByWebhookIdResponse: (state, action) => { - state.loadingWebhookFeatures = false - state.webhookFeatures = action.payload - }, - getWebhooksByFeatureId: (state) => { - state.loadingWebhooks = true - }, - getWebhooksByFeatureIdResponse: (state, action) => { - state.featureWebhooks = action.payload - state.loadingWebhooks = false - }, - setWebhookModal: (state, action) => { - state.webhookModal = action.payload - }, - triggerWebhook: (state, action) => { - state.triggerWebhookInProgress = true - state.triggerPayload = action.payload - }, - setTriggerWebhookResponse: (state, action) => { - state.triggerWebhookInProgress = false - state.triggerWebhookMessage = action.payload - }, - setWebhookTriggerErrors: (state, action) => { - state.webhookTriggerErrors = action.payload - }, - setTriggerPayload: (state, action) => { - state.triggerPayload = action.payload - }, - setFeatureToTrigger: (state, action) => { - state.featureToTrigger = action.payload - }, - setShowErrorModal: (state, action) => { - 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, state } = webhookSlice -export default reducer -reducerRegistry.register('webhookReducer', reducer) diff --git a/admin-ui/plugins/admin/redux/sagas/CustomScriptSaga.ts b/admin-ui/plugins/admin/redux/sagas/CustomScriptSaga.ts index e36ed3cd3c..3858b08a5d 100644 --- a/admin-ui/plugins/admin/redux/sagas/CustomScriptSaga.ts +++ b/admin-ui/plugins/admin/redux/sagas/CustomScriptSaga.ts @@ -35,7 +35,7 @@ import { getErrorMessage } from './types/common' import * as JansConfigApi from 'jans_config_api' import { initAudit } from 'Redux/sagas/SagaUtils' -import { triggerWebhook } from 'Plugins/admin/redux/sagas/WebhookSaga' +import { triggerScriptWebhook } from 'Plugins/admin/redux/sagas/WebhookSagaUtils' // Helper function to create ScriptApi instance function* createScriptApi(): Generator { @@ -118,7 +118,7 @@ export function* addScript({ { context: scriptApi, fn: scriptApi.addCustomScript }, payload.action.action_data as Record, ) - yield call(triggerWebhook, { payload: { createdFeatureValue: data } }) + yield* triggerScriptWebhook({ payload: { createdFeatureValue: data } }) yield put(addCustomScriptResponse({ data })) yield call(postUserAction, audit) yield* successToast() @@ -153,7 +153,7 @@ export function* editScript({ yield put(editCustomScriptResponse({ data })) yield call(postUserAction, audit) yield* successToast() - yield call(triggerWebhook, { payload: { createdFeatureValue: data } }) + yield* triggerScriptWebhook({ payload: { createdFeatureValue: data } }) return data } catch (e: unknown) { const errMsg = getErrorMessage(e) @@ -179,8 +179,9 @@ export function* deleteScript({ yield call({ context: scriptApi, fn: scriptApi.deleteCustomScript }, payload.action.action_data) yield* successToast() yield put(deleteCustomScriptResponse({ inum: payload.action.action_data })) - yield call(triggerWebhook, { + yield* triggerScriptWebhook({ payload: { createdFeatureValue: { inum: payload.action.action_data } }, + isDelete: true, }) yield call(postUserAction, audit) } catch (e: unknown) { diff --git a/admin-ui/plugins/admin/redux/sagas/WebhookSaga.ts b/admin-ui/plugins/admin/redux/sagas/WebhookSaga.ts deleted file mode 100644 index 48db452e9f..0000000000 --- a/admin-ui/plugins/admin/redux/sagas/WebhookSaga.ts +++ /dev/null @@ -1,413 +0,0 @@ -import { - call, - all, - put, - fork, - takeLatest, - select, - CallEffect, - PutEffect, - SelectEffect, - ForkEffect, - AllEffect, -} from 'redux-saga/effects' -import { - getWebhook, - getWebhookResponse, - createWebhookResponse, - deleteWebhookResponse, - updateWebhookResponse, - getFeaturesResponse, - getFeaturesByWebhookIdResponse, - getWebhooksByFeatureIdResponse, - setTriggerWebhookResponse, - setWebhookModal, - setWebhookTriggerErrors, - setFeatureToTrigger, - setShowErrorModal, -} from 'Plugins/admin/redux/features/WebhookSlice' -import { CREATE, FETCH, DELETION, UPDATE } from '../../../../app/audit/UserActionType' -import { getAPIAccessToken } from 'Redux/features/authSlice' -import { updateToast } from 'Redux/features/toastSlice' -import { isFourZeroOneError, addAdditionalData } from 'Utils/TokenController' -import WebhookApi from '../api/WebhookApi' -import { getClient } from 'Redux/api/base' -import { postUserAction } from 'Redux/api/backend-api' -const JansConfigApi = require('jans_config_api') -import { initAudit } from 'Redux/sagas/SagaUtils' -import { webhookOutputObject } from 'Plugins/admin/helper/utils' -import { - RootState, - Webhook, - WebhookActionPayload, - FeatureActionPayload, - TriggerWebhookPayload, - AuditLog, - ApiError, - WebhookResponse, -} from './types' - -function* newFunction(): Generator { - const token: string = yield select((state: RootState) => state.authReducer.token.access_token) - const issuer: string = yield select((state: RootState) => state.authReducer.issuer) - const api = new JansConfigApi.AdminUIWebhooksApi(getClient(JansConfigApi, token, issuer)) - return new WebhookApi(api) -} - -export function* getWebhooks({ - payload, -}: { - payload: WebhookActionPayload -}): Generator { - const audit: AuditLog = yield call(initAudit) - try { - payload = payload || { action: {} } - addAdditionalData(audit, FETCH, 'webhook', payload) - const webhookApi: WebhookApi = yield call(newFunction) - const data: any = yield call(webhookApi.getAllWebhooks, payload.action) - yield put(getWebhookResponse({ data })) - yield call(postUserAction, audit) - return data - } catch (e: unknown) { - const error = e as ApiError - yield put( - updateToast( - true, - 'error', - error?.response?.body?.responseMessage || error.message || 'Unknown error', - ), - ) - yield put(getWebhookResponse({ data: null })) - if (isFourZeroOneError(error)) { - const jwt: string = yield select((state: RootState) => state.authReducer.userinfo_jwt) - yield put(getAPIAccessToken(jwt)) - } - return error - } -} - -export function* createWebhook({ - payload, -}: { - payload: WebhookActionPayload -}): Generator { - const audit: AuditLog = yield call(initAudit) - try { - addAdditionalData(audit, CREATE, 'webhook', payload) - const webhookApi: WebhookApi = yield call(newFunction) - const data: any = yield call(webhookApi.createWebhook, payload.action?.action_data) - yield put(createWebhookResponse({ data })) - yield call(postUserAction, audit) - return data - } catch (e: unknown) { - const error = e as ApiError - yield put( - updateToast( - true, - 'error', - error?.response?.body?.responseMessage || error.message || 'Unknown error', - ), - ) - yield put(createWebhookResponse({ data: null })) - if (isFourZeroOneError(error)) { - const jwt: string = yield select((state: RootState) => state.authReducer.userinfo_jwt) - yield put(getAPIAccessToken(jwt)) - } - return error - } -} - -export function* deleteWebhook({ - payload, -}: { - payload: WebhookActionPayload -}): Generator { - const audit: AuditLog = yield call(initAudit) - try { - addAdditionalData(audit, DELETION, 'webhook', payload) - const webhookApi: WebhookApi = yield call(newFunction) - const data: any = yield call(webhookApi.deleteWebhookByInum, payload.action?.action_data?.inum) - yield put(deleteWebhookResponse()) - yield call(postUserAction, audit) - yield put(getWebhook({})) - return data - } catch (e: unknown) { - const error = e as ApiError - yield put( - updateToast( - true, - 'error', - error?.response?.body?.responseMessage || error.message || 'Unknown error', - ), - ) - yield put(deleteWebhookResponse()) - if (isFourZeroOneError(error)) { - const jwt: string = yield select((state: RootState) => state.authReducer.userinfo_jwt) - yield put(getAPIAccessToken(jwt)) - } - return error - } -} - -export function* updateWebhook({ - payload, -}: { - payload: WebhookActionPayload -}): Generator { - const audit: AuditLog = yield call(initAudit) - try { - addAdditionalData(audit, UPDATE, 'webhook', payload) - const webhookApi: WebhookApi = yield call(newFunction) - const data: any = yield call(webhookApi.updateWebhook, payload.action?.action_data) - yield put(updateWebhookResponse({ data })) - yield call(postUserAction, audit) - yield put(getWebhook({})) - return data - } catch (e: unknown) { - const error = e as ApiError - yield put( - updateToast( - true, - 'error', - error?.response?.body?.responseMessage || error.message || 'Unknown error', - ), - ) - yield put(updateWebhookResponse({ data: null })) - if (isFourZeroOneError(error)) { - const jwt: string = yield select((state: RootState) => state.authReducer.userinfo_jwt) - yield put(getAPIAccessToken(jwt)) - } - return error - } -} - -export function* getFeatures(): Generator { - const audit: AuditLog = yield call(initAudit) - try { - addAdditionalData(audit, FETCH, 'webhook', {}) - const webhookApi: WebhookApi = yield call(newFunction) - const data: any = yield call(webhookApi.getAllFeatures) - yield put(getFeaturesResponse(data?.body || [])) - yield call(postUserAction, audit) - return data - } catch (e: unknown) { - const error = e as ApiError - console.log('error: ', error) - yield put( - updateToast( - true, - 'error', - error?.response?.body?.responseMessage || error.message || 'Unknown error', - ), - ) - yield put(getFeaturesResponse([])) - if (isFourZeroOneError(error)) { - const jwt: string = yield select((state: RootState) => state.authReducer.userinfo_jwt) - yield put(getAPIAccessToken(jwt)) - } - return error - } -} - -export function* getFeaturesByWebhookId({ - payload, -}: { - payload: FeatureActionPayload -}): Generator { - const audit: AuditLog = yield call(initAudit) - try { - addAdditionalData(audit, FETCH, 'webhook', payload) - const webhookApi: WebhookApi = yield call(newFunction) - const data: any = yield call(webhookApi.getFeaturesByWebhookId, payload.webhookId || payload) - yield put(getFeaturesByWebhookIdResponse(data?.body || [])) - yield call(postUserAction, audit) - return data - } catch (e: unknown) { - const error = e as ApiError - console.log('error: ', error) - yield put( - updateToast( - true, - 'error', - error?.response?.body?.responseMessage || error.message || 'Unknown error', - ), - ) - yield put(getFeaturesByWebhookIdResponse([])) - if (isFourZeroOneError(error)) { - const jwt: string = yield select((state: RootState) => state.authReducer.userinfo_jwt) - yield put(getAPIAccessToken(jwt)) - } - return error - } -} - -export function* getWebhooksByFeatureId({ - payload, -}: { - payload: string -}): Generator { - const audit: AuditLog = yield call(initAudit) - try { - addAdditionalData(audit, FETCH, `/webhook/${payload}`, payload) - const webhookApi: WebhookApi = yield call(newFunction) - const data: any = yield call(webhookApi.getWebhooksByFeatureId, payload) - if (data?.body?.length && data?.body?.filter((item: Webhook) => item.jansEnabled)?.length) { - yield put(setWebhookModal(true)) - } - yield put(getWebhooksByFeatureIdResponse(data?.body || [])) - yield call(postUserAction, audit) - return data - } catch (e: unknown) { - const error = e as ApiError - console.log('error: ', error) - yield put( - updateToast( - true, - 'error', - error?.response?.body?.responseMessage || error.message || 'Unknown error', - ), - ) - yield put(getWebhooksByFeatureIdResponse([])) - if (isFourZeroOneError(error)) { - const jwt: string = yield select((state: RootState) => state.authReducer.userinfo_jwt) - yield put(getAPIAccessToken(jwt)) - } - return error - } -} - -export function* triggerWebhook({ - payload, -}: { - payload: TriggerWebhookPayload -}): Generator { - const audit: AuditLog = yield call(initAudit) - try { - const webhookApi: WebhookApi = yield call(newFunction) - const featureToTrigger: string = yield select( - (state: RootState) => state.webhookReducer.featureToTrigger, - ) - const featureWebhooks: Webhook[] = yield select( - (state: RootState) => state.webhookReducer.featureWebhooks, - ) - const enabledFeatureWebhooks: Webhook[] = featureWebhooks?.filter( - (item: Webhook) => item.jansEnabled, - ) - - if (!enabledFeatureWebhooks.length || !featureToTrigger) { - yield put(setFeatureToTrigger('')) - return - } - - const outputObject = webhookOutputObject(enabledFeatureWebhooks, payload.createdFeatureValue) - const data: any = yield call(webhookApi.triggerWebhook, { - feature: featureToTrigger, - outputObject, - }) - - const action_data = data?.body - ?.map((body: WebhookResponse) => { - for (const output of outputObject) { - if (output.inum === body?.responseObject?.inum) { - return { - ...body, - url: output?.url, - } - } - } - }) - ?.filter((item: any) => item) - - addAdditionalData(audit, FETCH, `/webhook/${featureToTrigger}`, { - action: { action_data: action_data || [] }, - }) - yield put(setFeatureToTrigger('')) - const all_succeded = data?.body?.every((item: WebhookResponse) => item.success) - if (all_succeded) { - yield put(setShowErrorModal(false)) - yield put(setWebhookModal(false)) - yield put(setTriggerWebhookResponse('')) - yield put(updateToast(true, 'success', 'All webhooks triggered successfully.')) - } else { - const errors = data?.body - ?.map((item: WebhookResponse) => !item.success && item) - ?.filter((err: any) => err) - yield put(setWebhookTriggerErrors(errors)) - yield put(setTriggerWebhookResponse('Something went wrong while triggering webhook.')) - yield put(updateToast(true, 'error', 'Something went wrong while triggering webhook.')) - yield put(setShowErrorModal(true)) - } - yield call(postUserAction, audit) - return data - } catch (e: unknown) { - const error = e as ApiError - console.log('error: ', error) - yield put( - updateToast( - true, - 'error', - error?.response?.body?.responseMessage || error.message || 'Unknown error', - ), - ) - yield put(setWebhookModal(true)) - yield put( - setTriggerWebhookResponse( - error?.response?.body?.responseMessage || error.message || 'Unknown error', - ), - ) - if (isFourZeroOneError(error)) { - const jwt: string = yield select((state: RootState) => state.authReducer.userinfo_jwt) - yield put(getAPIAccessToken(jwt)) - } - addAdditionalData(audit, FETCH, `/webhook/${payload}`, { - action: { action_data: { error: error, success: false } }, - }) - yield call(postUserAction, audit) - return error - } -} - -export function* watchGetWebhook(): Generator, void, unknown> { - yield takeLatest('webhook/getWebhook' as any, getWebhooks) -} - -export function* watchCreateWebhook(): Generator, void, unknown> { - yield takeLatest('webhook/createWebhook' as any, createWebhook) -} - -export function* watchDeleteWebhook(): Generator, void, unknown> { - yield takeLatest('webhook/deleteWebhook' as any, deleteWebhook) -} - -export function* watchUpdateWebhook(): Generator, void, unknown> { - yield takeLatest('webhook/updateWebhook' as any, updateWebhook) -} - -export function* watchGetFeatures(): Generator, void, unknown> { - yield takeLatest('webhook/getFeatures' as any, getFeatures) -} - -export function* watchGetFeaturesByWebhookId(): Generator, void, unknown> { - yield takeLatest('webhook/getFeaturesByWebhookId' as any, getFeaturesByWebhookId) -} - -export function* watchGetWebhooksByFeatureId(): Generator, void, unknown> { - yield takeLatest('webhook/getWebhooksByFeatureId' as any, getWebhooksByFeatureId) -} - -export function* watchGetTriggerWebhook(): Generator, void, unknown> { - yield takeLatest('webhook/triggerWebhook' as any, triggerWebhook) -} - -export default function* rootSaga(): Generator>, void, unknown> { - yield all([ - fork(watchGetWebhook), - fork(watchCreateWebhook), - fork(watchDeleteWebhook), - fork(watchUpdateWebhook), - fork(watchGetFeatures), - fork(watchGetFeaturesByWebhookId), - fork(watchGetWebhooksByFeatureId), - fork(watchGetTriggerWebhook), - ]) -} diff --git a/admin-ui/plugins/admin/redux/sagas/WebhookSagaUtils.ts b/admin-ui/plugins/admin/redux/sagas/WebhookSagaUtils.ts new file mode 100644 index 0000000000..188aeb31f7 --- /dev/null +++ b/admin-ui/plugins/admin/redux/sagas/WebhookSagaUtils.ts @@ -0,0 +1,128 @@ +import { call, select, put } from 'redux-saga/effects' +import type { CallEffect, SelectEffect, PutEffect } from 'redux-saga/effects' +import type { ShortCodeRequest } from 'JansConfigApi' +import type { RootState } from '../types/state' +import { updateToast } from 'Redux/features/toastSlice' +import { WEBHOOK_FEATURE_IDS } from '../../constants/webhookFeatures' +import { getClient } from 'Redux/api/base' + +import * as JansConfigApi from 'jans_config_api' + +interface WebhookPayload { + createdFeatureValue?: Record + deletedFeatureValue?: Record +} + +function* getApiInstance(): Generator { + const state = yield select((state: RootState) => state) + const token = state.authReducer.token.access_token + const issuer = state.authReducer.issuer + + return new JansConfigApi.AdminUIWebhooksApi(getClient(JansConfigApi, token, issuer)) +} + +export function* triggerWebhook({ + payload, + featureId, +}: { + payload: WebhookPayload + featureId: string +}): Generator { + try { + const isDelete = featureId.endsWith('_delete') + const valueKey = isDelete ? 'deletedFeatureValue' : 'createdFeatureValue' + const featureValue = isDelete ? payload.deletedFeatureValue : payload.createdFeatureValue + + if (!featureValue) { + console.warn(`No ${valueKey} provided for webhook trigger`) + return + } + + const api = yield* getApiInstance() + const webhookId = featureValue.inum || featureValue.dn || '' + const requestData: ShortCodeRequest = { + webhookId, + shortcodeValueMap: { + [valueKey]: featureValue as Record, + }, + } + + yield call([api, api.triggerWebhook], featureId, requestData) + } catch (error) { + const errorMessage = + (error as Error & { response?: { body?: { responseMessage?: string } } })?.response?.body + ?.responseMessage || + (error as Error)?.message || + 'Failed to trigger webhook' + + console.error(`Failed to trigger webhook for feature ${featureId}:`, error) + + yield put(updateToast(true, 'error', `Webhook trigger failed: ${errorMessage}`)) + + throw error + } +} + +export function* triggerOidcClientWebhook({ + payload, + isDelete = false, +}: { + payload: WebhookPayload + isDelete?: boolean +}): Generator { + const featureId = isDelete + ? WEBHOOK_FEATURE_IDS.OIDC_CLIENTS_DELETE + : WEBHOOK_FEATURE_IDS.OIDC_CLIENTS_WRITE + + const convertedPayload: WebhookPayload = isDelete + ? { deletedFeatureValue: payload.createdFeatureValue || payload.deletedFeatureValue } + : { createdFeatureValue: payload.createdFeatureValue || payload.deletedFeatureValue } + + yield* triggerWebhook({ payload: convertedPayload, featureId }) +} + +export function* triggerScopeWebhook({ + payload, + isDelete = false, +}: { + payload: WebhookPayload + isDelete?: boolean +}): Generator { + const featureId = isDelete ? WEBHOOK_FEATURE_IDS.SCOPES_DELETE : WEBHOOK_FEATURE_IDS.SCOPES_WRITE + + const convertedPayload: WebhookPayload = isDelete + ? { deletedFeatureValue: payload.createdFeatureValue || payload.deletedFeatureValue } + : { createdFeatureValue: payload.createdFeatureValue || payload.deletedFeatureValue } + + yield* triggerWebhook({ payload: convertedPayload, featureId }) +} + +export function* triggerScriptWebhook({ + payload, + isDelete = false, +}: { + payload: WebhookPayload + isDelete?: boolean +}): Generator { + const featureId = isDelete + ? WEBHOOK_FEATURE_IDS.CUSTOM_SCRIPT_DELETE + : WEBHOOK_FEATURE_IDS.CUSTOM_SCRIPT_WRITE + + const convertedPayload: WebhookPayload = isDelete + ? { deletedFeatureValue: payload.createdFeatureValue || payload.deletedFeatureValue } + : { createdFeatureValue: payload.createdFeatureValue || payload.deletedFeatureValue } + + yield* triggerWebhook({ payload: convertedPayload, featureId }) +} + +export function* triggerSamlWebhook({ + payload, + featureId, +}: { + payload: WebhookPayload + featureId: + | typeof WEBHOOK_FEATURE_IDS.SAML_CONFIGURATION_WRITE + | typeof WEBHOOK_FEATURE_IDS.SAML_IDP_WRITE +}): Generator { + yield* triggerWebhook({ payload, featureId }) +} diff --git a/admin-ui/plugins/admin/redux/sagas/types/state.ts b/admin-ui/plugins/admin/redux/sagas/types/state.ts index c3c478762b..4579bf54f3 100644 --- a/admin-ui/plugins/admin/redux/sagas/types/state.ts +++ b/admin-ui/plugins/admin/redux/sagas/types/state.ts @@ -1,6 +1,5 @@ import { CustomScriptItem } from '../../features/types/customScript' import { ScriptType } from '../../features/types/customScript' -import { Webhook } from './webhook' export interface RootState { authReducer: { @@ -23,8 +22,4 @@ export interface RootState { loadingScriptTypes: boolean item?: CustomScriptItem } - webhookReducer: { - featureToTrigger: string - featureWebhooks: Webhook[] - } } diff --git a/admin-ui/plugins/auth-server/hooks/useOidcWebhook.ts b/admin-ui/plugins/auth-server/hooks/useOidcWebhook.ts new file mode 100644 index 0000000000..92e14f9db6 --- /dev/null +++ b/admin-ui/plugins/auth-server/hooks/useOidcWebhook.ts @@ -0,0 +1,16 @@ +import { useCallback } from 'react' +import type { Client } from 'JansConfigApi' +import { useWebhookTrigger } from 'Plugins/admin/hooks/useWebhookTrigger' + +export function useOidcWebhook() { + const trigger = useWebhookTrigger() + + const triggerOidcWebhook = useCallback( + (client: Client, featureId: 'oidc_clients_write' | 'oidc_clients_delete'): void => { + trigger(client, featureId) + }, + [trigger], + ) + + return { triggerOidcWebhook } +} diff --git a/admin-ui/plugins/auth-server/hooks/useScopeWebhook.ts b/admin-ui/plugins/auth-server/hooks/useScopeWebhook.ts new file mode 100644 index 0000000000..6eb3c67bfd --- /dev/null +++ b/admin-ui/plugins/auth-server/hooks/useScopeWebhook.ts @@ -0,0 +1,20 @@ +import { useCallback } from 'react' +import type { Scope } from 'JansConfigApi' +import { useWebhookTrigger } from 'Plugins/admin/hooks/useWebhookTrigger' +import { WEBHOOK_FEATURE_IDS } from 'Plugins/admin/constants/webhookFeatures' + +export function useScopeWebhook() { + const trigger = useWebhookTrigger() + + const triggerScopeWebhook = useCallback( + ( + scope: Scope, + featureId: typeof WEBHOOK_FEATURE_IDS.SCOPES_WRITE | typeof WEBHOOK_FEATURE_IDS.SCOPES_DELETE, + ): void => { + trigger(scope, featureId) + }, + [trigger], + ) + + return { triggerScopeWebhook } +} diff --git a/admin-ui/plugins/auth-server/redux/sagas/OAuthScopeSaga.js b/admin-ui/plugins/auth-server/redux/sagas/OAuthScopeSaga.js index 0bb88ae52c..7448a577f2 100644 --- a/admin-ui/plugins/auth-server/redux/sagas/OAuthScopeSaga.js +++ b/admin-ui/plugins/auth-server/redux/sagas/OAuthScopeSaga.js @@ -7,7 +7,7 @@ import ScopeApi from '../api/ScopeApi' import { getClient } from 'Redux/api/base' import { isFourZeroOneError, addAdditionalData } from 'Utils/TokenController' import { postUserAction } from 'Redux/api/backend-api' -import { triggerWebhook } from 'Plugins/admin/redux/sagas/WebhookSaga' +import { triggerScopeWebhook } from 'Plugins/admin/redux/sagas/WebhookSagaUtils' const JansConfigApi = require('jans_config_api') import { initAudit } from 'Redux/sagas/SagaUtils' @@ -109,7 +109,7 @@ export function* addAScope({ payload }) { yield put(updateToast(true, 'success')) yield put(addScopeResponse({ data })) yield call(postUserAction, audit) - yield* triggerWebhook({ payload: { createdFeatureValue: data } }) + yield* triggerScopeWebhook({ payload: { createdFeatureValue: data } }) return data } catch (e) { yield put(updateToast(true, 'error')) @@ -132,7 +132,7 @@ export function* editAnScope({ payload }) { yield put(updateToast(true, 'success')) yield put(editScopeResponse({ data })) yield call(postUserAction, audit) - yield* triggerWebhook({ payload: { createdFeatureValue: data } }) + yield* triggerScopeWebhook({ payload: { createdFeatureValue: data } }) return data } catch (e) { yield put(updateToast(true, 'error')) @@ -155,7 +155,10 @@ export function* deleteAnScope({ payload }) { yield put(updateToast(true, 'success')) yield put(deleteScopeResponse({ data: payload.action.action_data?.inum })) yield call(postUserAction, audit) - yield* triggerWebhook({ payload: { createdFeatureValue: payload.action.action_data } }) + yield* triggerScopeWebhook({ + payload: { createdFeatureValue: payload.action.action_data }, + isDelete: true, + }) } catch (e) { yield put(updateToast(true, 'error')) yield put(deleteScopeResponse({ data: null })) diff --git a/admin-ui/plugins/auth-server/redux/sagas/OIDCSaga.js b/admin-ui/plugins/auth-server/redux/sagas/OIDCSaga.js index 18fb87091a..39fb5f1c2c 100644 --- a/admin-ui/plugins/auth-server/redux/sagas/OIDCSaga.js +++ b/admin-ui/plugins/auth-server/redux/sagas/OIDCSaga.js @@ -18,7 +18,7 @@ import OIDCApi from '../api/OIDCApi' import { getClient } from 'Redux/api/base' const JansConfigApi = require('jans_config_api') import { initAudit } from 'Redux/sagas/SagaUtils' -import { triggerWebhook } from 'Plugins/admin/redux/sagas/WebhookSaga' +import { triggerOidcClientWebhook } from 'Plugins/admin/redux/sagas/WebhookSagaUtils' import TokenApi from '../api/TokenApi' function* newFunction() { @@ -74,7 +74,7 @@ export function* addNewClient({ payload }) { yield put(addClientResponse({ data })) yield put(updateToast(true, 'success')) yield call(postUserAction, audit) - yield* triggerWebhook({ payload: { createdFeatureValue: data?.client } }) + yield* triggerOidcClientWebhook({ payload: { createdFeatureValue: data?.client } }) return data } catch (e) { yield put(updateToast(true, 'error')) @@ -99,7 +99,7 @@ export function* editAClient({ payload }) { yield put(editClientResponse({ data })) yield put(updateToast(true, 'success')) yield call(postUserAction, audit) - yield* triggerWebhook({ payload: { createdFeatureValue: data?.client } }) + yield* triggerOidcClientWebhook({ payload: { createdFeatureValue: data?.client } }) return data } catch (e) { yield put(updateToast(true, 'error')) @@ -122,8 +122,9 @@ export function* deleteAClient({ payload }) { yield put(updateToast(true, 'success')) yield put(getOpenidClients()) yield call(postUserAction, audit) - yield* triggerWebhook({ + yield* triggerOidcClientWebhook({ payload: { createdFeatureValue: payload.action.action_data }, + isDelete: true, }) } catch (e) { yield put(updateToast(true, 'error')) diff --git a/admin-ui/plugins/saml/hooks/useSamlWebhook.ts b/admin-ui/plugins/saml/hooks/useSamlWebhook.ts new file mode 100644 index 0000000000..7571d7f8ab --- /dev/null +++ b/admin-ui/plugins/saml/hooks/useSamlWebhook.ts @@ -0,0 +1,21 @@ +import { useCallback } from 'react' +import { useWebhookTrigger } from 'Plugins/admin/hooks/useWebhookTrigger' +import { WEBHOOK_FEATURE_IDS } from 'Plugins/admin/constants/webhookFeatures' + +export function useSamlWebhook() { + const trigger = useWebhookTrigger() + + const triggerSamlWebhook = useCallback( + ( + entity: Record, + featureId: + | typeof WEBHOOK_FEATURE_IDS.SAML_CONFIGURATION_WRITE + | typeof WEBHOOK_FEATURE_IDS.SAML_IDP_WRITE, + ): void => { + trigger(entity, featureId) + }, + [trigger], + ) + + return { triggerSamlWebhook } +} diff --git a/admin-ui/plugins/saml/redux/sagas/SamlSaga.js b/admin-ui/plugins/saml/redux/sagas/SamlSaga.js index f97c34d6e2..248ab830f8 100644 --- a/admin-ui/plugins/saml/redux/sagas/SamlSaga.js +++ b/admin-ui/plugins/saml/redux/sagas/SamlSaga.js @@ -20,7 +20,8 @@ import { import { getAPIAccessToken } from 'Redux/features/authSlice' import { updateToast } from 'Redux/features/toastSlice' import { CREATE, DELETION, UPDATE } from '../../../../app/audit/UserActionType' -import { triggerWebhook } from 'Plugins/admin/redux/sagas/WebhookSaga' +import { triggerSamlWebhook } from 'Plugins/admin/redux/sagas/WebhookSagaUtils' +import { WEBHOOK_FEATURE_IDS } from 'Plugins/admin/constants/webhookFeatures' const JansConfigApi = require('jans_config_api') @@ -83,7 +84,10 @@ export function* putSamlProperties({ payload }) { const data = yield call(api.putSamlProperties, { samlAppConfiguration: payload.action.action_data, }) - yield* triggerWebhook({ payload: { createdFeatureValue: data } }) + yield* triggerSamlWebhook({ + payload: { createdFeatureValue: data }, + featureId: WEBHOOK_FEATURE_IDS.SAML_CONFIGURATION_WRITE, + }) yield put(putSamlPropertiesResponse(data)) yield call(postUserAction, audit) } catch (error) { @@ -104,7 +108,10 @@ export function* postSamlIdentity({ payload }) { formdata: payload.action.action_data, token, }) - yield* triggerWebhook({ payload: { createdFeatureValue: data } }) + yield* triggerSamlWebhook({ + payload: { createdFeatureValue: data }, + featureId: WEBHOOK_FEATURE_IDS.SAML_IDP_WRITE, + }) yield put(toggleSavedFormFlag(true)) yield call(postUserAction, audit) } catch (error) { @@ -146,7 +153,10 @@ export function* postTrustRelationship({ payload }) { }) yield put(toggleSavedFormFlag(true)) yield call(postUserAction, audit) - yield* triggerWebhook({ payload: { createdFeatureValue: data } }) + yield* triggerSamlWebhook({ + payload: { createdFeatureValue: data }, + featureId: WEBHOOK_FEATURE_IDS.SAML_IDP_WRITE, + }) } catch (error) { console.log('Error: ', error) yield* errorToast({ error }) @@ -171,7 +181,10 @@ export function* updateTrustRelationship({ payload }) { }) yield put(toggleSavedFormFlag(true)) yield call(postUserAction, audit) - yield* triggerWebhook({ payload: { createdFeatureValue: data } }) + yield* triggerSamlWebhook({ + payload: { createdFeatureValue: data }, + featureId: WEBHOOK_FEATURE_IDS.SAML_IDP_WRITE, + }) } catch (error) { console.log('Error: ', error) yield* errorToast({ error }) @@ -193,7 +206,10 @@ export function* deleteTrustRelationship({ payload }) { yield put(deleteTrustRelationshipResponse(data)) yield getTrustRelationshipsSaga() yield call(postUserAction, audit) - yield* triggerWebhook({ payload: { createdFeatureValue: payload.action.action_data } }) + yield* triggerSamlWebhook({ + payload: { createdFeatureValue: payload.action.action_data }, + featureId: WEBHOOK_FEATURE_IDS.SAML_IDP_WRITE, + }) } catch (error) { yield* errorToast({ error }) @@ -214,7 +230,10 @@ export function* updateSamlIdentity({ payload }) { token, }) yield put(toggleSavedFormFlag(true)) - yield* triggerWebhook({ payload: { createdFeatureValue: data } }) + yield* triggerSamlWebhook({ + payload: { createdFeatureValue: data }, + featureId: WEBHOOK_FEATURE_IDS.SAML_IDP_WRITE, + }) yield call(postUserAction, audit) } catch (error) { console.log('Error: ', error) @@ -237,7 +256,10 @@ export function* deleteSamlIdentity({ payload }) { yield put(deleteSamlIdentityResponse(data)) yield put(getSamlIdentites()) yield call(postUserAction, audit) - yield* triggerWebhook({ payload: { createdFeatureValue: data } }) + yield* triggerSamlWebhook({ + payload: { createdFeatureValue: data }, + featureId: WEBHOOK_FEATURE_IDS.SAML_IDP_WRITE, + }) } catch (error) { yield* errorToast({ error }) diff --git a/admin-ui/plugins/schema/hooks/useSchemaWebhook.ts b/admin-ui/plugins/schema/hooks/useSchemaWebhook.ts index 851bc40e5d..e316ca98b0 100644 --- a/admin-ui/plugins/schema/hooks/useSchemaWebhook.ts +++ b/admin-ui/plugins/schema/hooks/useSchemaWebhook.ts @@ -1,20 +1,15 @@ import { useCallback } from 'react' -import { useDispatch } from 'react-redux' -import { triggerWebhook } from 'Plugins/admin/redux/features/WebhookSlice' import type { JansAttribute } from 'JansConfigApi' +import { useWebhookTrigger } from 'Plugins/admin/hooks/useWebhookTrigger' export function useSchemaWebhook() { - const dispatch = useDispatch() + const trigger = useWebhookTrigger() const triggerAttributeWebhook = useCallback( (attribute: Partial): void => { - dispatch( - triggerWebhook({ - createdFeatureValue: attribute, - }), - ) + trigger(attribute, 'attributes_write') }, - [dispatch], + [trigger], ) return { triggerAttributeWebhook } diff --git a/admin-ui/plugins/user-management/components/UserAddPage.tsx b/admin-ui/plugins/user-management/components/UserAddPage.tsx index e4f6f940be..e93d3cfe36 100644 --- a/admin-ui/plugins/user-management/components/UserAddPage.tsx +++ b/admin-ui/plugins/user-management/components/UserAddPage.tsx @@ -15,13 +15,14 @@ import { usePostUser, getGetUserQueryKey, CustomUser, CustomObjectAttribute } fr import { useQueryClient } from '@tanstack/react-query' import { updateToast } from 'Redux/features/toastSlice' import { logUserCreation, getErrorMessage } from '../helper/userAuditHelpers' -import { triggerUserWebhook } from '../helper/userWebhookHelpers' +import { useUserWebhook } from '../hooks/useUserWebhook' function UserAddPage() { const dispatch = useDispatch() const navigate = useNavigate() const queryClient = useQueryClient() const { t } = useTranslation() + const { triggerUserWebhook } = useUserWebhook() const personAttributes = useSelector( (state: UserManagementRootState) => state.attributesReducerRoot.items, ) @@ -30,7 +31,7 @@ function UserAddPage() { onSuccess: async (data, variables) => { dispatch(updateToast(true, 'success', t('messages.user_created_successfully'))) await logUserCreation(data, variables.data) - await triggerUserWebhook(data as Record) + triggerUserWebhook(data) queryClient.invalidateQueries({ queryKey: getGetUserQueryKey() }) navigate('/user/usersmanagement') }, diff --git a/admin-ui/plugins/user-management/components/UserEditPage.tsx b/admin-ui/plugins/user-management/components/UserEditPage.tsx index 7abaa1670f..2f3d555e9b 100644 --- a/admin-ui/plugins/user-management/components/UserEditPage.tsx +++ b/admin-ui/plugins/user-management/components/UserEditPage.tsx @@ -20,7 +20,7 @@ import { import { useQueryClient } from '@tanstack/react-query' import { updateToast } from 'Redux/features/toastSlice' import { logUserUpdate, getErrorMessage } from '../helper/userAuditHelpers' -import { triggerUserWebhook } from '../helper/userWebhookHelpers' +import { useUserWebhook } from '../hooks/useUserWebhook' function UserEditPage() { const dispatch = useDispatch() @@ -28,6 +28,7 @@ function UserEditPage() { const location = useLocation() const queryClient = useQueryClient() const { t } = useTranslation() + const { triggerUserWebhook } = useUserWebhook() const [userDetails] = useState(location.state?.selectedUser ?? null) useEffect(() => { @@ -57,7 +58,7 @@ function UserEditPage() { onSuccess: async (data, variables) => { dispatch(updateToast(true, 'success', t('messages.user_updated_successfully'))) await logUserUpdate(data, variables.data) - await triggerUserWebhook(data as Record) + triggerUserWebhook(data) queryClient.invalidateQueries({ queryKey: getGetUserQueryKey() }) navigate('/user/usersmanagement') }, diff --git a/admin-ui/plugins/user-management/components/UserForm.tsx b/admin-ui/plugins/user-management/components/UserForm.tsx index 788165e882..3ff3ec8b94 100644 --- a/admin-ui/plugins/user-management/components/UserForm.tsx +++ b/admin-ui/plugins/user-management/components/UserForm.tsx @@ -1,31 +1,22 @@ -// React and React-related imports import React, { useEffect, useState, useContext } from 'react' import { useSelector, useDispatch } from 'react-redux' import { useTranslation } from 'react-i18next' import { useFormik, FormikProps } from 'formik' import { useQueryClient, QueryClient } from '@tanstack/react-query' - -// Third-party libraries import * as Yup from 'yup' import { debounce } from 'lodash' import moment from 'moment/moment' import { Modal, ModalHeader, ModalBody, ModalFooter } from 'reactstrap' import { Dispatch } from '@reduxjs/toolkit' import { TFunction } from 'i18next' - -// UI Components import { Button, Col, Form, FormGroup } from 'Components' import GluuInputRow from 'Routes/Apps/Gluu/GluuInputRow' import GluuSelectRow from 'Routes/Apps/Gluu/GluuSelectRow' import GluuLoader from 'Routes/Apps/Gluu/GluuLoader' import GluuCommitDialog from 'Routes/Apps/Gluu/GluuCommitDialog' - -// Context and Redux import { ThemeContext } from 'Context/theme/themeContext' import { getAttributesRoot } from 'Redux/features/attributesSlice' import { updateToast } from 'Redux/features/toastSlice' - -// API and Services import { usePatchUserByInum, getGetUserQueryKey, @@ -35,17 +26,15 @@ import { } from 'JansConfigApi' import UserClaimEntry from './UserClaimEntry' import { logPasswordChange, getErrorMessage } from '../helper/userAuditHelpers' -import { triggerUserWebhook } from '../helper/userWebhookHelpers' +import { useUserWebhook } from '../hooks/useUserWebhook' import { adminUiFeatures } from 'Plugins/admin/helper/utils' - -// Types import { UserFormProps, UserFormState, FormOperation, UserEditFormValues, } from '../types/ComponentTypes' -import { ThemeContext as ThemeContextType } from '../types/CommonTypes' +import { ThemeContextType } from '../types/CommonTypes' import { PersonAttribute, CustomUser } from '../types/UserApiTypes' const usePasswordChange = ( @@ -54,13 +43,14 @@ const usePasswordChange = ( queryClient: QueryClient, dispatch: Dispatch, t: TFunction, + triggerWebhook: (user: Partial) => void, ) => { const changePasswordMutation = usePatchUserByInum({ mutation: { onSuccess: async (data: CustomUser, variables: { inum: string; data: UserPatchRequest }) => { dispatch(updateToast(true, 'success', t('messages.password_changed_successfully'))) await logPasswordChange(variables.inum, variables.data as Record) - await triggerUserWebhook(data as Record) + triggerWebhook(data) queryClient.invalidateQueries({ queryKey: getGetUserQueryKey() }) }, onError: (error: unknown) => { @@ -381,6 +371,7 @@ function UserForm({ onSubmitData, userDetails }: Readonly) { const dispatch = useDispatch() const queryClient = useQueryClient() const { t } = useTranslation() + const { triggerUserWebhook } = useUserWebhook() const DOC_SECTION = 'user' const [searchClaims, setSearchClaims] = useState('') const [selectedClaims, setSelectedClaims] = useState([]) @@ -423,6 +414,7 @@ function UserForm({ onSubmitData, userDetails }: Readonly) { queryClient, dispatch, t, + triggerUserWebhook, ) const toggle = () => { diff --git a/admin-ui/plugins/user-management/components/UserList.tsx b/admin-ui/plugins/user-management/components/UserList.tsx index b9b89274b2..55dc2f40ea 100644 --- a/admin-ui/plugins/user-management/components/UserList.tsx +++ b/admin-ui/plugins/user-management/components/UserList.tsx @@ -46,12 +46,13 @@ import { } from '../types' import { updateToast } from 'Redux/features/toastSlice' import { logUserDeletion, logUserUpdate, getErrorMessage } from '../helper/userAuditHelpers' -import { triggerUserWebhook } from '../helper/userWebhookHelpers' +import { useUserWebhook } from '../hooks/useUserWebhook' function UserList(): JSX.Element { const { hasCedarPermission, authorize } = useCedarling() const dispatch = useDispatch() const queryClient = useQueryClient() + const { triggerUserWebhook } = useUserWebhook() const renders = useRef(0) const { t } = useTranslation() @@ -119,7 +120,9 @@ function UserList(): JSX.Element { onSuccess: async (_data, variables) => { dispatch(updateToast(true, 'success', t('messages.user_deleted_successfully'))) await logUserDeletion(variables.inum, (deleteData as CustomUser) || undefined) - await triggerUserWebhook(deleteData as Record) + if (deleteData) { + triggerUserWebhook(deleteData as CustomUser) + } queryClient.invalidateQueries({ queryKey: getGetUserQueryKey() }) }, onError: (error: unknown) => { diff --git a/admin-ui/plugins/user-management/hooks/useUserWebhook.ts b/admin-ui/plugins/user-management/hooks/useUserWebhook.ts new file mode 100644 index 0000000000..a9a11844a8 --- /dev/null +++ b/admin-ui/plugins/user-management/hooks/useUserWebhook.ts @@ -0,0 +1,17 @@ +import { useCallback } from 'react' +import type { CustomUser } from 'JansConfigApi' +import { WEBHOOK_FEATURE_IDS } from 'Plugins/admin/constants/webhookFeatures' +import { useWebhookTrigger } from 'Plugins/admin/hooks/useWebhookTrigger' + +export function useUserWebhook() { + const trigger = useWebhookTrigger() + + const triggerUserWebhook = useCallback( + (user: Partial): void => { + trigger(user, WEBHOOK_FEATURE_IDS.USERS_EDIT) + }, + [trigger], + ) + + return { triggerUserWebhook } +} diff --git a/admin-ui/plugins/user-management/types/CommonTypes.ts b/admin-ui/plugins/user-management/types/CommonTypes.ts index 8b608ba86b..221e24a50e 100644 --- a/admin-ui/plugins/user-management/types/CommonTypes.ts +++ b/admin-ui/plugins/user-management/types/CommonTypes.ts @@ -1,5 +1,3 @@ -// Common type definitions used across user management components - export interface Role { role: string inum?: string @@ -9,9 +7,4 @@ export interface UserFormValues { [key: string]: string | string[] | null | undefined } -// Define theme context interface -export interface ThemeContext { - state: { - theme: string - } -} +export type { ThemeContextType } from 'Context/theme/themeContext'