From f0698aa517c5d87f04a848d112f56d664edfde8d Mon Sep 17 00:00:00 2001 From: Tom <20648924+moT01@users.noreply.github.com> Date: Wed, 17 Nov 2021 08:19:24 -0600 Subject: [PATCH] feat(client,api): add user tokens and route for submitting coderoad tutorials (#43304) Co-authored-by: Nicholas Carrigan (he/him) Co-authored-by: Shaun Hamilton Co-authored-by: Oliver Eyton-Williams --- api-server/src/common/models/user.json | 5 + api-server/src/server/boot/certificate.js | 7 +- api-server/src/server/boot/challenge.js | 90 +++++++++- api-server/src/server/boot/user.js | 70 +++++++- api-server/src/server/middlewares/csurf.js | 4 +- .../middlewares/request-authorization.js | 4 +- api-server/src/server/model-config.json | 4 + .../src/server/models/webhook-token.json | 20 +++ api-server/src/server/utils/certTypes.json | 3 +- .../chinese-traditional/translations.json | 19 ++- client/i18n/locales/chinese/translations.json | 19 ++- client/i18n/locales/english/translations.json | 19 ++- client/i18n/locales/espanol/translations.json | 19 ++- client/i18n/locales/italian/translations.json | 19 ++- .../i18n/locales/portuguese/translations.json | 19 ++- .../src/client-only-routes/show-settings.tsx | 5 +- .../settings/webhook-delete-modal.tsx | 66 ++++++++ .../src/components/settings/webhook-token.css | 45 +++++ .../src/components/settings/webhook-token.tsx | 154 ++++++++++++++++++ client/src/redux/action-types.js | 4 +- client/src/redux/index.js | 43 ++++- client/src/redux/webhook-saga.js | 60 +++++++ .../templates/Challenges/codeally/show.tsx | 31 +++- .../Introduction/super-block-intro.tsx | 4 + client/src/utils/ajax.ts | 15 +- 25 files changed, 722 insertions(+), 26 deletions(-) create mode 100644 api-server/src/server/models/webhook-token.json create mode 100644 client/src/components/settings/webhook-delete-modal.tsx create mode 100644 client/src/components/settings/webhook-token.css create mode 100644 client/src/components/settings/webhook-token.tsx create mode 100644 client/src/redux/webhook-saga.js diff --git a/api-server/src/common/models/user.json b/api-server/src/common/models/user.json index ea2ecec9c9..bc7187ab73 100644 --- a/api-server/src/common/models/user.json +++ b/api-server/src/common/models/user.json @@ -325,6 +325,11 @@ "type": "hasMany", "model": "article", "foreignKey": "externalId" + }, + "webhookTokens": { + "type": "hasMany", + "model": "WebhookToken", + "foreignKey": "userId" } }, "acls": [ diff --git a/api-server/src/server/boot/certificate.js b/api-server/src/server/boot/certificate.js index 42f3a626c5..f412a26912 100644 --- a/api-server/src/server/boot/certificate.js +++ b/api-server/src/server/boot/certificate.js @@ -36,7 +36,8 @@ const { infosecV7Id, sciCompPyV7Id, dataAnalysisPyV7Id, - machineLearningPyV7Id + machineLearningPyV7Id, + relationalDatabasesV8Id } = certIds; const log = debug('fcc:certification'); @@ -112,6 +113,10 @@ function createCertTypeIds(allChallenges) { [certTypes.machineLearningPyV7]: getCertById( machineLearningPyV7Id, allChallenges + ), + [certTypes.relationalDatabasesV8]: getCertById( + relationalDatabasesV8Id, + allChallenges ) }; } diff --git a/api-server/src/server/boot/challenge.js b/api-server/src/server/boot/challenge.js index 5cfea2df55..52641abacf 100644 --- a/api-server/src/server/boot/challenge.js +++ b/api-server/src/server/boot/challenge.js @@ -12,6 +12,7 @@ import { Observable } from 'rx'; import isNumeric from 'validator/lib/isNumeric'; import isURL from 'validator/lib/isURL'; +import { environment, deploymentEnv } from '../../../../config/env.json'; import { fixCompletedChallengeItem } from '../../common/utils'; import { getChallenges } from '../utils/get-curriculum'; import { ifNoUserSend } from '../utils/middleware'; @@ -59,6 +60,10 @@ export default async function bootChallenge(app, done) { router.get('/challenges/current-challenge', redirectToCurrentChallenge); + const coderoadChallengeCompleted = createCoderoadChallengeCompleted(app); + + api.post('/coderoad-challenge-completed', coderoadChallengeCompleted); + app.use(api); app.use(router); done(); @@ -78,7 +83,7 @@ export function buildUserUpdate( _completedChallenge, timezone ) { - const { files } = _completedChallenge; + const { files, completedDate = Date.now() } = _completedChallenge; let completedChallenge = {}; if (jsProjects.includes(challengeId)) { completedChallenge = { @@ -108,7 +113,7 @@ export function buildUserUpdate( } else { updateData.$push = { ...updateData.$push, - progressTimestamps: Date.now() + progressTimestamps: completedDate }; finalChallenge = { ...completedChallenge @@ -328,6 +333,87 @@ function backendChallengeCompleted(req, res, 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 // chinese.freecodecamp.org export function createRedirectToCurrentChallenge( diff --git a/api-server/src/server/boot/user.js b/api-server/src/server/boot/user.js index d0ed41a8dc..14ac343f7d 100644 --- a/api-server/src/server/boot/user.js +++ b/api-server/src/server/boot/user.js @@ -24,6 +24,8 @@ function bootUser(app) { const getSessionUser = createReadSessionUser(app); const postReportUserProfile = createPostReportUserProfile(app); const postDeleteAccount = createPostDeleteAccount(app); + const postWebhookToken = createPostWebhookToken(app); + const deleteWebhookToken = createDeleteWebhookToken(app); api.get('/account', sendNonUserToHome, getAccount); api.get('/account/unlink/:social', sendNonUserToHome, getUnlinkSocial); @@ -31,6 +33,7 @@ function bootUser(app) { api.post('/account/delete', ifNoUser401, postDeleteAccount); api.post('/account/reset-progress', ifNoUser401, postResetProgress); + api.post('/user/webhook-token', postWebhookToken); api.post( '/user/report-user/', ifNoUser401, @@ -38,14 +41,60 @@ function bootUser(app) { postReportUserProfile ); + api.delete('/user/webhook-token', deleteWebhookToken); + 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) { const { Donation } = app.models; - return function getSessionUser(req, res, next) { + return async function getSessionUser(req, res, next) { const queryUser = req.user; + + const webhookTokenArr = await queryUser.webhookTokens({ + userId: queryUser.id + }); + const webhookToken = webhookTokenArr[0]?.id; + const source = queryUser && Observable.forkJoin( @@ -83,7 +132,8 @@ function createReadSessionUser(app) { isTwitter: !!user.twitter, isWebsite: !!user.website, ...normaliseUserFields(user), - joinDate: user.id.getTimestamp() + joinDate: user.id.getTimestamp(), + webhookToken } }, sessionMeta, @@ -192,8 +242,20 @@ function postResetProgress(req, res, next) { } function createPostDeleteAccount(app) { - const { User } = app.models; - return function postDeleteAccount(req, res, next) { + const { User, WebhookToken } = app.models; + 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) { if (err) { return next(err); diff --git a/api-server/src/server/middlewares/csurf.js b/api-server/src/server/middlewares/csurf.js index 50b0cdafd3..2d7a70f248 100644 --- a/api-server/src/server/middlewares/csurf.js +++ b/api-server/src/server/middlewares/csurf.js @@ -14,7 +14,9 @@ export default function getCsurf() { const { path } = req; if ( // 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(); } else { diff --git a/api-server/src/server/middlewares/request-authorization.js b/api-server/src/server/middlewares/request-authorization.js index d1dc353810..aabc6c194e 100644 --- a/api-server/src/server/middlewares/request-authorization.js +++ b/api-server/src/server/middlewares/request-authorization.js @@ -26,6 +26,7 @@ const unsubscribeRE = /^\/u\/|^\/unsubscribe\/|^\/ue\//; const updateHooksRE = /^\/hooks\/update-paypal$/; // note: this would be replaced by webhooks later const donateRE = /^\/donate\/charge-stripe$/; +const submitCoderoadChallengeRE = /^\/coderoad-challenge-completed$/; const _pathsAllowedREs = [ authRE, @@ -40,7 +41,8 @@ const _pathsAllowedREs = [ unsubscribedRE, unsubscribeRE, updateHooksRE, - donateRE + donateRE, + submitCoderoadChallengeRE ]; export function isAllowedPath(path, pathsAllowedREs = _pathsAllowedREs) { diff --git a/api-server/src/server/model-config.json b/api-server/src/server/model-config.json index d441fa0db8..6da9b766d9 100644 --- a/api-server/src/server/model-config.json +++ b/api-server/src/server/model-config.json @@ -58,5 +58,9 @@ "User": { "dataSource": "db", "public": false + }, + "WebhookToken": { + "dataSource": "db", + "public": false } } diff --git a/api-server/src/server/models/webhook-token.json b/api-server/src/server/models/webhook-token.json new file mode 100644 index 0000000000..4743866abd --- /dev/null +++ b/api-server/src/server/models/webhook-token.json @@ -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": {} +} diff --git a/api-server/src/server/utils/certTypes.json b/api-server/src/server/utils/certTypes.json index 5d441b9a3f..9d928781e8 100644 --- a/api-server/src/server/utils/certTypes.json +++ b/api-server/src/server/utils/certTypes.json @@ -13,5 +13,6 @@ "sciCompPyV7": "isSciCompPyCertV7", "dataAnalysisPyV7": "isDataAnalysisPyCertV7", "machineLearningPyV7": "isMachineLearningPyCertV7", - "fullStack": "isFullStackCert" + "fullStack": "isFullStackCert", + "relationalDatabasesV8": "isRelationalDatabasesV8" } diff --git a/client/i18n/locales/chinese-traditional/translations.json b/client/i18n/locales/chinese-traditional/translations.json index 6f1f6a807e..8347457ce9 100644 --- a/client/i18n/locales/chinese-traditional/translations.json +++ b/client/i18n/locales/chinese-traditional/translations.json @@ -466,7 +466,11 @@ "unlink-success": "你已成功取消 {{website}} 鏈接", "provide-username": "檢查你是否提供用戶名和報告", "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": { "max-characters": "字符數最多爲 288 個,你還可以輸入 {{charsLeft}} 個字符", @@ -594,5 +598,18 @@ "add-code-one": "用你的複製代碼替換這兩句。", "add-code-two": "請保留上方的 ``` 行和下方的 ``` 行", "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" } } diff --git a/client/i18n/locales/chinese/translations.json b/client/i18n/locales/chinese/translations.json index d034726332..41eced360b 100644 --- a/client/i18n/locales/chinese/translations.json +++ b/client/i18n/locales/chinese/translations.json @@ -466,7 +466,11 @@ "unlink-success": "你已成功取消 {{website}} 链接", "provide-username": "检查你是否提供用户名和报告", "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": { "max-characters": "字符数最多为 288 个,你还可以输入 {{charsLeft}} 个字符", @@ -594,5 +598,18 @@ "add-code-one": "用你的复制代码替换这两句。", "add-code-two": "请保留上方的 ``` 行和下方的 ``` 行", "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" } } diff --git a/client/i18n/locales/english/translations.json b/client/i18n/locales/english/translations.json index c52abaedb2..4c8a9f0206 100644 --- a/client/i18n/locales/english/translations.json +++ b/client/i18n/locales/english/translations.json @@ -466,7 +466,11 @@ "unlink-success": "You've successfully unlinked your {{website}}", "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", - "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": { "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-two": "Please leave the ``` line above and the ``` line below,", "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" } } diff --git a/client/i18n/locales/espanol/translations.json b/client/i18n/locales/espanol/translations.json index 09bcd11826..a753b1f1b8 100644 --- a/client/i18n/locales/espanol/translations.json +++ b/client/i18n/locales/espanol/translations.json @@ -466,7 +466,11 @@ "unlink-success": "Has desvinculado correctamente tu {{website}}", "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", - "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": { "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-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." + }, + "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" } } diff --git a/client/i18n/locales/italian/translations.json b/client/i18n/locales/italian/translations.json index cabcad3ed4..7379fc1c62 100644 --- a/client/i18n/locales/italian/translations.json +++ b/client/i18n/locales/italian/translations.json @@ -466,7 +466,11 @@ "unlink-success": "Hai scollegato correttamente il tuo {{website}}", "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", - "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": { "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-two": "Si prega di lasciare la linea ``` sopra e la linea ``` sotto,", "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" } } diff --git a/client/i18n/locales/portuguese/translations.json b/client/i18n/locales/portuguese/translations.json index f26352fead..89584c4766 100644 --- a/client/i18n/locales/portuguese/translations.json +++ b/client/i18n/locales/portuguese/translations.json @@ -466,7 +466,11 @@ "unlink-success": "Você desvinculou seu {{website}} com sucesso", "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", - "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": { "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-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." + }, + "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" } } diff --git a/client/src/client-only-routes/show-settings.tsx b/client/src/client-only-routes/show-settings.tsx index d411f67fab..335c30cbec 100644 --- a/client/src/client-only-routes/show-settings.tsx +++ b/client/src/client-only-routes/show-settings.tsx @@ -16,6 +16,7 @@ import Honesty from '../components/settings/honesty'; import Internet from '../components/settings/internet'; import Portfolio from '../components/settings/portfolio'; import Privacy from '../components/settings/privacy'; +import WebhookToken from '../components/settings/webhook-token'; import { signInLoadingSelector, userSelector, @@ -25,7 +26,7 @@ import { import { User } from '../redux/prop-types'; import { submitNewAbout, updateUserFlag, verifyCert } from '../redux/settings'; -const { apiLocation } = envData; +const { apiLocation, showUpcomingChanges } = envData; // TODO: update types for actions interface ShowSettingsProps { @@ -198,6 +199,8 @@ export function ShowSettings(props: ShowSettingsProps): JSX.Element { username={username} verifyCert={verifyCert} /> + {showUpcomingChanges && } + {showUpcomingChanges && } diff --git a/client/src/components/settings/webhook-delete-modal.tsx b/client/src/components/settings/webhook-delete-modal.tsx new file mode 100644 index 0000000000..ec02cd457a --- /dev/null +++ b/client/src/components/settings/webhook-delete-modal.tsx @@ -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 ( + + + + {t('webhook-token.delete-title')} + + + +

{t('webhook-token.delete-p2')}

+

{t('webhook-token.delete-p3')}

+
+ + + +
+ + + +
+ ); +} + +WebhookDeleteModal.displayName = 'WebhookDeleteModal'; + +export default WebhookDeleteModal; diff --git a/client/src/components/settings/webhook-token.css b/client/src/components/settings/webhook-token.css new file mode 100644 index 0000000000..4ad360574b --- /dev/null +++ b/client/src/components/settings/webhook-token.css @@ -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); +} diff --git a/client/src/components/settings/webhook-token.tsx b/client/src/components/settings/webhook-token.tsx new file mode 100644 index 0000000000..e2a34aab45 --- /dev/null +++ b/client/src/components/settings/webhook-token.tsx @@ -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 { + 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 && ( +
+

{t('webhook-token.create-p1')}

+ + +
+ )} + + ) : ( +
+ + + {t('webhook-token.title')} + + {!webhookToken ? ( +

{t('webhook-token.create-p2')}

+ ) : ( +

{t('webhook-token.delete-p1')}

+ )} + + + + {!webhookToken ? ( + + ) : ( + + )} + + +
+ + this.deleteToken()} + onHide={() => this.toggleWebhookDeleteModal()} + show={this.state.webhookDeleteModal} + /> +
+
+ ); + } +} + +WebhookToken.displayName = 'WebhookToken'; + +export default connect( + mapStateToProps, + mapDispatchToProps +)(withTranslation()(WebhookToken)); diff --git a/client/src/redux/action-types.js b/client/src/redux/action-types.js index a2e776b715..611d9660ce 100644 --- a/client/src/redux/action-types.js +++ b/client/src/redux/action-types.js @@ -29,7 +29,9 @@ export const actionTypes = createTypes( ...createAsyncTypes('acceptTerms'), ...createAsyncTypes('showCert'), ...createAsyncTypes('reportUser'), - ...createAsyncTypes('postChargeStripeCard') + ...createAsyncTypes('postChargeStripeCard'), + ...createAsyncTypes('postWebhookToken'), + ...createAsyncTypes('deleteWebhookToken') ], ns ); diff --git a/client/src/redux/index.js b/client/src/redux/index.js index 905def7199..276d06772c 100644 --- a/client/src/redux/index.js +++ b/client/src/redux/index.js @@ -18,6 +18,7 @@ import { actionTypes as settingsTypes } from './settings/action-types'; import { createShowCertSaga } from './show-cert-saga'; import { createSoundModeSaga } from './sound-mode-saga'; import updateCompleteEpic from './update-complete-epic'; +import { createWebhookSaga } from './webhook-saga'; export const MainApp = 'app'; @@ -75,7 +76,8 @@ export const sagas = [ ...createFetchUserSaga(actionTypes), ...createShowCertSaga(actionTypes), ...createReportUserSaga(actionTypes), - ...createSoundModeSaga({ ...actionTypes, ...settingsTypes }) + ...createSoundModeSaga({ ...actionTypes, ...settingsTypes }), + ...createWebhookSaga(actionTypes) ]; export const appMount = createAction(actionTypes.appMount); @@ -167,6 +169,15 @@ export const showCert = createAction(actionTypes.showCert); export const showCertComplete = createAction(actionTypes.showCertComplete); 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( actionTypes.updateCurrentChallengeId ); @@ -230,6 +241,10 @@ export const shouldRequestDonationSelector = state => { return completionCount >= 3; }; +export const webhookTokenSelector = state => { + return userSelector(state).webhookToken; +}; + export const userByNameSelector = username => state => { const { user } = state[MainApp]; // 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 }) => ({ ...state, currentChallengeId: payload diff --git a/client/src/redux/webhook-saga.js b/client/src/redux/webhook-saga.js new file mode 100644 index 0000000000..78e9a23466 --- /dev/null +++ b/client/src/redux/webhook-saga.js @@ -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) + ]; +} diff --git a/client/src/templates/Challenges/codeally/show.tsx b/client/src/templates/Challenges/codeally/show.tsx index a588064f4f..d990c3f7ec 100644 --- a/client/src/templates/Challenges/codeally/show.tsx +++ b/client/src/templates/Challenges/codeally/show.tsx @@ -7,13 +7,22 @@ import Helmet from 'react-helmet'; import { connect } from 'react-redux'; import { bindActionCreators } from 'redux'; import type { Dispatch } from 'redux'; +import { createSelector } from 'reselect'; // Local Utilities import LearnLayout from '../../../components/layouts/learn'; +import { webhookTokenSelector } from '../../../redux'; import { ChallengeNode, ChallengeMeta } from '../../../redux/prop-types'; import { updateChallengeMeta, challengeMounted } from '../redux'; // Redux -const mapStateToProps = () => ({}); + +const mapStateToProps = createSelector( + webhookTokenSelector, + (webhookToken: string | null) => ({ + webhookToken + }) +); + const mapDispatchToProps = (dispatch: Dispatch) => bindActionCreators( { @@ -30,6 +39,7 @@ interface ShowCodeAllyProps { challengeMeta: ChallengeMeta; }; updateChallengeMeta: (arg0: ChallengeMeta) => void; + webhookToken: string | null; } // Component @@ -48,10 +58,19 @@ class ShowCodeAlly extends Component { render() { const { - title, - fields: { blockName }, - url - } = this.props.data.challengeNode; + data: { + challengeNode: { + title, + fields: { blockName }, + url + } + }, + webhookToken = null + } = this.props; + + const envVariables = webhookToken + ? `&envVariables=CODEROAD_WEBHOOK_TOKEN=${webhookToken}` + : ''; return ( @@ -60,7 +79,7 @@ class ShowCodeAlly extends Component { className='codeally-frame' // eslint-disable-next-line @typescript-eslint/restrict-template-expressions 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' /> diff --git a/client/src/templates/Introduction/super-block-intro.tsx b/client/src/templates/Introduction/super-block-intro.tsx index 150d4733ff..256e5eaaf5 100644 --- a/client/src/templates/Introduction/super-block-intro.tsx +++ b/client/src/templates/Introduction/super-block-intro.tsx @@ -14,6 +14,7 @@ import DonateModal from '../../components/Donation/DonationModal'; import Login from '../../components/Header/components/Login'; import Map from '../../components/Map'; import { Spacer } from '../../components/helpers'; +import WebhookToken from '../../components/settings/webhook-token'; import { currentChallengeIdSelector, userFetchStateSelector, @@ -187,6 +188,9 @@ const SuperBlockIntroductionPage = (props: SuperBlockProp) => { + {superBlock === 'relational-databases' && isSignedIn && ( + + )}

{t(`intro:misc-text.courses`)} diff --git a/client/src/utils/ajax.ts b/client/src/utils/ajax.ts index 2e8a2a18f5..209a1f2d68 100644 --- a/client/src/utils/ajax.ts +++ b/client/src/utils/ajax.ts @@ -35,8 +35,12 @@ function put(path: string, body: unknown): Promise { return request('PUT', path, body); } +function deleteRequest(path: string, body: unknown): Promise { + return request('DELETE', path, body); +} + async function request( - method: 'POST' | 'PUT', + method: 'POST' | 'PUT' | 'DELETE', path: string, body: unknown ): Promise { @@ -207,6 +211,10 @@ export function postResetProgress(): Promise { return post('/account/reset-progress', {}); } +export function postWebhookToken(): Promise { + return post('/user/webhook-token', {}); +} + /** PUT **/ interface MyAbout { @@ -249,3 +257,8 @@ export function putUserUpdateEmail(email: string): Promise { export function putVerifyCert(certSlug: string): Promise { return put('/certificate/verify', { certSlug }); } + +/** DELETE **/ +export function deleteWebhookToken(): Promise { + return deleteRequest('/user/webhook-token', {}); +}