diff --git a/api/src/routes/protected/user.ts b/api/src/routes/protected/user.ts index 530b1ff0034..53e2ae01fc7 100644 --- a/api/src/routes/protected/user.ts +++ b/api/src/routes/protected/user.ts @@ -536,6 +536,14 @@ export const userRoutes: FastifyPluginCallbackTypebox = ( examEnvironmentTokenHandler ); + fastify.get( + '/user/exam-environment/token', + { + schema: schemas.getUserExamEnvironmentToken + }, + getExamEnvironmentToken + ); + fastify.get( '/user/exam-environment/exam/attempts', { @@ -810,3 +818,41 @@ export const userGetRoutes: FastifyPluginCallbackTypebox = ( done(); }; + +async function getExamEnvironmentToken( + this: FastifyInstance, + req: UpdateReqType, + reply: FastifyReply +) { + const logger = this.log.child({ req, res: reply }); + logger.info(`User ${req.user?.id} requested their exam environment token`); + const userId = req.user?.id; + if (!userId) { + throw new Error('Unreachable. User should be authenticated.'); + } + + const token = await this.prisma.examEnvironmentAuthorizationToken.findUnique({ + where: { + userId, + expireAt: { + gt: new Date() + } + } + }); + + if (!token) { + void reply.code(404); + return reply.send( + ERRORS.FCC_ERR_EXAM_ENVIRONMENT('No valid token found for user.') + ); + } + + const examEnvironmentAuthorizationToken = jwt.sign( + { examEnvironmentAuthorizationToken: token.id }, + JWT_SECRET + ); + + return reply.send({ + examEnvironmentAuthorizationToken + }); +} diff --git a/api/src/schemas.ts b/api/src/schemas.ts index df5af894edc..08bcb8bf5df 100644 --- a/api/src/schemas.ts +++ b/api/src/schemas.ts @@ -44,5 +44,8 @@ export { postMsUsername } from './schemas/user/post-ms-username.js'; export { reportUser } from './schemas/user/report-user.js'; export { resetMyProgress } from './schemas/user/reset-my-progress.js'; export { submitSurvey } from './schemas/user/submit-survey.js'; -export { userExamEnvironmentToken } from './schemas/user/exam-environment-token.js'; +export { + userExamEnvironmentToken, + getUserExamEnvironmentToken +} from './schemas/user/exam-environment-token.js'; export { sentryPostEvent } from './schemas/sentry/event.js'; diff --git a/api/src/schemas/user/exam-environment-token.ts b/api/src/schemas/user/exam-environment-token.ts index db24e2f5105..8070c8e930c 100644 --- a/api/src/schemas/user/exam-environment-token.ts +++ b/api/src/schemas/user/exam-environment-token.ts @@ -10,3 +10,12 @@ export const userExamEnvironmentToken = { // default: STANDARD_ERROR } }; + +export const getUserExamEnvironmentToken = { + response: { + 200: Type.Object({ + examEnvironmentAuthorizationToken: Type.String() + }), + 404: STANDARD_ERROR + } +}; diff --git a/client/i18n/locales/english/translations.json b/client/i18n/locales/english/translations.json index 94d4350c6fe..64936a1e998 100644 --- a/client/i18n/locales/english/translations.json +++ b/client/i18n/locales/english/translations.json @@ -391,7 +391,8 @@ "version": "The latest version of our app is: {{version}}.", "download-details": "Manually download the app", "unable-to-detect-os": "We were unable to detect your operating system. Please manually download the app below.", - "download-trouble": "If you have trouble downloading the correct version, do not hesitate to contact support:" + "download-trouble": "If you have trouble downloading the correct version, do not hesitate to contact support:", + "open-exam-application": "Open Exam Environment Application" }, "profile": { "you-change-privacy": "You need to change your privacy setting in order for your portfolio to be seen by others. This is a preview of how your portfolio will look when made public.", @@ -1280,12 +1281,16 @@ "exam-token": { "exam-token": "Exam Token", "note": "Your exam token is a secret key that allows you to access exams. Do not share this token with anyone.", - "invalidation": "If you generate a new token, your old token will be invalidated.", + "invalidation-1": "It looks like you have a valid exam token. If you generate a new one, your existing token will be invalidated.", + "invalidation-2": "If you generate a new token, your existing token will be invalidated.", "generate-exam-token": "Generate Exam Token", "error": "There was an error generating your token, please try again in a moment.", - "your-exam-token": "Your Exam Token is: {{token}}", + "no-token": "It looks like you don't have a valid exam token.", + "copy": "Copy Exam Token", "copied": "Token copied to clipboard", - "copy-error": "Error copying token to clipboard" + "copy-error": "Error copying token to clipboard", + "token-usage": "Your Exam Environment authorization token is used to log you into the desktop application.", + "generated": "A new Exam Environment authorization token has been generated for your account." }, "shortcuts": { "title": "Keyboard shortcuts", diff --git a/client/src/redux/create-store.ts b/client/src/redux/create-store.ts index 40deb667bd0..775f47cde1c 100644 --- a/client/src/redux/create-store.ts +++ b/client/src/redux/create-store.ts @@ -6,7 +6,10 @@ import { configureStore } from '@reduxjs/toolkit'; import envData from '../../config/env.json'; import { isBrowser } from '../../utils'; -import { examAttempts } from '../utils/ajax'; +import { + examAttempts, + examEnvironmentAuthorizationTokenApi +} from '../utils/ajax'; import rootEpic from './root-epic'; import rootReducer from './root-reducer'; import rootSaga from './root-saga'; @@ -44,8 +47,13 @@ export const createStore = (preloadedState = {}) => { store = reduxCreateStore( rootReducer, preloadedState, - // @ts-expect-error RTK uses unknown, Redux uses any - applyMiddleware(sagaMiddleware, epicMiddleware, examAttempts.middleware) + applyMiddleware( + sagaMiddleware, + epicMiddleware, + // @ts-expect-error RTK uses unknown, Redux uses any + examAttempts.middleware, + examEnvironmentAuthorizationTokenApi.middleware + ) ); } else { // store = reduxCreateStore( @@ -63,6 +71,7 @@ export const createStore = (preloadedState = {}) => { middleware: getDefaultMiddleware => { return getDefaultMiddleware() .concat(examAttempts.middleware) + .concat(examEnvironmentAuthorizationTokenApi.middleware) .concat(sagaMiddleware) .concat(epicMiddleware); }, diff --git a/client/src/redux/index.js b/client/src/redux/index.js index e061abd5bb8..edf7a47c84e 100644 --- a/client/src/redux/index.js +++ b/client/src/redux/index.js @@ -180,21 +180,23 @@ export const reducer = handleActions( ...state, userProfileFetchState: { ...defaultFetchState } }), - [actionTypes.fetchUserComplete]: (state, { payload: { user } }) => ({ - ...state, - user: { - ...state.user, - sessionUser: user - }, - currentChallengeId: - user?.currentChallengeId || store.get(CURRENT_CHALLENGE_KEY), - userFetchState: { - pending: false, - complete: true, - errored: false, - error: null - } - }), + [actionTypes.fetchUserComplete]: (state, { payload: { user } }) => { + return { + ...state, + user: { + ...state.user, + sessionUser: user + }, + currentChallengeId: + user?.currentChallengeId || store.get(CURRENT_CHALLENGE_KEY), + userFetchState: { + pending: false, + complete: true, + errored: false, + error: null + } + }; + }, [actionTypes.fetchUserTimeout]: state => ({ ...state, userFetchState: { diff --git a/client/src/redux/root-reducer.ts b/client/src/redux/root-reducer.ts index 56ae50b3856..d388b2d96cb 100644 --- a/client/src/redux/root-reducer.ts +++ b/client/src/redux/root-reducer.ts @@ -13,7 +13,10 @@ import { ns as curriculumMapNameSpace, reducer as curriculumMap } from '../templates/Introduction/redux'; -import { examAttempts } from '../utils/ajax'; +import { + examAttempts, + examEnvironmentAuthorizationTokenApi +} from '../utils/ajax'; import { ns as appNameSpace } from './action-types'; import { ns as settingsNameSpace, reducer as settings } from './settings'; import { FlashApp as flashNameSpace } from './types'; @@ -26,5 +29,7 @@ export default combineReducers({ [flashNameSpace]: flash, [searchNameSpace]: search, [settingsNameSpace]: settings, - [examAttempts.reducerPath]: examAttempts.reducer + [examAttempts.reducerPath]: examAttempts.reducer, + [examEnvironmentAuthorizationTokenApi.reducerPath]: + examEnvironmentAuthorizationTokenApi.reducer }); diff --git a/client/src/templates/Challenges/exam-download/attempts.tsx b/client/src/templates/Challenges/exam-download/attempts.tsx index ebedcdf1ced..acdeb2bae22 100644 --- a/client/src/templates/Challenges/exam-download/attempts.tsx +++ b/client/src/templates/Challenges/exam-download/attempts.tsx @@ -6,13 +6,14 @@ import { Loader } from '../../../components/helpers'; import { examAttempts } from '../../../utils/ajax'; interface AttemptsProps { - id: string; + examChallengeId: string; } -export function Attempts({ id }: AttemptsProps) { +export function Attempts({ examChallengeId }: AttemptsProps) { const { t } = useTranslation(); - const examIdsQuery = examAttempts.useGetExamIdsByChallengeIdQuery(id); + const examIdsQuery = + examAttempts.useGetExamIdsByChallengeIdQuery(examChallengeId); const [getAttempts, attemptsMutation] = examAttempts.useGetExamAttemptsByExamIdMutation(); @@ -21,7 +22,11 @@ export function Attempts({ id }: AttemptsProps) { return; } - const examId = examIdsQuery.data.at(0)!.examId; + const examId = examIdsQuery.data.at(0)?.examId; + if (examId === undefined) { + return; + } + void getAttempts(examId); }, [examIdsQuery.data, getAttempts]); @@ -40,11 +45,7 @@ export function Attempts({ id }: AttemptsProps) { const attempts = attemptsMutation.data; - if (attempts === undefined) { - return ; - } - - if (attempts.length === 0) { + if (attempts === undefined || attempts.length === 0) { return

{t('exam.no-attempts-yet')}

; } @@ -63,7 +64,7 @@ export function Attempts({ id }: AttemptsProps) { {new Date(attempt.startTime).toTimeString()} {attempt.result - ? `${attempt.result.percent}%` + ? `${attempt.result.score.toFixed(2)}%` : t('exam.pending')} diff --git a/client/src/templates/Challenges/exam-download/exam-token-controls.tsx b/client/src/templates/Challenges/exam-download/exam-token-controls.tsx new file mode 100644 index 00000000000..c656be389a3 --- /dev/null +++ b/client/src/templates/Challenges/exam-download/exam-token-controls.tsx @@ -0,0 +1,95 @@ +import React, { useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Button, Spacer } from '@freecodecamp/ui'; + +import { examEnvironmentAuthorizationTokenApi } from '../../../utils/ajax'; +import { Loader } from '../../../components/helpers'; + +export function ExamTokenControls(): JSX.Element { + const { t } = useTranslation(); + + const [copySuccess, setCopySuccess] = useState(null); + const [copyError, setCopyError] = useState(null); + + const [generateToken, generateMutation] = + examEnvironmentAuthorizationTokenApi.usePostGenerateExamEnvironmentAuthorizationTokenMutation(); + + const getTokenQuery = + examEnvironmentAuthorizationTokenApi.useGetExamEnvironmentAuthorizationTokenQuery(); + const existingToken = getTokenQuery.data?.examEnvironmentAuthorizationToken; + const updatedToken = generateMutation.data?.examEnvironmentAuthorizationToken; + const token = updatedToken ?? existingToken; + + function handleCopyExamToken() { + navigator.clipboard.writeText(token ?? '').then( + () => { + setCopySuccess(t('exam-token.copied')); + setCopyError(null); + }, + () => { + setCopyError(t('exam-token.copy-error')); + setCopySuccess(null); + } + ); + } + + return ( + <> +

{t('exam-token.exam-token')}

+

{t('exam-token.token-usage')}

+ {generateMutation.isError && ( +

{t('exam-token.error')}

+ )} + {generateMutation.isSuccess && ( +

+ {t('exam-token.generated')} +

+ )} + {!!token && generateMutation.isSuccess && ( +

+ {t('exam-token.invalidation-2')} +

+ )} + {!!token && !generateMutation.isSuccess && ( +

+ {t('exam-token.invalidation-1')} +

+ )} + {getTokenQuery.isError && !token && ( +

+ {t('exam-token.no-token')} +

+ )} + {generateMutation.isLoading || getTokenQuery.isLoading ? ( + + ) : ( + + )} + + {copySuccess && ( +

{copySuccess}

+ )} + {copyError &&

{copyError}

} + {generateMutation.isLoading || getTokenQuery.isLoading ? ( + + ) : ( + + )} + + + ); +} + +export default ExamTokenControls; diff --git a/client/src/templates/Challenges/exam-download/show.tsx b/client/src/templates/Challenges/exam-download/show.tsx index 3ae6465ea56..ef11b9779ab 100644 --- a/client/src/templates/Challenges/exam-download/show.tsx +++ b/client/src/templates/Challenges/exam-download/show.tsx @@ -20,6 +20,7 @@ import { ChallengeNode } from '../../../redux/prop-types'; import { isSignedInSelector } from '../../../redux/selectors'; import { isChallengeCompletedSelector } from '../redux/selectors'; import { Attempts } from './attempts'; +import ExamTokenControls from './exam-token-controls'; interface GitProps { tag_name: string; @@ -156,8 +157,9 @@ function ShowExamDownload({ {isSignedIn && ( <>

{t('exam.attempts')}

- + + )}

@@ -165,6 +167,11 @@ function ShowExamDownload({ version: latestVersion || '...' })}

+ {/* TODO: confirm this works on MacOS */} + +