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 <ojeytonwilliams@gmail.com> 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
This commit is contained in:
		@@ -341,9 +341,9 @@
 | 
			
		||||
      "model": "article",
 | 
			
		||||
      "foreignKey": "externalId"
 | 
			
		||||
    },
 | 
			
		||||
    "webhookTokens": {
 | 
			
		||||
    "userTokens": {
 | 
			
		||||
      "type": "hasMany",
 | 
			
		||||
      "model": "WebhookToken",
 | 
			
		||||
      "model": "UserToken",
 | 
			
		||||
      "foreignKey": "userId"
 | 
			
		||||
    }
 | 
			
		||||
  },
 | 
			
		||||
 
 | 
			
		||||
@@ -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 => {
 | 
			
		||||
 
 | 
			
		||||
@@ -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();
 | 
			
		||||
 
 | 
			
		||||
@@ -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;
 | 
			
		||||
 | 
			
		||||
  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'
 | 
			
		||||
      });
 | 
			
		||||
function deleteUserTokenResponse(req, res) {
 | 
			
		||||
  if (!req.userTokenDeleted) {
 | 
			
		||||
    return res.status(500).send('Error deleting user token');
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
    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);
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										25
									
								
								api-server/src/server/middlewares/delete-user-token.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										25
									
								
								api-server/src/server/middlewares/delete-user-token.js
									
									
									
									
									
										Normal file
									
								
							@@ -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();
 | 
			
		||||
  };
 | 
			
		||||
}
 | 
			
		||||
@@ -59,7 +59,7 @@
 | 
			
		||||
    "dataSource": "db",
 | 
			
		||||
    "public": false
 | 
			
		||||
  },
 | 
			
		||||
  "WebhookToken": {
 | 
			
		||||
  "UserToken": {
 | 
			
		||||
    "dataSource": "db",
 | 
			
		||||
    "public": false
 | 
			
		||||
  }
 | 
			
		||||
 
 | 
			
		||||
@@ -1,5 +1,5 @@
 | 
			
		||||
{
 | 
			
		||||
  "name": "WebhookToken",
 | 
			
		||||
  "name": "UserToken",
 | 
			
		||||
  "description": "Tokens for submitting curricula through CodeRoad",
 | 
			
		||||
  "base": "AccessToken",
 | 
			
		||||
  "idInjection": true,
 | 
			
		||||
@@ -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",
 | 
			
		||||
 
 | 
			
		||||
@@ -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' && <Spacer />}
 | 
			
		||||
          {deploymentEnv == 'staging' && <WebhookToken />}
 | 
			
		||||
          {userToken && (
 | 
			
		||||
            <>
 | 
			
		||||
              <Spacer />
 | 
			
		||||
              <UserToken />
 | 
			
		||||
            </>
 | 
			
		||||
          )}
 | 
			
		||||
          <Spacer />
 | 
			
		||||
          <DangerZone />
 | 
			
		||||
        </main>
 | 
			
		||||
 
 | 
			
		||||
@@ -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',
 | 
			
		||||
 
 | 
			
		||||
@@ -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);
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										61
									
								
								client/src/components/settings/user-token.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										61
									
								
								client/src/components/settings/user-token.tsx
									
									
									
									
									
										Normal file
									
								
							@@ -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<UserTokenProps> {
 | 
			
		||||
  static displayName: string;
 | 
			
		||||
 | 
			
		||||
  deleteToken = () => {
 | 
			
		||||
    this.props.deleteUserToken();
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  render() {
 | 
			
		||||
    const { t } = this.props;
 | 
			
		||||
 | 
			
		||||
    return (
 | 
			
		||||
      <div className='user-token text-center'>
 | 
			
		||||
        <FullWidthRow>
 | 
			
		||||
          <Panel className='user-panel'>
 | 
			
		||||
            <Panel.Heading>{t('user-token.title')}</Panel.Heading>
 | 
			
		||||
            <Spacer />
 | 
			
		||||
            <p>{t('user-token.delete-p1')}</p>
 | 
			
		||||
            <FullWidthRow>
 | 
			
		||||
              <ButtonSpacer />
 | 
			
		||||
              <Button
 | 
			
		||||
                block={true}
 | 
			
		||||
                bsSize='lg'
 | 
			
		||||
                bsStyle='danger'
 | 
			
		||||
                className='btn-info'
 | 
			
		||||
                onClick={this.deleteToken}
 | 
			
		||||
                type='button'
 | 
			
		||||
              >
 | 
			
		||||
                {t('user-token.delete')}
 | 
			
		||||
              </Button>
 | 
			
		||||
              <Spacer />
 | 
			
		||||
            </FullWidthRow>
 | 
			
		||||
          </Panel>
 | 
			
		||||
        </FullWidthRow>
 | 
			
		||||
      </div>
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
UserToken.displayName = 'UserToken';
 | 
			
		||||
 | 
			
		||||
export default connect(null, mapDispatchToProps)(withTranslation()(UserToken));
 | 
			
		||||
@@ -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 (
 | 
			
		||||
    <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;
 | 
			
		||||
@@ -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<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 { isChallengePage = false, t, webhookToken = null } = this.props;
 | 
			
		||||
 | 
			
		||||
    return isChallengePage ? (
 | 
			
		||||
      <>
 | 
			
		||||
        {!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));
 | 
			
		||||
@@ -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
 | 
			
		||||
);
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										41
									
								
								client/src/redux/codeally-saga.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										41
									
								
								client/src/redux/codeally-saga.js
									
									
									
									
									
										Normal file
									
								
							@@ -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)];
 | 
			
		||||
}
 | 
			
		||||
@@ -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
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										35
									
								
								client/src/redux/user-token-saga.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										35
									
								
								client/src/redux/user-token-saga.js
									
									
									
									
									
										Normal file
									
								
							@@ -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)];
 | 
			
		||||
}
 | 
			
		||||
