From 9e5f9b2a7c6a0ea2ec0535b83ab676504f1cde9d Mon Sep 17 00:00:00 2001 From: Tom <20648924+moT01@users.noreply.github.com> Date: Fri, 11 Mar 2022 15:58:23 -0600 Subject: [PATCH] fix: webhook process (#45385) * fix: token rework functional fix: clean up fix: more clean up fix: more clean up fix: add widget back to settings fix: fix: fix: cypress Apply suggestions from code review Co-authored-by: Oliver Eyton-Williams fix: use flash enum * chore: rename webhookToken -> userToken fix: add translations I forgot to save * fix: add missing tones for flash messages * fix: node test --- api-server/src/common/models/user.json | 4 +- api-server/src/server/boot/authentication.js | 4 +- api-server/src/server/boot/challenge.js | 18 +- api-server/src/server/boot/user.js | 80 ++++----- .../server/middlewares/delete-user-token.js | 25 +++ api-server/src/server/model-config.json | 2 +- .../{webhook-token.json => user-token.json} | 2 +- client/i18n/locales/english/translations.json | 23 +-- .../src/client-only-routes/show-settings.tsx | 25 ++- .../components/Flash/redux/flash-messages.ts | 3 +- .../{webhook-token.css => user-token.css} | 22 +-- client/src/components/settings/user-token.tsx | 61 +++++++ .../settings/webhook-delete-modal.tsx | 66 -------- .../src/components/settings/webhook-token.tsx | 154 ------------------ client/src/redux/action-types.js | 7 +- client/src/redux/codeally-saga.js | 41 +++++ client/src/redux/index.js | 50 ++++-- client/src/redux/user-token-saga.js | 35 ++++ client/src/redux/webhook-saga.js | 61 ------- .../templates/Challenges/codeally/show.tsx | 59 ++++--- client/src/utils/ajax.ts | 8 +- client/src/utils/tone/index.ts | 3 +- cypress/integration/settings/user-token.js | 37 +++++ tools/scripts/seed/seedAuthUser.js | 14 ++ 24 files changed, 370 insertions(+), 434 deletions(-) create mode 100644 api-server/src/server/middlewares/delete-user-token.js rename api-server/src/server/models/{webhook-token.json => user-token.json} (93%) rename client/src/components/settings/{webhook-token.css => user-token.css} (58%) create mode 100644 client/src/components/settings/user-token.tsx delete mode 100644 client/src/components/settings/webhook-delete-modal.tsx delete mode 100644 client/src/components/settings/webhook-token.tsx create mode 100644 client/src/redux/codeally-saga.js create mode 100644 client/src/redux/user-token-saga.js delete mode 100644 client/src/redux/webhook-saga.js create mode 100644 cypress/integration/settings/user-token.js diff --git a/api-server/src/common/models/user.json b/api-server/src/common/models/user.json index 88419eaedb..e65bd7fa92 100644 --- a/api-server/src/common/models/user.json +++ b/api-server/src/common/models/user.json @@ -341,9 +341,9 @@ "model": "article", "foreignKey": "externalId" }, - "webhookTokens": { + "userTokens": { "type": "hasMany", - "model": "WebhookToken", + "model": "UserToken", "foreignKey": "userId" } }, diff --git a/api-server/src/server/boot/authentication.js b/api-server/src/server/boot/authentication.js index 5e9320b7c3..e9df1cb686 100644 --- a/api-server/src/server/boot/authentication.js +++ b/api-server/src/server/boot/authentication.js @@ -16,6 +16,7 @@ import { wrapHandledError } from '../utils/create-handled-error.js'; import { removeCookies } from '../utils/getSetAccessToken'; import { ifUserRedirectTo, ifNoUserRedirectHome } from '../utils/middleware'; import { getRedirectParams } from '../utils/redirection'; +import { createDeleteUserToken } from '../middlewares/delete-user-token'; const passwordlessGetValidators = [ check('email') @@ -38,6 +39,7 @@ module.exports = function enableAuthentication(app) { const devSaveAuthCookies = devSaveResponseAuthCookies(); const devLoginSuccessRedirect = devLoginRedirect(); const api = app.loopback.Router(); + const deleteUserToken = createDeleteUserToken(app); // Use a local mock strategy for signing in if we are in dev mode. // Otherwise we use auth0 login. We use a string for 'true' because values @@ -62,7 +64,7 @@ module.exports = function enableAuthentication(app) { ); } - api.get('/signout', (req, res) => { + api.get('/signout', deleteUserToken, (req, res) => { const { origin, returnTo } = getRedirectParams(req); req.logout(); req.session.destroy(err => { diff --git a/api-server/src/server/boot/challenge.js b/api-server/src/server/boot/challenge.js index dcc30ec7c1..f9f4eae6af 100644 --- a/api-server/src/server/boot/challenge.js +++ b/api-server/src/server/boot/challenge.js @@ -396,15 +396,15 @@ function createCoderoadChallengeCompleted(app) { * req.headers: { coderoad-user-token: '8kFIlZiwMioY6hqqt...' } */ - const { WebhookToken, User } = app.models; + const { UserToken, User } = app.models; return async function coderoadChallengeCompleted(req, res) { - const { 'coderoad-user-token': userWebhookToken } = req.headers; + const { 'coderoad-user-token': userToken } = req.headers; const { tutorialId } = req.body; if (!tutorialId) return res.send(`'tutorialId' not found in request body`); - if (!userWebhookToken) + if (!userToken) return res.send(`'coderoad-user-token' not found in request headers`); const tutorialRepo = tutorialId?.split(':')[0]; @@ -427,21 +427,21 @@ function createCoderoadChallengeCompleted(app) { const { id: challengeId, challengeType } = challenge; try { - // check if webhook token is in database - const tokenInfo = await WebhookToken.findOne({ - where: { id: userWebhookToken } + // check if user token is in database + const tokenInfo = await UserToken.findOne({ + where: { id: userToken } }); - if (!tokenInfo) return res.send('User webhook token not found'); + if (!tokenInfo) return res.send('User token not found'); const { userId } = tokenInfo; - // check if user exists for webhook token + // check if user exists for user token const user = await User.findOne({ where: { id: userId } }); - if (!user) return res.send('User for webhook token not found'); + if (!user) return res.send('User for user token not found'); // submit challenge const completedDate = Date.now(); diff --git a/api-server/src/server/boot/user.js b/api-server/src/server/boot/user.js index 3bd732bad5..4c4baca1e9 100644 --- a/api-server/src/server/boot/user.js +++ b/api-server/src/server/boot/user.js @@ -17,6 +17,7 @@ import { } from '../utils/publicUserProps'; import { getRedirectParams } from '../utils/redirection'; import { trimTags } from '../utils/validators'; +import { createDeleteUserToken } from '../middlewares/delete-user-token'; const log = debugFactory('fcc:boot:user'); const sendNonUserToHome = ifNoUserRedirectHome(); @@ -27,16 +28,19 @@ function bootUser(app) { const getSessionUser = createReadSessionUser(app); const postReportUserProfile = createPostReportUserProfile(app); const postDeleteAccount = createPostDeleteAccount(app); - const postWebhookToken = createPostWebhookToken(app); - const deleteWebhookToken = createDeleteWebhookToken(app); + const postUserToken = createPostUserToken(app); + const deleteUserToken = createDeleteUserToken(app); api.get('/account', sendNonUserToHome, getAccount); api.get('/account/unlink/:social', sendNonUserToHome, getUnlinkSocial); api.get('/user/get-session-user', getSessionUser); - - api.post('/account/delete', ifNoUser401, postDeleteAccount); - api.post('/account/reset-progress', ifNoUser401, postResetProgress); - api.post('/user/webhook-token', postWebhookToken); + api.post('/account/delete', ifNoUser401, deleteUserToken, postDeleteAccount); + api.post( + '/account/reset-progress', + ifNoUser401, + deleteUserToken, + postResetProgress + ); api.post( '/user/report-user/', ifNoUser401, @@ -44,47 +48,41 @@ function bootUser(app) { postReportUserProfile ); - api.delete('/user/webhook-token', deleteWebhookToken); + api.post('/user/user-token', ifNoUser401, postUserToken); + api.delete( + '/user/user-token', + ifNoUser401, + deleteUserToken, + deleteUserTokenResponse + ); app.use(api); } -function createPostWebhookToken(app) { - const { WebhookToken } = app.models; +function createPostUserToken(app) { + const { UserToken } = app.models; - return async function postWebhookToken(req, res) { + return async function postUserToken(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 }); + await UserToken.destroyAll({ userId: req.user.id }); + newToken = await UserToken.create({ ttl, userId: req.user.id }); } catch (e) { - return res.status(500).json({ - type: 'danger', - message: 'flash.create-token-err' - }); + return res.status(500).send('Error starting project'); } - return res.json(newToken?.id); + return res.json({ token: newToken?.id }); }; } -function createDeleteWebhookToken(app) { - const { WebhookToken } = app.models; +function deleteUserTokenResponse(req, res) { + if (!req.userTokenDeleted) { + return res.status(500).send('Error deleting user token'); + } - 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); - }; + return res.send({ token: null }); } function createReadSessionUser(app) { @@ -93,10 +91,10 @@ function createReadSessionUser(app) { return async function getSessionUser(req, res, next) { const queryUser = req.user; - const webhookTokenArr = await queryUser.webhookTokens({ + const userTokenArr = await queryUser.userTokens({ userId: queryUser.id }); - const webhookToken = webhookTokenArr[0]?.id; + const userToken = userTokenArr[0]?.id; const source = queryUser && @@ -153,7 +151,7 @@ function createReadSessionUser(app) { isWebsite: !!user.website, ...normaliseUserFields(user), joinDate: user.id.getTimestamp(), - webhookToken + userToken } }, sessionMeta, @@ -263,20 +261,8 @@ function postResetProgress(req, res, next) { } function createPostDeleteAccount(app) { - const { User, WebhookToken } = app.models; + const { User } = 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/delete-user-token.js b/api-server/src/server/middlewares/delete-user-token.js new file mode 100644 index 0000000000..6101fa52a7 --- /dev/null +++ b/api-server/src/server/middlewares/delete-user-token.js @@ -0,0 +1,25 @@ +import debugFactory from 'debug'; +const log = debugFactory('fcc:boot:user'); + +/* + * User tokens for submitting external curriculum are deleted when they sign + * out, reset their account, or delete their account + */ + +export function createDeleteUserToken(app) { + const { UserToken } = app.models; + + return async function deleteUserToken(req, res, next) { + try { + await UserToken.destroyAll({ userId: req.user.id }); + req.userTokenDeleted = true; + } catch (e) { + req.userTokenDeleted = false; + log( + `An error occurred deleting user token for user with id ${req.user.id}` + ); + } + + next(); + }; +} diff --git a/api-server/src/server/model-config.json b/api-server/src/server/model-config.json index 6da9b766d9..62382112c3 100644 --- a/api-server/src/server/model-config.json +++ b/api-server/src/server/model-config.json @@ -59,7 +59,7 @@ "dataSource": "db", "public": false }, - "WebhookToken": { + "UserToken": { "dataSource": "db", "public": false } diff --git a/api-server/src/server/models/webhook-token.json b/api-server/src/server/models/user-token.json similarity index 93% rename from api-server/src/server/models/webhook-token.json rename to api-server/src/server/models/user-token.json index 4743866abd..8fcf782505 100644 --- a/api-server/src/server/models/webhook-token.json +++ b/api-server/src/server/models/user-token.json @@ -1,5 +1,5 @@ { - "name": "WebhookToken", + "name": "UserToken", "description": "Tokens for submitting curricula through CodeRoad", "base": "AccessToken", "idInjection": true, diff --git a/client/i18n/locales/english/translations.json b/client/i18n/locales/english/translations.json index 892149051c..d21e950b28 100644 --- a/client/i18n/locales/english/translations.json +++ b/client/i18n/locales/english/translations.json @@ -520,10 +520,11 @@ "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", - "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.", + "create-token-err": "An error occurred while creating your user token", + "delete-token-err": "An error occurred while deleting your user token", + "token-created": "You have successfully created a new user token.", + "token-deleted": "Your user token has been deleted.", + "start-project-err": "Something went wrong trying to start the project. Please try again.", "complete-project-first": "You must complete the project first.", "local-code-save-error": "Oops, your code did not save, your browser's local storage may be full.", "local-code-saved": "Saved! Your code was saved to your browser's local storage." @@ -656,14 +657,14 @@ "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", + "user-token": { + "title": "User 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.", + "create-p1": "It looks like you don't have a user token. Create one to save your progress on this section", + "create-p2": "Create a user token to save your progress on the curriculum sections that use a virtual machine.", + "delete": "Delete my user token", + "delete-title": "Delete My User Token", + "delete-p1": "Your user token is used to save your progress on curriculum sections that use a virtual machine. If you suspect it has been compromised, you can delete it without losing any progress. A new one will be created automatically the next time you open a project.", "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", diff --git a/client/src/client-only-routes/show-settings.tsx b/client/src/client-only-routes/show-settings.tsx index c31ac63e98..209639b00a 100644 --- a/client/src/client-only-routes/show-settings.tsx +++ b/client/src/client-only-routes/show-settings.tsx @@ -17,17 +17,18 @@ import Internet from '../components/settings/internet'; import Portfolio from '../components/settings/portfolio'; import Privacy from '../components/settings/privacy'; import { Themes } from '../components/settings/theme'; -import WebhookToken from '../components/settings/webhook-token'; +import UserToken from '../components/settings/user-token'; import { signInLoadingSelector, userSelector, isSignedInSelector, - hardGoTo as navigate + hardGoTo as navigate, + userTokenSelector } from '../redux'; import { User } from '../redux/prop-types'; import { submitNewAbout, updateUserFlag, verifyCert } from '../redux/settings'; -const { apiLocation, deploymentEnv } = envData; +const { apiLocation } = envData; // TODO: update types for actions interface ShowSettingsProps { @@ -45,16 +46,19 @@ interface ShowSettingsProps { user: User; verifyCert: () => void; path?: string; + userToken: string | null; } const mapStateToProps = createSelector( signInLoadingSelector, userSelector, isSignedInSelector, - (showLoading: boolean, user: User, isSignedIn) => ({ + userTokenSelector, + (showLoading: boolean, user: User, isSignedIn, userToken: string | null) => ({ showLoading, user, - isSignedIn + isSignedIn, + userToken }) ); @@ -122,7 +126,8 @@ export function ShowSettings(props: ShowSettingsProps): JSX.Element { updateInternetSettings, updatePortfolio, updateIsHonest, - verifyCert + verifyCert, + userToken } = props; if (showLoading) { @@ -202,8 +207,12 @@ export function ShowSettings(props: ShowSettingsProps): JSX.Element { username={username} verifyCert={verifyCert} /> - {deploymentEnv == 'staging' && } - {deploymentEnv == 'staging' && } + {userToken && ( + <> + + + + )} diff --git a/client/src/components/Flash/redux/flash-messages.ts b/client/src/components/Flash/redux/flash-messages.ts index 7118fa0779..efd4d37109 100644 --- a/client/src/components/Flash/redux/flash-messages.ts +++ b/client/src/components/Flash/redux/flash-messages.ts @@ -6,7 +6,6 @@ export enum FlashMessages { CertificateMissing = 'flash.certificate-missing', CertsPrivate = 'flash.certs-private', CompleteProjectFirst = 'flash.complete-project-first', - CreateTokenErr = 'flash.create-token-err', DeleteTokenErr = 'flash.delete-token-err', EmailValid = 'flash.email-valid', HonestFirst = 'flash.honest-first', @@ -24,7 +23,7 @@ export enum FlashMessages { ReallyWeird = 'flash.really-weird', ReportSent = 'flash.report-sent', SigninSuccess = 'flash.signin-success', - TokenCreated = 'flash.token-created', + StartProjectErr = 'flash.start-project-err', TokenDeleted = 'flash.token-deleted', UpdatedPreferences = 'flash.updated-preferences', UsernameNotFound = 'flash.username-not-found', diff --git a/client/src/components/settings/webhook-token.css b/client/src/components/settings/user-token.css similarity index 58% rename from client/src/components/settings/webhook-token.css rename to client/src/components/settings/user-token.css index 4ad360574b..784e1ef70e 100644 --- a/client/src/components/settings/webhook-token.css +++ b/client/src/components/settings/user-token.css @@ -1,21 +1,7 @@ -.webhook-panel { +.user-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); @@ -29,17 +15,17 @@ border-color: var(--highlight-background); } -.webhook-token .panel-heading { +.user-token .panel-heading { color: var(--highlight-color); background-color: var(--highlight-background); border-radius: 0; border: none; } -.webhook-token .panel-info { +.user-token .panel-info { border-color: var(--highlight-background); } -.webhook-token p { +.user-token p { color: var(--highlight-color); } diff --git a/client/src/components/settings/user-token.tsx b/client/src/components/settings/user-token.tsx new file mode 100644 index 0000000000..82576d8533 --- /dev/null +++ b/client/src/components/settings/user-token.tsx @@ -0,0 +1,61 @@ +/* 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 { deleteUserToken } from '../../redux'; +import { ButtonSpacer, FullWidthRow, Spacer } from '../helpers'; + +import './user-token.css'; + +type UserTokenProps = { + deleteUserToken: () => void; + t: TFunction; +}; + +const mapDispatchToProps = { + deleteUserToken +}; + +class UserToken extends Component { + static displayName: string; + + deleteToken = () => { + this.props.deleteUserToken(); + }; + + render() { + const { t } = this.props; + + return ( +
+ + + {t('user-token.title')} + +

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

+ + + + + +
+
+
+ ); + } +} + +UserToken.displayName = 'UserToken'; + +export default connect(null, mapDispatchToProps)(withTranslation()(UserToken)); diff --git a/client/src/components/settings/webhook-delete-modal.tsx b/client/src/components/settings/webhook-delete-modal.tsx deleted file mode 100644 index ec02cd457a..0000000000 --- a/client/src/components/settings/webhook-delete-modal.tsx +++ /dev/null @@ -1,66 +0,0 @@ -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.tsx b/client/src/components/settings/webhook-token.tsx deleted file mode 100644 index 5dfd065d49..0000000000 --- a/client/src/components/settings/webhook-token.tsx +++ /dev/null @@ -1,154 +0,0 @@ -/* 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; - isChallengePage?: 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 { isChallengePage = false, t, webhookToken = null } = this.props; - - return isChallengePage ? ( - <> - {!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 611d9660ce..f07170889d 100644 --- a/client/src/redux/action-types.js +++ b/client/src/redux/action-types.js @@ -8,19 +8,23 @@ export const actionTypes = createTypes( 'hardGoTo', 'allowBlockDonationRequests', 'closeDonationModal', + 'hideCodeAlly', 'preventBlockDonationRequests', 'preventProgressDonationRequests', 'openDonationModal', 'onlineStatusChange', 'serverStatusChange', 'resetUserData', + 'tryToShowCodeAlly', 'tryToShowDonationModal', 'executeGA', + 'showCodeAlly', 'submitComplete', 'updateComplete', 'updateCurrentChallengeId', 'updateFailed', 'updateDonationFormState', + 'updateUserToken', ...createAsyncTypes('fetchUser'), ...createAsyncTypes('addDonation'), ...createAsyncTypes('createStripeSession'), @@ -30,8 +34,7 @@ export const actionTypes = createTypes( ...createAsyncTypes('showCert'), ...createAsyncTypes('reportUser'), ...createAsyncTypes('postChargeStripeCard'), - ...createAsyncTypes('postWebhookToken'), - ...createAsyncTypes('deleteWebhookToken') + ...createAsyncTypes('deleteUserToken') ], ns ); diff --git a/client/src/redux/codeally-saga.js b/client/src/redux/codeally-saga.js new file mode 100644 index 0000000000..90b7bbaa84 --- /dev/null +++ b/client/src/redux/codeally-saga.js @@ -0,0 +1,41 @@ +import { call, put, select, takeEvery } from 'redux-saga/effects'; +import { createFlashMessage } from '../components/Flash/redux'; +import { FlashMessages } from '../components/Flash/redux/flash-messages'; +import { postUserToken } from '../utils/ajax'; +import { + isSignedInSelector, + showCodeAlly, + updateUserToken, + userTokenSelector +} from './'; + +const startProjectErrMessage = { + type: 'danger', + message: FlashMessages.StartProjectErr +}; + +function* tryToShowCodeAllySaga() { + const isSignedIn = yield select(isSignedInSelector); + const hasUserToken = !!(yield select(userTokenSelector)); + + if (!isSignedIn || hasUserToken) { + yield put(showCodeAlly()); + } else { + try { + const response = yield call(postUserToken); + + if (response?.token) { + yield put(updateUserToken(response.token)); + yield put(showCodeAlly()); + } else { + yield put(createFlashMessage(startProjectErrMessage)); + } + } catch (e) { + yield put(createFlashMessage(startProjectErrMessage)); + } + } +} + +export function createCodeAllySaga(types) { + return [takeEvery(types.tryToShowCodeAlly, tryToShowCodeAllySaga)]; +} diff --git a/client/src/redux/index.js b/client/src/redux/index.js index 9c1475decb..1c05c5c246 100644 --- a/client/src/redux/index.js +++ b/client/src/redux/index.js @@ -9,6 +9,7 @@ import { emailToABVariant } from '../utils/A-B-tester'; import { createAcceptTermsSaga } from './accept-terms-saga'; import { actionTypes } from './action-types'; import { createAppMountSaga } from './app-mount-saga'; +import { createCodeAllySaga } from './codeally-saga'; import { createDonationSaga } from './donation-saga'; import failedUpdatesEpic from './failed-updates-epic'; import { createFetchUserSaga } from './fetch-user-saga'; @@ -20,7 +21,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'; +import { createUserTokenSaga } from './user-token-saga'; export const MainApp = 'app'; @@ -52,6 +53,7 @@ const initialState = { showCertFetchState: { ...defaultFetchState }, + showCodeAlly: false, user: {}, userFetchState: { ...defaultFetchState @@ -73,13 +75,14 @@ export const epics = [hardGoToEpic, failedUpdatesEpic, updateCompleteEpic]; export const sagas = [ ...createAcceptTermsSaga(actionTypes), ...createAppMountSaga(actionTypes), + ...createCodeAllySaga(actionTypes), ...createDonationSaga(actionTypes), ...createGaSaga(actionTypes), ...createFetchUserSaga(actionTypes), ...createShowCertSaga(actionTypes), ...createReportUserSaga(actionTypes), ...createSoundModeSaga({ ...actionTypes, ...settingsTypes }), - ...createWebhookSaga(actionTypes) + ...createUserTokenSaga(actionTypes) ]; export const appMount = createAction(actionTypes.appMount); @@ -171,15 +174,16 @@ 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 updateUserToken = createAction(actionTypes.updateUserToken); +export const deleteUserToken = createAction(actionTypes.deleteUserToken); +export const deleteUserTokenComplete = createAction( + actionTypes.deleteUserTokenComplete ); +export const hideCodeAlly = createAction(actionTypes.hideCodeAlly); +export const showCodeAlly = createAction(actionTypes.showCodeAlly); +export const tryToShowCodeAlly = createAction(actionTypes.tryToShowCodeAlly); + export const updateCurrentChallengeId = createAction( actionTypes.updateCurrentChallengeId ); @@ -242,8 +246,12 @@ export const shouldRequestDonationSelector = state => { return completionCount >= 3; }; -export const webhookTokenSelector = state => { - return userSelector(state).webhookToken; +export const userTokenSelector = state => { + return userSelector(state).userToken; +}; + +export const showCodeAllySelector = state => { + return state[MainApp].showCodeAlly; }; export const userByNameSelector = username => state => { @@ -652,7 +660,7 @@ export const reducer = handleActions( } }; }, - [actionTypes.postWebhookTokenComplete]: (state, { payload }) => { + [actionTypes.updateUserToken]: (state, { payload }) => { const { appUsername } = state; return { ...state, @@ -660,12 +668,12 @@ export const reducer = handleActions( ...state.user, [appUsername]: { ...state.user[appUsername], - webhookToken: payload + userToken: payload } } }; }, - [actionTypes.deleteWebhookTokenComplete]: state => { + [actionTypes.deleteUserTokenComplete]: state => { const { appUsername } = state; return { ...state, @@ -673,11 +681,23 @@ export const reducer = handleActions( ...state.user, [appUsername]: { ...state.user[appUsername], - webhookToken: null + userToken: null } } }; }, + [actionTypes.hideCodeAlly]: state => { + return { + ...state, + showCodeAlly: false + }; + }, + [actionTypes.showCodeAlly]: state => { + return { + ...state, + showCodeAlly: true + }; + }, [challengeTypes.challengeMounted]: (state, { payload }) => ({ ...state, currentChallengeId: payload diff --git a/client/src/redux/user-token-saga.js b/client/src/redux/user-token-saga.js new file mode 100644 index 0000000000..5c6d976939 --- /dev/null +++ b/client/src/redux/user-token-saga.js @@ -0,0 +1,35 @@ +import { call, put, takeEvery } from 'redux-saga/effects'; +import { createFlashMessage } from '../components/Flash/redux'; +import { FlashMessages } from '../components/Flash/redux/flash-messages'; +import { deleteUserToken } from '../utils/ajax'; +import { deleteUserTokenComplete } from '.'; + +const message = { + deleted: { + type: 'info', + message: FlashMessages.TokenDeleted + }, + deleteErr: { + type: 'danger', + message: FlashMessages.DeleteTokenErr + } +}; + +function* deleteUserTokenSaga() { + try { + const response = yield call(deleteUserToken); + + if (response && Object.prototype.hasOwnProperty.call(response, 'token')) { + yield put(deleteUserTokenComplete()); + yield put(createFlashMessage(message.deleted)); + } else { + yield put(createFlashMessage(message.deleteErr)); + } + } catch (e) { + yield put(createFlashMessage(message.deleteErr)); + } +} + +export function createUserTokenSaga(types) { + return [takeEvery(types.deleteUserToken, deleteUserTokenSaga)]; +} diff --git a/client/src/redux/webhook-saga.js b/client/src/redux/webhook-saga.js deleted file mode 100644 index dc0960650b..0000000000 --- a/client/src/redux/webhook-saga.js +++ /dev/null @@ -1,61 +0,0 @@ -import { call, put, takeEvery } from 'redux-saga/effects'; -import { createFlashMessage } from '../components/Flash/redux'; -import { FlashMessages } from '../components/Flash/redux/flash-messages'; -import { postWebhookToken, deleteWebhookToken } from '../utils/ajax'; -import { postWebhookTokenComplete, deleteWebhookTokenComplete } from '.'; - -const message = { - created: { - type: 'success', - message: FlashMessages.TokenCreated - }, - createErr: { - type: 'danger', - message: FlashMessages.CreateTokenErr - }, - deleted: { - type: 'info', - message: FlashMessages.TokenDeleted - }, - deleteErr: { - type: 'danger', - message: FlashMessages.DeleteTokenErr - } -}; - -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 fa54870ce7..3c796abbc5 100644 --- a/client/src/templates/Challenges/codeally/show.tsx +++ b/client/src/templates/Challenges/codeally/show.tsx @@ -22,8 +22,11 @@ import Hotkeys from '../components/Hotkeys'; import { completedChallengesSelector, isSignedInSelector, + hideCodeAlly, partiallyCompletedChallengesSelector, - webhookTokenSelector + showCodeAllySelector, + tryToShowCodeAlly, + userTokenSelector } from '../../../redux'; import { challengeMounted, @@ -40,7 +43,6 @@ import { } from '../../../redux/prop-types'; import ProjectToolPanel from '../projects/tool-panel'; import SolutionForm from '../projects/solution-form'; -import WebhookToken from '../../../components/settings/webhook-token'; import { FlashMessages } from '../../../components/Flash/redux/flash-messages'; import './codeally.css'; @@ -51,19 +53,22 @@ const mapStateToProps = createSelector( isChallengeCompletedSelector, isSignedInSelector, partiallyCompletedChallengesSelector, - webhookTokenSelector, + showCodeAllySelector, + userTokenSelector, ( completedChallenges: CompletedChallenge[], isChallengeCompleted: boolean, isSignedIn: boolean, partiallyCompletedChallenges: CompletedChallenge[], - webhookToken: string | null + showCodeAlly: boolean, + userToken: string | null ) => ({ completedChallenges, isChallengeCompleted, isSignedIn, partiallyCompletedChallenges, - webhookToken + showCodeAlly, + userToken }) ); @@ -72,7 +77,9 @@ const mapDispatchToProps = (dispatch: Dispatch) => { challengeMounted, createFlashMessage, + hideCodeAlly, openCompletionModal: () => openModal('completion'), + tryToShowCodeAlly, updateChallengeMeta, updateSolutionFormValues }, @@ -85,6 +92,7 @@ interface ShowCodeAllyProps { completedChallenges: CompletedChallenge[]; createFlashMessage: typeof createFlashMessage; data: { challengeNode: ChallengeNode }; + hideCodeAlly: () => void; isChallengeCompleted: boolean; isSignedIn: boolean; openCompletionModal: () => void; @@ -92,26 +100,18 @@ interface ShowCodeAllyProps { challengeMeta: ChallengeMeta; }; partiallyCompletedChallenges: CompletedChallenge[]; + showCodeAlly: boolean; t: TFunction; + tryToShowCodeAlly: () => void; updateChallengeMeta: (arg0: ChallengeMeta) => void; updateSolutionFormValues: () => void; - webhookToken: string | null; -} - -interface ShowCodeAllyState { - showIframe: boolean; + userToken: string | null; } // Component -class ShowCodeAlly extends Component { +class ShowCodeAlly extends Component { static displayName: string; private _container: HTMLElement | null = null; - constructor(props: ShowCodeAllyProps) { - super(props); - this.state = { - showIframe: false - }; - } componentDidMount(): void { const { @@ -134,11 +134,9 @@ class ShowCodeAlly extends Component { this._container?.focus(); } - showIframe = () => { - this.setState({ - showIframe: true - }); - }; + componentWillUnmount() { + this.props.hideCodeAlly(); + } handleSubmit = ({ showCompletionModal @@ -153,6 +151,7 @@ class ShowCodeAlly extends Component { challenge: { id: challengeId } } }, + openCompletionModal, partiallyCompletedChallenges } = this.props; @@ -170,7 +169,7 @@ class ShowCodeAlly extends Component { message: FlashMessages.CompleteProjectFirst }); } else if (showCompletionModal) { - this.props.openCompletionModal(); + openCompletionModal(); } }; @@ -201,14 +200,15 @@ class ShowCodeAlly extends Component { challengeMeta: { nextChallengePath, prevChallengePath } }, partiallyCompletedChallenges, + showCodeAlly, t, + tryToShowCodeAlly, updateSolutionFormValues, - webhookToken = null + userToken = null } = this.props; - const { showIframe } = this.state; - const envVariables = webhookToken - ? `&envVariables=CODEROAD_WEBHOOK_TOKEN=${webhookToken}` + const envVariables = userToken + ? `&envVariables=CODEROAD_WEBHOOK_TOKEN=${userToken}` : ''; const isPartiallyCompleted = partiallyCompletedChallenges.some( @@ -219,7 +219,7 @@ class ShowCodeAlly extends Component { challenge => challenge.id === challengeId ); - return showIframe ? ( + return showCodeAlly ? (