diff --git a/frontend/public/locales/en/translation.json b/frontend/public/locales/en/translation.json index c5d9448c..ac638cb9 100644 --- a/frontend/public/locales/en/translation.json +++ b/frontend/public/locales/en/translation.json @@ -50,6 +50,8 @@ "filter.cidr_domain": "cidr or domain", "info.related": "Related information", "login.do_not_have_an_account": "Don't have an account?", + "login.or": "or", + "login.sso": "Login with SSO", "menu.about": "About", "menu.analyzermappings": "Analyzer Mappings", "menu.cases": "Cases", @@ -131,6 +133,8 @@ "ngen.color": "Color", "ngen.color.validate": "Enter a color", "ngen.comments": "Comments", + "ngen.connection.connected": "Connected to server", + "ngen.connection.disconnected": "No connection to server", "ngen.contact.create": "Create contact", "ngen.contact.detail": "Contact detail", "ngen.contact.placeholder": "Contact type has not been selected", @@ -536,6 +540,7 @@ "search.by.name.description": "by name or description", "search.by.name.user.email": "by name, username or email", "search.bycode": "by code", + "search.clear": "Clear search", "search.taxonomy_feed_affectedresource": "by taxonomy, feed or affected resource", "selectOption": "Select an option", "session.last": "Last login", diff --git a/frontend/public/locales/es/translation.json b/frontend/public/locales/es/translation.json index e7fead3f..72952672 100644 --- a/frontend/public/locales/es/translation.json +++ b/frontend/public/locales/es/translation.json @@ -50,6 +50,8 @@ "filter.cidr_domain": "CIDR o dominio", "info.related": "Información relacionada", "login.do_not_have_an_account": "¿No tienes una cuenta?", + "login.or": "o", + "login.sso": "Ingresar con SSO", "menu.about": "Acerca de", "menu.analyzermappings": "Mapeo de Analizadores", "menu.cases": "Casos", @@ -131,6 +133,8 @@ "ngen.color": "Color", "ngen.color.validate": "Introducir un color", "ngen.comments": "Comentarios", + "ngen.connection.connected": "Conectado al servidor", + "ngen.connection.disconnected": "Sin conexión con el servidor", "ngen.contact.create": "Crear contacto", "ngen.contact.detail": "Detalle del contacto", "ngen.contact.placeholder": "El tipo de contacto no ha sido seleccionado", @@ -536,6 +540,7 @@ "search.by.name.description": "por nombre o descripción", "search.by.name.user.email": "por nombre, nombre de usuario o correo electrónico", "search.bycode": "por código", + "search.clear": "Borrar búsqueda", "search.taxonomy_feed_affectedresource": "por taxonomía, feed o recurso afectado", "selectOption": "Seleccione una opción", "session.last": "Último inicio de sesión", diff --git a/frontend/src/api/services/backendHealth.js b/frontend/src/api/services/backendHealth.js new file mode 100644 index 00000000..0e87d59a --- /dev/null +++ b/frontend/src/api/services/backendHealth.js @@ -0,0 +1,56 @@ +const CHANNEL_NAME = "ngen_health"; +const LOCK_NAME = "ngen_health_leader"; +const STATUS_KEY = "ngen_health_status"; + +let channel = null; +try { + channel = new BroadcastChannel(CHANNEL_NAME); +} catch (e) { + channel = null; +} + +export const readCachedStatus = () => localStorage.getItem(STATUS_KEY); + +export const writeCachedStatus = (status) => { + localStorage.setItem(STATUS_KEY, status); +}; + +export const publishHealthStatus = (status) => { + try { + channel && channel.postMessage({ status }); + } catch (e) { + // ignore + } +}; + +export const subscribeHealthStatus = (callback) => { + if (channel) { + channel.onmessage = (e) => { + if (e.data?.status) callback(e.data.status); + }; + } + return () => {}; +}; + +export const subscribeLeadership = (callback) => { + if (!navigator.locks || !navigator.locks.request) { + callback(true); + return () => {}; + } + + let cancelled = false; + let release = null; + + navigator.locks.request(LOCK_NAME, async () => { + if (cancelled) return; + callback(true); + return new Promise((resolve) => { + release = resolve; + }); + }); + + return () => { + cancelled = true; + release && release(); + }; +}; diff --git a/frontend/src/api/services/loadEnv.jsx b/frontend/src/api/services/loadEnv.jsx index f86079c0..03ad5608 100644 --- a/frontend/src/api/services/loadEnv.jsx +++ b/frontend/src/api/services/loadEnv.jsx @@ -29,5 +29,12 @@ export const loadEnv = () => { const normalizeApiUrl = (host, port, path) => { let portValue = port || window.location.port; - return `${window.location.protocol}//${host || window.location.hostname}${portValue ? ":" + portValue : ""}${path || "/api/"}`; + let normalizedPath = path || "/api/"; + if (!normalizedPath.startsWith("/")) { + normalizedPath = "/" + normalizedPath; + } + if (!normalizedPath.endsWith("/")) { + normalizedPath += "/"; + } + return `${window.location.protocol}//${host || window.location.hostname}${portValue ? ":" + portValue : ""}${normalizedPath}`; }; diff --git a/frontend/src/assets/scss/partials/_custom.scss b/frontend/src/assets/scss/partials/_custom.scss index e019e9ff..65ba9245 100644 --- a/frontend/src/assets/scss/partials/_custom.scss +++ b/frontend/src/assets/scss/partials/_custom.scss @@ -368,3 +368,73 @@ margin: 2px 0; } } + +.search-input-group { + position: relative; + display: flex; + align-items: stretch; + width: 100%; + + .search-field { + position: relative; + flex: 1 1 auto; + width: 1%; + min-width: 0; + + .form-control { + height: 100%; + padding-right: 2.25rem; + border-top-right-radius: 0; + border-bottom-right-radius: 0; + } + + .search-clear-btn { + position: absolute; + top: 50%; + right: 0.5rem; + transform: translateY(-50%); + z-index: 5; + padding: 0; + border: none; + background: transparent; + color: var(--bs-secondary-color); + font-size: 0.9rem; + line-height: 1; + cursor: pointer; + + &:hover { + color: var(--bs-body-color); + } + + &:focus-visible { + outline: 2px solid var(--bs-primary, #04a9f5); + outline-offset: 2px; + } + } + } + + > .search-btn { + margin: 0; + border-top-left-radius: 0; + border-bottom-left-radius: 0; + border-left: none; + } +} + +[data-bs-theme="dark"] .form-control.is-invalid, +[data-bs-theme="dark"] .form-control.is-invalid:focus { + border-color: var(--bs-form-invalid-border-color); +} + +.login-feedback { + min-height: 1.75em; + margin-top: 0.25rem; + font-size: 0.875em; + color: var(--bs-form-invalid-color); + text-align: left; + padding: 0 5px; +} + +.auth-wrapper .mb-3 { + margin-bottom: 0.5rem !important; +} diff --git a/frontend/src/components/Button/FilterToolbar.jsx b/frontend/src/components/Button/FilterToolbar.jsx index 4dad12d4..aa2e756a 100644 --- a/frontend/src/components/Button/FilterToolbar.jsx +++ b/frontend/src/components/Button/FilterToolbar.jsx @@ -8,7 +8,7 @@ const FilterToolbar = ({ open, setOpen, onReload, onClearFilters }) => { return (
- + {setOpen && } {onClearFilters && ( + )} +
+ diff --git a/frontend/src/config/constant.jsx b/frontend/src/config/constant.jsx index 0252b4b4..ac7bc249 100644 --- a/frontend/src/config/constant.jsx +++ b/frontend/src/config/constant.jsx @@ -39,6 +39,7 @@ export const CONFIG = { }; export const COMPONENT_URL = { + health: "health/", tlp: "administration/tlp/", feed: "administration/feed/", priority: "administration/priority/", diff --git a/frontend/src/hooks/useBackendHealth.jsx b/frontend/src/hooks/useBackendHealth.jsx new file mode 100644 index 00000000..76378d5c --- /dev/null +++ b/frontend/src/hooks/useBackendHealth.jsx @@ -0,0 +1,64 @@ +import { useEffect, useState } from "react"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { COMPONENT_URL } from "config/constant"; +import { + publishHealthStatus, + readCachedStatus, + subscribeHealthStatus, + subscribeLeadership, + writeCachedStatus +} from "api/services/backendHealth"; + +const fetchHealth = async () => { + const apiServer = localStorage.getItem("API_SERVER"); + if (!apiServer) { + throw new Error("API_SERVER is not set"); + } + const res = await fetch(apiServer + COMPONENT_URL.health, { cache: "no-store" }); + if (!res.ok) { + throw new Error("Backend health check failed"); + } + return res.json(); +}; + +const useBackendHealth = () => { + const queryClient = useQueryClient(); + const [isLeader, setIsLeader] = useState(false); + + useEffect(() => subscribeLeadership(setIsLeader), []); + + useEffect( + () => + subscribeHealthStatus((status) => { + writeCachedStatus(status); + queryClient.setQueryData(["backend-health"], { status }); + }), + [queryClient] + ); + + const query = useQuery({ + queryKey: ["backend-health"], + queryFn: fetchHealth, + refetchInterval: isLeader ? 3000 : false, + refetchIntervalInBackground: true, + retry: false, + staleTime: Infinity, + initialData: () => { + const cached = readCachedStatus(); + return cached ? { status: cached } : undefined; + } + }); + + useEffect(() => { + if (!isLeader) return; + const status = query.isError ? "down" : query.data?.status; + if (status) { + writeCachedStatus(status); + publishHealthStatus(status); + } + }, [isLeader, query.data, query.isError]); + + return !query.isError && query.data?.status === "ok"; +}; + +export default useBackendHealth; diff --git a/frontend/src/i18n.js b/frontend/src/i18n.js index 02a590c2..62898eb9 100644 --- a/frontend/src/i18n.js +++ b/frontend/src/i18n.js @@ -10,7 +10,13 @@ const options = { }; const initializeI18n = async () => { - const lang = await getSettingLanguage(); + let lang; + try { + lang = await getSettingLanguage(); + } catch (error) { + console.error("Error obteniendo el idioma del backend:", error); + lang = localStorage.getItem("NGEN_LANG") || "en"; + } i18n .use(Backend) diff --git a/frontend/src/index.jsx b/frontend/src/index.jsx index 5a82ac1e..4421aaf3 100644 --- a/frontend/src/index.jsx +++ b/frontend/src/index.jsx @@ -36,10 +36,18 @@ const initializeApp = async () => { try { // Cargar variables de entorno await loadEnv(); + } catch (error) { + console.error("Error cargando el entorno:", error); + } + try { // Inicializar i18n await initializeI18n(); + } catch (error) { + console.error("Error inicializando i18n:", error); + } + try { // Renderizar la aplicación const app = ( diff --git a/frontend/src/layouts/AdminLayout/NavBar/NavRight/index.jsx b/frontend/src/layouts/AdminLayout/NavBar/NavRight/index.jsx index 7053e75f..a776bcf3 100644 --- a/frontend/src/layouts/AdminLayout/NavBar/NavRight/index.jsx +++ b/frontend/src/layouts/AdminLayout/NavBar/NavRight/index.jsx @@ -5,6 +5,7 @@ import { useSelector } from "react-redux"; import { useTranslation } from "react-i18next"; import i18n from "i18next"; import { ThemeContext } from "../../../../contexts/ThemeContext"; +import { logout } from "../../../../api/services/auth"; const CURRENT_LANG = (i18n.language || localStorage.getItem("NGEN_LANG") || "en").substring(0, 2); @@ -76,12 +77,9 @@ const NavRight = () => { { - localStorage.removeItem("ngen-account"); - }} + onClick={() => logout()} > {t("button.logout")} diff --git a/frontend/src/views/analyzer/ListAnalyzers.jsx b/frontend/src/views/analyzer/ListAnalyzers.jsx index 4da9927c..18e48e9c 100644 --- a/frontend/src/views/analyzer/ListAnalyzers.jsx +++ b/frontend/src/views/analyzer/ListAnalyzers.jsx @@ -2,8 +2,8 @@ import React, { useEffect, useState } from "react"; import { Card, Col, Row } from "react-bootstrap"; import CrudButton from "../../components/Button/CrudButton"; import AdvancedPagination from "../../components/Pagination/AdvancedPagination"; -import Search from "../../components/Search/Search"; import TableAnalyzer from "./components/TableAnalyzer"; +import ListViewHeader from "../../components/ListViewHeader/ListViewHeader"; import { useTranslation } from "react-i18next"; import { getAnalyzers } from "../../api/services/analyzer"; @@ -19,11 +19,24 @@ const ListAnalyzers = () => { const [wordToSearch, setWordToSearch] = useState(""); const [order, setOrder] = useState("name"); const [refreshKey, setRefreshKey] = useState(0); + const [refresh, setRefresh] = useState(true); function updatePage(chosenPage) { setCurrentPage(chosenPage); } + const reloadPage = () => { + setLoading(true); + setRefresh((prev) => !prev); + }; + + const clearFilters = () => { + setLoading(true); + setWordToSearch(""); + setCurrentPage(1); + setRefresh((prev) => !prev); + }; + useEffect(() => { setLoading(true); getAnalyzers(currentPage, wordToSearch, order) @@ -37,7 +50,7 @@ const ListAnalyzers = () => { }) .catch((error) => console.error(error)) .finally(() => setLoading(false)); - }, [currentPage, order, wordToSearch, refreshKey]); + }, [currentPage, order, wordToSearch, refreshKey, refresh]); return ( @@ -45,25 +58,22 @@ const ListAnalyzers = () => { - - - - - - - - + + + { const [order, setOrder] = useState("date"); const [refreshKey, setRefreshKey] = useState(0); + const [refresh, setRefresh] = useState(true); function updatePage(chosenPage) { setCurrentPage(chosenPage); } + const reloadPage = () => { + setLoading(true); + setRefresh((prev) => !prev); + }; + + const clearFilters = () => { + setLoading(true); + setWordToSearch(""); + setCurrentPage(1); + setRefresh((prev) => !prev); + }; + useEffect(() => { setLoading(true); getAnalyzerMappings(currentPage, wordToSearch, order) @@ -42,7 +55,7 @@ const ListAnalyzerMappings = () => { .finally(() => { setLoading(false); }); - }, [currentPage, order, wordToSearch, refreshKey]); + }, [currentPage, order, wordToSearch, refreshKey, refresh]); return ( @@ -50,25 +63,22 @@ const ListAnalyzerMappings = () => { - - - - - - - - + + + { const [updatePagination, setUpdatePagination] = useState(false); const [disabledPagination, setDisabledPagination] = useState(true); const [open, setOpen] = useState(false); + const [refresh, setRefresh] = useState(false); const { t } = useTranslation(); function updatePage(chosenPage) { setCurrentPage(chosenPage); } + const reloadPage = () => { + setLoading(true); + setRefresh((prev) => !prev); + }; + const buildFilters = () => { const parts = []; if (wordToSearch) parts.push(`search=${encodeURIComponent(wordToSearch)}`); @@ -58,7 +63,7 @@ const ListAudit = () => { }) .catch(() => {}) .finally(() => setLoading(false)); - }, [currentPage, wordToSearch, actionFilter, actorFilter, typeFilter, dateFrom, dateTo, order]); + }, [currentPage, wordToSearch, actionFilter, actorFilter, typeFilter, dateFrom, dateTo, order, refresh]); const clearFilters = () => { setActionFilter(""); @@ -75,20 +80,17 @@ const ListAudit = () => { - - - setCurrentPage(1)} onClearFilters={clearFilters} /> - - - - - +
diff --git a/frontend/src/views/auth/signin/RestLogin.jsx b/frontend/src/views/auth/signin/RestLogin.jsx index 32813306..cab8bb87 100644 --- a/frontend/src/views/auth/signin/RestLogin.jsx +++ b/frontend/src/views/auth/signin/RestLogin.jsx @@ -1,5 +1,5 @@ import React, { useState } from "react"; -import { Button, Col, Row } from "react-bootstrap"; +import { Button } from "react-bootstrap"; import * as Yup from "yup"; import { Formik } from "formik"; @@ -8,7 +8,7 @@ import store from "./../../../store"; import Alert from "./../../../components/Alert/Alert"; import { useTranslation } from "react-i18next"; -const RestLogin = ({ className, ...rest }) => { +const RestLogin = ({ className, connected = true, ...rest }) => { const { t } = useTranslation(); const [showAlert, setShowAlert] = useState(false); const { dispatch } = store; @@ -39,12 +39,13 @@ const RestLogin = ({ className, ...rest }) => { login(values.username, values.password); }} > - {({ errors, handleBlur, handleChange, handleSubmit, isSubmitting, touched, values }) => ( + {({ errors, handleBlur, handleChange, handleSubmit, isSubmitting, submitCount, touched, values }) => { + return (
-
+
{ type="text" value={values.username} /> - {touched.username && errors.username && {errors.username}} +
+ {(touched.username || submitCount > 0) && errors.username ? errors.username : ""} +
-
+
{ type="password" value={values.password} /> - {touched.password && errors.password && {errors.password}} +
+ {(touched.password || submitCount > 0) && errors.password ? errors.password : ""} +
- - - - - +
+