diff --git a/.circleci/config.yml b/.circleci/config.yml index 52c5ec8476..e14a08fc78 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -349,7 +349,7 @@ workflows: filters: branches: only: - - free + - feature/un-677 # This is alternate dev env for parallel testing - "build-test": context : org-global diff --git a/__tests__/shared/components/Settings/Account/__snapshots__/index.jsx.snap b/__tests__/shared/components/Settings/Account/__snapshots__/index.jsx.snap index 3b2db9879e..207666379e 100644 --- a/__tests__/shared/components/Settings/Account/__snapshots__/index.jsx.snap +++ b/__tests__/shared/components/Settings/Account/__snapshots__/index.jsx.snap @@ -12,6 +12,702 @@ exports[`renders account setting page correctly 1`] = ` Account information & Security + { + const handleChange = () => { + onSelectionChange(value); + }; + + const inputId = `roundedInputFieldRadioGroup-${value}`; + return ( +
+
+ {text} +
+ +
+ ); +}; + + +FormInputRadio.propTypes = { + text: PT.string.isRequired, + value: PT.string.isRequired, + selectedValue: PT.string.isRequired, + onSelectionChange: PT.func.isRequired, +}; + +export default FormInputRadio; diff --git a/src/shared/components/Settings/Account/MyPrimaryRole/FormInputRadio/styles.scss b/src/shared/components/Settings/Account/MyPrimaryRole/FormInputRadio/styles.scss new file mode 100644 index 0000000000..58366d3fdb --- /dev/null +++ b/src/shared/components/Settings/Account/MyPrimaryRole/FormInputRadio/styles.scss @@ -0,0 +1,104 @@ +@import "~styles/mixins"; + +.rounded-input-field { + box-sizing: border-box; + display: flex; + flex-direction: row; + align-items: center; + justify-content: space-between; + padding-left: 16px; + height: 72px; + background: #fff; + border: 1px solid #d4d4d4; + border-radius: 8px; + + &:last-child { + margin-right: 0; + margin-bottom: 0; + } + + @media (min-width: 768px) { + width: calc(50% - 32px); // Subtract half the gap size from each button width + margin-right: 32px; // Add the other half of the gap size as margin + } + + @media (max-width: 768px) { + margin-bottom: 24px; + + &:last-child { + margin-bottom: 0; + } + } +} + +.input-option { + display: flex; + flex-direction: row; + align-items: center; + justify-content: space-between; + width: 100%; +} + +.input-text-wrapper { + display: flex; + align-items: center; +} + +.input-text { + @include roboto-medium; + + font-size: 16px; + line-height: 20px; + letter-spacing: 0.5px; + text-transform: capitalize; + color: $tco-black; + flex-grow: 0; +} + +.input-radio-wrapper { + position: relative; + display: flex; + align-items: center; + flex: none; + order: 0; + align-self: stretch; + flex-grow: 0; +} + +.input-radio { + position: absolute; + opacity: 0; + width: 20px; + height: 20px; + cursor: pointer; + z-index: 1; +} + +.custom-radio { + position: absolute; + right: 16px; + top: 55%; + transform: translateY(-50%); + width: 20px; + height: 20px; + background-color: white; + border: 2px solid #aaa; + border-radius: 50%; + cursor: pointer; +} + +.input-radio:checked ~ .custom-radio { + border-color: #137d60; +} + +.input-radio:checked ~ .custom-radio::after { + content: ""; + position: absolute; + width: 10px; + height: 10px; + background-color: #137d60; + border-radius: 50%; + left: 50%; + top: 50%; + transform: translate(-50%, -50%); +} diff --git a/src/shared/components/Settings/Account/MyPrimaryRole/index.jsx b/src/shared/components/Settings/Account/MyPrimaryRole/index.jsx new file mode 100644 index 0000000000..79100e28df --- /dev/null +++ b/src/shared/components/Settings/Account/MyPrimaryRole/index.jsx @@ -0,0 +1,103 @@ +import React, { useState, useEffect } from 'react'; +import { withRouter } from 'react-router-dom'; +import { SettingBannerV2 as Collapse } from 'components/Settings/SettingsBanner'; +import { Modal } from 'topcoder-react-ui-kit'; +import { config } from 'topcoder-react-utils'; +import PT from 'prop-types'; +import FormInputRadio from './FormInputRadio'; + +import style from './styles.scss'; + +const MyPrimaryRole = ({ + user, tokenV3, updatePrimaryRole, +}) => { + const [primaryRole, setPrimaryRole] = useState(''); + const [showModal, setShowModal] = useState(false); + + useEffect(() => { + if (user.roles.indexOf('Topcoder Talent') !== -1) { + setPrimaryRole('Topcoder Talent'); + } else { + setPrimaryRole('Topcoder Customer'); + } + }, [user.roles]); + + const handleRoleChange = (value) => { + setPrimaryRole(value); + updatePrimaryRole(value, tokenV3); + setShowModal(true); + }; + + const AUTH_URL = config.URL.AUTH; + const handleSignoutClick = () => { + window.location.href = `${AUTH_URL}?logout=true&retUrl=${encodeURIComponent(config.URL.COMMUNITY_APP)}`; + }; + + return ( + + {showModal && ( + +
+ +
+
+ CONFIRMED +
+
+ +
+ + You have successfully changed your account role. Please sign out of your account + and login to complete this update. + +
+ +
+ +
+ +
+
+ )} +
+ +