@@ -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)
 | 
			
		||||
  ];
 | 
			
		||||
}
 | 
			
		||||
@@ -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<ShowCodeAllyProps, ShowCodeAllyState> {
 | 
			
		||||
class ShowCodeAlly extends Component<ShowCodeAllyProps> {
 | 
			
		||||
  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<ShowCodeAllyProps, ShowCodeAllyState> {
 | 
			
		||||
    this._container?.focus();
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  showIframe = () => {
 | 
			
		||||
    this.setState({
 | 
			
		||||
      showIframe: true
 | 
			
		||||
    });
 | 
			
		||||
  };
 | 
			
		||||
  componentWillUnmount() {
 | 
			
		||||
    this.props.hideCodeAlly();
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  handleSubmit = ({
 | 
			
		||||
    showCompletionModal
 | 
			
		||||
@@ -153,6 +151,7 @@ class ShowCodeAlly extends Component<ShowCodeAllyProps, ShowCodeAllyState> {
 | 
			
		||||
          challenge: { id: challengeId }
 | 
			
		||||
        }
 | 
			
		||||
      },
 | 
			
		||||
      openCompletionModal,
 | 
			
		||||
      partiallyCompletedChallenges
 | 
			
		||||
    } = this.props;
 | 
			
		||||
 | 
			
		||||
@@ -170,7 +169,7 @@ class ShowCodeAlly extends Component<ShowCodeAllyProps, ShowCodeAllyState> {
 | 
			
		||||
        message: FlashMessages.CompleteProjectFirst
 | 
			
		||||
      });
 | 
			
		||||
    } else if (showCompletionModal) {
 | 
			
		||||
      this.props.openCompletionModal();
 | 
			
		||||
      openCompletionModal();
 | 
			
		||||
    }
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
@@ -201,14 +200,15 @@ class ShowCodeAlly extends Component<ShowCodeAllyProps, ShowCodeAllyState> {
 | 
			
		||||
        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<ShowCodeAllyProps, ShowCodeAllyState> {
 | 
			
		||||
      challenge => challenge.id === challengeId
 | 
			
		||||
    );
 | 
			
		||||
 | 
			
		||||
    return showIframe ? (
 | 
			
		||||
    return showCodeAlly ? (
 | 
			
		||||
      <LearnLayout>
 | 
			
		||||
        <Helmet title={`${blockName}: ${title} | freeCodeCamp.org`} />
 | 
			
		||||
        <iframe
 | 
			
		||||
@@ -251,7 +251,6 @@ class ShowCodeAlly extends Component<ShowCodeAllyProps, ShowCodeAllyState> {
 | 
			
		||||
                  {title}
 | 
			
		||||
                </ChallengeTitle>
 | 
			
		||||
                <Spacer />
 | 
			
		||||
                {isSignedIn && <WebhookToken isChallengePage={true} />}
 | 
			
		||||
                <PrismFormatted text={description} />
 | 
			
		||||
                <Spacer />
 | 
			
		||||
                <div className='ca-description'>
 | 
			
		||||
@@ -304,7 +303,7 @@ class ShowCodeAlly extends Component<ShowCodeAllyProps, ShowCodeAllyState> {
 | 
			
		||||
                  <Button
 | 
			
		||||
                    block={true}
 | 
			
		||||
                    bsStyle='primary'
 | 
			
		||||
                    onClick={this.showIframe}
 | 
			
		||||
                    onClick={tryToShowCodeAlly}
 | 
			
		||||
                  >
 | 
			
		||||
                    {challengeType === challengeTypes.codeAllyCert
 | 
			
		||||
                      ? t('buttons.click-start-project')
 | 
			
		||||
 
 | 
			
		||||
@@ -203,8 +203,8 @@ export function postResetProgress(): Promise<void> {
 | 
			
		||||
  return post('/account/reset-progress', {});
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export function postWebhookToken(): Promise<void> {
 | 
			
		||||
  return post('/user/webhook-token', {});
 | 
			
		||||
export function postUserToken(): Promise<void> {
 | 
			
		||||
  return post('/user/user-token', {});
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/** PUT **/
 | 
			
		||||
@@ -251,6 +251,6 @@ export function putVerifyCert(certSlug: string): Promise<void> {
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/** DELETE **/
 | 
			
		||||
export function deleteWebhookToken(): Promise<void> {
 | 
			
		||||
  return deleteRequest('/user/webhook-token', {});
 | 
			
		||||
export function deleteUserToken(): Promise<void> {
 | 
			
		||||
  return deleteRequest('/user/user-token', {});
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -21,7 +21,6 @@ const toneUrls = {
 | 
			
		||||
  [FlashMessages.CertificateMissing]: TRY_AGAIN,
 | 
			
		||||
  [FlashMessages.CertsPrivate]: TRY_AGAIN,
 | 
			
		||||
  [FlashMessages.CompleteProjectFirst]: TRY_AGAIN,
 | 
			
		||||
  [FlashMessages.CreateTokenErr]: TRY_AGAIN,
 | 
			
		||||
  [FlashMessages.DeleteTokenErr]: TRY_AGAIN,
 | 
			
		||||
  [FlashMessages.EmailValid]: CHAL_COMP,
 | 
			
		||||
  [FlashMessages.HonestFirst]: TRY_AGAIN,
 | 
			
		||||
@@ -39,7 +38,7 @@ const toneUrls = {
 | 
			
		||||
  [FlashMessages.ReallyWeird]: TRY_AGAIN,
 | 
			
		||||
  [FlashMessages.ReportSent]: CHAL_COMP,
 | 
			
		||||
  [FlashMessages.SigninSuccess]: CHAL_COMP,
 | 
			
		||||
  [FlashMessages.TokenCreated]: CHAL_COMP,
 | 
			
		||||
  [FlashMessages.StartProjectErr]: TRY_AGAIN,
 | 
			
		||||
  [FlashMessages.TokenDeleted]: CHAL_COMP,
 | 
			
		||||
  [FlashMessages.UpdatedPreferences]: CHAL_COMP,
 | 
			
		||||
  [FlashMessages.UsernameNotFound]: TRY_AGAIN,
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										37
									
								
								cypress/integration/settings/user-token.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										37
									
								
								cypress/integration/settings/user-token.js
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,37 @@
 | 
			
		||||
describe('User token widget on settings page,', function () {
 | 
			
		||||
  describe('initially', function () {
 | 
			
		||||
    before(() => {
 | 
			
		||||
      cy.exec('npm run seed');
 | 
			
		||||
      cy.login();
 | 
			
		||||
      cy.visit('/settings');
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    it('should not render', function () {
 | 
			
		||||
      cy.contains('Danger Zone');
 | 
			
		||||
      cy.get('.user-token').should('not.exist');
 | 
			
		||||
    });
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  describe('after creating token', function () {
 | 
			
		||||
    beforeEach(() => {
 | 
			
		||||
      cy.exec('npm run seed');
 | 
			
		||||
      cy.login();
 | 
			
		||||
      cy.visit(
 | 
			
		||||
        '/learn/relational-database/learn-bash-by-building-a-boilerplate/build-a-boilerplate'
 | 
			
		||||
      );
 | 
			
		||||
      cy.contains('Click here to start the course').click();
 | 
			
		||||
      cy.wait(2000);
 | 
			
		||||
      cy.visit('/settings');
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    it('should render', function () {
 | 
			
		||||
      cy.contains('Danger Zone');
 | 
			
		||||
      cy.get('.user-token').should('have.length', 1);
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    it('should allow you to delete your token', function () {
 | 
			
		||||
      cy.contains('Delete my user token').click();
 | 
			
		||||
      cy.contains('Your user token has been deleted.');
 | 
			
		||||
    });
 | 
			
		||||
  });
 | 
			
		||||
});
 | 
			
		||||
@@ -153,7 +153,20 @@ MongoClient.connect(MONGOHQ_URL, { useNewUrlParser: true }, (err, client) => {
 | 
			
		||||
  const db = client.db('freecodecamp');
 | 
			
		||||
  const user = db.collection('user');
 | 
			
		||||
 | 
			
		||||
  const dropUserTokens = async function () {
 | 
			
		||||
    await db.collection('UserToken').deleteMany({
 | 
			
		||||
      userId: {
 | 
			
		||||
        $in: [
 | 
			
		||||
          ObjectId('5fa2db00a25c1c1fa49ce067'),
 | 
			
		||||
          ObjectId('5bd30e0f1caf6ac3ddddddb5'),
 | 
			
		||||
          ObjectId('5bd30e0f1caf6ac3ddddddb9')
 | 
			
		||||
        ]
 | 
			
		||||
      }
 | 
			
		||||
    });
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  if (process.argv[2] === 'certUser') {
 | 
			
		||||
    dropUserTokens();
 | 
			
		||||
    user.deleteMany(
 | 
			
		||||
      {
 | 
			
		||||
        _id: {
 | 
			
		||||
@@ -179,6 +192,7 @@ MongoClient.connect(MONGOHQ_URL, { useNewUrlParser: true }, (err, client) => {
 | 
			
		||||
      }
 | 
			
		||||
    );
 | 
			
		||||
  } else {
 | 
			
		||||
    dropUserTokens();
 | 
			
		||||
    user.deleteMany(
 | 
			
		||||
      {
 | 
			
		||||
        _id: {
 | 
			
		||||
 
 | 
			
		||||
		Reference in New Issue
	
	Block a user