feat(client,api): add user tokens and route for submitting coderoad tutorials (#43304)

Co-authored-by: Nicholas Carrigan (he/him) <nhcarrigan@gmail.com>
Co-authored-by: Shaun Hamilton <shauhami020@gmail.com>
Co-authored-by: Oliver Eyton-Williams <ojeytonwilliams@gmail.com>
This commit is contained in:
Tom
2021-11-17 08:19:24 -06:00
committed by GitHub
parent 4eb036a2bb
commit f0698aa517
25 changed files with 722 additions and 26 deletions

View File

@ -325,6 +325,11 @@
"type": "hasMany", "type": "hasMany",
"model": "article", "model": "article",
"foreignKey": "externalId" "foreignKey": "externalId"
},
"webhookTokens": {
"type": "hasMany",
"model": "WebhookToken",
"foreignKey": "userId"
} }
}, },
"acls": [ "acls": [

View File

@ -36,7 +36,8 @@ const {
infosecV7Id, infosecV7Id,
sciCompPyV7Id, sciCompPyV7Id,
dataAnalysisPyV7Id, dataAnalysisPyV7Id,
machineLearningPyV7Id machineLearningPyV7Id,
relationalDatabasesV8Id
} = certIds; } = certIds;
const log = debug('fcc:certification'); const log = debug('fcc:certification');
@ -112,6 +113,10 @@ function createCertTypeIds(allChallenges) {
[certTypes.machineLearningPyV7]: getCertById( [certTypes.machineLearningPyV7]: getCertById(
machineLearningPyV7Id, machineLearningPyV7Id,
allChallenges allChallenges
),
[certTypes.relationalDatabasesV8]: getCertById(
relationalDatabasesV8Id,
allChallenges
) )
}; };
} }

View File

