diff --git a/app/components/SignatureCard/index.tsx b/app/components/SignatureCard/index.tsx index df8cb9b46..4b675091a 100644 --- a/app/components/SignatureCard/index.tsx +++ b/app/components/SignatureCard/index.tsx @@ -45,19 +45,25 @@ const SignatureCard = ({ const { userDetails } = useSecurity(); const [userSignature, setUserSignature] = useState(); - const [role, setRole] = useState(''); + const [role, setRole] = useState(); useEffect(() => { + if (type) { // need to do these separately because signatures may be null + if (type === 'author') { + setRole('author'); + } else if (type === 'reviewer') { + setRole('reviewer'); + } else if (type === 'creator') { + setRole('creator'); + } + } if (signatures && type) { if (type === 'author') { setUserSignature(signatures.authorSignature); - setRole('author'); } else if (type === 'reviewer') { setUserSignature(signatures.reviewerSignature); - setRole('reviewer'); } else if (type === 'creator') { setUserSignature(signatures.creatorSignature); - setRole('bioinformatician'); } } }, [signatures, type, setRole]); @@ -65,12 +71,18 @@ const SignatureCard = ({ const handleSign = useCallback(async () => { let newReport = null; + let reportRole = role; // Assign user try { + if (role === 'creator') { + reportRole = 'bioinformatician'; + } else if (role === 'author') { + // Hardcode analyst role here because report does not accept 'author' + reportRole = 'analyst'; + } newReport = await api.post( `/reports/${report.ident}/user`, - // Hardcode analyst role here because report does not accept 'author' - { user: userDetails.ident, role: 'analyst' }, + { user: userDetails.ident, role: reportRole }, {}, ).request(); } catch (e) { @@ -79,7 +91,6 @@ const SignatureCard = ({ snackbar.error('Error assigning user to report: ', e.message); } } - // Do signature try { const newSignature = await api.put( @@ -198,9 +209,9 @@ const SignatureCard = ({ Date {renderDate ?? ( - - {NON_BREAKING_SPACE} - + + {NON_BREAKING_SPACE} + )} {userSignature?.ident && canEdit && ( diff --git a/app/context/ResourceContext/index.tsx b/app/context/ResourceContext/index.tsx index e00f555ed..9daf7a45a 100644 --- a/app/context/ResourceContext/index.tsx +++ b/app/context/ResourceContext/index.tsx @@ -42,6 +42,7 @@ const useResources = (): ResourceContextType => { */ const [reportAssignmentAccess, setReportAssignmentAccess] = useState(false); const [adminAccess, setAdminAccess] = useState(false); + const [allProjectsAccess, setAllProjectsAccess] = useState(false); const [managerAccess, setManagerAccess] = useState(false); /** * Is the user allowed to see the settings page @@ -67,6 +68,10 @@ const useResources = (): ResourceContextType => { setAdminAccess(true); } + if (checkAccess(groups, [...ADMIN_ACCESS, 'all projects access'], ADMIN_BLOCK)) { + setAllProjectsAccess(true); + } + if (checkAccess(groups, [...ADMIN_ACCESS, 'manager'], ADMIN_BLOCK)) { setManagerAccess(true); } @@ -99,38 +104,40 @@ const useResources = (): ResourceContextType => { }, [groups]); return { - germlineAccess, - reportsAccess, adminAccess, + allProjectsAccess, + allStates: ALL_STATES, + appendixEditAccess, + germlineAccess, managerAccess, - reportSettingAccess, - reportEditAccess, - reportAssignmentAccess, - unreviewedAccess, nonproductionAccess, + nonproductionStates: NONPRODUCTION_STATES, + reportAssignmentAccess, + reportEditAccess, + reportSettingAccess, + reportsAccess, templateEditAccess, - appendixEditAccess, - allStates: ALL_STATES, + unreviewedAccess, unreviewedStates: UNREVIEWED_STATES, - nonproductionStates: NONPRODUCTION_STATES, }; }; const ResourceContext = createContext({ - germlineAccess: false, - reportsAccess: false, adminAccess: false, + allProjectsAccess: false, + allStates: ALL_STATES, + appendixEditAccess: false, + germlineAccess: false, managerAccess: false, - reportSettingAccess: false, - reportEditAccess: false, - reportAssignmentAccess: false, - unreviewedAccess: false, nonproductionAccess: false, + nonproductionStates: NONPRODUCTION_STATES, + reportAssignmentAccess: false, + reportEditAccess: false, + reportSettingAccess: false, + reportsAccess: false, templateEditAccess: false, - appendixEditAccess: false, - allStates: ALL_STATES, + unreviewedAccess: false, unreviewedStates: UNREVIEWED_STATES, - nonproductionStates: NONPRODUCTION_STATES, }); type ResourceContextProviderProps = { @@ -139,44 +146,55 @@ type ResourceContextProviderProps = { const ResourceContextProvider = ({ children }: ResourceContextProviderProps): JSX.Element => { const { - germlineAccess, reportsAccess, adminAccess, managerAccess, reportSettingAccess, reportEditAccess, reportAssignmentAccess, unreviewedAccess, nonproductionAccess, - templateEditAccess, - appendixEditAccess, + adminAccess, + allProjectsAccess, allStates, - unreviewedStates, + appendixEditAccess, + germlineAccess, + managerAccess, + nonproductionAccess, nonproductionStates, + reportAssignmentAccess, + reportEditAccess, + reportSettingAccess, + reportsAccess, + templateEditAccess, + unreviewedAccess, + unreviewedStates, } = useResources(); const providerValue = useMemo(() => ({ - germlineAccess, - reportsAccess, adminAccess, + allProjectsAccess, + allStates, + appendixEditAccess, + germlineAccess, managerAccess, - reportSettingAccess, - reportEditAccess, - reportAssignmentAccess, - unreviewedAccess, nonproductionAccess, + nonproductionStates, + reportAssignmentAccess, + reportEditAccess, + reportSettingAccess, + reportsAccess, templateEditAccess, - appendixEditAccess, - allStates, + unreviewedAccess, unreviewedStates, - nonproductionStates, }), [ - germlineAccess, - reportsAccess, adminAccess, + allProjectsAccess, + allStates, + appendixEditAccess, + germlineAccess, managerAccess, - reportSettingAccess, - reportEditAccess, - reportAssignmentAccess, - unreviewedAccess, nonproductionAccess, + nonproductionStates, + reportAssignmentAccess, + reportEditAccess, + reportSettingAccess, + reportsAccess, templateEditAccess, - appendixEditAccess, - allStates, + unreviewedAccess, unreviewedStates, - nonproductionStates, ]); return ( diff --git a/app/context/ResourceContext/types.d.ts b/app/context/ResourceContext/types.d.ts index 8c818d4fd..d95f0bb8e 100644 --- a/app/context/ResourceContext/types.d.ts +++ b/app/context/ResourceContext/types.d.ts @@ -1,18 +1,19 @@ type ResourceContextType = { - germlineAccess: boolean; - reportsAccess: boolean; adminAccess: boolean; - managerAccess: boolean; - templateEditAccess: boolean; + allStates: string[]; + allProjectsAccess: boolean; appendixEditAccess: boolean; - reportSettingAccess: boolean; - reportEditAccess: boolean; + germlineAccess: boolean; + managerAccess: boolean; + nonproductionAccess: boolean; + nonproductionStates: string[]; reportAssignmentAccess: boolean; + reportEditAccess: boolean; + reportSettingAccess: boolean; + reportsAccess: boolean; + templateEditAccess: boolean; unreviewedAccess: boolean; - nonproductionAccess: boolean; - allStates: string[]; unreviewedStates: string[]; - nonproductionStates: string[]; }; export default ResourceContextType; diff --git a/app/views/AdminView/components/Users/components/AddEditUserDialog/index.tsx b/app/views/AdminView/components/Users/components/AddEditUserDialog/index.tsx index 45c042c92..3749ec341 100644 --- a/app/views/AdminView/components/Users/components/AddEditUserDialog/index.tsx +++ b/app/views/AdminView/components/Users/components/AddEditUserDialog/index.tsx @@ -1,5 +1,7 @@ /* eslint-disable @typescript-eslint/ban-ts-comment */ -import React, { useState, useEffect, useCallback } from 'react'; +import React, { + useState, useEffect, useCallback, useMemo, +} from 'react'; import { CircularProgress, Dialog, @@ -60,64 +62,82 @@ const AddEditUserDialog = ({ register, control, handleSubmit, formState: { errors: formErrors, dirtyFields }, setValue, } = useForm({ mode: 'onTouched', - defaultValues: { - username: '', - firstName: '', - lastName: '', - email: '', - projects: [], - groups: [], - type: CONFIG.STORAGE.DATABASE_TYPE, - }, + defaultValues: useMemo(() => { + if (editData) { + return ({ + username: editData.username, + firstName: editData.firstName, + lastName: editData.lastName, + email: editData.email, + projects: editData.projects.map(({ ident }) => ident), + groups: editData.groups.map(({ ident }) => ident), + type: editData.type, + }); + } + return ({ + username: '', + firstName: '', + lastName: '', + email: '', + projects: [], + groups: [], + type: CONFIG.STORAGE.DATABASE_TYPE, + }); + }, [editData]), }); const [projectOptions, setProjectOptions] = useState([]); const [groupOptions, setGroupOptions] = useState([]); const [dialogTitle, setDialogTitle] = useState(''); const [isApiCalling, setIsApiCalling] = useState(false); + const [isDataLoading, setIsDataLoading] = useState(false); const { userDetails } = useSecurity(); - const { adminAccess } = useResource(); + const { adminAccess, allProjectsAccess } = useResource(); // Grab project and groups useEffect(() => { let cancelled = false; const getData = async () => { - const [projectsResp, groupsResp] = await Promise.all([ - api.get('/project').request(), - api.get('/user/group').request(), - ]); - if (!cancelled) { - const nonAdminGroups = []; - groupsResp.forEach((group) => (group.name !== 'admin' ? nonAdminGroups.push(group) : null)); - if (adminAccess) { - setProjectOptions(projectsResp); - setGroupOptions(groupsResp); - } else if (editData) { - const combinedProjects = userDetails.projects.concat(editData.projects); - const combinedUniqueProjects = [...new Map(combinedProjects.map((project) => [project.ident, project])).values()]; - setProjectOptions(combinedUniqueProjects); - setGroupOptions(nonAdminGroups); - } else { - setProjectOptions(userDetails.projects); - setGroupOptions(nonAdminGroups); + try { + setIsDataLoading(true); + if (!cancelled) { + const [projectsResp, groupsResp] = await Promise.all([ + api.get('/project').request(), + api.get('/user/group').request(), + ]); + const nonAdminGroups = groupsResp.filter((group) => (group.name !== 'admin')); + + if (adminAccess) { + setProjectOptions(projectsResp); + setGroupOptions(groupsResp); + } else if (editData) { + // Editing existing entry + const combinedProjects = userDetails.projects.concat(editData.projects); + const combinedUniqueProjects = [...new Map(combinedProjects.map((project) => [project.ident, project])).values()]; + + setProjectOptions(allProjectsAccess ? projectsResp : combinedUniqueProjects); + setGroupOptions(nonAdminGroups); + } else { + // New entry + setProjectOptions(allProjectsAccess ? projectsResp : userDetails.projects); + setGroupOptions(nonAdminGroups); + } } + } catch (projectGroupErr) { + snackbar.error('Failed to retrieve list of project and groups for current user.'); + console.error(projectGroupErr); + } finally { + setIsDataLoading(false); } }; getData(); return function cleanup() { cancelled = true; }; - }, [adminAccess, editData, userDetails.projects]); + }, [adminAccess, allProjectsAccess, editData, userDetails.projects]); // When params changed useEffect(() => { if (editData) { setDialogTitle('Edit user'); - Object.entries(editData).forEach(([key, val]) => { - let nextVal = val; - if (Array.isArray(val)) { - nextVal = val.map(({ ident }) => ident); - } - setValue(key as keyof UserForm, nextVal as string | string[]); - }); } else { setDialogTitle('Add user'); } @@ -251,6 +271,87 @@ const AddEditUserDialog = ({ if (type === 'pattern') emailErrorText = 'Email format is invalid'; } + const projectSelectSection = useMemo(() => ( + + Projects + ( + + )} + /> + + ), [adminAccess, control, editData?.projects, projectOptions, userDetails?.projects]); + + const groupSelectionSection = useMemo(() => ( + + Groups + ( + + )} + /> + + ), [control, groupOptions]); + return ( - {projectOptions.length && groupOptions.length ? ( + {!isDataLoading ? ( <> - - Projects - ( - - )} - /> - - - Groups - ( - - )} - /> - + {projectSelectSection} + {groupSelectionSection} ) : (
diff --git a/app/views/AdminView/components/Users/index.scss b/app/views/AdminView/components/Users/index.scss index 594511d2a..6c837b642 100644 --- a/app/views/AdminView/components/Users/index.scss +++ b/app/views/AdminView/components/Users/index.scss @@ -1,3 +1,3 @@ .admin-table__container { - height: 93.75%; + height: calc(100vh - 56px - 48px - 8px); } diff --git a/app/views/MainView/index.tsx b/app/views/MainView/index.tsx index d5982e707..0d59bd912 100644 --- a/app/views/MainView/index.tsx +++ b/app/views/MainView/index.tsx @@ -25,6 +25,7 @@ import snackbar from '@/services/SnackbarUtils'; import { keycloak, logout } from '@/services/management/auth'; import './index.scss'; import { Box } from '@mui/system'; +import { toInteger } from 'lodash'; const LoginView = lazy(() => import('../LoginView')); const TermsView = lazy(() => import('../TermsView')); @@ -42,9 +43,40 @@ const LinkOutView = lazy(() => import('../LinkOutView')); const TemplateView = lazy(() => import('../TemplateView')); const ProjectsView = lazy(() => import('../ProjectsView')); +function formatTime(seconds) { + const hours = Math.floor(seconds / 3600); + const minutes = Math.floor((seconds % 3600) / 60); + const remainingSeconds = seconds % 60; + return `${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}:${remainingSeconds.toString().padStart(2, '0')}`; +} + +const CountDown = memo(({ startingTime }: { + startingTime: number; +}) => { + const [seconds, setSeconds] = useState(0); + useEffect(() => { + setSeconds(startingTime); + }, [startingTime]); + + useEffect(() => { + const intervalId = setInterval(() => { + setSeconds((s) => { + if (s > 0) { + return s - 1; + } + clearInterval(intervalId); + return 0; + }); + }, 1000); // countdown interval of 1 second + + return () => clearInterval(intervalId); // cleanup on unmount + }, [startingTime]); + + return {formatTime(seconds)}; +}); + // What fraction of TIME ELAPSED should the user be notified of expiring token const TIMEOUT_FRACTION = 0.9; -const MIN_TIMEOUT = 60000; type TimeoutModalPropTypes = { authorizationToken: string; @@ -54,6 +86,8 @@ type TimeoutModalPropTypes = { const TimeoutModal = memo(({ authorizationToken, setAuthorizationToken }: TimeoutModalPropTypes) => { const { location: { key: locationKey } } = useHistory(); const [open, setIsOpen] = useState(false); + // Seconds in which to show in the countdown component + const [countDown, setCountDown] = useState(0); const [isLoading, setIsLoading] = useState(false); const timerRef = useRef(null); @@ -71,12 +105,15 @@ const TimeoutModal = memo(({ authorizationToken, setAuthorizationToken }: Timeou // First load is untracked, until authorizationToken changes useEffect(() => { if (authorizationToken) { - // Depending on KC setting, whichever one of these token expire will cause a 400 for refresh, so we take the lower one - const leastTimeToExp = (Math.min(keycloak.tokenParsed.exp, keycloak.refreshTokenParsed.exp) * 1000 - Date.now()) * TIMEOUT_FRACTION; - // Minimum 1 min timeout timer - const timeout = Math.max(leastTimeToExp, MIN_TIMEOUT); + // Ms in which token will expire + const minTimeToExpire = Math.min(keycloak.tokenParsed.exp, keycloak.refreshTokenParsed.exp) * 1000; + + const timeToShowModal = (minTimeToExpire - Date.now()) * TIMEOUT_FRACTION; - timerRef.current = setTimeout(() => { setIsOpen(true); }, timeout); + timerRef.current = setTimeout(() => { + setIsOpen(true); + setCountDown(toInteger((minTimeToExpire - Date.now()) / 1000)); + }, timeToShowModal); } return () => { if (timerRef.current) { clearTimeout(timerRef.current); } @@ -105,7 +142,11 @@ const TimeoutModal = memo(({ authorizationToken, setAuthorizationToken }: Timeou Session Timeout Notification -

Your session is about to expire, would you like to remain logged in?

+

+ {'Your session is about to expire in '} + + , would you like to remain logged in? +

diff --git a/package-lock.json b/package-lock.json index bccb28a1c..971ec2fed 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "ipr-client", - "version": "6.31.0", + "version": "6.31.1", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "ipr-client", - "version": "6.31.0", + "version": "6.31.1", "license": "GPL-3.0", "dependencies": { "@ag-grid-community/client-side-row-model": "~25.3.0", diff --git a/package.json b/package.json index e1c323164..57ea05772 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "private": true, "name": "ipr-client", - "version": "6.31.0", + "version": "6.31.1", "keywords": [], "license": "GPL-3.0", "sideEffects": false,