Skip to content

Commit

Permalink
[DUOS-2973][risk=no] Bug Fix: Show eRA Commons authentication status …
Browse files Browse the repository at this point in the history
…on the read-only DAR form (#2513)
  • Loading branch information
rushtong authored Apr 9, 2024
1 parent 3085c27 commit fc75667
Show file tree
Hide file tree
Showing 5 changed files with 234 additions and 144 deletions.
112 changes: 112 additions & 0 deletions src/components/ERACommons.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
.era-logo-style {
height: 27px;
width: 38px;
background-repeat: no-repeat;
background-size: contain;
background-image: url(../images/era-commons-logo.png);
display: inline-block;
}

.era-button-state:hover {
box-shadow: 1px 1px 3px #00609f;
}

.era-button-state {
height: 34px;
width: 210px;
display: block;
border: 1px solid #d3d3d3;
padding: 3px;
text-align: center;
margin-top: 3px;
background-color: #fff;
border-radius: 2px;
box-shadow: 1px 1px 3px #999999;
cursor: pointer;
color: #999;
font-weight: 500;
font-size: .8em;
transition: all .2s ease;
}

.era-button-state-error {
height: 34px;
width: 210px;
display: block;
border: 1px solid #d3d3d3;
padding: 3px;
text-align: center;
margin-top: 3px;
background-color: #fff;
border-radius: 2px;
box-shadow: 1px 1px 3px #999999;
cursor: pointer;
font-weight: 500;
font-size: .8em;
transition: all .2s ease;
color: #D13B07;
}

.era-fadein {
-webkit-animation: fadein .6s;
-moz-animation: fadein .6s;
-o-animation: fadein .6s;
animation: fadein .6s;
}

.era-control-label {
font-weight: bold;
color: #333F52;
font-size: 16px;
margin-top: 2rem;
margin-bottom: 1rem;
transition: color .3s ease-in-out;
text-align: left;
}

.era-cancel-color {
color: #D13B07 !important;
}

.era-required-field-error-span {
font-weight: normal;
line-height: 25px;
font-size: 13px;
vertical-align: middle;
}

.era-commons-id-value {
-webkit-text-size-adjust: 100%;
-webkit-tap-highlight-color: rgba(0,0,0,0);
color: #333;
font-size: 14px;
line-height: 1.42857143;
box-sizing: border-box;
min-height: 35px;
}

.era-delete-icon {
-webkit-text-size-adjust: 100%;
-webkit-tap-highlight-color: rgba(0,0,0,0);
box-sizing: border-box;
font: inherit;
overflow: visible;
text-transform: none;
color: #000;
font-size: 21px;
font-weight: 700;
line-height: 1;
opacity: .2;
text-shadow: 0 1px 0 #fff;
appearance: none;
background: 0 0;
border: 0;
cursor: pointer;
padding: 0;
margin: 2px 0 0 10px;
}

.era-expiration-value {
font-style: italic;
display: block;
}
229 changes: 92 additions & 137 deletions src/components/ERACommons.jsx
Original file line number Diff line number Diff line change
@@ -1,175 +1,130 @@
import React, {useEffect, useState} from 'react';
import {get, merge} from 'lodash';
import {find, getOr, isNil} from 'lodash/fp';
import {get} from 'lodash';
import {isNil} from 'lodash/fp';
import queryString from 'query-string';
import './ERACommons.css';
import {AuthenticateNIH, User} from '../libs/ajax';
import {Config} from '../libs/config';
import eraIcon from '../images/era-commons-logo.png';
import './Animations.css';
import {decodeNihToken, expirationCountFromDate} from '../../src/utils/ERACommonsUtils';
import {decodeNihToken, extractEraAuthenticationState} from '../../src/utils/ERACommonsUtils';
import ReactTooltip from 'react-tooltip';

export default function ERACommons(props) {

const {onNihStatusUpdate, header = false, required = false, destination = '', researcherProfile = undefined, readOnly = false} = props;
const [search, setSearch] = useState(props.location?.search || '');
const [isAuthorized, setAuthorized] = useState(false);
const [expirationCount, setExpirationCount] = useState(0);
const [eraCommonsId, setEraCommonsId] = useState('');
const [nihError, setNihError] = useState(false);
const [hovered, setHovered] = useState(false);
const [researcherProfile, setResearcherProfile] = useState({});

const getUserInfo = async () => {
const response = await User.getMe();
const propsData = response.researcherProperties;
setCommonResearcherPropertyState(propsData, response.eraCommonsId);
};

/**
* This function sets the common ERA Commons properties used in state. The user we are setting properties for
* can either be a provided researcher object or the current authenticated user.
* @param properties List of user properties to read from.
* @param eraCommonsId The user's eRA Commons ID
*/
const setCommonResearcherPropertyState = (properties, eraCommonsId) => {
const authProp = find({'propertyKey':'eraAuthorized'})(properties);
const expProp = find({'propertyKey':'eraExpiration'})(properties);
const isAuthorized = isNil(authProp) ? false : getOr(false,'propertyValue')(authProp);
const expirationCount = isNil(expProp) ? 0 : expirationCountFromDate(getOr(0,'propertyValue')(expProp));
const nihValid = isAuthorized && expirationCount > 0;
props.onNihStatusUpdate(nihValid);
setAuthorized(isAuthorized);
setExpirationCount(expirationCount);
setEraCommonsId(isNil(eraCommonsId) ? '' : eraCommonsId);
};

/**
* If called as a user who has just authenticated with NIH, this function will save the token and update the user's
* properties. Otherwise, it will populate state from either the provided researcher object or the current user.
* This hook is called only when the user is redirected back to the original page after authenticating with NIH.
*/
useEffect(() => {
const fetchData = async () => {
// If we have a token to verify, save it before getting user info
if (props.location !== undefined && props.location.search !== '') {
await saveNIHAuthentication(props.location.search);
} else if (props.readOnly && props.researcherProfile) {
// In the read-only case, we are provided a researcher object and do not need to query for the current user.
// We should never be in this state without a provided researcher profile object.
setResearcherProfile(props.researcherProfile);
const propsData = researcherProfile.properties;
setCommonResearcherPropertyState(propsData, researcherProfile.eraCommonsId);
} else {
await getUserInfo();
// If we have a token to verify, save it before getting user info
const initEraAuthSuccess = async () => {
if (search !== '') {
const rawToken = queryString.parse(search);
const decodedToken = await decodeNihToken(rawToken);
if (isNil(decodedToken)) {
setNihError(true);
return;
}
// Rewrite the payload to match the expected format in Consent, so we can save the values on the back end.
const nihPayload = {
'linkedNihUsername': `${decodedToken.eraCommonsUsername}`,
'linkExpireTime': `${decodedToken.exp}`,
'status': true
};
const newUserProps = await AuthenticateNIH.saveNihUsr(nihPayload);
const eraAuthState = extractEraAuthenticationState({properties: newUserProps, eraCommonsId: decodedToken.eraCommonsUsername});
setAuthorized(eraAuthState.isAuthorized);
setExpirationCount(eraAuthState.expirationCount);
setEraCommonsId(eraAuthState.eraCommonsId);
onNihStatusUpdate(eraAuthState.nihValid);
document.getElementById('era-commons-id').scrollIntoView({block: 'start', inline: 'nearest', behavior: 'smooth'});
}
};
fetchData();
}, []);
initEraAuthSuccess();
}, [onNihStatusUpdate, search]);

const onMouseEnter = () => {
setHovered(true);
};

const onMouseLeave = () => {
setHovered(false);
};

const saveNIHAuthentication = async (searchArg) => {
const rawToken = queryString.parse(searchArg);
const decodedToken = await decodeNihToken(rawToken);
if (isNil(decodedToken)) {
setNihError(true);
return;
}
// We need to rewrite the payload to match the expected format in Consent.
const nihPayload = {
'linkedNihUsername': `${decodedToken.eraCommonsUsername}`,
'linkExpireTime': `${decodedToken.exp}`,
'status': true
/**
* This will populate state from either the provided researcher object or the current user.
*/
useEffect(() => {
const initResearcherProfile = async () => {
// In the case we are provided a researcherProfile object, we do not need to query for the current user.
const user = (researcherProfile) ? researcherProfile : await User.getMe();
const eraAuthState = extractEraAuthenticationState(user);
setAuthorized(eraAuthState.isAuthorized);
setExpirationCount(eraAuthState.expirationCount);
setEraCommonsId(eraAuthState.eraCommonsId);
onNihStatusUpdate(eraAuthState.nihValid);
};
const updatedUserProps = await AuthenticateNIH.saveNihUsr(nihPayload);
setResearcherProfile(updatedUserProps);
setCommonResearcherPropertyState(updatedUserProps, decodedToken.eraCommonsUsername);
document.getElementById('era-commons-id').scrollIntoView(
{block: 'start', inline: 'nearest', behavior: 'smooth'}
);
};
initResearcherProfile();
}, [researcherProfile, onNihStatusUpdate]);

const redirectToNihLogin = async () => {
const destination = window.location.origin + '/' + props.destination + '?nih-username-token=<token>';
const nihUrl = `${ await Config.getNihUrl() }?${queryString.stringify({ 'return-url': destination })}`;
window.location.href = nihUrl;
const returnUrl = window.location.origin + '/' + destination + '?nih-username-token=<token>';
window.location.href = `${ await Config.getNihUrl() }?${queryString.stringify({ 'return-url': returnUrl })}`;
};

const deleteNihAccount = async () => {
AuthenticateNIH.deleteAccountLinkage().then(
() => getUserInfo(),
() => setNihError(true)
);
const deleteResponse = await AuthenticateNIH.deleteAccountLinkage();
if (deleteResponse) {
const response = await User.getMe();
const eraAuthState = extractEraAuthenticationState(response.researcherProperties);
setAuthorized(eraAuthState.isAuthorized);
setExpirationCount(eraAuthState.expirationCount);
setEraCommonsId(researcherProfile.eraCommonsId);
onNihStatusUpdate(eraAuthState.nihValid);
setSearch('');
} else {
setNihError(true);
document.getElementById('era-commons-id').scrollIntoView({block: 'start', inline: 'nearest', behavior: 'smooth'});
}
};

const logoStyle = {
height: 27,
width: 38,
backgroundRepeat: 'no-repeat',
backgroundSize: 'contain',
backgroundImage: `url(${eraIcon})`,
display: 'inline-block'
};
const buttonHoverState = {
boxShadow: '1px 1px 3px #00609f'
};
const buttonNormalState = {
height: 34,
width: 210,
display: 'block',
border: '1px solid #d3d3d3',
padding: 3,
textAlign: 'center',
marginTop: 3,
backgroundColor: '#fff',
borderRadius: 2,
boxShadow: '1px 1px 3px #999999',
cursor: 'pointer',
color: '#999',
fontWeight: 500,
fontSize: '.8em',
transition: 'all .2s ease'
};
const validationErrorState = get(props, 'validationError', false) ? {
color: '#D13B07'
} : {};
const buttonStyle =
merge(hovered ? merge(buttonNormalState, buttonHoverState) : buttonNormalState, validationErrorState);
const validationErrorState = get(props, 'validationError', false);
const nihErrorMessage = 'Something went wrong. Please try again.';

return (
<div id={'era-commons-id'} className={props.className} style={{ minHeight: 65, ...props.style }}>
{props.header && <label className="control-label">
<span data-cy="era-commons-header">NIH eRA Commons ID
{isNil(props.required) || props.required === true ? <span data-cy="era-commons-required">*</span> : ''}
<div id={'era-commons-id'} style={{ minHeight: 65 }}>
{header && <label className='era-control-label'>
<span data-cy='era-commons-header'>NIH eRA Commons ID
{required ? <span data-cy='era-commons-required'>*</span> : ''}
</span>
</label>}
{(!isAuthorized || expirationCount < 0) && (!props.readOnly &&
<a
data-cy="era-commons-authenticate-link"
style={buttonStyle}
onMouseEnter={onMouseEnter}
onMouseLeave={onMouseLeave}
onClick={redirectToNihLogin}
target="_blank">
<div style={logoStyle} />
<span style={{ verticalAlign: '50%' }}>Authenticate your account</span>
</a>
{(!isAuthorized || expirationCount < 0) && (!readOnly &&
<a
data-cy='era-commons-authenticate-link'
className={validationErrorState ? 'era-button-state-error' : 'era-button-state'}
onClick={redirectToNihLogin}
target='_blank'>
<div className={'era-logo-style'}/>
<span style={{verticalAlign: '50%'}}>Authenticate your account</span>
</a>
)}
{nihError && <span className="cancel-color required-field-error-span">{nihErrorMessage}</span>}
{nihError && <span className='era-cancel-color era-required-field-error-span'>{nihErrorMessage}</span>}
{isAuthorized && <div>
{expirationCount >= 0 && <div className="col-lg-12 col-md-12 col-sm-12 col-xs-12 no-padding">
<div data-cy="era-commons-id-value" style={{ float: 'left', fontWeight: 500, display: 'inline', paddingTop: 5 }}>{eraCommonsId}</div>
{!props.readOnly && <button style={{ float: 'left', margin: '2px 0 0 10px' }} type="button" onClick={deleteNihAccount} className="close">
<span className="glyphicon glyphicon-remove-circle" data-tip="Clear account" data-for="tip_clearNihAccount" />
</button>}
{expirationCount >= 0 && <div className='era-commons-id-value'>
<span data-cy='era-commons-id-value'>{eraCommonsId}</span>
{!readOnly &&
<button className='era-delete-icon' type='button' onClick={deleteNihAccount}>
<span className='glyphicon glyphicon-remove-circle' data-tip='Clear account' data-for='tip_clear_era_commons_link' />
</button>
}
{!readOnly &&
<ReactTooltip
place={'right'}
effect={'solid'}
id={`tip_clear_era_commons_link`}>Clear eRA Commons Account Link</ReactTooltip>
}
</div>}
<div style={{ marginTop: 8, fontStyle: 'italic', display: 'block' }} className="col-lg-12 col-md-12 col-sm-6 col-xs-12 no-padding">
{expirationCount >= 0 && <div className="fadein">{`${props.readOnly ? 'This user\'s' : 'Your'} NIH authentication will expire in ${expirationCount} days`}</div>}
{expirationCount < 0 && <div className="fadein">{`${props.readOnly ? 'This user\'s' : 'Your'} NIH authentication has expired`}</div>}
<div className='era-expiration-value'>
{expirationCount >= 0 && <div className='era-fadein'>{`${readOnly ? 'This user\'s' : 'Your'} NIH authentication will expire in ${expirationCount} days`}</div>}
{expirationCount < 0 && <div className='era-fadein'>{`${readOnly ? 'This user\'s' : 'Your'} NIH authentication has expired`}</div>}
</div>
</div>}
</div>
Expand Down
Loading

0 comments on commit fc75667

Please sign in to comment.