mirror of
https://github.com/freeCodeCamp/freeCodeCamp.git
synced 2026-05-28 18:26:54 +00:00
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:
@@ -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
@@ -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",
|
||||
|
||||
@@ -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
@@ -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: {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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'
|
||||
})
|
||||
})
|
||||
})
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user