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;