Refactor and update dependencies for improved performance and maintainability; enhance routing and authentication components

This commit is contained in:
Cédric Verstraeten
2026-05-25 20:13:33 +00:00
parent d0f13187a1
commit efdf8396ab
16 changed files with 207 additions and 237 deletions

View File

@@ -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

View File

@@ -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 {
</Link>
)}
<MainBody>{children}</MainBody>
<MainBody>
<Outlet />
</MainBody>
</Main>
</div>
</>
@@ -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,

View File

@@ -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');
};
};

View File

@@ -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 = () => {
<List>
<ListSubheader>{t('navigation.choose_language')}</ListSubheader>
{Object.keys(languageMap)?.map((item) => (
<ListItem
button
<ListItemButton
key={item}
onClick={() => {
i18next.changeLanguage(item);
@@ -79,7 +78,7 @@ const LanguageSelect = () => {
}}
>
{languageMap[item] ? languageMap[item].label : ''}
</ListItem>
</ListItemButton>
))}
<hr />
<a
@@ -87,9 +86,9 @@ const LanguageSelect = () => {
rel="noreferrer"
target="_blank"
>
<ListItem button key="contribute-language">
<ListItemButton key="contribute-language">
Contribute language
</ListItem>
</ListItemButton>
</a>
</List>
</div>

View File

@@ -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 (
<div>
{/* eslint-disable-next-line react/jsx-props-no-spreading */}
{isAuthenticated ? <ComposedComponent {...this.props} /> : null}
</div>
);
}
export default function RequireAuth({ children }) {
const isAuthenticated = useSelector((s) => s.authentication.loggedIn);
if (!isAuthenticated) {
return <Navigate to="/login" replace />;
}
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,
};

View File

@@ -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 <div>{!isAuthenticated ? <ComposedComponent /> : null}</div>;
}
export default function RequireGuest({ children }) {
const isAuthenticated = useSelector((s) => s.authentication.loggedIn);
const isInstalled = useSelector((s) => s.authentication.installed);
if (!isInstalled) {
return <Navigate to="/install" replace />;
}
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 <Navigate to="/" replace />;
}
return children;
}
RequireGuest.propTypes = {
children: PropTypes.node.isRequired,
};

View File

@@ -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 <div>{!isInstalled ? <ComposedComponent /> : null}</div>;
}
export default function RequireInstall({ children }) {
const isAuthenticated = useSelector((s) => s.authentication.loggedIn);
const isInstalled = useSelector((s) => s.authentication.installed);
if (isInstalled) {
return <Navigate to={isAuthenticated ? '/' : '/login'} replace />;
}
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,
};

View File

@@ -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;

View File

@@ -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 = () => <div>loading...</div>;
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(
<Provider store={store}>
<ConnectedRouter history={history}>
<Switch>
<Route path="/login" component={RequireGuest(Login)} />
<Suspense fallback={<Loader />}>
<App>
<Route exact path="/" component={RequireAuth(Dashboard)} />
<Route exact path="/dashboard" component={RequireAuth(Dashboard)} />
<Route exact path="/" render={() => <Redirect to="/dashboard" />} />
<Route exact path="/media" component={RequireAuth(Media)} />
<Route exact path="/settings" component={RequireAuth(Settings)} />
</App>
</Suspense>
</Switch>
</ConnectedRouter>
</Provider>,
document.getElementById('root')
<BrowserRouter>
<NavigationSetup />
<Suspense fallback={<Loader />}>
<Routes>
<Route
path="/login"
element={
<RequireGuest>
<Login />
</RequireGuest>
}
/>
<Route element={<App />}>
<Route
path="/"
element={<Navigate to="/dashboard" replace />}
/>
<Route
path="/dashboard"
element={
<RequireAuth>
<Dashboard />
</RequireAuth>
}
/>
<Route
path="/media"
element={
<RequireAuth>
<Media />
</RequireAuth>
}
/>
<Route
path="/settings"
element={
<RequireAuth>
<Settings />
</RequireAuth>
}
/>
</Route>
</Routes>
</Suspense>
</BrowserRouter>
</Provider>
);

16
ui/src/navigation.js Normal file
View File

@@ -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);
}
};

View File

@@ -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 {
}
>
<Ellipse status="success" />{' '}
<p data-tip="10m and 5s ago">{event.time}</p>
<p
data-tooltip-id="dashboard-tip"
data-tooltip-content="10m and 5s ago"
>
{event.time}
</p>
</div>
</>,
<>
@@ -716,7 +722,7 @@ class Dashboard extends React.Component {
</div>
</div>
</div>
<ReactTooltip />
<ReactTooltip id="dashboard-tip" />
</div>
);
}
@@ -746,5 +752,6 @@ Dashboard.propTypes = {
};
export default withTranslation()(
withRouter(connect(mapStateToProps, mapDispatchToProps)(Dashboard))
connect(mapStateToProps, mapDispatchToProps)(Dashboard)
)
);

View File

@@ -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);

View File

@@ -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);

View File

@@ -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)
)
);

View File

@@ -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)
)
);

View File

@@ -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;