From efdf8396ab8bab093ff4b00b08a00df789e4d0e7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A9dric=20Verstraeten?= Date: Mon, 25 May 2026 20:13:33 +0000 Subject: [PATCH] Refactor and update dependencies for improved performance and maintainability; enhance routing and authentication components --- ui/package.json | 53 ++++----- ui/src/App.jsx | 19 +-- ui/src/actions/authentication.js | 6 +- .../LanguageSelect/LanguageSelect.jsx | 17 ++- ui/src/containers/RequireAuth.jsx | 53 ++------- ui/src/containers/RequireGuest.jsx | 61 +++------- ui/src/containers/RequireInstall.jsx | 56 ++------- ui/src/i18n.js | 4 +- ui/src/index.jsx | 109 +++++++++++++----- ui/src/navigation.js | 16 +++ ui/src/pages/Dashboard/Dashboard.jsx | 17 ++- ui/src/pages/Login/Login.jsx | 3 +- .../Login/blocks/LoginForm/LoginForm.jsx | 5 +- ui/src/pages/Media/Media.jsx | 5 +- ui/src/pages/Settings/Settings.jsx | 5 +- ui/src/reducers/index.js | 15 ++- 16 files changed, 207 insertions(+), 237 deletions(-) create mode 100644 ui/src/navigation.js diff --git a/ui/package.json b/ui/package.json index acc465b..8956e0c 100644 --- a/ui/package.json +++ b/ui/package.json @@ -2,44 +2,39 @@ "name": "agent-ui", "version": "0.1.0", "dependencies": { + "@emotion/react": "^11.13.3", + "@emotion/styled": "^11.13.0", "@giantmachines/redux-websocket": "^1.5.1", "@kerberos-io/ui": "^1.76.0", - "@material-ui/core": "^4.12.4", - "@material-ui/icons": "^4.11.3", - "@testing-library/jest-dom": "^5.16.5", - "@testing-library/react": "^11.2.7", - "@testing-library/user-event": "^12.8.3", - "axios": "^0.21.4", - "classnames": "^2.3.1", - "connected-react-router": "^6.9.3", + "@mui/icons-material": "^6.1.6", + "@mui/material": "^6.1.6", + "@testing-library/jest-dom": "^6.6.3", + "@testing-library/react": "^16.0.1", + "@testing-library/user-event": "^14.5.2", + "axios": "^1.7.7", + "classnames": "^2.5.1", "eslint-config-prettier": "8.5.*", "eslint-plugin-prettier": "4.2.*", "eslint-watch": "^7.0.0", - "history": "4.10.1", - "i18next": "^21.9.1", - "i18next-browser-languagedetector": "^6.1.5", - "i18next-http-backend": "^1.4.1", - "i18next-xhr-backend": "^3.2.2", + "i18next": "^23.16.4", + "i18next-browser-languagedetector": "^8.0.0", + "i18next-http-backend": "^2.6.2", "md5": "^2.3.0", - "prettier": "^2.7.1", + "prettier": "^3.3.3", "prop-types": "^15.8.1", - "react": "^17.0.1", - "react-dom": "^17.0.2", - "react-i18next": "^11.18.5", - "react-redux": "^7.2.8", - "react-router": "^5.2.0", - "react-router-dom": "^5.3.3", - "react-router-redux": "^4.0.8", - "react-scripts": "5.*", - "react-tooltip": "^4.2.21", - "redux": "^4.2.0", - "redux-devtools-extension": "^2.13.9", - "redux-thunk": "^2.4.1", - "rxjs": "^7.5.6", + "react": "^19.0.0", + "react-dom": "^19.0.0", + "react-i18next": "^15.1.1", + "react-redux": "^9.1.2", + "react-router-dom": "^6.27.0", + "react-scripts": "5.0.1", + "react-tooltip": "^5.28.0", + "redux": "^5.0.1", + "redux-thunk": "^3.1.0", + "rxjs": "^7.8.1", "typeface-roboto": "^1.1.13", - "uuidv4": "2.0.0", "vott-ct": "^2.3.3", - "web-vitals": "^0.2.4" + "web-vitals": "^4.2.4" }, "browser": { "crypto": false diff --git a/ui/src/App.jsx b/ui/src/App.jsx index a60a78b..d9627f8 100644 --- a/ui/src/App.jsx +++ b/ui/src/App.jsx @@ -1,12 +1,17 @@ import React from 'react'; import PropTypes from 'prop-types'; import { withTranslation } from 'react-i18next'; -import uuid from 'uuidv4'; +import { Outlet } from 'react-router-dom'; import { connect as connectWS, disconnect as disconnectWS, send, } from '@giantmachines/redux-websocket'; + +const genClientId = () => + typeof crypto !== 'undefined' && crypto.randomUUID + ? crypto.randomUUID() + : `${Date.now()}-${Math.random().toString(16).slice(2)}`; import { Badge, Main, @@ -60,7 +65,7 @@ class App extends React.Component { const { connected: connectedPrev } = prevProps; if (connectedPrev === false && connected === true) { const message = { - client_id: uuid(), + client_id: genClientId(), message_type: 'hello', }; dispatchSend(message); @@ -76,7 +81,7 @@ class App extends React.Component { this.subscription.unsubscribe(); this.connectionSubscription.unsubscribe(); const message = { - client_id: uuid(), + client_id: genClientId(), message_type: 'goodbye', }; const { dispatchSend, dispatchDisconnect } = this.props; @@ -90,7 +95,7 @@ class App extends React.Component { render() { const { t, connected } = this.props; - const { children, username, dashboard, dispatchLogout } = this.props; + const { username, dashboard, dispatchLogout } = this.props; const cloudOnline = this.getCurrentTimestamp() - dashboard.cloudOnline < 30; return ( <> @@ -205,7 +210,9 @@ class App extends React.Component { )} - {children} + + + @@ -236,8 +243,6 @@ App.propTypes = { dispatchConnect: PropTypes.func.isRequired, dispatchDisconnect: PropTypes.func.isRequired, dispatchSend: PropTypes.func.isRequired, - // eslint-disable-next-line react/forbid-prop-types - children: PropTypes.array.isRequired, username: PropTypes.string.isRequired, connected: PropTypes.bool.isRequired, dashboard: PropTypes.object.isRequired, diff --git a/ui/src/actions/authentication.js b/ui/src/actions/authentication.js index f7fd934..60f6d9f 100644 --- a/ui/src/actions/authentication.js +++ b/ui/src/actions/authentication.js @@ -1,4 +1,4 @@ -import { push } from 'react-router-redux'; +import { navigate } from '../navigation'; import { doLogin, doCheckIfInstalled } from '../api/authentication'; export const login = (username, password) => @@ -15,7 +15,7 @@ export const login = (username, password) => token: data.token, expire: data.expire, }); - dispatch(push('/')); + navigate('/'); }, (error) => { dispatch({ @@ -60,6 +60,6 @@ export const logout = () => { dispatch({ type: 'LOGOUT', }); - dispatch(push('/login')); + navigate('/login'); }; }; diff --git a/ui/src/components/LanguageSelect/LanguageSelect.jsx b/ui/src/components/LanguageSelect/LanguageSelect.jsx index 113b92b..799f844 100644 --- a/ui/src/components/LanguageSelect/LanguageSelect.jsx +++ b/ui/src/components/LanguageSelect/LanguageSelect.jsx @@ -1,9 +1,9 @@ import React from 'react'; import i18next from 'i18next'; -import Popover from '@material-ui/core/Popover'; -import List from '@material-ui/core/List'; -import ListItem from '@material-ui/core/ListItem'; -import ListSubheader from '@material-ui/core/ListSubheader'; +import Popover from '@mui/material/Popover'; +import List from '@mui/material/List'; +import ListItemButton from '@mui/material/ListItemButton'; +import ListSubheader from '@mui/material/ListSubheader'; import { Icon } from '@kerberos-io/ui'; import { useTranslation } from 'react-i18next'; import './LanguageSelect.scss'; @@ -69,8 +69,7 @@ const LanguageSelect = () => { {t('navigation.choose_language')} {Object.keys(languageMap)?.map((item) => ( - { i18next.changeLanguage(item); @@ -79,7 +78,7 @@ const LanguageSelect = () => { }} > {languageMap[item] ? languageMap[item].label : ''} - + ))}
{ rel="noreferrer" target="_blank" > - + Contribute language - +
diff --git a/ui/src/containers/RequireAuth.jsx b/ui/src/containers/RequireAuth.jsx index d47661b..67f477d 100644 --- a/ui/src/containers/RequireAuth.jsx +++ b/ui/src/containers/RequireAuth.jsx @@ -1,47 +1,16 @@ import React from 'react'; -import { bindActionCreators } from 'redux'; -import { connect } from 'react-redux'; -import { push } from 'react-router-redux'; import PropTypes from 'prop-types'; +import { useSelector } from 'react-redux'; +import { Navigate } from 'react-router-dom'; -export default function RequireAuth(ComposedComponent) { - class Auth extends React.Component { - componentDidMount() { - const { isAuthenticated, redirect } = this.props; - if (!isAuthenticated) { - redirect(); - } - } - - render() { - const { isAuthenticated } = this.props; - return ( -
- {/* eslint-disable-next-line react/jsx-props-no-spreading */} - {isAuthenticated ? : null} -
- ); - } +export default function RequireAuth({ children }) { + const isAuthenticated = useSelector((s) => s.authentication.loggedIn); + if (!isAuthenticated) { + return ; } - - const mapStateToProps = (state) => ({ - isAuthenticated: state.authentication.loggedIn, - isInstalled: state.authentication.installed, - }); - - const mapDispatchToProps = (dispatch) => - bindActionCreators( - { - redirect: () => push('/login'), - }, - dispatch - ); - - Auth.propTypes = { - isAuthenticated: PropTypes.bool.isRequired, - isInstalled: PropTypes.bool.isRequired, - redirect: PropTypes.func.isRequired, - }; - - return connect(mapStateToProps, mapDispatchToProps)(Auth); + return children; } + +RequireAuth.propTypes = { + children: PropTypes.node.isRequired, +}; diff --git a/ui/src/containers/RequireGuest.jsx b/ui/src/containers/RequireGuest.jsx index ba43280..08b22b5 100644 --- a/ui/src/containers/RequireGuest.jsx +++ b/ui/src/containers/RequireGuest.jsx @@ -1,51 +1,20 @@ import React from 'react'; -import { bindActionCreators } from 'redux'; -import { connect } from 'react-redux'; -import { push } from 'react-router-redux'; import PropTypes from 'prop-types'; +import { useSelector } from 'react-redux'; +import { Navigate } from 'react-router-dom'; -export default function RequireGuest(ComposedComponent) { - class Guest extends React.Component { - componentDidMount() { - const { - isAuthenticated, - isInstalled, - redirectInstallation, - redirectDashboard, - } = this.props; - if (!isInstalled) { - redirectInstallation(); - } else if (isAuthenticated) { - redirectDashboard(); - } - } - - render() { - const { isAuthenticated } = this.props; - return
{!isAuthenticated ? : null}
; - } +export default function RequireGuest({ children }) { + const isAuthenticated = useSelector((s) => s.authentication.loggedIn); + const isInstalled = useSelector((s) => s.authentication.installed); + if (!isInstalled) { + return ; } - - const mapStateToProps = (state) => ({ - isAuthenticated: state.authentication.loggedIn, - isInstalled: state.authentication.installed, - }); - - const mapDispatchToProps = (dispatch) => - bindActionCreators( - { - redirectDashboard: () => push('/'), - redirectInstallation: () => push('/install'), - }, - dispatch - ); - - Guest.propTypes = { - isAuthenticated: PropTypes.bool.isRequired, - isInstalled: PropTypes.bool.isRequired, - redirectInstallation: PropTypes.func.isRequired, - redirectDashboard: PropTypes.func.isRequired, - }; - - return connect(mapStateToProps, mapDispatchToProps)(Guest); + if (isAuthenticated) { + return ; + } + return children; } + +RequireGuest.propTypes = { + children: PropTypes.node.isRequired, +}; diff --git a/ui/src/containers/RequireInstall.jsx b/ui/src/containers/RequireInstall.jsx index 4ace122..9f3c2d8 100644 --- a/ui/src/containers/RequireInstall.jsx +++ b/ui/src/containers/RequireInstall.jsx @@ -1,49 +1,17 @@ import React from 'react'; -import { bindActionCreators } from 'redux'; -import { connect } from 'react-redux'; -import { push } from 'react-router-redux'; import PropTypes from 'prop-types'; +import { useSelector } from 'react-redux'; +import { Navigate } from 'react-router-dom'; -export default function RequireInstall(ComposedComponent) { - class Install extends React.Component { - componentDidMount() { - const { isAuthenticated, isInstalled, redirectDashboard, redirectLogin } = - this.props; - if (isInstalled) { - if (isAuthenticated) { - redirectDashboard(); - } else if (!isAuthenticated) { - redirectLogin(); - } - } - } - - render() { - const { isInstalled } = this.props; - return
{!isInstalled ? : null}
; - } +export default function RequireInstall({ children }) { + const isAuthenticated = useSelector((s) => s.authentication.loggedIn); + const isInstalled = useSelector((s) => s.authentication.installed); + if (isInstalled) { + return ; } - - const mapStateToProps = (state) => ({ - isAuthenticated: state.authentication.loggedIn, - isInstalled: state.authentication.installed, - }); - - const mapDispatchToProps = (dispatch) => - bindActionCreators( - { - redirectDashboard: () => push('/'), - redirectLogin: () => push('/login'), - }, - dispatch - ); - - Install.propTypes = { - isAuthenticated: PropTypes.bool.isRequired, - isInstalled: PropTypes.bool.isRequired, - redirectDashboard: PropTypes.func.isRequired, - redirectLogin: PropTypes.func.isRequired, - }; - - return connect(mapStateToProps, mapDispatchToProps)(Install); + return children; } + +RequireInstall.propTypes = { + children: PropTypes.node.isRequired, +}; diff --git a/ui/src/i18n.js b/ui/src/i18n.js index 2e0577e..8440b08 100644 --- a/ui/src/i18n.js +++ b/ui/src/i18n.js @@ -1,6 +1,6 @@ import i18n from 'i18next'; import { initReactI18next } from 'react-i18next'; -import Backend from 'i18next-xhr-backend'; +import Backend from 'i18next-http-backend'; import LanguageDetector from 'i18next-browser-languagedetector'; i18n @@ -14,7 +14,7 @@ i18n escapeValue: false, }, load: 'languageOnly', - whitelist: ['de', 'en', 'nl', 'fr', 'pl', 'es', 'pt', 'ja', 'ru', 'ro'], + supportedLngs: ['de', 'en', 'nl', 'fr', 'pl', 'es', 'pt', 'ja', 'ru', 'ro'], }); export default i18n; diff --git a/ui/src/index.jsx b/ui/src/index.jsx index 512e838..0ccf4c3 100644 --- a/ui/src/index.jsx +++ b/ui/src/index.jsx @@ -1,14 +1,16 @@ -import React, { Suspense } from 'react'; -import ReactDOM from 'react-dom'; -import { Route, Switch } from 'react-router-dom'; -import { createStore, applyMiddleware } from 'redux'; -import { createBrowserHistory } from 'history'; -import { routerMiddleware, ConnectedRouter } from 'connected-react-router'; +import React, { Suspense, useEffect } from 'react'; +import { createRoot } from 'react-dom/client'; +import { + BrowserRouter, + Routes, + Route, + Navigate, + useNavigate, +} from 'react-router-dom'; +import { createStore, applyMiddleware, compose } from 'redux'; import { Provider } from 'react-redux'; import reduxWebsocket from '@giantmachines/redux-websocket'; -import { composeWithDevTools } from 'redux-devtools-extension'; -import thunk from 'redux-thunk'; -import { Redirect } from 'react-router'; +import { thunk } from 'redux-thunk'; import rootReducer from './reducers'; import App from './App'; import './index.scss'; @@ -18,10 +20,9 @@ import Media from './pages/Media/Media'; import Settings from './pages/Settings/Settings'; import RequireAuth from './containers/RequireAuth'; import RequireGuest from './containers/RequireGuest'; +import { setNavigator } from './navigation'; import './i18n'; -const history = createBrowserHistory(); - // We get the token from the store to initialise the store. // So we know if the user is still signed in. function getAuthState() { @@ -54,32 +55,76 @@ const reduxWebsocketMiddleware = reduxWebsocket({ reconnectOnClose: true, }); +// eslint-disable-next-line no-underscore-dangle +const composeEnhancers = window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ || compose; + const store = createStore( - rootReducer(history), + rootReducer, { ...getAuthState() }, - composeWithDevTools( - applyMiddleware(thunk, reduxWebsocketMiddleware, routerMiddleware(history)) - ) + composeEnhancers(applyMiddleware(thunk, reduxWebsocketMiddleware)) ); const Loader = () =>
loading...
; -ReactDOM.render( +// Bridges React Router's navigate function into a module-scoped singleton +// so Redux thunks (e.g. login/logout) can navigate without a hook. +function NavigationSetup() { + const nav = useNavigate(); + useEffect(() => { + setNavigator(nav); + }, [nav]); + return null; +} + +const container = document.getElementById('root'); +const root = createRoot(container); + +root.render( - - - - }> - - - - } /> - - - - - - - , - document.getElementById('root') + + + }> + + + + + } + /> + }> + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + + ); diff --git a/ui/src/navigation.js b/ui/src/navigation.js new file mode 100644 index 0000000..9d19d6c --- /dev/null +++ b/ui/src/navigation.js @@ -0,0 +1,16 @@ +// Lightweight navigation singleton so non-React code (Redux thunks) can +// trigger client-side navigation. The value is set from a component that +// has access to React Router's `useNavigate` hook (see NavigationSetup in +// index.jsx). + +let navigatorFn = null; + +export const setNavigator = (fn) => { + navigatorFn = fn; +}; + +export const navigate = (path, options) => { + if (navigatorFn) { + navigatorFn(path, options); + } +}; diff --git a/ui/src/pages/Dashboard/Dashboard.jsx b/ui/src/pages/Dashboard/Dashboard.jsx index 50877ba..a7c7b92 100644 --- a/ui/src/pages/Dashboard/Dashboard.jsx +++ b/ui/src/pages/Dashboard/Dashboard.jsx @@ -1,6 +1,6 @@ import React from 'react'; import PropTypes from 'prop-types'; -import { Link, withRouter } from 'react-router-dom'; +import { Link } from 'react-router-dom'; import { withTranslation } from 'react-i18next'; import { send } from '@giantmachines/redux-websocket'; import { connect } from 'react-redux'; @@ -24,7 +24,8 @@ import { ModalFooter, } from '@kerberos-io/ui'; import './Dashboard.scss'; -import ReactTooltip from 'react-tooltip'; +import { Tooltip as ReactTooltip } from 'react-tooltip'; +import 'react-tooltip/dist/react-tooltip.css'; import config from '../../config'; import { getConfig } from '../../actions/agent'; @@ -617,7 +618,12 @@ class Dashboard extends React.Component { } > {' '} -

{event.time}

+

+ {event.time} +

, <> @@ -716,7 +722,7 @@ class Dashboard extends React.Component { - + ); } @@ -746,5 +752,6 @@ Dashboard.propTypes = { }; export default withTranslation()( - withRouter(connect(mapStateToProps, mapDispatchToProps)(Dashboard)) + connect(mapStateToProps, mapDispatchToProps)(Dashboard) +) ); diff --git a/ui/src/pages/Login/Login.jsx b/ui/src/pages/Login/Login.jsx index f6991d1..79cb57e 100644 --- a/ui/src/pages/Login/Login.jsx +++ b/ui/src/pages/Login/Login.jsx @@ -11,7 +11,6 @@ import { Icon, LandingLayout, } from '@kerberos-io/ui'; -import { withRouter } from 'react-router-dom'; import { connect } from 'react-redux'; import { login, resetLogin } from '../../actions'; import config from '../../config'; @@ -166,4 +165,4 @@ Login.propTypes = { dispatchResetLogin: PropTypes.func.isRequired, }; -export default withRouter(connect(mapStateToProps, mapDispatchToProps)(Login)); +export default connect(mapStateToProps, mapDispatchToProps)(Login); diff --git a/ui/src/pages/Login/blocks/LoginForm/LoginForm.jsx b/ui/src/pages/Login/blocks/LoginForm/LoginForm.jsx index 1697e90..9158b3c 100644 --- a/ui/src/pages/Login/blocks/LoginForm/LoginForm.jsx +++ b/ui/src/pages/Login/blocks/LoginForm/LoginForm.jsx @@ -1,7 +1,6 @@ import React from 'react'; import { connect } from 'react-redux'; import classNames from 'classnames'; -import { withRouter } from 'react-router-dom'; import PropTypes from 'prop-types'; import { login } from '../../../../actions'; import styles from './LoginForm.module.scss'; @@ -103,6 +102,4 @@ LoginForm.propTypes = { dispatchLogin: PropTypes.func.isRequired, }; -export default withRouter( - connect(mapStateToProps, mapDispatchToProps)(LoginForm) -); +export default connect(mapStateToProps, mapDispatchToProps)(LoginForm); diff --git a/ui/src/pages/Media/Media.jsx b/ui/src/pages/Media/Media.jsx index 7d90c92..553153f 100644 --- a/ui/src/pages/Media/Media.jsx +++ b/ui/src/pages/Media/Media.jsx @@ -11,7 +11,7 @@ import { ModalBody, ModalFooter, } from '@kerberos-io/ui'; -import { Link, withRouter } from 'react-router-dom'; +import { Link } from 'react-router-dom'; import { connect } from 'react-redux'; import { getEvents } from '../../actions/agent'; import config from '../../config'; @@ -351,5 +351,6 @@ Media.propTypes = { }; export default withTranslation()( - withRouter(connect(mapStateToProps, mapDispatchToProps)(Media)) + connect(mapStateToProps, mapDispatchToProps)(Media) +) ); diff --git a/ui/src/pages/Settings/Settings.jsx b/ui/src/pages/Settings/Settings.jsx index fceb4f6..63532dc 100644 --- a/ui/src/pages/Settings/Settings.jsx +++ b/ui/src/pages/Settings/Settings.jsx @@ -17,7 +17,7 @@ import { Icon, Toggle, } from '@kerberos-io/ui'; -import { Link, withRouter } from 'react-router-dom'; +import { Link } from 'react-router-dom'; import { connect } from 'react-redux'; import { interval } from 'rxjs'; import { send } from '@giantmachines/redux-websocket'; @@ -2825,5 +2825,6 @@ Settings.propTypes = { }; export default withTranslation()( - withRouter(connect(mapStateToProps, mapDispatchToProps)(Settings)) + connect(mapStateToProps, mapDispatchToProps)(Settings) +) ); diff --git a/ui/src/reducers/index.js b/ui/src/reducers/index.js index bd3e778..623f433 100644 --- a/ui/src/reducers/index.js +++ b/ui/src/reducers/index.js @@ -1,13 +1,12 @@ import { combineReducers } from 'redux'; -import { connectRouter } from 'connected-react-router'; import authentication from './authentication'; import agent from './agent'; import wss from './wss'; -export default (history) => - combineReducers({ - authentication, - agent, - wss, - router: connectRouter(history), - }); +const rootReducer = combineReducers({ + authentication, + agent, + wss, +}); + +export default rootReducer;