From 24d2e0c294ebbc7d05368c60d4e6e515d6973444 Mon Sep 17 00:00:00 2001 From: Axel Bocciarelli Date: Thu, 21 Nov 2024 11:08:07 +0100 Subject: [PATCH 1/5] Clean up `SessionTable` --- docs/source/dev/login.md | 16 +- ui/src/actions/login.js | 7 - ui/src/actions/remoteAccess.js | 2 +- .../components/LoginForm/SelectProposal.jsx | 37 ++- .../components/LoginForm/SessionDateTime.jsx | 20 ++ .../LoginForm/SessionDateTime.module.css | 4 + ui/src/components/LoginForm/SessionTable.jsx | 220 ++++++++---------- .../LoginForm/SessionTable.module.css | 9 +- ui/src/containers/SampleListViewContainer.jsx | 2 +- 9 files changed, 148 insertions(+), 169 deletions(-) create mode 100644 ui/src/components/LoginForm/SessionDateTime.jsx create mode 100644 ui/src/components/LoginForm/SessionDateTime.module.css diff --git a/docs/source/dev/login.md b/docs/source/dev/login.md index 1d8a57195..b6adec64c 100644 --- a/docs/source/dev/login.md +++ b/docs/source/dev/login.md @@ -13,36 +13,36 @@ and authentication has to be delegated to a process dedicated to authentication. The authorization for a user to use a beamline is performed via the user portal or LIMS system. -### Authentication with single sign on +### Authentication with single sign-on -MXCUBE can be configured to use Single sign-on (SSO) through OpenIDConnect for +MXCUBE can be configured to use single sign-on (SSO) through OpenIDConnect for user authentication. The OpenIDConnect configuration is located in the -`server.yaml` file, which should contain an `sso` section like the one below. +`server.yaml` file, which should contain an `sso` section like the one below: ``` sso: - USE_SSO: false # True to use SSO false otherwise + USE_SSO: false # `true` to use SSO ISSUER: https://websso.[site].[com]/realms/[site]/ # OpenIDConnect issuer URI LOGOUT_URI: "" # OpenIDConnect logout URI TOKEN_INFO_URI: "" # OpenIDConnect token info URI CLIENT_SECRET: ASECRETKEY # OpenIDConnect client secret CLIENT_ID: mxcube # OpenIDConnect client ID - SCOPE: openid email profile # OpenIDConnect defualt scopes, none scope is actually beeing used + SCOPE: openid email profile # OpenIDConnect default scopes, none scope is actually being used CODE_CHALLANGE_METHOD: S256 # OpenIDConnect challange method ``` User authorization is delegated to the LIMS client inheriting `AbstractLims` and is performed in the `login` method. -## HTTP Session management +## HTTP session management -MXCuBE web sessions are meant to expire when there is no activity +MXCuBE web sessions are meant to expire when there is no activity. For this purpose: - Flask configuration setting `PERMANENT_SESSION_LIFETIME` is set to the preferred value (seconds). -- Flask configuration setting `SESSION_REFRESH_EACH_REQUEST` is set, +- Flask configuration setting `SESSION_REFRESH_EACH_REQUEST` is enabled, which is the default anyway. - Flask session setting `session.permanent` is set diff --git a/ui/src/actions/login.js b/ui/src/actions/login.js index d57bc84b3..61661a9c6 100644 --- a/ui/src/actions/login.js +++ b/ui/src/actions/login.js @@ -54,13 +54,6 @@ export function hideProposalsForm() { }; } -export function selectProposalAction(prop) { - return { - type: 'SELECT_PROPOSAL', - proposal: prop, - }; -} - export function setInitialState(data) { return { type: 'SET_INITIAL_STATE', data }; } diff --git a/ui/src/actions/remoteAccess.js b/ui/src/actions/remoteAccess.js index c4b70abfe..694aa5004 100644 --- a/ui/src/actions/remoteAccess.js +++ b/ui/src/actions/remoteAccess.js @@ -16,7 +16,7 @@ import { getLoginInfo } from './login'; import { showWaitDialog } from './waitDialog'; export function getRaState() { - return async (dispatch, getState) => { + return async (dispatch) => { const data = await fetchRemoteAccessState(); dispatch({ type: 'SET_RA_STATE', data: data.data }); }; diff --git a/ui/src/components/LoginForm/SelectProposal.jsx b/ui/src/components/LoginForm/SelectProposal.jsx index 05f8e0d79..9cc9c1641 100644 --- a/ui/src/components/LoginForm/SelectProposal.jsx +++ b/ui/src/components/LoginForm/SelectProposal.jsx @@ -1,6 +1,4 @@ import React from 'react'; -import { connect } from 'react-redux'; -import { reduxForm } from 'redux-form'; import { Modal, Button, Tabs, Tab, Form } from 'react-bootstrap'; import SessionTable from './SessionTable'; @@ -13,11 +11,10 @@ class SelectProposal extends React.Component { this.handleOnSessionSelected = this.handleOnSessionSelected.bind(this); this.state = { - pId: 0, - pNumber: null, + pId: null, session: null, proposal: null, - filter: null, + filter: '', sessions: props.data.proposalList, filteredSessions: props.data.proposalList, }; @@ -28,7 +25,7 @@ class SelectProposal extends React.Component { } handleSelectProposal() { - this.props.selectProposal(this.state.pNumber); + this.props.selectProposal(this.state.pId); } getProposalBySession(session) { @@ -43,7 +40,6 @@ class SelectProposal extends React.Component { proposal: this.getProposalBySession(session), session, pId: session.session_id, - pNumber: session.session_id, }); } @@ -106,7 +102,6 @@ class SelectProposal extends React.Component { sessions={scheduledSessions} selectedSessionId={this.state.pId} filter={this.state.filter} - params={{ showBeamline: false }} onSessionSelected={this.handleOnSessionSelected} /> @@ -117,10 +112,10 @@ class SelectProposal extends React.Component { >
@@ -129,22 +124,22 @@ class SelectProposal extends React.Component { {session && - session.is_scheduled_beamline === true && - session.is_scheduled_time === false && ( + session.is_scheduled_beamline && + !session.is_scheduled_time && ( )} - {session && session.is_scheduled_beamline === false && ( + {session && !session.is_scheduled_beamline && ( + ); + } + + if (!selectedSession.is_scheduled_beamline) { + return ( + + ); + } + + if (!selectedSession.is_scheduled_time) { + return ( + + ); + } + + return ( + + ); +} + +export default ActionButton; diff --git a/ui/src/components/LoginForm/SelectProposal.jsx b/ui/src/components/LoginForm/SelectProposal.jsx index 9cc9c1641..2893ab45f 100644 --- a/ui/src/components/LoginForm/SelectProposal.jsx +++ b/ui/src/components/LoginForm/SelectProposal.jsx @@ -1,171 +1,117 @@ -import React from 'react'; +import React, { useState } from 'react'; import { Modal, Button, Tabs, Tab, Form } from 'react-bootstrap'; import SessionTable from './SessionTable'; +import { useDispatch, useSelector } from 'react-redux'; +import { useNavigate } from 'react-router-dom'; +import { + hideProposalsForm, + selectProposal, + signOut, +} from '../../actions/login'; +import styles from './SessionTable.module.css'; +import ActionButton from './ActionButton'; -class SelectProposal extends React.Component { - constructor(props) { - super(props); +function SelectProposal() { + const dispatch = useDispatch(); + const navigate = useNavigate(); - this.handleSelectProposal = this.handleSelectProposal.bind(this); - this.handleCancel = this.handleCancel.bind(this); - this.handleOnSessionSelected = this.handleOnSessionSelected.bind(this); + const login = useSelector((state) => state.login); + const { loginType, proposalList, selectedProposalID, showProposalsForm } = + login; - this.state = { - pId: null, - session: null, - proposal: null, - filter: '', - sessions: props.data.proposalList, - filteredSessions: props.data.proposalList, - }; - } + const show = + showProposalsForm || (loginType === 'User' && selectedProposalID === null); - handleCancel() { - this.props.handleHide(); - } + const [selectedSession, setSelectedSession] = useState( + proposalList.find((s) => s.session_id === selectedProposalID), + ); + const selectedSessionId = selectedSession ? selectedSession.session_id : null; - handleSelectProposal() { - this.props.selectProposal(this.state.pId); - } + const [filter, setFilter] = useState(''); + const filteredSessions = proposalList.filter( + ({ title, number, code }) => + title.includes(filter) || + number.includes(filter) || + code.includes(filter), + ); - getProposalBySession(session) { - if (!session) { - return ''; - } - return `${session.code}-${session.number}`; - } - - handleOnSessionSelected(session) { - this.setState({ - proposal: this.getProposalBySession(session), - session, - pId: session.session_id, - }); - } + filteredSessions.sort( + (a, b) => (a.actual_start_date < b.actual_start_date ? 1 : -1), // sort by start date + ); - handleChange(event) { - const filteredSessions = this.state.sessions.filter((s) => { - return ( - s.title.includes(event.target.value) || - s.number.includes(event.target.value) || - s.code.includes(event.target.value) - ); - }); + const scheduledSessions = filteredSessions.filter( + (s) => s.is_scheduled_beamline && s.is_scheduled_time, + ); + const unscheduledSessions = filteredSessions.filter( + (s) => !s.is_scheduled_beamline || !s.is_scheduled_time, + ); - this.setState({ - filter: event.target.value, - filteredSessions, - }); + function handleHide() { + if (selectedProposalID === null) { + dispatch(signOut()); + } else { + dispatch(hideProposalsForm()); + } } - render() { - /** sort by start date */ - const sortedlist = this.state.filteredSessions.sort((a, b) => - a.actual_start_date < b.actual_start_date ? 1 : -1, - ); - - const { session } = this.state; - - const scheduledSessions = sortedlist.filter( - (s) => s.is_scheduled_beamline && s.is_scheduled_time, - ); + return ( + handleHide()}> + + Select a session + + + setFilter(evt.target.value)} + /> - const nonScheduledSessions = sortedlist.filter( - (s) => !(s.is_scheduled_beamline && s.is_scheduled_time), - ); - return ( - - - Select a session - - - -
- - - -
- -
-
- -
- -
-
-
-
- - {session && - session.is_scheduled_beamline && - !session.is_scheduled_time && ( - - )} - {session && !session.is_scheduled_beamline && ( - - )} - - - -
- ); - } +
+ +
+ + +
+ +
+
+ +
+ + dispatch(selectProposal(selectedSessionId, navigate))} + /> + + +
+ ); } export default SelectProposal; diff --git a/ui/src/components/LoginForm/SessionDateTime.jsx b/ui/src/components/LoginForm/SessionDateTime.jsx index 7eda17f74..339289303 100644 --- a/ui/src/components/LoginForm/SessionDateTime.jsx +++ b/ui/src/components/LoginForm/SessionDateTime.jsx @@ -1,5 +1,5 @@ import React from 'react'; -import styles from './SessionDateTime.module.css'; +import styles from './SessionTable.module.css'; function SessionDateTime(props) { const { date, time } = props; diff --git a/ui/src/components/LoginForm/SessionDateTime.module.css b/ui/src/components/LoginForm/SessionDateTime.module.css deleted file mode 100644 index 7f6b42fad..000000000 --- a/ui/src/components/LoginForm/SessionDateTime.module.css +++ /dev/null @@ -1,4 +0,0 @@ -.time { - font-size: small; - color: gray; -} diff --git a/ui/src/components/LoginForm/SessionTable.jsx b/ui/src/components/LoginForm/SessionTable.jsx index b9125ced2..ced12711f 100644 --- a/ui/src/components/LoginForm/SessionTable.jsx +++ b/ui/src/components/LoginForm/SessionTable.jsx @@ -13,18 +13,22 @@ export default function SessionTable(props) { onSessionSelected, } = props; + if (sessions.length === 0) { + return No sessions; + } + return ( - - {showBeamline && } - - - - - - + + {showBeamline && } + + + + + + @@ -33,7 +37,14 @@ export default function SessionTable(props) { key={session.session_id} className={styles.row} data-selected={selectedSessionId === session.session_id} + tabIndex={0} onClick={() => onSessionSelected(session)} + onKeyDown={(evt) => { + // For keyboard accessibility; ideally add "Select" button to each row instead of making rows clickable + if (evt.key === 'Enter' || evt.key === ' ') { + onSessionSelected(session); + } + }} > {showBeamline && } @@ -88,6 +99,7 @@ export default function SessionTable(props) { className="p-1" target="_blank" rel="noreferrer" + aria-label="View session in Data Portal" > @@ -98,6 +110,7 @@ export default function SessionTable(props) { className="p-1" target="_blank" rel="noreferrer" + aria-label="View session in User Portal" > @@ -108,6 +121,7 @@ export default function SessionTable(props) { className="p-1" target="_blank" rel="noreferrer" + aria-label="View session logbook" > diff --git a/ui/src/components/LoginForm/SessionTable.module.css b/ui/src/components/LoginForm/SessionTable.module.css index 34ba8efef..454f6bf88 100644 --- a/ui/src/components/LoginForm/SessionTable.module.css +++ b/ui/src/components/LoginForm/SessionTable.module.css @@ -1,3 +1,13 @@ +.table { + overflow: auto; + height: 30rem; + padding: 1rem 0; +} + +.row { + outline-offset: -2px; +} + .row[data-selected='true'] { font-weight: bold; background-color: #add8e6; @@ -6,3 +16,14 @@ .row[data-selected='false'] { cursor: pointer; } + +.time { + margin-left: 0.25rem; + color: #555; + font-size: small; +} + +.fallback { + font-style: italic; + padding: 0 1rem; +} diff --git a/ui/src/components/Main.jsx b/ui/src/components/Main.jsx index 33a95b9c2..4d5088c3f 100644 --- a/ui/src/components/Main.jsx +++ b/ui/src/components/Main.jsx @@ -13,19 +13,19 @@ import PassControlDialog from './RemoteAccess/PassControlDialog'; import ConfirmCollectDialog from '../containers/ConfirmCollectDialog'; import WorkflowParametersDialog from '../containers/WorkflowParametersDialog'; import GphlWorkflowParametersDialog from '../containers/GphlWorkflowParametersDialog'; -import SelectProposalContainer from '../containers/SelectProposalContainer'; -import diagonalNoise from '../img/diagonal-noise.png'; -import { showDialog } from '../actions/general'; import { LimsResultDialog } from './Lims/LimsResultDialog'; import LoadingScreen from './LoadingScreen/LoadingScreen'; +import MXNavbar from './MXNavbar/MXNavbar'; +import ChatWidget from './ChatWidget'; +import ClearQueueDialog from './SampleGrid/ClearQueueDialog'; +import SelectProposal from './LoginForm/SelectProposal'; +import { getInitialState } from '../actions/login'; +import { showDialog } from '../actions/general'; +import diagonalNoise from '../img/diagonal-noise.png'; import styles from './Main.module.css'; import 'react-chat-widget/lib/styles.css'; import './rachat.css'; -import { getInitialState } from '../actions/login'; -import MXNavbar from './MXNavbar/MXNavbar'; -import ChatWidget from './ChatWidget'; -import ClearQueueDialog from './SampleGrid/ClearQueueDialog'; function Main() { const dispatch = useDispatch(); @@ -54,7 +54,7 @@ function Main() { /> )} - + diff --git a/ui/src/containers/SelectProposalContainer.jsx b/ui/src/containers/SelectProposalContainer.jsx deleted file mode 100644 index 7b6e5cbe8..000000000 --- a/ui/src/containers/SelectProposalContainer.jsx +++ /dev/null @@ -1,36 +0,0 @@ -import React from 'react'; -import { useDispatch, useSelector } from 'react-redux'; -import { signOut, selectProposal, hideProposalsForm } from '../actions/login'; -import SelectProposal from '../components/LoginForm/SelectProposal'; -import { useNavigate } from 'react-router-dom'; - -function SelectProposalContainer() { - const dispatch = useDispatch(); - const navigate = useNavigate(); - const login = useSelector((state) => state.login); - - function handleHide() { - if (login.selectedProposalID === null) { - dispatch(signOut()); - } else { - dispatch(hideProposalsForm()); - } - } - - const show = - (login.loginType === 'User' && login.selectedProposalID === null) || - login.showProposalsForm; - - return ( - { - dispatch(selectProposal(selected, navigate)); - }} - /> - ); -} - -export default SelectProposalContainer; From cc4de37ec908ed269a8c174831e8d864ea008b34 Mon Sep 17 00:00:00 2001 From: Axel Bocciarelli Date: Thu, 21 Nov 2024 15:29:35 +0100 Subject: [PATCH 3/5] Make `SelectProposal` truly modal when and only when no session is selected --- ui/src/actions/login.js | 4 +--- ui/src/components/LoginForm/SelectProposal.jsx | 16 +++++++++++----- 2 files changed, 12 insertions(+), 8 deletions(-) diff --git a/ui/src/actions/login.js b/ui/src/actions/login.js index 61661a9c6..2cba30250 100644 --- a/ui/src/actions/login.js +++ b/ui/src/actions/login.js @@ -58,16 +58,14 @@ export function setInitialState(data) { return { type: 'SET_INITIAL_STATE', data }; } -export function selectProposal(number, navigate) { +export function selectProposal(number) { return async (dispatch) => { try { await sendSelectProposal(number); - navigate('/'); dispatch(hideProposalsForm()); dispatch(getLoginInfo()); } catch { dispatch(showErrorPanel(true, 'Server refused to select proposal')); - navigate('/login'); } }; } diff --git a/ui/src/components/LoginForm/SelectProposal.jsx b/ui/src/components/LoginForm/SelectProposal.jsx index 2893ab45f..6dd776bee 100644 --- a/ui/src/components/LoginForm/SelectProposal.jsx +++ b/ui/src/components/LoginForm/SelectProposal.jsx @@ -2,7 +2,6 @@ import React, { useState } from 'react'; import { Modal, Button, Tabs, Tab, Form } from 'react-bootstrap'; import SessionTable from './SessionTable'; import { useDispatch, useSelector } from 'react-redux'; -import { useNavigate } from 'react-router-dom'; import { hideProposalsForm, selectProposal, @@ -13,7 +12,6 @@ import ActionButton from './ActionButton'; function SelectProposal() { const dispatch = useDispatch(); - const navigate = useNavigate(); const login = useSelector((state) => state.login); const { loginType, proposalList, selectedProposalID, showProposalsForm } = @@ -55,7 +53,15 @@ function SelectProposal() { } return ( - handleHide()}> + { + if (showProposalsForm) { + handleHide(); + } + }} + > Select a session @@ -100,14 +106,14 @@ function SelectProposal() { dispatch(selectProposal(selectedSessionId, navigate))} + onClick={() => dispatch(selectProposal(selectedSessionId))} /> From ae5f888aff89e06ef10c385085395464f107c07d Mon Sep 17 00:00:00 2001 From: Axel Bocciarelli Date: Thu, 21 Nov 2024 15:44:54 +0100 Subject: [PATCH 4/5] Fix connection lost dialog appearing when logging out and back in quickly --- ui/src/serverIO.js | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/ui/src/serverIO.js b/ui/src/serverIO.js index 5c5620218..bc7b43dd5 100644 --- a/ui/src/serverIO.js +++ b/ui/src/serverIO.js @@ -73,9 +73,11 @@ class ServerIO { constructor() { this.hwrSocket = null; this.loggingSocket = null; + this.connectionLostTimeout = undefined; } listen() { + clearTimeout(this.connectionLostTimeout); this.disconnect(); // noop if `disconnect` is properly called on logout this.connectHwr(); @@ -105,7 +107,7 @@ class ServerIO { this.hwrSocket.on('disconnect', (reason) => { console.log('hwrSocket disconnected!'); // eslint-disable-line no-console - setTimeout(() => { + this.connectionLostTimeout = setTimeout(() => { dispatch( // Show message if socket still hasn't reconnected (and wasn't manually disconnected in the first place) showConnectionLostDialog(this.hwrSocket && !this.hwrSocket.connected), From 28d028972fe8d65afa25b66e6503bc34444e8a22 Mon Sep 17 00:00:00 2001 From: Axel Bocciarelli Date: Thu, 21 Nov 2024 15:55:27 +0100 Subject: [PATCH 5/5] Use try/finally syntax --- ui/src/actions/login.js | 16 +++++++--------- 1 file changed, 7 insertions(+), 9 deletions(-) diff --git a/ui/src/actions/login.js b/ui/src/actions/login.js index 2cba30250..826baa56b 100644 --- a/ui/src/actions/login.js +++ b/ui/src/actions/login.js @@ -99,16 +99,14 @@ export function ssoLogIn() { export function signOut() { return async (dispatch) => { + dispatch(resetLoginInfo()); // disconnect sockets before actually logging out (cf. `App.jsx`) dispatch(applicationFetched(false)); - // We make sure that user data is reseted so that websockets - // are keept dicconnected while logging out. - dispatch(resetLoginInfo()); - await sendSignOut().finally(() => - dispatch( - // Retreiving the user data from the backend - getLoginInfo(), - ), - ); + + try { + await sendSignOut(); + } finally { + dispatch(getLoginInfo()); + } }; }
IDBeamlineTitleStartEndPortalUserLogbookIDBeamlineTitleStartEndPortalUserLogbook
{`${session.code}-${session.number}`}{session.beamline_name}