@ -12,6 +12,7 @@ import { Observable } from 'rx';
import isNumeric from 'validator/lib/isNumeric'; import isNumeric from 'validator/lib/isNumeric';
import isURL from 'validator/lib/isURL'; import isURL from 'validator/lib/isURL';
import { environment, deploymentEnv } from '../../../../config/env.json';
import { fixCompletedChallengeItem } from '../../common/utils'; import { fixCompletedChallengeItem } from '../../common/utils';
import { getChallenges } from '../utils/get-curriculum'; import { getChallenges } from '../utils/get-curriculum';
import { ifNoUserSend } from '../utils/middleware'; import { ifNoUserSend } from '../utils/middleware';
@ -59,6 +60,10 @@ export default async function bootChallenge(app, done) {
router.get('/challenges/current-challenge', redirectToCurrentChallenge); router.get('/challenges/current-challenge', redirectToCurrentChallenge);
const coderoadChallengeCompleted = createCoderoadChallengeCompleted(app);
api.post('/coderoad-challenge-completed', coderoadChallengeCompleted);
app.use(api); app.use(api);
app.use(router); app.use(router);
done(); done();
@ -78,7 +83,7 @@ export function buildUserUpdate(
_completedChallenge, _completedChallenge,
timezone timezone
) { ) {
const { files } = _completedChallenge; const { files, completedDate = Date.now() } = _completedChallenge;
let completedChallenge = {}; let completedChallenge = {};
if (jsProjects.includes(challengeId)) { if (jsProjects.includes(challengeId)) {
completedChallenge = { completedChallenge = {
@ -108,7 +113,7 @@ export function buildUserUpdate(
} else { } else {
updateData.$push = { updateData.$push = {
...updateData.$push, ...updateData.$push,
progressTimestamps: Date.now() progressTimestamps: completedDate
}; };
finalChallenge = { finalChallenge = {
...completedChallenge ...completedChallenge
@ -328,6 +333,87 @@ function backendChallengeCompleted(req, res, next) {
.subscribe(() => {}, next); .subscribe(() => {}, next);
} }
function createCoderoadChallengeCompleted(app) {
/* Example request coming from CodeRoad:
* req.body: { tutorialId: 'freeCodeCamp/learn-bash-by-building-a-boilerplate:v1.0.0' }
* req.headers: { coderoad-user-token: '8kFIlZiwMioY6hqqt...' }
*/
const { WebhookToken, User } = app.models;
return async function coderoadChallengeCompleted(req, res) {
const { 'coderoad-user-token': userWebhookToken } = req.headers;
const { tutorialId } = req.body;
if (!tutorialId) return res.send(`'tutorialId' not found in request body`);
if (!userWebhookToken)
return res.send(`'coderoad-user-token' not found in request headers`);
const tutorialRepoPath = tutorialId?.split(':')[0];
const tutorialSplit = tutorialRepoPath?.split('/');
const tutorialOrg = tutorialSplit?.[0];
const tutorialRepoName = tutorialSplit?.[1];
// this allows any GH account to host the repo in development or staging
// .org submissions should always be from repos hosted on the fCC GH org
if (deploymentEnv !== 'staging' && environment !== 'development') {
if (tutorialOrg !== 'freeCodeCamp')
return res.send('Tutorial not hosted on freeCodeCamp GitHub account');
}
const codeRoadChallenges = getChallenges().filter(
challenge => challenge.challengeType === 12
);
// validate tutorial name is in codeRoadChallenges object
const tutorialInfo = codeRoadChallenges.find(tutorial =>
tutorial.url?.includes(tutorialRepoName)
);
if (!tutorialInfo) return res.send('Tutorial name is not valid');
const tutorialMongoId = tutorialInfo?.id;
try {
// check if webhook token is in database
const tokenInfo = await WebhookToken.findOne({
where: { id: userWebhookToken }
});
if (!tokenInfo) return res.send('User webhook token not found');
const { userId } = tokenInfo;
// check if user exists for webhook token
const user = await User.findOne({
where: { id: userId }
});
if (!user) return res.send('User for webhook token not found');
// submit challenge
const completedDate = Date.now();
const userUpdateInfo = buildUserUpdate(user, tutorialMongoId, {
id: tutorialMongoId,
completedDate
});
const updatedUser = await user.updateAttributes(
userUpdateInfo?.updateData
);
if (!updatedUser)
return res.send('An error occurred trying to submit the challenge');
} catch (e) {
return res.send('An error occurred trying to submit the challenge');
}
return res.send('Successfully submitted challenge');
};
}
// TODO: extend tests to cover www.freecodecamp.org/language and // TODO: extend tests to cover www.freecodecamp.org/language and
// chinese.freecodecamp.org // chinese.freecodecamp.org
export function createRedirectToCurrentChallenge( export function createRedirectToCurrentChallenge(

View File

@ -24,6 +24,8 @@ function bootUser(app) {
const getSessionUser = createReadSessionUser(app); const getSessionUser = createReadSessionUser(app);
const postReportUserProfile = createPostReportUserProfile(app); const postReportUserProfile = createPostReportUserProfile(app);
const postDeleteAccount = createPostDeleteAccount(app); const postDeleteAccount = createPostDeleteAccount(app);
const postWebhookToken = createPostWebhookToken(app);
const deleteWebhookToken = createDeleteWebhookToken(app);
api.get('/account', sendNonUserToHome, getAccount); api.get('/account', sendNonUserToHome, getAccount);
api.get('/account/unlink/:social', sendNonUserToHome, getUnlinkSocial); api.get('/account/unlink/:social', sendNonUserToHome, getUnlinkSocial);
@ -31,6 +33,7 @@ function bootUser(app) {
api.post('/account/delete', ifNoUser401, postDeleteAccount); api.post('/account/delete', ifNoUser401, postDeleteAccount);
api.post('/account/reset-progress', ifNoUser401, postResetProgress); api.post('/account/reset-progress', ifNoUser401, postResetProgress);
api.post('/user/webhook-token', postWebhookToken);
api.post( api.post(
'/user/report-user/', '/user/report-user/',
ifNoUser401, ifNoUser401,
@ -38,14 +41,60 @@ function bootUser(app) {
postReportUserProfile postReportUserProfile
); );
api.delete('/user/webhook-token', deleteWebhookToken);
app.use(api); app.use(api);
} }
function createPostWebhookToken(app) {
const { WebhookToken } = app.models;
return async function postWebhookToken(req, res) {
const ttl = 900 * 24 * 60 * 60 * 1000;
let newToken;
try {
await WebhookToken.destroyAll({ userId: req.user.id });
newToken = await WebhookToken.create({ ttl, userId: req.user.id });
} catch (e) {
return res.status(500).json({
type: 'danger',
message: 'flash.create-token-err'
});
}
return res.json(newToken?.id);
};
}
function createDeleteWebhookToken(app) {
const { WebhookToken } = app.models;
return async function deleteWebhookToken(req, res) {
try {
await WebhookToken.destroyAll({ userId: req.user.id });
} catch (e) {
return res.status(500).json({
type: 'danger',
message: 'flash.delete-token-err'
});
}
return res.json(null);
};
}
function createReadSessionUser(app) { function createReadSessionUser(app) {
const { Donation } = app.models; const { Donation } = app.models;
return function getSessionUser(req, res, next) { return async function getSessionUser(req, res, next) {
const queryUser = req.user; const queryUser = req.user;
const webhookTokenArr = await queryUser.webhookTokens({
userId: queryUser.id
});
const webhookToken = webhookTokenArr[0]?.id;
const source = const source =
queryUser && queryUser &&
Observable.forkJoin( Observable.forkJoin(
@ -83,7 +132,8 @@ function createReadSessionUser(app) {
isTwitter: !!user.twitter, isTwitter: !!user.twitter,
isWebsite: !!user.website, isWebsite: !!user.website,
...normaliseUserFields(user), ...normaliseUserFields(user),
joinDate: user.id.getTimestamp() joinDate: user.id.getTimestamp(),
webhookToken
} }
}, },
sessionMeta, sessionMeta,
@ -192,8 +242,20 @@ function postResetProgress(req, res, next) {
} }
function createPostDeleteAccount(app) { function createPostDeleteAccount(app) {
const { User } = app.models; const { User, WebhookToken } = app.models;
return function postDeleteAccount(req, res, next) { return async function postDeleteAccount(req, res, next) {
const {
user: { id: userId }
} = req;
try {
await WebhookToken.destroyAll({ userId });
} catch (err) {
log(
`An error occurred deleting webhook tokens for user with id ${userId} when they tried to delete their account`
);
}
return User.destroyById(req.user.id, function (err) { return User.destroyById(req.user.id, function (err) {
if (err) { if (err) {
return next(err); return next(err);

View File

@ -14,7 +14,9 @@ export default function getCsurf() {
const { path } = req; const { path } = req;
if ( if (
// eslint-disable-next-line max-len // eslint-disable-next-line max-len
/^\/hooks\/update-paypal$|^\/donate\/charge-stripe$/.test(path) /^\/hooks\/update-paypal$|^\/donate\/charge-stripe$|^\/coderoad-challenge-completed$/.test(
path
)
) { ) {
next(); next();
} else { } else {

View File

@ -26,6 +26,7 @@ const unsubscribeRE = /^\/u\/|^\/unsubscribe\/|^\/ue\//;
const updateHooksRE = /^\/hooks\/update-paypal$/; const updateHooksRE = /^\/hooks\/update-paypal$/;
// note: this would be replaced by webhooks later // note: this would be replaced by webhooks later
const donateRE = /^\/donate\/charge-stripe$/; const donateRE = /^\/donate\/charge-stripe$/;
const submitCoderoadChallengeRE = /^\/coderoad-challenge-completed$/;
const _pathsAllowedREs = [ const _pathsAllowedREs = [
authRE, authRE,
@ -40,7 +41,8 @@ const _pathsAllowedREs = [
unsubscribedRE, unsubscribedRE,
unsubscribeRE, unsubscribeRE,
updateHooksRE, updateHooksRE,
donateRE donateRE,
submitCoderoadChallengeRE
]; ];
export function isAllowedPath(path, pathsAllowedREs = _pathsAllowedREs) { export function isAllowedPath(path, pathsAllowedREs = _pathsAllowedREs) {

View File

@ -58,5 +58,9 @@
"User": { "User": {
"dataSource": "db", "dataSource": "db",
"public": false "public": false
},
"WebhookToken": {
"dataSource": "db",
"public": false
} }
} }

View File

@ -0,0 +1,20 @@
{
"name": "WebhookToken",
"description": "Tokens for submitting curricula through CodeRoad",
"base": "AccessToken",
"idInjection": true,
"options": {
"validateUpsert": true
},
"properties": {},
"validations": [],
"relations": {
"user": {
"type": "belongsTo",
"model": "user",
"foreignKey": "userId"
}
},
"acls": [],
"methods": {}
}

View File

@ -13,5 +13,6 @@
"sciCompPyV7": "isSciCompPyCertV7", "sciCompPyV7": "isSciCompPyCertV7",
"dataAnalysisPyV7": "isDataAnalysisPyCertV7", "dataAnalysisPyV7": "isDataAnalysisPyCertV7",
"machineLearningPyV7": "isMachineLearningPyCertV7", "machineLearningPyV7": "isMachineLearningPyCertV7",
"fullStack": "isFullStackCert" "fullStack": "isFullStackCert",
"relationalDatabasesV8": "isRelationalDatabasesV8"
} }

View File

@ -466,7 +466,11 @@
"unlink-success": "你已成功取消 {{website}} 鏈接", "unlink-success": "你已成功取消 {{website}} 鏈接",
"provide-username": "檢查你是否提供用戶名和報告", "provide-username": "檢查你是否提供用戶名和報告",
"report-sent": "已通過 {{email}} 向團隊發送副本報告", "report-sent": "已通過 {{email}} 向團隊發送副本報告",
"certificate-missing": "你嘗試查看的認證不存在" "certificate-missing": "你嘗試查看的認證不存在",
"create-token-err": "An error occurred trying to create a token",
"delete-token-err": "An error occurred trying to delete your token",
"token-created": "You have successfully created a new token.",
"token-deleted": "Your token has been deleted."
}, },
"validation": { "validation": {
"max-characters": "字符數最多爲 288 個,你還可以輸入 {{charsLeft}} 個字符", "max-characters": "字符數最多爲 288 個,你還可以輸入 {{charsLeft}} 個字符",
@ -594,5 +598,18 @@
"add-code-one": "用你的複製代碼替換這兩句。", "add-code-one": "用你的複製代碼替換這兩句。",
"add-code-two": "請保留上方的 ``` 行和下方的 ``` 行", "add-code-two": "請保留上方的 ``` 行和下方的 ``` 行",
"add-code-three": "因爲它們允許你的代碼在帖子中被正確格式化。" "add-code-three": "因爲它們允許你的代碼在帖子中被正確格式化。"
},
"webhook-token": {
"title": "Webhook Token",
"create": "Create a new token",
"create-p1": "It looks like you don't have a webhook token. Create one to save your progress on this section",
"create-p2": "Create a webhook token to save your progress on the curriculum sections that use a virtual machine.",
"delete": "Delete my token",
"delete-title": "Delete My Webhook Token",
"delete-p1": "Your webhook token below is used to save your progress on the curriculum sections that use a virtual machine.",
"delete-p2": "If you suspect your token has been compromised, you can delete it to make it unusable. Progress on previously submitted lessons will not be lost.",
"delete-p3": "You will need to create a new token to save future progress on the curriculum sections that use a virtual machine.",
"no-thanks": "No thanks, I would like to keep my token",
"yes-please": "Yes please, I would like to delete my token"
} }
} }

View File

@ -466,7 +466,11 @@
"unlink-success": "你已成功取消 {{website}} 链接", "unlink-success": "你已成功取消 {{website}} 链接",
"provide-username": "检查你是否提供用户名和报告", "provide-username": "检查你是否提供用户名和报告",
"report-sent": "已通过 {{email}} 向团队发送副本报告", "report-sent": "已通过 {{email}} 向团队发送副本报告",
"certificate-missing": "你尝试查看的认证不存在" "certificate-missing": "你尝试查看的认证不存在",
"create-token-err": "An error occurred trying to create a token",
"delete-token-err": "An error occurred trying to delete your token",
"token-created": "You have successfully created a new token.",
"token-deleted": "Your token has been deleted."
}, },
"validation": { "validation": {
"max-characters": "字符数最多为 288 个,你还可以输入 {{charsLeft}} 个字符", "max-characters": "字符数最多为 288 个,你还可以输入 {{charsLeft}} 个字符",
@ -594,5 +598,18 @@
"add-code-one": "用你的复制代码替换这两句。", "add-code-one": "用你的复制代码替换这两句。",
"add-code-two": "请保留上方的 ``` 行和下方的 ``` 行", "add-code-two": "请保留上方的 ``` 行和下方的 ``` 行",
"add-code-three": "因为它们允许你的代码在帖子中被正确格式化。" "add-code-three": "因为它们允许你的代码在帖子中被正确格式化。"
},
"webhook-token": {
"title": "Webhook Token",
"create": "Create a new token",
"create-p1": "It looks like you don't have a webhook token. Create one to save your progress on this section",
"create-p2": "Create a webhook token to save your progress on the curriculum sections that use a virtual machine.",
"delete": "Delete my token",
"delete-title": "Delete My Webhook Token",
"delete-p1": "Your webhook token below is used to save your progress on the curriculum sections that use a virtual machine.",
"delete-p2": "If you suspect your token has been compromised, you can delete it to make it unusable. Progress on previously submitted lessons will not be lost.",
"delete-p3": "You will need to create a new token to save future progress on the curriculum sections that use a virtual machine.",
"no-thanks": "No thanks, I would like to keep my token",
"yes-please": "Yes please, I would like to delete my token"
} }
} }

View File

@ -466,7 +466,11 @@
"unlink-success": "You've successfully unlinked your {{website}}", "unlink-success": "You've successfully unlinked your {{website}}",
"provide-username": "Check if you have provided a username and a report", "provide-username": "Check if you have provided a username and a report",
"report-sent": "A report was sent to the team with {{email}} in copy", "report-sent": "A report was sent to the team with {{email}} in copy",
"certificate-missing": "The certification you tried to view does not exist" "certificate-missing": "The certification you tried to view does not exist",
"create-token-err": "An error occurred trying to create a token",
"delete-token-err": "An error occurred trying to delete your token",
"token-created": "You have successfully created a new token.",
"token-deleted": "Your token has been deleted."
}, },
"validation": { "validation": {
"max-characters": "There is a maximum limit of 288 characters, you have {{charsLeft}} left", "max-characters": "There is a maximum limit of 288 characters, you have {{charsLeft}} left",
@ -594,5 +598,18 @@
"add-code-one": "Replace these two sentences with your copied code.", "add-code-one": "Replace these two sentences with your copied code.",
"add-code-two": "Please leave the ``` line above and the ``` line below,", "add-code-two": "Please leave the ``` line above and the ``` line below,",
"add-code-three": "because they allow your code to properly format in the post." "add-code-three": "because they allow your code to properly format in the post."
},
"webhook-token": {
"title": "Webhook Token",
"create": "Create a new token",
"create-p1": "It looks like you don't have a webhook token. Create one to save your progress on this section",
"create-p2": "Create a webhook token to save your progress on the curriculum sections that use a virtual machine.",
"delete": "Delete my token",
"delete-title": "Delete My Webhook Token",
"delete-p1": "Your webhook token below is used to save your progress on the curriculum sections that use a virtual machine.",
"delete-p2": "If you suspect your token has been compromised, you can delete it to make it unusable. Progress on previously submitted lessons will not be lost.",
"delete-p3": "You will need to create a new token to save future progress on the curriculum sections that use a virtual machine.",
"no-thanks": "No thanks, I would like to keep my token",
"yes-please": "Yes please, I would like to delete my token"
} }
} }

View File

@ -466,7 +466,11 @@
"unlink-success": "Has desvinculado correctamente tu {{website}}", "unlink-success": "Has desvinculado correctamente tu {{website}}",
"provide-username": "Comprueba si has proporcionado un nombre de usuario y un reporte", "provide-username": "Comprueba si has proporcionado un nombre de usuario y un reporte",
"report-sent": "Se envió un informe al equipo con {{email}} en copia", "report-sent": "Se envió un informe al equipo con {{email}} en copia",
"certificate-missing": "La certificación que intentaste ver no existe" "certificate-missing": "La certificación que intentaste ver no existe",
"create-token-err": "An error occurred trying to create a token",
"delete-token-err": "An error occurred trying to delete your token",
"token-created": "You have successfully created a new token.",
"token-deleted": "Your token has been deleted."
}, },
"validation": { "validation": {
"max-characters": "Hay un límite máximo de 288 caracteres, te quedan {{charsLeft}}", "max-characters": "Hay un límite máximo de 288 caracteres, te quedan {{charsLeft}}",
@ -594,5 +598,18 @@
"add-code-one": "Reemplaza estas dos oraciones con tu código copiado.", "add-code-one": "Reemplaza estas dos oraciones con tu código copiado.",
"add-code-two": "Por favor, deja la línea ``` arriba y la línea ``` abajo,", "add-code-two": "Por favor, deja la línea ``` arriba y la línea ``` abajo,",
"add-code-three": "porque permiten que tu código formatee correctamente en el post." "add-code-three": "porque permiten que tu código formatee correctamente en el post."
},
"webhook-token": {
"title": "Webhook Token",
"create": "Create a new token",
"create-p1": "It looks like you don't have a webhook token. Create one to save your progress on this section",
"create-p2": "Create a webhook token to save your progress on the curriculum sections that use a virtual machine.",
"delete": "Delete my token",
"delete-title": "Delete My Webhook Token",
"delete-p1": "Your webhook token below is used to save your progress on the curriculum sections that use a virtual machine.",
"delete-p2": "If you suspect your token has been compromised, you can delete it to make it unusable. Progress on previously submitted lessons will not be lost.",
"delete-p3": "You will need to create a new token to save future progress on the curriculum sections that use a virtual machine.",
"no-thanks": "No thanks, I would like to keep my token",
"yes-please": "Yes please, I would like to delete my token"
} }
} }

View File

@ -466,7 +466,11 @@
"unlink-success": "Hai scollegato correttamente il tuo {{website}}", "unlink-success": "Hai scollegato correttamente il tuo {{website}}",
"provide-username": "Spunta la casella se hai fornito un nome utente e un report", "provide-username": "Spunta la casella se hai fornito un nome utente e un report",
"report-sent": "Un report è stato inviato al team con {{email}} in copia", "report-sent": "Un report è stato inviato al team con {{email}} in copia",
"certificate-missing": "La certificazione che hai cercato di visualizzare non esiste" "certificate-missing": "La certificazione che hai cercato di visualizzare non esiste",
"create-token-err": "An error occurred trying to create a token",
"delete-token-err": "An error occurred trying to delete your token",
"token-created": "You have successfully created a new token.",
"token-deleted": "Your token has been deleted."
}, },
"validation": { "validation": {
"max-characters": "C'è un limite massimo di 288 caratteri, hai {{charsLeft}} rimanenti", "max-characters": "C'è un limite massimo di 288 caratteri, hai {{charsLeft}} rimanenti",
@ -594,5 +598,18 @@
"add-code-one": "Sostituisci queste due frasi con il codice copiato.", "add-code-one": "Sostituisci queste due frasi con il codice copiato.",
"add-code-two": "Si prega di lasciare la linea ``` sopra e la linea ``` sotto,", "add-code-two": "Si prega di lasciare la linea ``` sopra e la linea ``` sotto,",
"add-code-three": "perché consentono al codice di disporsi correttamente nel post." "add-code-three": "perché consentono al codice di disporsi correttamente nel post."
},
"webhook-token": {
"title": "Webhook Token",
"create": "Create a new token",
"create-p1": "It looks like you don't have a webhook token. Create one to save your progress on this section",
"create-p2": "Create a webhook token to save your progress on the curriculum sections that use a virtual machine.",
"delete": "Delete my token",
"delete-title": "Delete My Webhook Token",
"delete-p1": "Your webhook token below is used to save your progress on the curriculum sections that use a virtual machine.",
"delete-p2": "If you suspect your token has been compromised, you can delete it to make it unusable. Progress on previously submitted lessons will not be lost.",
"delete-p3": "You will need to create a new token to save future progress on the curriculum sections that use a virtual machine.",
"no-thanks": "No thanks, I would like to keep my token",
"yes-please": "Yes please, I would like to delete my token"
} }
} }

View File

@ -466,7 +466,11 @@
"unlink-success": "Você desvinculou seu {{website}} com sucesso", "unlink-success": "Você desvinculou seu {{website}} com sucesso",
"provide-username": "Verifique se você forneceu um nome de usuário e um relatório", "provide-username": "Verifique se você forneceu um nome de usuário e um relatório",
"report-sent": "Um relatório foi enviado para a equipe com {{email}} em cópia", "report-sent": "Um relatório foi enviado para a equipe com {{email}} em cópia",
"certificate-missing": "A certificação que você tentou visualizar não existe" "certificate-missing": "A certificação que você tentou visualizar não existe",
"create-token-err": "An error occurred trying to create a token",
"delete-token-err": "An error occurred trying to delete your token",
"token-created": "You have successfully created a new token.",
"token-deleted": "Your token has been deleted."
}, },
"validation": { "validation": {
"max-characters": "Há um limite máximo de 288 caracteres, você tem {{charsLeft}} restante(s)", "max-characters": "Há um limite máximo de 288 caracteres, você tem {{charsLeft}} restante(s)",
@ -594,5 +598,18 @@
"add-code-one": "Substitua essas duas frases pelo código que você copiou.", "add-code-one": "Substitua essas duas frases pelo código que você copiou.",
"add-code-two": "Deixe as linhas com ``` acima e ``` abaixo de seu código,", "add-code-two": "Deixe as linhas com ``` acima e ``` abaixo de seu código,",
"add-code-three": "pois elas permitirão a formatação adequada de seu código na publicação." "add-code-three": "pois elas permitirão a formatação adequada de seu código na publicação."
},
"webhook-token": {
"title": "Webhook Token",
"create": "Create a new token",
"create-p1": "It looks like you don't have a webhook token. Create one to save your progress on this section",
"create-p2": "Create a webhook token to save your progress on the curriculum sections that use a virtual machine.",
"delete": "Delete my token",
"delete-title": "Delete My Webhook Token",
"delete-p1": "Your webhook token below is used to save your progress on the curriculum sections that use a virtual machine.",
"delete-p2": "If you suspect your token has been compromised, you can delete it to make it unusable. Progress on previously submitted lessons will not be lost.",
"delete-p3": "You will need to create a new token to save future progress on the curriculum sections that use a virtual machine.",
"no-thanks": "No thanks, I would like to keep my token",
"yes-please": "Yes please, I would like to delete my token"
} }
} }

View File

@ -16,6 +16,7 @@ import Honesty from '../components/settings/honesty';
import Internet from '../components/settings/internet'; import Internet from '../components/settings/internet';
import Portfolio from '../components/settings/portfolio'; import Portfolio from '../components/settings/portfolio';
import Privacy from '../components/settings/privacy'; import Privacy from '../components/settings/privacy';
import WebhookToken from '../components/settings/webhook-token';
import { import {
signInLoadingSelector, signInLoadingSelector,
userSelector, userSelector,
@ -25,7 +26,7 @@ import {
import { User } from '../redux/prop-types'; import { User } from '../redux/prop-types';
import { submitNewAbout, updateUserFlag, verifyCert } from '../redux/settings'; import { submitNewAbout, updateUserFlag, verifyCert } from '../redux/settings';
const { apiLocation } = envData; const { apiLocation, showUpcomingChanges } = envData;
// TODO: update types for actions // TODO: update types for actions
interface ShowSettingsProps { interface ShowSettingsProps {
@ -198,6 +199,8 @@ export function ShowSettings(props: ShowSettingsProps): JSX.Element {
username={username} username={username}
verifyCert={verifyCert} verifyCert={verifyCert}
/> />
{showUpcomingChanges && <Spacer />}
{showUpcomingChanges && <WebhookToken />}
<Spacer /> <Spacer />
<DangerZone /> <DangerZone />
</main> </main>

View File

@ -0,0 +1,66 @@
import { Modal, Button } from '@freecodecamp/react-bootstrap';
import React from 'react';
import { useTranslation } from 'react-i18next';
import { ButtonSpacer } from '../helpers';
type WebhookDeleteModalProps = {
onHide: () => void;
deleteFunction: () => void;
show: boolean;
};
function WebhookDeleteModal(props: WebhookDeleteModalProps): JSX.Element {
const { t } = useTranslation();
const { show, onHide, deleteFunction } = props;
return (
<Modal
aria-labelledby='modal-title'
backdrop={true}
bsSize='lg'
className='text-center'
keyboard={true}
onHide={onHide}
show={show}
>
<Modal.Header closeButton={true}>
<Modal.Title id='modal-title'>
{t('webhook-token.delete-title')}
</Modal.Title>
</Modal.Header>
<Modal.Body>
<p>{t('webhook-token.delete-p2')}</p>
<p>{t('webhook-token.delete-p3')}</p>
<hr />
<Button
block={true}
bsSize='lg'
bsStyle='primary'
className='btn-invert'
onClick={props.onHide}
type='button'
>
{t('webhook-token.no-thanks')}
</Button>
<ButtonSpacer />
<Button
block={true}
bsSize='lg'
bsStyle='danger'
className='btn-danger'
onClick={deleteFunction}
type='button'
>
{t('webhook-token.yes-please')}
</Button>
</Modal.Body>
<Modal.Footer>
<Button onClick={props.onHide}>{t('buttons.close')}</Button>
</Modal.Footer>
</Modal>
);
}
WebhookDeleteModal.displayName = 'WebhookDeleteModal';
export default WebhookDeleteModal;

View File

@ -0,0 +1,45 @@
.webhook-panel {
border-color: var(--highlight-background);
}
.webhook-token-input {
display: block;
width: 100%;
padding: 3px 8px;
background-color: var(--primary-background);
border-color: var(--highlight-background);
border-style: solid;
color: var(--highlight-color);
}
.webhook-token-input:focus {
border-color: var(--highlight-color);
}
.btn-info {
background-color: var(--highlight-color);
color: var(--highlight-background);
border-color: var(--highlight-background);
}
.btn-info:hover,
.btn-info:focus {
color: var(--highlight-color);
background-color: var(--highlight-background);
border-color: var(--highlight-background);
}
.webhook-token .panel-heading {
color: var(--highlight-color);
background-color: var(--highlight-background);
border-radius: 0;
border: none;
}
.webhook-token .panel-info {
border-color: var(--highlight-background);
}
.webhook-token p {
color: var(--highlight-color);
}

View File

@ -0,0 +1,154 @@
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
import { Button, Panel } from '@freecodecamp/react-bootstrap';
import React, { Component } from 'react';
import { TFunction, withTranslation } from 'react-i18next';
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import {
postWebhookToken,
deleteWebhookToken,
webhookTokenSelector
} from '../../redux';
import { ButtonSpacer, FullWidthRow, Spacer } from '../helpers';
import WebhookDeleteModal from './webhook-delete-modal';
import './webhook-token.css';
type WebhookTokenProps = {
deleteWebhookToken: () => void;
isSuperBlockPage?: boolean;
postWebhookToken: () => void;
t: TFunction;
webhookToken: string | null;
};
type WebhookTokenState = {
webhookDeleteModal: boolean;
};
const mapStateToProps = createSelector(
webhookTokenSelector,
(webhookToken: string | null) => ({
webhookToken
})
);
const mapDispatchToProps = {
postWebhookToken,
deleteWebhookToken
};
class WebhookToken extends Component<WebhookTokenProps, WebhookTokenState> {
static displayName: string;
constructor(props: WebhookTokenProps) {
super(props);
this.state = {
webhookDeleteModal: false
};
this.createToken = this.createToken.bind(this);
this.deleteToken = this.deleteToken.bind(this);
}
createToken = () => {
this.props.postWebhookToken();
};
deleteToken = () => {
this.props.deleteWebhookToken();
this.toggleWebhookDeleteModal();
};
toggleWebhookDeleteModal = () => {
return this.setState(state => ({
...state,
webhookDeleteModal: !state.webhookDeleteModal
}));
};
render() {
const { isSuperBlockPage = false, t, webhookToken = null } = this.props;
return isSuperBlockPage ? (
<>
{!webhookToken && (
<div className='alert alert-info'>
<p>{t('webhook-token.create-p1')}</p>
<Spacer />
<Button
block={true}
bsSize='lg'
onClick={() => this.createToken()}
type='button'
>
{t('webhook-token.create')}
</Button>
</div>
)}
</>
) : (
<div className='webhook-token text-center'>
<FullWidthRow>
<Panel className='webhook-panel'>
<Panel.Heading>{t('webhook-token.title')}</Panel.Heading>
<Spacer />
{!webhookToken ? (
<p>{t('webhook-token.create-p2')}</p>
) : (
<p>{t('webhook-token.delete-p1')}</p>
)}
<FullWidthRow>
<input
aria-label='Webhook token'
className='webhook-token-input'
readOnly={true}
type='text'
value={webhookToken || ''}
/>
<ButtonSpacer />
{!webhookToken ? (
<Button
block={true}
bsSize='lg'
bsStyle='danger'
className='btn-info'
onClick={() => this.createToken()}
type='button'
>
{t('webhook-token.create')}
</Button>
) : (
<Button
block={true}
bsSize='lg'
bsStyle='danger'
className='btn-info'
onClick={() => this.toggleWebhookDeleteModal()}
type='button'
>
{t('webhook-token.delete')}
</Button>
)}
<Spacer />
</FullWidthRow>
</Panel>
<WebhookDeleteModal
deleteFunction={() => this.deleteToken()}
onHide={() => this.toggleWebhookDeleteModal()}
show={this.state.webhookDeleteModal}
/>
</FullWidthRow>
</div>
);
}
}
WebhookToken.displayName = 'WebhookToken';
export default connect(
mapStateToProps,
mapDispatchToProps
)(withTranslation()(WebhookToken));

View File

@ -29,7 +29,9 @@ export const actionTypes = createTypes(
...createAsyncTypes('acceptTerms'), ...createAsyncTypes('acceptTerms'),
...createAsyncTypes('showCert'), ...createAsyncTypes('showCert'),
...createAsyncTypes('reportUser'), ...createAsyncTypes('reportUser'),
...createAsyncTypes('postChargeStripeCard') ...createAsyncTypes('postChargeStripeCard'),
...createAsyncTypes('postWebhookToken'),
...createAsyncTypes('deleteWebhookToken')
], ],
ns ns
); );

View File

@ -18,6 +18,7 @@ import { actionTypes as settingsTypes } from './settings/action-types';
import { createShowCertSaga } from './show-cert-saga'; import { createShowCertSaga } from './show-cert-saga';
import { createSoundModeSaga } from './sound-mode-saga'; import { createSoundModeSaga } from './sound-mode-saga';
import updateCompleteEpic from './update-complete-epic'; import updateCompleteEpic from './update-complete-epic';
import { createWebhookSaga } from './webhook-saga';
export const MainApp = 'app'; export const MainApp = 'app';
@ -75,7 +76,8 @@ export const sagas = [
...createFetchUserSaga(actionTypes), ...createFetchUserSaga(actionTypes),
...createShowCertSaga(actionTypes), ...createShowCertSaga(actionTypes),
...createReportUserSaga(actionTypes), ...createReportUserSaga(actionTypes),
...createSoundModeSaga({ ...actionTypes, ...settingsTypes }) ...createSoundModeSaga({ ...actionTypes, ...settingsTypes }),
...createWebhookSaga(actionTypes)
]; ];
export const appMount = createAction(actionTypes.appMount); export const appMount = createAction(actionTypes.appMount);
@ -167,6 +169,15 @@ export const showCert = createAction(actionTypes.showCert);
export const showCertComplete = createAction(actionTypes.showCertComplete); export const showCertComplete = createAction(actionTypes.showCertComplete);
export const showCertError = createAction(actionTypes.showCertError); export const showCertError = createAction(actionTypes.showCertError);
export const postWebhookToken = createAction(actionTypes.postWebhookToken);
export const postWebhookTokenComplete = createAction(
actionTypes.postWebhookTokenComplete
);
export const deleteWebhookToken = createAction(actionTypes.deleteWebhookToken);
export const deleteWebhookTokenComplete = createAction(
actionTypes.deleteWebhookTokenComplete
);
export const updateCurrentChallengeId = createAction( export const updateCurrentChallengeId = createAction(
actionTypes.updateCurrentChallengeId actionTypes.updateCurrentChallengeId
); );
@ -230,6 +241,10 @@ export const shouldRequestDonationSelector = state => {
return completionCount >= 3; return completionCount >= 3;
}; };
export const webhookTokenSelector = state => {
return userSelector(state).webhookToken;
};
export const userByNameSelector = username => state => { export const userByNameSelector = username => state => {
const { user } = state[MainApp]; const { user } = state[MainApp];
// return initial state empty user empty object instead of empty // return initial state empty user empty object instead of empty
@ -633,6 +648,32 @@ export const reducer = handleActions(
} }
}; };
}, },
[actionTypes.postWebhookTokenComplete]: (state, { payload }) => {
const { appUsername } = state;
return {
...state,
user: {
...state.user,
[appUsername]: {
...state.user[appUsername],
webhookToken: payload
}
}
};
},
[actionTypes.deleteWebhookTokenComplete]: state => {
const { appUsername } = state;
return {
...state,
user: {
...state.user,
[appUsername]: {
...state.user[appUsername],
webhookToken: null
}
}
};
},
[challengeTypes.challengeMounted]: (state, { payload }) => ({ [challengeTypes.challengeMounted]: (state, { payload }) => ({
...state, ...state,
currentChallengeId: payload currentChallengeId: payload

View File

@ -0,0 +1,60 @@
import { call, put, takeEvery } from 'redux-saga/effects';
import { createFlashMessage } from '../components/Flash/redux';
import { postWebhookToken, deleteWebhookToken } from '../utils/ajax';
import { postWebhookTokenComplete, deleteWebhookTokenComplete } from '.';
const message = {
created: {
type: 'success',
message: 'flash.token-created'
},
createErr: {
type: 'danger',
message: 'flash.create-token-err'
},
deleted: {
type: 'info',
message: 'flash.token-deleted'
},
deleteErr: {
type: 'danger',
message: 'flash.delete-token-err'
}
};
function* postWebhookTokenSaga() {
try {
const response = yield call(postWebhookToken);
if (response?.message) {
yield put(createFlashMessage(response));
} else {
yield put(postWebhookTokenComplete(response));
yield put(createFlashMessage(message.created));
}
} catch (e) {
yield put(createFlashMessage(message.createErr));
}
}
function* deleteWebhookTokenSaga() {
try {
const response = yield call(deleteWebhookToken);
if (response?.message) {
yield put(createFlashMessage(response));
} else {
yield put(deleteWebhookTokenComplete());
yield put(createFlashMessage(message.deleted));
}
} catch (e) {
yield put(createFlashMessage(message.deleteErr));
}
}
export function createWebhookSaga(types) {
return [
takeEvery(types.postWebhookToken, postWebhookTokenSaga),
takeEvery(types.deleteWebhookToken, deleteWebhookTokenSaga)
];
}

View File

@ -7,13 +7,22 @@ import Helmet from 'react-helmet';
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import { bindActionCreators } from 'redux'; import { bindActionCreators } from 'redux';
import type { Dispatch } from 'redux'; import type { Dispatch } from 'redux';
import { createSelector } from 'reselect';
// Local Utilities // Local Utilities
import LearnLayout from '../../../components/layouts/learn'; import LearnLayout from '../../../components/layouts/learn';
import { webhookTokenSelector } from '../../../redux';
import { ChallengeNode, ChallengeMeta } from '../../../redux/prop-types'; import { ChallengeNode, ChallengeMeta } from '../../../redux/prop-types';
import { updateChallengeMeta, challengeMounted } from '../redux'; import { updateChallengeMeta, challengeMounted } from '../redux';
// Redux // Redux
const mapStateToProps = () => ({});
const mapStateToProps = createSelector(
webhookTokenSelector,
(webhookToken: string | null) => ({
webhookToken
})
);
const mapDispatchToProps = (dispatch: Dispatch) => const mapDispatchToProps = (dispatch: Dispatch) =>
bindActionCreators( bindActionCreators(
{ {
@ -30,6 +39,7 @@ interface ShowCodeAllyProps {
challengeMeta: ChallengeMeta; challengeMeta: ChallengeMeta;
}; };
updateChallengeMeta: (arg0: ChallengeMeta) => void; updateChallengeMeta: (arg0: ChallengeMeta) => void;
webhookToken: string | null;
} }
// Component // Component
@ -48,10 +58,19 @@ class ShowCodeAlly extends Component<ShowCodeAllyProps> {
render() { render() {
const { const {
title, data: {
fields: { blockName }, challengeNode: {
url title,
} = this.props.data.challengeNode; fields: { blockName },
url
}
},
webhookToken = null
} = this.props;
const envVariables = webhookToken
? `&envVariables=CODEROAD_WEBHOOK_TOKEN=${webhookToken}`
: '';
return ( return (
<LearnLayout> <LearnLayout>
@ -60,7 +79,7 @@ class ShowCodeAlly extends Component<ShowCodeAllyProps> {
className='codeally-frame' className='codeally-frame'
// eslint-disable-next-line @typescript-eslint/restrict-template-expressions // eslint-disable-next-line @typescript-eslint/restrict-template-expressions
sandbox='allow-modals allow-forms allow-popups allow-scripts allow-same-origin' sandbox='allow-modals allow-forms allow-popups allow-scripts allow-same-origin'
src={`https://codeally.io/embed/?repoUrl=${url}`} src={`https://codeally.io/embed/?repoUrl=${url}${envVariables}`}
title='Editor' title='Editor'
/> />
</LearnLayout> </LearnLayout>

View File

@ -14,6 +14,7 @@ import DonateModal from '../../components/Donation/DonationModal';
import Login from '../../components/Header/components/Login'; import Login from '../../components/Header/components/Login';
import Map from '../../components/Map'; import Map from '../../components/Map';
import { Spacer } from '../../components/helpers'; import { Spacer } from '../../components/helpers';
import WebhookToken from '../../components/settings/webhook-token';
import { import {
currentChallengeIdSelector, currentChallengeIdSelector,
userFetchStateSelector, userFetchStateSelector,
@ -187,6 +188,9 @@ const SuperBlockIntroductionPage = (props: SuperBlockProp) => {
<Col md={8} mdOffset={2} sm={10} smOffset={1} xs={12}> <Col md={8} mdOffset={2} sm={10} smOffset={1} xs={12}>
<Spacer size={2} /> <Spacer size={2} />
<SuperBlockIntro superBlock={superBlock} /> <SuperBlockIntro superBlock={superBlock} />
{superBlock === 'relational-databases' && isSignedIn && (
<WebhookToken isSuperBlockPage={true} />
)}
<Spacer size={2} /> <Spacer size={2} />
<h2 className='text-center big-subheading'> <h2 className='text-center big-subheading'>
{t(`intro:misc-text.courses`)} {t(`intro:misc-text.courses`)}

View File

@ -35,8 +35,12 @@ function put<T = void>(path: string, body: unknown): Promise<T> {
return request('PUT', path, body); return request('PUT', path, body);
} }
function deleteRequest<T = void>(path: string, body: unknown): Promise<T> {
return request('DELETE', path, body);
}
async function request<T>( async function request<T>(
method: 'POST' | 'PUT', method: 'POST' | 'PUT' | 'DELETE',
path: string, path: string,
body: unknown body: unknown
): Promise<T> { ): Promise<T> {
@ -207,6 +211,10 @@ export function postResetProgress(): Promise<void> {
return post('/account/reset-progress', {}); return post('/account/reset-progress', {});
} }
export function postWebhookToken(): Promise<void> {
return post('/user/webhook-token', {});
}
/** PUT **/ /** PUT **/
interface MyAbout { interface MyAbout {
@ -249,3 +257,8 @@ export function putUserUpdateEmail(email: string): Promise<void> {
export function putVerifyCert(certSlug: string): Promise<void> { export function putVerifyCert(certSlug: string): Promise<void> {
return put('/certificate/verify', { certSlug }); return put('/certificate/verify', { certSlug });
} }
/** DELETE **/
export function deleteWebhookToken(): Promise<void> {
return deleteRequest('/user/webhook-token', {});
}