feat(client): exam token UI (#55687)

Co-authored-by: Shaun Hamilton <shauhami020@gmail.com>
This commit is contained in:
Sem Bauke
2024-10-17 10:51:39 +02:00
committed by GitHub
parent d82a01d257
commit 068c5a7db0
6 changed files with 181 additions and 0 deletions
@@ -7,6 +7,7 @@
"first-lesson": "Go to the first lesson",
"close": "Close",
"edit": "Edit",
"copy": "Copy",
"view": "View",
"view-code": "View Code",
"view-project": "View Project",
@@ -1111,6 +1112,16 @@
"no-thanks": "No thanks, I would like to keep my token",
"yes-please": "Yes please, I would like to delete my token"
},
"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.",
"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}}",
"copied": "Token copied to clipboard",
"copy-error": "Error copying token to clipboard"
},
"shortcuts": {
"title": "Keyboard shortcuts",
"table-header-action": "Action",
@@ -5,6 +5,7 @@ import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import { Callout, Container } from '@freecodecamp/ui';
import { useFeatureIsOn } from '@growthbook/growthbook-react';
import store from 'store';
import envData from '../../config/env.json';
@@ -18,6 +19,7 @@ import Honesty from '../components/settings/honesty';
import Privacy from '../components/settings/privacy';
import { type ThemeProps, Themes } from '../components/settings/theme';
import UserToken from '../components/settings/user-token';
import ExamToken from '../components/settings/exam-token';
import { hardGoTo as navigate } from '../redux/actions';
import {
signInLoadingSelector,
@@ -35,6 +37,7 @@ import {
updateMyKeyboardShortcuts,
verifyCert
} from '../redux/settings/actions';
const { apiLocation } = envData;
// TODO: update types for actions
@@ -126,6 +129,8 @@ export function ShowSettings(props: ShowSettingsProps): JSX.Element {
} = props;
const isSignedInRef = useRef(isSignedIn);
const examTokenFlag = useFeatureIsOn('exam-token-widget');
if (showLoading) {
return <Loader fullScreen={true} />;
}
@@ -172,6 +177,7 @@ export function ShowSettings(props: ShowSettingsProps): JSX.Element {
<Spacer size='medium' />
<Honesty isHonest={isHonest} updateIsHonest={updateIsHonest} />
<Spacer size='medium' />
{examTokenFlag && <ExamToken />}
<Certification
completedChallenges={completedChallenges}
createFlashMessage={createFlashMessage}
@@ -0,0 +1,110 @@
import React, { useState } from 'react';
import { Button, Panel, Modal, Spacer } from '@freecodecamp/ui';
import { useTranslation } from 'react-i18next';
import { FullWidthRow } from '../helpers';
import { generateExamToken } from '../../utils/ajax';
function ExamToken(): JSX.Element {
const [examToken, setExamToken] = useState<string | null>(null);
const [examTokenError, setExamTokenError] = useState<string | null>(null);
const [recentlyGenerated, setRecentlyGenerated] = useState(false);
const [copySuccess, setCopySuccess] = useState<string | null>(null);
const [copyError, setCopyError] = useState<string | null>(null);
const { t } = useTranslation();
const getToken = async () => {
try {
const response = await generateExamToken();
const {
data: {
data: { examEnvironmentAuthorizationToken }
}
} = response;
setExamToken(examEnvironmentAuthorizationToken);
setExamTokenError('');
} catch (e) {
setExamTokenError(t('exam-token.error'));
}
setRecentlyGenerated(true);
setTimeout(() => setRecentlyGenerated(false), 10000);
};
return (
<FullWidthRow>
<Modal
open={!!examToken}
onClose={() => {
setExamToken(null);
setCopySuccess(null);
setCopyError(null);
}}
>
<Modal.Header>{t('exam-token.exam-token')}</Modal.Header>
<Modal.Body>
{examToken && (
<p style={{ wordBreak: 'break-word' }}>
{t('exam-token.your-exam-token', {
token: examToken
})}
</p>
)}
{examTokenError && <p style={{ color: 'red' }}>{examTokenError}</p>}
{copySuccess && <p style={{ color: 'green' }}>{copySuccess}</p>}
{copyError && <p style={{ color: 'red' }}>{copyError}</p>}
</Modal.Body>
<Modal.Footer>
<Button
onClick={() => {
navigator.clipboard.writeText(examToken ?? '').then(
() => {
setCopySuccess(t('exam-token.copied'));
setCopyError(null);
},
() => {
setCopyError(t('exam-token.copy-error'));
setCopySuccess(null);
}
);
}}
>
{t('buttons.copy')}
</Button>
<Spacer size='s' />
<Button
onClick={() => {
setExamToken(null);
setCopySuccess(null);
setCopyError(null);
}}
>
{t('buttons.close')}
</Button>
</Modal.Footer>
</Modal>
<Panel variant='info' id='exam-environment-authorization-token'>
<Panel.Heading>{t('exam-token.exam-token')}</Panel.Heading>
<Panel.Body>
<p>{t('exam-token.note')}</p>
<strong>{t('exam-token.invalidation')}</strong>
<Spacer size='s' />
<Button
block={true}
disabled={recentlyGenerated}
onClick={() => void getToken()}
>
{t('exam-token.generate-exam-token')}
</Button>
</Panel.Body>
</Panel>
</FullWidthRow>
);
}
ExamToken.displayName = 'ExamToken';
export default ExamToken;
+5
View File
@@ -469,6 +469,11 @@ export interface GenerateExamResponseWithData {
data: GenerateExamResponse;
}
export interface ExamTokenResponse {
data: {
examEnvironmentAuthorizationToken: string;
};
}
// User Exam (null until they answer the question)
interface UserExamAnswer {
id: string | null;
+7
View File
@@ -5,6 +5,7 @@ import type {
ChallengeFile,
ChallengeFiles,
CompletedChallenge,
ExamTokenResponse,
GenerateExamResponseWithData,
SavedChallenge,
SavedChallengeFile,
@@ -258,6 +259,12 @@ export function postChargeStripeCard(
return post('/donate/charge-stripe-card', body);
}
export function generateExamToken(): Promise<
ResponseWithData<ExamTokenResponse>
> {
return post('/user/exam-environment/token', {});
}
type PaymentIntentResponse = Promise<
ResponseWithData<
| {
+42
View File
@@ -0,0 +1,42 @@
// TO ENABLE THESE TESTS GROWTHBOOK HAS TO BE SET IN THE ENVIRONMENT VARIABLES`
// UNCOMMENT WHEN NEW API IS READY.
// import { test, expect } from '@playwright/test';
// test.use({ storageState: 'playwright/.auth/certified-user.json' });
// test.beforeEach(async ({ page }) => {
// await page.goto('/settings');
// });
// test.describe('Exam Token Widget', () => {
// test('should tell you to not share the token with anyone', async ({
// page
// }) => {
// await expect(
// page.getByText(
// 'Your exam token is a secret key that allows you to access the exam. Do not share this token with anyone'
// )
// ).toBeVisible();
// await expect(
// page.getByText(
// 'If you generate a new token, your old token will be invalidated.'
// )
// ).toBeVisible();
// await expect(
// page.getByRole('button', { name: 'Generate Exam Token' })
// ).toBeVisible();
// });
// test('should be able to generate a token', async ({ page }) => {
// await page.getByRole('button', { name: 'Generate Exam Token' }).click();
// await expect(page.getByText('Your Exam Token is:')).toBeVisible();
// await expect(page.getByRole('button', { name: 'Copy' })).toBeVisible();
// await expect(
// page.getByRole('button', { name: 'Close' }).nth(1)
// ).toBeVisible();
// });
// });