diff --git a/src-ts/.eslintrc.js b/src-ts/.eslintrc.js index c54020a0f..10a2a1fb5 100644 --- a/src-ts/.eslintrc.js +++ b/src-ts/.eslintrc.js @@ -251,6 +251,12 @@ module.exports = { 2, 4, ], + 'react/jsx-no-bind': [ + 'error', + { + allowFunctions: true, + } + ], 'react/jsx-no-useless-fragment': [ 0 ], diff --git a/src-ts/config/environments/environment.default.config.ts b/src-ts/config/environments/environment.default.config.ts index c616a5f2d..525463eda 100644 --- a/src-ts/config/environments/environment.default.config.ts +++ b/src-ts/config/environments/environment.default.config.ts @@ -36,6 +36,7 @@ export const EnvironmentConfigDefault: EnvironmentConfigModel = { 'eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJyb2xlcyI6WyJUb3Bjb2RlciBVc2VyIl0sImlzcyI6Imh0dHBzOi8vYXBpLnRvcGNvZGVyLWRldi5jb20iLCJoYW5kbGUiOiJ0ZXN0MSIsImV4cCI6MjU2MzA3NjY4OSwidXNlcklkIjoiNDAwNTEzMzMiLCJpYXQiOjE0NjMwNzYwODksImVtYWlsIjoidGVzdEB0b3Bjb2Rlci5jb20iLCJqdGkiOiJiMzNiNzdjZC1iNTJlLTQwZmUtODM3ZS1iZWI4ZTBhZTZhNGEifQ.jl6Lp_friVNwEP8nfsfmL-vrQFzOFp2IfM_HC7AwGcg', }, TOPCODER_URLS: { + ACCOUNT_SETTINGS: `${COMMUNITY_WEBSITE}/settings/account`, API_BASE: `${COMMUNITY_WEBSITE}/api`, BLOG_PAGE: `${COMMUNITY_WEBSITE}/blog`, CHALLENGES_PAGE: `${COMMUNITY_WEBSITE}/challenges`, diff --git a/src-ts/config/environments/environment.prod.config.ts b/src-ts/config/environments/environment.prod.config.ts index da9a2b5e9..222edbb03 100644 --- a/src-ts/config/environments/environment.prod.config.ts +++ b/src-ts/config/environments/environment.prod.config.ts @@ -34,6 +34,7 @@ export const EnvironmentConfigProd: EnvironmentConfigModel = { 'eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJyb2xlcyI6WyJUb3Bjb2RlciBVc2VyIl0sImlzcyI6Imh0dHBzOi8vYXBpLnRvcGNvZGVyLWRldi5jb20iLCJoYW5kbGUiOiJ0ZXN0MSIsImV4cCI6MjU2MzA3NjY4OSwidXNlcklkIjoiNDAwNTEzMzMiLCJpYXQiOjE0NjMwNzYwODksImVtYWlsIjoidGVzdEB0b3Bjb2Rlci5jb20iLCJqdGkiOiJiMzNiNzdjZC1iNTJlLTQwZmUtODM3ZS1iZWI4ZTBhZTZhNGEifQ.jl6Lp_friVNwEP8nfsfmL-vrQFzOFp2IfM_HC7AwGcg', }, TOPCODER_URLS: { + ACCOUNT_SETTINGS: `${COMMUNITY_WEBSITE}/settings/account`, API_BASE: `${COMMUNITY_WEBSITE}/api`, BLOG_PAGE: `${COMMUNITY_WEBSITE}/blog`, CHALLENGES_PAGE: `${COMMUNITY_WEBSITE}/challenges`, diff --git a/src-ts/lib/functions/token-functions/token.functions.ts b/src-ts/lib/functions/token-functions/token.functions.ts index 32b523196..3e8059fe9 100644 --- a/src-ts/lib/functions/token-functions/token.functions.ts +++ b/src-ts/lib/functions/token-functions/token.functions.ts @@ -15,9 +15,10 @@ export async function getAsync(): Promise { } try { - const { handle, roles }: { + const { handle, roles, userId }: { handle?: string roles?: Array + userId?: number } = decodeToken(token) // if we didn't find the handle, we have a bad token @@ -26,8 +27,9 @@ export async function getAsync(): Promise { return Promise.resolve({}) } - return Promise.resolve({ handle, roles, token }) + return Promise.resolve({ handle, roles, token, userId }) + // eslint-disable-next-line @typescript-eslint/no-explicit-any } catch (error: any) { logError(error) return Promise.resolve({}) diff --git a/src-ts/lib/functions/token-functions/token.model.ts b/src-ts/lib/functions/token-functions/token.model.ts index 9a5c03bec..dd4b8e1b9 100644 --- a/src-ts/lib/functions/token-functions/token.model.ts +++ b/src-ts/lib/functions/token-functions/token.model.ts @@ -2,4 +2,5 @@ export interface TokenModel { handle?: string roles?: Array token?: string + userId?: number } diff --git a/src-ts/lib/functions/user-functions/index.ts b/src-ts/lib/functions/user-functions/index.ts index 617ec8e4a..7a5ad1a47 100644 --- a/src-ts/lib/functions/user-functions/index.ts +++ b/src-ts/lib/functions/user-functions/index.ts @@ -1 +1,4 @@ -export { updatePasswordAsync as userUpdatePasswordAsync } from './user.functions' +export { + getDiceStatusAsync as userGetDiceStatusAsync, + updatePasswordAsync as userUpdatePasswordAsync, +} from './user.functions' diff --git a/src-ts/lib/functions/user-functions/user-store/index.ts b/src-ts/lib/functions/user-functions/user-store/index.ts index fa880fb95..5e88d6a17 100644 --- a/src-ts/lib/functions/user-functions/user-store/index.ts +++ b/src-ts/lib/functions/user-functions/user-store/index.ts @@ -1,2 +1,5 @@ -export { patchAsync as userPatchAsync } from './user-xhr.store' +export { + getMfaStatusAsync as userStoreGetMfaStatusAsync, + patchAsync as userStorePatchAsync, +} from './user-xhr.store' export { type UserPatchRequest } from './user-xhr.store' diff --git a/src-ts/lib/functions/user-functions/user-store/user-xhr.store.ts b/src-ts/lib/functions/user-functions/user-store/user-xhr.store.ts index 789e41458..82a5bca8d 100644 --- a/src-ts/lib/functions/user-functions/user-store/user-xhr.store.ts +++ b/src-ts/lib/functions/user-functions/user-store/user-xhr.store.ts @@ -1,8 +1,17 @@ import { User } from '../../../../../types/tc-auth-lib' -import { xhrPatchAsync } from '../../xhr-functions' +import { xhrGetAsync, xhrPatchAsync } from '../../xhr-functions' import { user as userEndpoint } from './user-endpoint.config' +export interface MfaStatusResult { + result: { + content: { + diceEnabled: boolean + mfaEnabled: boolean + } + } +} + export interface UserPatchRequest { param: { credential: { @@ -12,6 +21,10 @@ export interface UserPatchRequest { } } +export async function getMfaStatusAsync(userId: number): Promise { + return xhrGetAsync(`${userEndpoint(userId)}/2fa`) +} + export async function patchAsync(userId: number, request: UserPatchRequest): Promise { const url: string = userEndpoint(userId) return xhrPatchAsync(url, request) diff --git a/src-ts/lib/functions/user-functions/user.functions.ts b/src-ts/lib/functions/user-functions/user.functions.ts index 792d33791..613e7100c 100644 --- a/src-ts/lib/functions/user-functions/user.functions.ts +++ b/src-ts/lib/functions/user-functions/user.functions.ts @@ -1,4 +1,10 @@ -import { userPatchAsync, UserPatchRequest } from './user-store' +import { UserPatchRequest, userStoreGetMfaStatusAsync, userStorePatchAsync } from './user-store' +import { MfaStatusResult } from './user-store/user-xhr.store' + +export async function getDiceStatusAsync(userId: number): Promise { + const result: MfaStatusResult = await userStoreGetMfaStatusAsync(userId) + return !!result.result.content.mfaEnabled && !!result.result.content.diceEnabled +} export async function updatePasswordAsync(userId: number, currentPassword: string, password: string): Promise { const request: UserPatchRequest = { @@ -9,6 +15,6 @@ export async function updatePasswordAsync(userId: number, currentPassword: strin }, }, } - return userPatchAsync(userId, request) + return userStorePatchAsync(userId, request) .then(() => undefined) } diff --git a/src-ts/lib/global-config.model.ts b/src-ts/lib/global-config.model.ts index 911bc42ba..18820b175 100644 --- a/src-ts/lib/global-config.model.ts +++ b/src-ts/lib/global-config.model.ts @@ -29,6 +29,7 @@ export interface GlobalConfig { CUSTOMER_TOKEN: string } TOPCODER_URLS: { + ACCOUNT_SETTINGS: string API_BASE: string BLOG_PAGE: string CHALLENGES_PAGE: string diff --git a/src-ts/lib/page-footer/PageFooter.tsx b/src-ts/lib/page-footer/PageFooter.tsx index 1762265a8..9b3bdfbfa 100644 --- a/src-ts/lib/page-footer/PageFooter.tsx +++ b/src-ts/lib/page-footer/PageFooter.tsx @@ -7,13 +7,17 @@ const PageFooter: FC<{}> = () => { const navElementId: string = 'footer-nav-el' - tcUniNav( - 'init', - navElementId, - { - type: 'footer', - }, - ) + // delay the initialization so + // the nav element has time to render + setTimeout(() => { + tcUniNav( + 'init', + navElementId, + { + type: 'footer', + }, + ) + }, 10) return
} diff --git a/src-ts/lib/profile-provider/profile-functions/profile-factory/profile.factory.ts b/src-ts/lib/profile-provider/profile-functions/profile-factory/profile.factory.ts index 7e5fb0aff..13fe53fdd 100644 --- a/src-ts/lib/profile-provider/profile-functions/profile-factory/profile.factory.ts +++ b/src-ts/lib/profile-provider/profile-functions/profile-factory/profile.factory.ts @@ -3,18 +3,24 @@ import { UserProfile } from '../../user-profile.model' import { UserRole } from './user-role.enum' -export function create(profile: UserProfile, token: TokenModel): UserProfile { - // TODO: create the profile full name property +export function create(profile: UserProfile, token: TokenModel, hasDiceEnabled: boolean): UserProfile { + // Currently, the "Self-Service Customer" role is being set when a user is created // during the self-service workflow. There are no other roles being set to distinguish // between Customers and Members. // Therefore, the only way to know if a user is a Member is if s/he is not a Customer. // This is imperfect, bc a user could be both a Customer or a Member, but for now // we are okay with this and will have a more in-depth initiave to properly assign - // rolees. + // roles. profile.isCustomer = !!token.roles?.some(role => role === UserRole.customer) profile.isMember = !profile.isCustomer + + profile.isWipro = profile.email.endsWith('@wipro.com') + profile.diceEnabled = hasDiceEnabled + // store roles for custom capability checks - profile.roles = token.roles + profile.roles = token.roles || [] + + // TODO: create the profile full name property return profile } diff --git a/src-ts/lib/profile-provider/profile-functions/profile.functions.ts b/src-ts/lib/profile-provider/profile-functions/profile.functions.ts index 2499043a6..ede0f9ef7 100644 --- a/src-ts/lib/profile-provider/profile-functions/profile.functions.ts +++ b/src-ts/lib/profile-provider/profile-functions/profile.functions.ts @@ -1,3 +1,4 @@ +import { userGetDiceStatusAsync } from '../../functions/user-functions' import { tokenGetAsync, TokenModel } from '../../functions/token-functions' import { EditNameRequest } from '../edit-name-request.model' import { UserProfile } from '../user-profile.model' @@ -11,19 +12,22 @@ export async function getAsync(handle?: string): Promise = profileStoreGet(safeHandle) + const dicePromise: Promise = userGetDiceStatusAsync(token.userId) + + const [profileResult, diceEnabled]: [UserProfile, boolean] = await Promise.all([profilePromise, dicePromise]) // make the changes we need based on the token - const output: UserProfile = profileFactoryCreate(profileResult, token) + const output: UserProfile = profileFactoryCreate(profileResult, token, diceEnabled) return output } -export async function editNameAsync(handle: string, profile: EditNameRequest): Promise { +export async function editNameAsync(handle: string, profile: EditNameRequest): Promise { return profileStorePatchName(handle, profile) } diff --git a/src-ts/lib/profile-provider/user-profile.model.ts b/src-ts/lib/profile-provider/user-profile.model.ts index 51ae0c2e1..d557c5e22 100644 --- a/src-ts/lib/profile-provider/user-profile.model.ts +++ b/src-ts/lib/profile-provider/user-profile.model.ts @@ -1,6 +1,7 @@ export interface UserProfile { competitionCountryCode: string createdAt: number + diceEnabled: boolean email: string firstName: string handle: string @@ -8,6 +9,7 @@ export interface UserProfile { homeCountryCode: string isCustomer?: boolean isMember?: boolean + isWipro: boolean lastName: string photoURL?: string roles: Array diff --git a/src-ts/tools/learn/course-details/course-curriculum/CourseCurriculum.tsx b/src-ts/tools/learn/course-details/course-curriculum/CourseCurriculum.tsx index 408c65466..c6ee2a0a5 100644 --- a/src-ts/tools/learn/course-details/course-curriculum/CourseCurriculum.tsx +++ b/src-ts/tools/learn/course-details/course-curriculum/CourseCurriculum.tsx @@ -24,6 +24,7 @@ import { import { CurriculumSummary } from './curriculum-summary' import { TcAcademyPolicyModal } from './tc-academy-policy-modal' +import { DiceModal } from './dice-modal' import styles from './CourseCurriculum.module.scss' interface CourseCurriculumProps { @@ -37,11 +38,14 @@ interface CourseCurriculumProps { const CourseCurriculum: FC = (props: CourseCurriculumProps) => { const navigate: NavigateFunction = useNavigate() - const [searchParams]: any = useSearchParams() + const [searchParams]: [URLSearchParams, unknown] = useSearchParams() const isLoggedIn: boolean = !!props.profile - const [isTcAcademyPolicyModal, setIsTcAcademyPolicyModal]: [boolean, Dispatch>] = useState(false) + const [isTcAcademyPolicyModal, setIsTcAcademyPolicyModal]: [boolean, Dispatch>] + = useState(false) + const [isDiceModalOpen, setIsDiceModalOpen]: [boolean, Dispatch>] + = useState(false) const status: string = props.progress?.status ?? UserCertificationProgressStatus.inititialized const completedPercentage: number = (props.progress?.courseProgressPercentage ?? 0) / 100 @@ -76,6 +80,7 @@ const CourseCurriculum: FC = (props: CourseCurriculumProp * Handle user click on start course/resume/login button */ const handleStartCourseClick: () => void = useCallback(() => { + // if user is not logged in, redirect to login page if (!isLoggedIn) { // add a flag to the return url to show the academic policy modal @@ -84,6 +89,13 @@ const CourseCurriculum: FC = (props: CourseCurriculumProp return } + // if the user is wipro and s/he hasn't set up DICE, + // let the user know + if (props.profile?.isWipro && !props.profile.diceEnabled) { + setIsDiceModalOpen(true) + return + } + // Check if user accepted policy and resume(or start) the course if (props.progress?.academicHonestyPolicyAcceptedAt) { handleStartCourse() @@ -92,6 +104,7 @@ const CourseCurriculum: FC = (props: CourseCurriculumProp // show the academic policy modal before starting a new course setIsTcAcademyPolicyModal(true) + // eslint-disable-next-line react-hooks/exhaustive-deps }, [ handleStartCourse, isLoggedIn, @@ -130,6 +143,7 @@ const CourseCurriculum: FC = (props: CourseCurriculumProp } handleStartCourse() + // eslint-disable-next-line react-hooks/exhaustive-deps }, [ handleStartCourse, props.course.certificationId, @@ -149,11 +163,20 @@ const CourseCurriculum: FC = (props: CourseCurriculumProp * proceed as if the user just clicked "Start course" button */ useEffect(() => { + // eslint-disable-next-line no-null/no-null if (props.progressReady && isLoggedIn && searchParams.get(LEARN_PATHS.startCourseRouteFlag) !== null) { handleStartCourseClick() } }, [handleStartCourseClick, isLoggedIn, props.progressReady, searchParams]) + function onAcademicHonestyModalClose(): void { + setIsTcAcademyPolicyModal(false) + } + + function onDiceModalClose(): void { + setIsDiceModalOpen(false) + } + return ( <>
@@ -198,9 +221,14 @@ const CourseCurriculum: FC = (props: CourseCurriculumProp setIsTcAcademyPolicyModal(false)} + onClose={onAcademicHonestyModalClose} onConfirm={handlePolicyAccept} /> + + ) } diff --git a/src-ts/tools/learn/course-details/course-curriculum/dice-modal/DiceModal.module.scss b/src-ts/tools/learn/course-details/course-curriculum/dice-modal/DiceModal.module.scss new file mode 100644 index 000000000..df1b5b572 --- /dev/null +++ b/src-ts/tools/learn/course-details/course-curriculum/dice-modal/DiceModal.module.scss @@ -0,0 +1,13 @@ +@import '../../../../../lib/styles/includes'; + +.diceModal { + + p { + margin-bottom: $space-lg; + + &.buttonContainer { + display: flex; + justify-content: center; + } + } +} diff --git a/src-ts/tools/learn/course-details/course-curriculum/dice-modal/DiceModal.tsx b/src-ts/tools/learn/course-details/course-curriculum/dice-modal/DiceModal.tsx new file mode 100644 index 000000000..b4e7d88c5 --- /dev/null +++ b/src-ts/tools/learn/course-details/course-curriculum/dice-modal/DiceModal.tsx @@ -0,0 +1,64 @@ +import { Dispatch, FC, SetStateAction, useEffect, useState } from 'react' + +import { EnvironmentConfig } from '../../../../../config' +import { BaseModal, Button } from '../../../../../lib' + +import styles from './DiceModal.module.scss' + +interface DiceModalProps { + isOpen: boolean + onClose: () => void +} + +const DiceModal: FC = (props: DiceModalProps) => { + + const [isOpen, setIsOpen]: [boolean, Dispatch>] + = useState(false) + + useEffect(() => { + setIsOpen(props.isOpen) + }, [props.isOpen]) + + return ( + +
+ +

+ Wipro requires employees to enable Multifactor Authentication + with DICE ID in order to take Topcoder Academy courses. +

+

+ Please go to Account Settings to configure your account. +

+

+

+ +
+ ) +} + +export default DiceModal diff --git a/src-ts/tools/learn/course-details/course-curriculum/dice-modal/index.ts b/src-ts/tools/learn/course-details/course-curriculum/dice-modal/index.ts new file mode 100644 index 000000000..b74a55774 --- /dev/null +++ b/src-ts/tools/learn/course-details/course-curriculum/dice-modal/index.ts @@ -0,0 +1 @@ +export { default as DiceModal } from './DiceModal' diff --git a/src-ts/tools/learn/free-code-camp/FreeCodeCamp.tsx b/src-ts/tools/learn/free-code-camp/FreeCodeCamp.tsx index c76e6e532..725bfe76a 100644 --- a/src-ts/tools/learn/free-code-camp/FreeCodeCamp.tsx +++ b/src-ts/tools/learn/free-code-camp/FreeCodeCamp.tsx @@ -436,19 +436,37 @@ const FreeCodeCamp: FC<{}> = () => { /** * Check if the user accepted the academic honesty policy + * and either is not a wipro user or the wipro user has dice enabled. * if not, redirect user to course details page to accept the policy */ useLayoutEffect(() => { - if (ready && !(isLoggedIn && certificateProgress?.academicHonestyPolicyAcceptedAt)) { - const coursePath: string = getCoursePath( - providerParam, - certificationParam, - ) - navigate(coursePath) + + // if we're not ready, there's nothing to do + if (!ready) { + return } + + // if the user is logged in, + // and the user is a either not wipro user or is a wipro user with dice enabled, + // and if the user has accepted the academic honesty policy, + // the user is permitted to take the course, so there's nothing to do. + if (isLoggedIn + && (!profile?.isWipro || !!profile?.diceEnabled) + && !!certificateProgress?.academicHonestyPolicyAcceptedAt) { + return + } + + // redirect the user to course details page to perform the + // necessary actions + const coursePath: string = getCoursePath( + providerParam, + certificationParam, + ) + navigate(coursePath) }, [ ready, certificateProgress, + profile, providerParam, certificationParam, navigate,