Account Role

+
+
+ Access to Topcoder tools and applications are based on your account + role. If you change this setting, you will be required to sign out + of your account and login. +
+
+
+ + + +
+
+
+
+
+ ); +}; + +MyPrimaryRole.propTypes = { + user: PT.shape().isRequired, + tokenV3: PT.string.isRequired, + updatePrimaryRole: PT.func.isRequired, +}; + + +export default withRouter(MyPrimaryRole); diff --git a/src/shared/components/Settings/Account/MyPrimaryRole/styles.scss b/src/shared/components/Settings/Account/MyPrimaryRole/styles.scss new file mode 100644 index 0000000000..787ff5d4dc --- /dev/null +++ b/src/shared/components/Settings/Account/MyPrimaryRole/styles.scss @@ -0,0 +1,254 @@ +@import "../../style"; +@import "~styles/mixins"; + +.hide { + display: none; +} + +.form-container-default { + display: flex; + flex-direction: column; + + .form-default { + display: block; + + @include upto-sm { + display: none; + } + } + + .form-mobile { + display: none; + + @include upto-sm { + display: block; + + .row { + display: flex; + flex-direction: column; + } + } + } + + input { + @include roboto-regular; + + height: 40px; + font-size: 15px; + line-height: 20px; + font-weight: 400; + color: $tc-black; + border: 1px solid $tc-gray-20; + border-radius: $corner-radius * 2 $corner-radius * 2 $corner-radius * 2 $corner-radius * 2; + margin-bottom: 0; + } + + .form-field { + background: white; + color: black; + + &:disabled { + color: #b7b7b7; + } + + &.grey { + background-color: #fcfcfc; + color: #151516; + } + } +} + +.form-container { + padding: $pad-xxxxl; + background-color: $color-tc-white; + border-radius: 4px; + margin: $margin-sm 0 $margin-xxxxl 0; + + .account-form { + display: flex; + justify-content: space-between; + width: 100%; + + @media (max-width: 768px) { + flex-direction: column; + } + } +} + +.form-title { + @include barlow-semi-bold; + + font-size: 20px; + line-height: 22px; + color: inherit; + text-transform: uppercase; + padding-bottom: $pad-xxxxl; +} + +.form-content { + display: flex; + flex-wrap: wrap; + align-items: flex-start; +} + +.form-label { + flex: 0 0 calc(50% - 13px); + padding-right: 230px; + + @include roboto-regular; + + font-size: 16px; + line-height: 26px; + color: inherit; +} + +.form-body { + flex: 0 0 calc(50% + 13px); +} + +@include xs-to-md { + .form-container { + padding: $pad-xxl $pad-lg; + } + + .form-title { + @include barlow-semi-bold; + + font-size: 20px; + line-height: 22px; + padding-bottom: $pad-xxl; + } + + .form-label { + flex: 1 1 100%; + padding: 0; + margin-bottom: $margin-xxl; + font-size: 14px; + line-height: 20px; + } + + .form-body { + flex: 1 1 100%; + } + + .form-footer { + margin: 0; + } +} + +.nagModal { + display: flex; + flex-direction: column; + margin: 32px; + + @include xs-to-md { + flex-direction: column; + margin-top: 24px; + } + + .header { + display: flex; + align-items: flex-start; + justify-content: space-between; + border-bottom: 2px solid #e9e9e9; + + @include xs-to-md { + margin-top: 24px; + text-align: center; + } + + .title { + @include barlow-bold; + + color: $tco-black; + font-size: 22px; + font-weight: 600; + line-height: 26px; + margin-bottom: 12px; + text-transform: uppercase; + + @include xs-to-md { + text-align: center; + } + } + + .icon { + cursor: pointer; + } + } + + .description { + @include roboto-regular; + + font-weight: 400; + color: $tco-black; + font-size: 16px; + line-height: 24px; + margin-top: 24px; + + strong { + font-weight: 700; + } + + .badgeWrap { + display: flex; + justify-content: center; + margin-bottom: 12px; + } + + span span { + color: #137d60; + font-weight: bold; + } + } +} + +.container { + box-shadow: 0 1px 5px rgba(0, 0, 0, 0.2); + border-radius: 8px; + min-width: 600px; + max-width: 700px; + + @include xs-to-sm { + width: 90%; + min-width: unset; + } +} + +.overlay { + background-color: #0c0c0c; + opacity: 0.85; +} + +.actionButtons { + display: flex; + align-items: center; + justify-content: flex-end; + margin-top: 32px; + padding-top: 24px; + border-top: 2px solid #e9e9e9; + + .primaryBtn { + background-color: #137d60; + border-radius: 24px; + color: #fff; + font-size: 13px; + font-weight: bolder; + text-decoration: none; + text-transform: uppercase; + line-height: 32px; + padding: 0 20px; + border: none; + outline: none; + display: flex; + + &:hover { + box-shadow: 0 1px 5px 0 rgba(0, 0, 0, 0.2); + background-color: #0ab88a; + } + + @include xs-to-sm { + margin-bottom: 20px; + } + } +} diff --git a/src/shared/components/Settings/Account/index.jsx b/src/shared/components/Settings/Account/index.jsx index 01cefef021..ff6370da5a 100644 --- a/src/shared/components/Settings/Account/index.jsx +++ b/src/shared/components/Settings/Account/index.jsx @@ -3,6 +3,7 @@ import React from 'react'; import PT from 'prop-types'; import { PrimaryButton } from 'topcoder-react-ui-kit'; import MyAccount from './MyAccount'; +import MyPrimaryRole from './MyPrimaryRole'; import Security from './Security'; import ErrorWrapper from '../ErrorWrapper'; @@ -75,6 +76,7 @@ export default class Account extends React.Component {

Account information & Security

+ { + dispatch(identityActions.identity.updatePrimaryRoleInit()); + dispatch(identityActions.identity.updatePrimaryRoleDone(role, tokenV3)); + }, }; } diff --git a/src/shared/reducers/identity.js b/src/shared/reducers/identity.js new file mode 100644 index 0000000000..d1c34c860c --- /dev/null +++ b/src/shared/reducers/identity.js @@ -0,0 +1,53 @@ +/** + * Reducer for identity service + */ + +import actions from 'actions/mfa'; +import { handleActions } from 'redux-actions'; +import { errors } from 'topcoder-react-lib'; +import _ from 'lodash'; + +/** + * Handles IDENTITY_SERVICE/UPDATE_PRIMARY_ROLE_DONE action. + * @param {Object} state + * @param {Object} action Payload will be JSON from api call + * @return {Object} New state + */ +function onUpdatePrimaryRoleDone(state, { payload, error }) { + if (error) { + errors.fireErrorMessage('Failed to update users primary role', payload.message); + return { ...state, updatingPrimaryRole: false }; + } + + return ({ + ...state, + updatingPrimaryRole: false, + }); +} + +/** + * Creates a new mfa reducer with the specified initial state. + * @param {Object} initialState Optional. Initial state. + * @return {Function} mfa reducer. + */ +function create(initialState) { + const a = actions.usermfa; + return handleActions({ + [a.updatePrimaryRoleInit]: state => ({ ...state, updatingPrimaryRole: true }), + [a.updatePrimaryRoleDone]: onUpdatePrimaryRoleDone, + }, _.defaults(initialState, { + updatingPrimaryRole: false, + })); +} + +/** + * Factory which creates a new reducer. + * @return {Promise} + * @resolves {Function(state, action): state} New reducer. + */ +export function factory() { + return Promise.resolve(create()); +} + +/* Default reducer with empty initial state. */ +export default create(); diff --git a/src/shared/reducers/index.js b/src/shared/reducers/index.js index c27bf01ad8..5c0b88db5a 100644 --- a/src/shared/reducers/index.js +++ b/src/shared/reducers/index.js @@ -47,6 +47,7 @@ import growSurf from './growSurf'; import thrive from './contentful/thrive'; import dashboard from './dashboard'; import blog from './blog'; +import identity from './identity'; /** * Given HTTP request, generates options for SSR by topcoder-react-lib's reducer @@ -186,6 +187,7 @@ export function factory(req) { dashboard, blog, timelineWall, + identity, })); } diff --git a/src/shared/services/identity.js b/src/shared/services/identity.js new file mode 100644 index 0000000000..54031098a8 --- /dev/null +++ b/src/shared/services/identity.js @@ -0,0 +1,64 @@ + +import { services } from 'topcoder-react-lib'; + +const { getApi } = services.api; + +/** + * Handles the response from identity service + * @param {Object} res response + * @return {Promise} Resolves to the payload. + */ +async function handleResponse(res) { + const { result } = await res.json(); + + if (!res.ok) { + throw new Error(result ? result.content : ''); + } + + if (!result) { + return null; + } + + if ((!result.success)) { + throw new Error(result.content); + } + return result.content; +} + +class IdentityService { + /** + * @param {String} tokenV3 Auth token for Topcoder API v3. + */ + constructor(tokenV3) { + this.private = { + api: getApi('V3', tokenV3), + tokenV3, + }; + } + + /** + * Update users primary role + * @param {String} role - role to be updated can be 'Topcoder Talent' or 'Topcoder Customer' + * @return {Promise} + */ + async updatePrimaryRole(role) { + const res = await this.private.api.postJson('/users/updatePrimaryRole', { param: { primaryRole: role } }); + return handleResponse(res); + } +} + +let lastInstance = null; +/** + * Returns a new or existing lookup service. + * @param {String} tokenV3 Optional. Auth token for Topcoder API v3. + * @return {IdentityService} Mfa service object + */ +export function getService(tokenV3) { + if (!lastInstance || tokenV3 !== lastInstance.private.tokenV3) { + lastInstance = new IdentityService(tokenV3); + } + return lastInstance; +} + +/* Using default export would be confusing in this case. */ +export default undefined;