feat: copy and generate exam token (#62623)

Co-authored-by: moT01 <20648924+moT01@users.noreply.github.com>
Co-authored-by: Oliver Eyton-Williams <ojeytonwilliams@gmail.com>
This commit is contained in:
Shaun Hamilton
2025-10-24 10:22:15 +02:00
committed by GitHub
parent 556abb4b9f
commit c2c6ca37b8
11 changed files with 250 additions and 38 deletions
+46
View File
@@ -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<typeof schemas.getUserExamEnvironmentToken>,
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
});
}
+4 -1
View File
@@ -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';
@@ -10,3 +10,12 @@ export const userExamEnvironmentToken = {
// default: STANDARD_ERROR
}
};
export const getUserExamEnvironmentToken = {
response: {
200: Type.Object({
examEnvironmentAuthorizationToken: Type.String()
}),
404: STANDARD_ERROR
}
};
@@ -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",
+12 -3
View File
@@ -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);
},
+17 -15
View File
@@ -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: {
+7 -2
View File
@@ -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
});
@@ -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 <Loader />;
}
if (attempts.length === 0) {
if (attempts === undefined || attempts.length === 0) {
return <p>{t('exam.no-attempts-yet')}</p>;
}
@@ -63,7 +64,7 @@ export function Attempts({ id }: AttemptsProps) {
<td>{new Date(attempt.startTime).toTimeString()}</td>
<td>
{attempt.result
? `${attempt.result.percent}%`
? `${attempt.result.score.toFixed(2)}%`
: t('exam.pending')}
</td>
<td>
@@ -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<string | null>(null);
const [copyError, setCopyError] = useState<string | null>(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 (
<>
<h3>{t('exam-token.exam-token')}</h3>
<p>{t('exam-token.token-usage')}</p>
{generateMutation.isError && (
<p style={{ color: 'var(--danger-color)' }}>{t('exam-token.error')}</p>
)}
{generateMutation.isSuccess && (
<p style={{ color: 'var(--success-color)' }}>
{t('exam-token.generated')}
</p>
)}
{!!token && generateMutation.isSuccess && (
<p style={{ color: 'var(--yellow-color)' }}>
{t('exam-token.invalidation-2')}
</p>
)}
{!!token && !generateMutation.isSuccess && (
<p style={{ color: 'var(--yellow-color)' }}>
{t('exam-token.invalidation-1')}
</p>
)}
{getTokenQuery.isError && !token && (
<p style={{ color: 'var(--highlight-color)' }}>
{t('exam-token.no-token')}
</p>
)}
{generateMutation.isLoading || getTokenQuery.isLoading ? (
<Button block={true}>
<Loader />
</Button>
) : (
<Button
block={true}
disabled={generateMutation.isLoading || getTokenQuery.isLoading}
onClick={() => void generateToken()}
>
{t('exam-token.generate-exam-token')}
</Button>
)}
<Spacer size='s' />
{copySuccess && (
<p style={{ color: 'var(--success-color)' }}>{copySuccess}</p>
)}
{copyError && <p style={{ color: 'var(--danger-color)' }}>{copyError}</p>}
{generateMutation.isLoading || getTokenQuery.isLoading ? (
<Button block={true}>
<Loader />
</Button>
) : (
<Button block={true} disabled={!token} onClick={handleCopyExamToken}>
{t('exam-token.copy')}
</Button>
)}
<Spacer size='m' />
</>
);
}
export default ExamTokenControls;
@@ -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 && (
<>
<h2>{t('exam.attempts')}</h2>
<Attempts id={id} />
<Attempts examChallengeId={id} />
<Spacer size='l' />
<ExamTokenControls />
</>
)}
<p>
@@ -165,6 +167,11 @@ function ShowExamDownload({
version: latestVersion || '...'
})}
</p>
{/* TODO: confirm this works on MacOS */}
<Button href={'exam-environment://'}>
{t('exam.open-exam-application')}
</Button>
<Spacer size='s' />
<Button
disabled={!downloadLink}
aria-disabled={!downloadLink}
@@ -186,6 +193,7 @@ function ShowExamDownload({
{downloadLinks
.filter(link => !link.match(/\.sig|\.json/))
.map((link, index) => {
const urlEnd = link.split('/').pop() ?? '';
return (
<MenuItem
href={link}
@@ -193,7 +201,7 @@ function ShowExamDownload({
key={index}
variant='primary'
>
{link}
{urlEnd}
</MenuItem>
);
})}
@@ -202,6 +210,7 @@ function ShowExamDownload({
<Spacer size='l' />
<strong>{t('exam.download-trouble')}</strong>{' '}
<a href='mailto: support@freecodecamp.org'>support@freecodecamp.org</a>
<Spacer size='l' />
</Container>
</LearnLayout>
);
+29 -1
View File
@@ -228,7 +228,7 @@ export interface Attempt {
questionSets: unknown[];
result?: {
passed: boolean;
percent: number;
score: number;
};
}
@@ -449,3 +449,31 @@ export const examAttempts = createApi({
})
})
});
export const examEnvironmentAuthorizationTokenApi = createApi({
reducerPath: 'exam-environment-authorization-token',
baseQuery: fetchBaseQuery({
baseUrl: apiLocation,
headers: {
'CSRF-Token': getCSRFToken()
},
credentials: 'include'
}),
endpoints: build => ({
postGenerateExamEnvironmentAuthorizationToken: build.mutation<
ExamTokenResponse,
void
>({
query: () => ({
url: `/user/exam-environment/token`,
method: 'POST'
})
}),
getExamEnvironmentAuthorizationToken: build.query<ExamTokenResponse, void>({
query: () => ({
url: `/user/exam-environment/token`,
method: 'GET'
})
})
})
});