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",
|
"model": "article",
|
||||||
"foreignKey": "externalId"
|
"foreignKey": "externalId"
|
||||||
},
|
},
|
||||||
"webhookTokens": {
|
"userTokens": {
|
||||||
"type": "hasMany",
|
"type": "hasMany",
|
||||||
"model": "WebhookToken",
|
"model": "UserToken",
|
||||||
"foreignKey": "userId"
|
"foreignKey": "userId"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
@ -16,6 +16,7 @@ import { wrapHandledError } from '../utils/create-handled-error.js';
|
|||||||
import { removeCookies } from '../utils/getSetAccessToken';
|
import { removeCookies } from '../utils/getSetAccessToken';
|
||||||
import { ifUserRedirectTo, ifNoUserRedirectHome } from '../utils/middleware';
|
import { ifUserRedirectTo, ifNoUserRedirectHome } from '../utils/middleware';
|
||||||
import { getRedirectParams } from '../utils/redirection';
|
import { getRedirectParams } from '../utils/redirection';
|
||||||
|
import { createDeleteUserToken } from '../middlewares/delete-user-token';
|
||||||
|
|
||||||
const passwordlessGetValidators = [
|
const passwordlessGetValidators = [
|
||||||
check('email')
|
check('email')
|
||||||
@ -38,6 +39,7 @@ module.exports = function enableAuthentication(app) {
|
|||||||
const devSaveAuthCookies = devSaveResponseAuthCookies();
|
const devSaveAuthCookies = devSaveResponseAuthCookies();
|
||||||
const devLoginSuccessRedirect = devLoginRedirect();
|
const devLoginSuccessRedirect = devLoginRedirect();
|
||||||
const api = app.loopback.Router();
|
const api = app.loopback.Router();
|
||||||
|
const deleteUserToken = createDeleteUserToken(app);
|
||||||
|
|
||||||
// Use a local mock strategy for signing in if we are in dev mode.
|
// 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
|
// 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);
|
const { origin, returnTo } = getRedirectParams(req);
|
||||||
req.logout();
|
req.logout();
|
||||||
req.session.destroy(err => {
|
req.session.destroy(err => {
|
||||||
|
@ -396,15 +396,15 @@ function createCoderoadChallengeCompleted(app) {
|
|||||||
* req.headers: { coderoad-user-token: '8kFIlZiwMioY6hqqt...' }
|
* req.headers: { coderoad-user-token: '8kFIlZiwMioY6hqqt...' }
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const { WebhookToken, User } = app.models;
|
const { UserToken, User } = app.models;
|
||||||
|
|
||||||
return async function coderoadChallengeCompleted(req, res) {
|
return async function coderoadChallengeCompleted(req, res) {
|
||||||
const { 'coderoad-user-token': userWebhookToken } = req.headers;
|
const { 'coderoad-user-token': userToken } = req.headers;
|
||||||
const { tutorialId } = req.body;
|
const { tutorialId } = req.body;
|
||||||
|
|
||||||
if (!tutorialId) return res.send(`'tutorialId' not found in request 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`);
|
return res.send(`'coderoad-user-token' not found in request headers`);
|
||||||
|
|
||||||
const tutorialRepo = tutorialId?.split(':')[0];
|
const tutorialRepo = tutorialId?.split(':')[0];
|
||||||
@ -427,21 +427,21 @@ function createCoderoadChallengeCompleted(app) {
|
|||||||
const { id: challengeId, challengeType } = challenge;
|
const { id: challengeId, challengeType } = challenge;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// check if webhook token is in database
|
// check if user token is in database
|
||||||
const tokenInfo = await WebhookToken.findOne({
|
const tokenInfo = await UserToken.findOne({
|
||||||
where: { id: userWebhookToken }
|
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;
|
const { userId } = tokenInfo;
|
||||||
|
|
||||||
// check if user exists for webhook token
|
// check if user exists for user token
|
||||||
const user = await User.findOne({
|
const user = await User.findOne({
|
||||||
where: { id: userId }
|
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
|
// submit challenge
|
||||||
const completedDate = Date.now();
|
const completedDate = Date.now();
|
||||||
|
@ -17,6 +17,7 @@ import {
|
|||||||
} from '../utils/publicUserProps';
|
} from '../utils/publicUserProps';
|
||||||
import { getRedirectParams } from '../utils/redirection';
|
import { getRedirectParams } from '../utils/redirection';
|
||||||
import { trimTags } from '../utils/validators';
|
import { trimTags } from '../utils/validators';
|
||||||
|
import { createDeleteUserToken } from '../middlewares/delete-user-token';
|
||||||
|
|
||||||
const log = debugFactory('fcc:boot:user');
|
const log = debugFactory('fcc:boot:user');
|
||||||
const sendNonUserToHome = ifNoUserRedirectHome();
|
const sendNonUserToHome = ifNoUserRedirectHome();
|
||||||
@ -27,16 +28,19 @@ function bootUser(app) {
|
|||||||
const getSessionUser = createReadSessionUser(app);
|
const getSessionUser = createReadSessionUser(app);
|
||||||
const postReportUserProfile = createPostReportUserProfile(app);
|
const postReportUserProfile = createPostReportUserProfile(app);
|
||||||
const postDeleteAccount = createPostDeleteAccount(app);
|
const postDeleteAccount = createPostDeleteAccount(app);
|
||||||
const postWebhookToken = createPostWebhookToken(app);
|
const postUserToken = createPostUserToken(app);
|
||||||
const deleteWebhookToken = createDeleteWebhookToken(app);
|
const deleteUserToken = createDeleteUserToken(app);
|
||||||
|
|
||||||
api.get('/account', sendNonUserToHome, getAccount);
|
api.get('/account', sendNonUserToHome, getAccount);
|
||||||
api.get('/account/unlink/:social', sendNonUserToHome, getUnlinkSocial);
|
api.get('/account/unlink/:social', sendNonUserToHome, getUnlinkSocial);
|
||||||
api.get('/user/get-session-user', getSessionUser);
|
api.get('/user/get-session-user', getSessionUser);
|
||||||
|
api.post('/account/delete', ifNoUser401, deleteUserToken, postDeleteAccount);
|
||||||
api.post('/account/delete', ifNoUser401, postDeleteAccount);
|
api.post(
|
||||||
api.post('/account/reset-progress', ifNoUser401, postResetProgress);
|
'/account/reset-progress',
|
||||||
api.post('/user/webhook-token', postWebhookToken);
|
ifNoUser401,
|
||||||
|
deleteUserToken,
|
||||||
|
postResetProgress
|
||||||
|
);
|
||||||
api.post(
|
api.post(
|
||||||
'/user/report-user/',
|
'/user/report-user/',
|
||||||
ifNoUser401,
|
ifNoUser401,
|
||||||
@ -44,47 +48,41 @@ function bootUser(app) {
|
|||||||
postReportUserProfile
|
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);
|
app.use(api);
|
||||||
}
|
}
|
||||||
|
|
||||||
function createPostWebhookToken(app) {
|
function createPostUserToken(app) {
|
||||||
const { WebhookToken } = app.models;
|
const { UserToken } = app.models;
|
||||||
|
|
||||||
return async function postWebhookToken(req, res) {
|
return async function postUserToken(req, res) {
|
||||||
const ttl = 900 * 24 * 60 * 60 * 1000;
|
const ttl = 900 * 24 * 60 * 60 * 1000;
|
||||||
let newToken;
|
let newToken;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await WebhookToken.destroyAll({ userId: req.user.id });
|
await UserToken.destroyAll({ userId: req.user.id });
|
||||||
newToken = await WebhookToken.create({ ttl, userId: req.user.id });
|
newToken = await UserToken.create({ ttl, userId: req.user.id });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return res.status(500).json({
|
return res.status(500).send('Error starting project');
|
||||||
type: 'danger',
|
|
||||||
message: 'flash.create-token-err'
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return res.json(newToken?.id);
|
return res.json({ token: newToken?.id });
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function createDeleteWebhookToken(app) {
|
function deleteUserTokenResponse(req, res) {
|
||||||
const { WebhookToken } = app.models;
|
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) {
|
function createReadSessionUser(app) {
|
||||||
@ -93,10 +91,10 @@ function createReadSessionUser(app) {
|
|||||||
return async function getSessionUser(req, res, next) {
|
return async function getSessionUser(req, res, next) {
|
||||||
const queryUser = req.user;
|
const queryUser = req.user;
|
||||||
|
|
||||||
const webhookTokenArr = await queryUser.webhookTokens({
|
const userTokenArr = await queryUser.userTokens({
|
||||||
userId: queryUser.id
|
userId: queryUser.id
|
||||||
});
|
});
|
||||||
const webhookToken = webhookTokenArr[0]?.id;
|
const userToken = userTokenArr[0]?.id;
|
||||||
|
|
||||||
const source =
|
const source =
|
||||||
queryUser &&
|
queryUser &&
|
||||||
@ -153,7 +151,7 @@ function createReadSessionUser(app) {
|
|||||||
isWebsite: !!user.website,
|
isWebsite: !!user.website,
|
||||||
...normaliseUserFields(user),
|
...normaliseUserFields(user),
|
||||||
joinDate: user.id.getTimestamp(),
|
joinDate: user.id.getTimestamp(),
|
||||||
webhookToken
|
userToken
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
sessionMeta,
|
sessionMeta,
|
||||||
@ -263,20 +261,8 @@ function postResetProgress(req, res, next) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function createPostDeleteAccount(app) {
|
function createPostDeleteAccount(app) {
|
||||||
const { User, WebhookToken } = app.models;
|
const { User } = app.models;
|
||||||
return async function postDeleteAccount(req, res, next) {
|
return async function postDeleteAccount(req, res, next) {
|
||||||
const {
|
|
||||||
user: { id: userId }
|
|
||||||
} = req;
|
|
||||||
|
|
||||||
try {
|
|
||||||
await WebhookToken.destroyAll({ userId });
|
|
||||||
} catch (err) {
|
|
||||||
log(
|
|
||||||
`An error occurred deleting webhook tokens for user with id ${userId} when they tried to delete their account`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return User.destroyById(req.user.id, function (err) {
|
return User.destroyById(req.user.id, function (err) {
|
||||||
if (err) {
|
if (err) {
|
||||||
return next(err);
|
return next(err);
|
||||||
|
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",
|
"dataSource": "db",
|
||||||
"public": false
|
"public": false
|
||||||
},
|
},
|
||||||
"WebhookToken": {
|
"UserToken": {
|
||||||
"dataSource": "db",
|
"dataSource": "db",
|
||||||
"public": false
|
"public": false
|
||||||
}
|
}
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"name": "WebhookToken",
|
"name": "UserToken",
|
||||||
"description": "Tokens for submitting curricula through CodeRoad",
|
"description": "Tokens for submitting curricula through CodeRoad",
|
||||||
"base": "AccessToken",
|
"base": "AccessToken",
|
||||||
"idInjection": true,
|
"idInjection": true,
|
@ -520,10 +520,11 @@
|
|||||||
"provide-username": "Check if you have provided a username and a report",
|
"provide-username": "Check if you have provided a username and a report",
|
||||||
"report-sent": "A report was sent to the team with {{email}} in copy",
|
"report-sent": "A report was sent to the team with {{email}} in copy",
|
||||||
"certificate-missing": "The certification you tried to view does not exist",
|
"certificate-missing": "The certification you tried to view does not exist",
|
||||||
"create-token-err": "An error occurred trying to create a token",
|
"create-token-err": "An error occurred while creating your user token",
|
||||||
"delete-token-err": "An error occurred trying to delete your token",
|
"delete-token-err": "An error occurred while deleting your user token",
|
||||||
"token-created": "You have successfully created a new token.",
|
"token-created": "You have successfully created a new user token.",
|
||||||
"token-deleted": "Your token has been deleted.",
|
"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.",
|
"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-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."
|
"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-two": "Please leave the ``` line above and the ``` line below,",
|
||||||
"add-code-three": "because they allow your code to properly format in the post."
|
"add-code-three": "because they allow your code to properly format in the post."
|
||||||
},
|
},
|
||||||
"webhook-token": {
|
"user-token": {
|
||||||
"title": "Webhook Token",
|
"title": "User Token",
|
||||||
"create": "Create a new 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-p1": "It looks like you don't have a user 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.",
|
"create-p2": "Create a user token to save your progress on the curriculum sections that use a virtual machine.",
|
||||||
"delete": "Delete my token",
|
"delete": "Delete my user token",
|
||||||
"delete-title": "Delete My Webhook Token",
|
"delete-title": "Delete My User Token",
|
||||||
"delete-p1": "Your webhook token below is used to save your progress on the curriculum sections that use a virtual machine.",
|
"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-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.",
|
"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",
|
"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 Portfolio from '../components/settings/portfolio';
|
||||||
import Privacy from '../components/settings/privacy';
|
import Privacy from '../components/settings/privacy';
|
||||||
import { Themes } from '../components/settings/theme';
|
import { Themes } from '../components/settings/theme';
|
||||||
import WebhookToken from '../components/settings/webhook-token';
|
import UserToken from '../components/settings/user-token';
|
||||||
import {
|
import {
|
||||||
signInLoadingSelector,
|
signInLoadingSelector,
|
||||||
userSelector,
|
userSelector,
|
||||||
isSignedInSelector,
|
isSignedInSelector,
|
||||||
hardGoTo as navigate
|
hardGoTo as navigate,
|
||||||
|
userTokenSelector
|
||||||
} from '../redux';
|
} from '../redux';
|
||||||
import { User } from '../redux/prop-types';
|
import { User } from '../redux/prop-types';
|
||||||
import { submitNewAbout, updateUserFlag, verifyCert } from '../redux/settings';
|
import { submitNewAbout, updateUserFlag, verifyCert } from '../redux/settings';
|
||||||
|
|
||||||
const { apiLocation, deploymentEnv } = envData;
|
const { apiLocation } = envData;
|
||||||
|
|
||||||
// TODO: update types for actions
|
// TODO: update types for actions
|
||||||
interface ShowSettingsProps {
|
interface ShowSettingsProps {
|
||||||
@ -45,16 +46,19 @@ interface ShowSettingsProps {
|
|||||||
user: User;
|
user: User;
|
||||||
verifyCert: () => void;
|
verifyCert: () => void;
|
||||||
path?: string;
|
path?: string;
|
||||||
|
userToken: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const mapStateToProps = createSelector(
|
const mapStateToProps = createSelector(
|
||||||
signInLoadingSelector,
|
signInLoadingSelector,
|
||||||
userSelector,
|
userSelector,
|
||||||
isSignedInSelector,
|
isSignedInSelector,
|
||||||
(showLoading: boolean, user: User, isSignedIn) => ({
|
userTokenSelector,
|
||||||
|
(showLoading: boolean, user: User, isSignedIn, userToken: string | null) => ({
|
||||||
showLoading,
|
showLoading,
|
||||||
user,
|
user,
|
||||||
isSignedIn
|
isSignedIn,
|
||||||
|
userToken
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -122,7 +126,8 @@ export function ShowSettings(props: ShowSettingsProps): JSX.Element {
|
|||||||
updateInternetSettings,
|
updateInternetSettings,
|
||||||
updatePortfolio,
|
updatePortfolio,
|
||||||
updateIsHonest,
|
updateIsHonest,
|
||||||
verifyCert
|
verifyCert,
|
||||||
|
userToken
|
||||||
} = props;
|
} = props;
|
||||||
|
|
||||||
if (showLoading) {
|
if (showLoading) {
|
||||||
@ -202,8 +207,12 @@ export function ShowSettings(props: ShowSettingsProps): JSX.Element {
|
|||||||
username={username}
|
username={username}
|
||||||
verifyCert={verifyCert}
|
verifyCert={verifyCert}
|
||||||
/>
|
/>
|
||||||
{deploymentEnv == 'staging' && <Spacer />}
|
{userToken && (
|
||||||
{deploymentEnv == 'staging' && <WebhookToken />}
|
<>
|
||||||
|
<Spacer />
|
||||||
|
<UserToken />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
<Spacer />
|
<Spacer />
|
||||||
<DangerZone />
|
<DangerZone />
|
||||||
</main>
|
</main>
|
||||||
|
@ -6,7 +6,6 @@ export enum FlashMessages {
|
|||||||
CertificateMissing = 'flash.certificate-missing',
|
CertificateMissing = 'flash.certificate-missing',
|
||||||
CertsPrivate = 'flash.certs-private',
|
CertsPrivate = 'flash.certs-private',
|
||||||
CompleteProjectFirst = 'flash.complete-project-first',
|
CompleteProjectFirst = 'flash.complete-project-first',
|
||||||
CreateTokenErr = 'flash.create-token-err',
|
|
||||||
DeleteTokenErr = 'flash.delete-token-err',
|
DeleteTokenErr = 'flash.delete-token-err',
|
||||||
EmailValid = 'flash.email-valid',
|
EmailValid = 'flash.email-valid',
|
||||||
HonestFirst = 'flash.honest-first',
|
HonestFirst = 'flash.honest-first',
|
||||||
@ -24,7 +23,7 @@ export enum FlashMessages {
|
|||||||
ReallyWeird = 'flash.really-weird',
|
ReallyWeird = 'flash.really-weird',
|
||||||
ReportSent = 'flash.report-sent',
|
ReportSent = 'flash.report-sent',
|
||||||
SigninSuccess = 'flash.signin-success',
|
SigninSuccess = 'flash.signin-success',
|
||||||
TokenCreated = 'flash.token-created',
|
StartProjectErr = 'flash.start-project-err',
|
||||||
TokenDeleted = 'flash.token-deleted',
|
TokenDeleted = 'flash.token-deleted',
|
||||||
UpdatedPreferences = 'flash.updated-preferences',
|
UpdatedPreferences = 'flash.updated-preferences',
|
||||||
UsernameNotFound = 'flash.username-not-found',
|
UsernameNotFound = 'flash.username-not-found',
|
||||||
|
@ -1,21 +1,7 @@
|
|||||||
.webhook-panel {
|
.user-panel {
|
||||||
border-color: var(--highlight-background);
|
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 {
|
.btn-info {
|
||||||
background-color: var(--highlight-color);
|
background-color: var(--highlight-color);
|
||||||
color: var(--highlight-background);
|
color: var(--highlight-background);
|
||||||
@ -29,17 +15,17 @@
|
|||||||
border-color: var(--highlight-background);
|
border-color: var(--highlight-background);
|
||||||
}
|
}
|
||||||
|
|
||||||
.webhook-token .panel-heading {
|
.user-token .panel-heading {
|
||||||
color: var(--highlight-color);
|
color: var(--highlight-color);
|
||||||
background-color: var(--highlight-background);
|
background-color: var(--highlight-background);
|
||||||
border-radius: 0;
|
border-radius: 0;
|
||||||
border: none;
|
border: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.webhook-token .panel-info {
|
.user-token .panel-info {
|
||||||
border-color: var(--highlight-background);
|
border-color: var(--highlight-background);
|
||||||
}
|
}
|
||||||
|
|
||||||
.webhook-token p {
|
.user-token p {
|
||||||
color: var(--highlight-color);
|
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',
|
'hardGoTo',
|
||||||
'allowBlockDonationRequests',
|
'allowBlockDonationRequests',
|
||||||
'closeDonationModal',
|
'closeDonationModal',
|
||||||
|
'hideCodeAlly',
|
||||||
'preventBlockDonationRequests',
|
'preventBlockDonationRequests',
|
||||||
'preventProgressDonationRequests',
|
'preventProgressDonationRequests',
|
||||||
'openDonationModal',
|
'openDonationModal',
|
||||||
'onlineStatusChange',
|
'onlineStatusChange',
|
||||||
'serverStatusChange',
|
'serverStatusChange',
|
||||||
'resetUserData',
|
'resetUserData',
|
||||||
|
'tryToShowCodeAlly',
|
||||||
'tryToShowDonationModal',
|
'tryToShowDonationModal',
|
||||||
'executeGA',
|
'executeGA',
|
||||||
|
'showCodeAlly',
|
||||||
'submitComplete',
|
'submitComplete',
|
||||||
'updateComplete',
|
'updateComplete',
|
||||||
'updateCurrentChallengeId',
|
'updateCurrentChallengeId',
|
||||||
'updateFailed',
|
'updateFailed',
|
||||||
'updateDonationFormState',
|
'updateDonationFormState',
|
||||||
|
'updateUserToken',
|
||||||
...createAsyncTypes('fetchUser'),
|
...createAsyncTypes('fetchUser'),
|
||||||
...createAsyncTypes('addDonation'),
|
...createAsyncTypes('addDonation'),
|
||||||
...createAsyncTypes('createStripeSession'),
|
...createAsyncTypes('createStripeSession'),
|
||||||
@ -30,8 +34,7 @@ export const actionTypes = createTypes(
|
|||||||
...createAsyncTypes('showCert'),
|
...createAsyncTypes('showCert'),
|
||||||
...createAsyncTypes('reportUser'),
|
...createAsyncTypes('reportUser'),
|
||||||
...createAsyncTypes('postChargeStripeCard'),
|
...createAsyncTypes('postChargeStripeCard'),
|
||||||
...createAsyncTypes('postWebhookToken'),
|
...createAsyncTypes('deleteUserToken')
|
||||||
...createAsyncTypes('deleteWebhookToken')
|
|
||||||
],
|
],
|
||||||
ns
|
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 { createAcceptTermsSaga } from './accept-terms-saga';
|
||||||
import { actionTypes } from './action-types';
|
import { actionTypes } from './action-types';
|
||||||
import { createAppMountSaga } from './app-mount-saga';
|
import { createAppMountSaga } from './app-mount-saga';
|
||||||
|
import { createCodeAllySaga } from './codeally-saga';
|
||||||
import { createDonationSaga } from './donation-saga';
|
import { createDonationSaga } from './donation-saga';
|
||||||
import failedUpdatesEpic from './failed-updates-epic';
|
import failedUpdatesEpic from './failed-updates-epic';
|
||||||
import { createFetchUserSaga } from './fetch-user-saga';
|
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 { createShowCertSaga } from './show-cert-saga';
|
||||||
import { createSoundModeSaga } from './sound-mode-saga';
|
import { createSoundModeSaga } from './sound-mode-saga';
|
||||||
import updateCompleteEpic from './update-complete-epic';
|
import updateCompleteEpic from './update-complete-epic';
|
||||||
import { createWebhookSaga } from './webhook-saga';
|
import { createUserTokenSaga } from './user-token-saga';
|
||||||
|
|
||||||
export const MainApp = 'app';
|
export const MainApp = 'app';
|
||||||
|
|
||||||
@ -52,6 +53,7 @@ const initialState = {
|
|||||||
showCertFetchState: {
|
showCertFetchState: {
|
||||||
...defaultFetchState
|
...defaultFetchState
|
||||||
},
|
},
|
||||||
|
showCodeAlly: false,
|
||||||
user: {},
|
user: {},
|
||||||
userFetchState: {
|
userFetchState: {
|
||||||
...defaultFetchState
|
...defaultFetchState
|
||||||
@ -73,13 +75,14 @@ export const epics = [hardGoToEpic, failedUpdatesEpic, updateCompleteEpic];
|
|||||||
export const sagas = [
|
export const sagas = [
|
||||||
...createAcceptTermsSaga(actionTypes),
|
...createAcceptTermsSaga(actionTypes),
|
||||||
...createAppMountSaga(actionTypes),
|
...createAppMountSaga(actionTypes),
|
||||||
|
...createCodeAllySaga(actionTypes),
|
||||||
...createDonationSaga(actionTypes),
|
...createDonationSaga(actionTypes),
|
||||||
...createGaSaga(actionTypes),
|
...createGaSaga(actionTypes),
|
||||||
...createFetchUserSaga(actionTypes),
|
...createFetchUserSaga(actionTypes),
|
||||||
...createShowCertSaga(actionTypes),
|
...createShowCertSaga(actionTypes),
|
||||||
...createReportUserSaga(actionTypes),
|
...createReportUserSaga(actionTypes),
|
||||||
...createSoundModeSaga({ ...actionTypes, ...settingsTypes }),
|
...createSoundModeSaga({ ...actionTypes, ...settingsTypes }),
|
||||||
...createWebhookSaga(actionTypes)
|
...createUserTokenSaga(actionTypes)
|
||||||
];
|
];
|
||||||
|
|
||||||
export const appMount = createAction(actionTypes.appMount);
|
export const appMount = createAction(actionTypes.appMount);
|
||||||
@ -171,15 +174,16 @@ export const showCert = createAction(actionTypes.showCert);
|
|||||||
export const showCertComplete = createAction(actionTypes.showCertComplete);
|
export const showCertComplete = createAction(actionTypes.showCertComplete);
|
||||||
export const showCertError = createAction(actionTypes.showCertError);
|
export const showCertError = createAction(actionTypes.showCertError);
|
||||||
|
|
||||||
export const postWebhookToken = createAction(actionTypes.postWebhookToken);
|
export const updateUserToken = createAction(actionTypes.updateUserToken);
|
||||||
export const postWebhookTokenComplete = createAction(
|
export const deleteUserToken = createAction(actionTypes.deleteUserToken);
|
||||||
actionTypes.postWebhookTokenComplete
|
export const deleteUserTokenComplete = createAction(
|
||||||
);
|
actionTypes.deleteUserTokenComplete
|
||||||
export const deleteWebhookToken = createAction(actionTypes.deleteWebhookToken);
|
|
||||||
export const deleteWebhookTokenComplete = createAction(
|
|
||||||
actionTypes.deleteWebhookTokenComplete
|
|
||||||
);
|
);
|
||||||
|
|
||||||
|
export const hideCodeAlly = createAction(actionTypes.hideCodeAlly);
|
||||||
|
export const showCodeAlly = createAction(actionTypes.showCodeAlly);
|
||||||
|
export const tryToShowCodeAlly = createAction(actionTypes.tryToShowCodeAlly);
|
||||||
|
|
||||||
export const updateCurrentChallengeId = createAction(
|
export const updateCurrentChallengeId = createAction(
|
||||||
actionTypes.updateCurrentChallengeId
|
actionTypes.updateCurrentChallengeId
|
||||||
);
|
);
|
||||||
@ -242,8 +246,12 @@ export const shouldRequestDonationSelector = state => {
|
|||||||
return completionCount >= 3;
|
return completionCount >= 3;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const webhookTokenSelector = state => {
|
export const userTokenSelector = state => {
|
||||||
return userSelector(state).webhookToken;
|
return userSelector(state).userToken;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const showCodeAllySelector = state => {
|
||||||
|
return state[MainApp].showCodeAlly;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const userByNameSelector = username => state => {
|
export const userByNameSelector = username => state => {
|
||||||
@ -652,7 +660,7 @@ export const reducer = handleActions(
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
[actionTypes.postWebhookTokenComplete]: (state, { payload }) => {
|
[actionTypes.updateUserToken]: (state, { payload }) => {
|
||||||
const { appUsername } = state;
|
const { appUsername } = state;
|
||||||
return {
|
return {
|
||||||
...state,
|
...state,
|
||||||
@ -660,12 +668,12 @@ export const reducer = handleActions(
|
|||||||
...state.user,
|
...state.user,
|
||||||
[appUsername]: {
|
[appUsername]: {
|
||||||
...state.user[appUsername],
|
...state.user[appUsername],
|
||||||
webhookToken: payload
|
userToken: payload
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
[actionTypes.deleteWebhookTokenComplete]: state => {
|
[actionTypes.deleteUserTokenComplete]: state => {
|
||||||
const { appUsername } = state;
|
const { appUsername } = state;
|
||||||
return {
|
return {
|
||||||
...state,
|
...state,
|
||||||
@ -673,11 +681,23 @@ export const reducer = handleActions(
|
|||||||
...state.user,
|
...state.user,
|
||||||
[appUsername]: {
|
[appUsername]: {
|
||||||
...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 }) => ({
|
[challengeTypes.challengeMounted]: (state, { payload }) => ({
|
||||||
...state,
|
...state,
|
||||||
currentChallengeId: payload
|
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 {
|
import {
|
||||||
completedChallengesSelector,
|
completedChallengesSelector,
|
||||||
isSignedInSelector,
|
isSignedInSelector,
|
||||||
|
hideCodeAlly,
|
||||||
partiallyCompletedChallengesSelector,
|
partiallyCompletedChallengesSelector,
|
||||||
webhookTokenSelector
|
showCodeAllySelector,
|
||||||
|
tryToShowCodeAlly,
|
||||||
|
userTokenSelector
|
||||||
} from '../../../redux';
|
} from '../../../redux';
|
||||||
import {
|
import {
|
||||||
challengeMounted,
|
challengeMounted,
|
||||||
@ -40,7 +43,6 @@ import {
|
|||||||
} from '../../../redux/prop-types';
|
} from '../../../redux/prop-types';
|
||||||
import ProjectToolPanel from '../projects/tool-panel';
|
import ProjectToolPanel from '../projects/tool-panel';
|
||||||
import SolutionForm from '../projects/solution-form';
|
import SolutionForm from '../projects/solution-form';
|
||||||
import WebhookToken from '../../../components/settings/webhook-token';
|
|
||||||
import { FlashMessages } from '../../../components/Flash/redux/flash-messages';
|
import { FlashMessages } from '../../../components/Flash/redux/flash-messages';
|
||||||
|
|
||||||
import './codeally.css';
|
import './codeally.css';
|
||||||
@ -51,19 +53,22 @@ const mapStateToProps = createSelector(
|
|||||||
isChallengeCompletedSelector,
|
isChallengeCompletedSelector,
|
||||||
isSignedInSelector,
|
isSignedInSelector,
|
||||||
partiallyCompletedChallengesSelector,
|
partiallyCompletedChallengesSelector,
|
||||||
webhookTokenSelector,
|
showCodeAllySelector,
|
||||||
|
userTokenSelector,
|
||||||
(
|
(
|
||||||
completedChallenges: CompletedChallenge[],
|
completedChallenges: CompletedChallenge[],
|
||||||
isChallengeCompleted: boolean,
|
isChallengeCompleted: boolean,
|
||||||
isSignedIn: boolean,
|
isSignedIn: boolean,
|
||||||
partiallyCompletedChallenges: CompletedChallenge[],
|
partiallyCompletedChallenges: CompletedChallenge[],
|
||||||
webhookToken: string | null
|
showCodeAlly: boolean,
|
||||||
|
userToken: string | null
|
||||||
) => ({
|
) => ({
|
||||||
completedChallenges,
|
completedChallenges,
|
||||||
isChallengeCompleted,
|
isChallengeCompleted,
|
||||||
isSignedIn,
|
isSignedIn,
|
||||||
partiallyCompletedChallenges,
|
partiallyCompletedChallenges,
|
||||||
webhookToken
|
showCodeAlly,
|
||||||
|
userToken
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -72,7 +77,9 @@ const mapDispatchToProps = (dispatch: Dispatch) =>
|
|||||||
{
|
{
|
||||||
challengeMounted,
|
challengeMounted,
|
||||||
createFlashMessage,
|
createFlashMessage,
|
||||||
|
hideCodeAlly,
|
||||||
openCompletionModal: () => openModal('completion'),
|
openCompletionModal: () => openModal('completion'),
|
||||||
|
tryToShowCodeAlly,
|
||||||
updateChallengeMeta,
|
updateChallengeMeta,
|
||||||
updateSolutionFormValues
|
updateSolutionFormValues
|
||||||
},
|
},
|
||||||
@ -85,6 +92,7 @@ interface ShowCodeAllyProps {
|
|||||||
completedChallenges: CompletedChallenge[];
|
completedChallenges: CompletedChallenge[];
|
||||||
createFlashMessage: typeof createFlashMessage;
|
createFlashMessage: typeof createFlashMessage;
|
||||||
data: { challengeNode: ChallengeNode };
|
data: { challengeNode: ChallengeNode };
|
||||||
|
hideCodeAlly: () => void;
|
||||||
isChallengeCompleted: boolean;
|
isChallengeCompleted: boolean;
|
||||||
isSignedIn: boolean;
|
isSignedIn: boolean;
|
||||||
openCompletionModal: () => void;
|
openCompletionModal: () => void;
|
||||||
@ -92,26 +100,18 @@ interface ShowCodeAllyProps {
|
|||||||
challengeMeta: ChallengeMeta;
|
challengeMeta: ChallengeMeta;
|
||||||
};
|
};
|
||||||
partiallyCompletedChallenges: CompletedChallenge[];
|
partiallyCompletedChallenges: CompletedChallenge[];
|
||||||
|
showCodeAlly: boolean;
|
||||||
t: TFunction;
|
t: TFunction;
|
||||||
|
tryToShowCodeAlly: () => void;
|
||||||
updateChallengeMeta: (arg0: ChallengeMeta) => void;
|
updateChallengeMeta: (arg0: ChallengeMeta) => void;
|
||||||
updateSolutionFormValues: () => void;
|
updateSolutionFormValues: () => void;
|
||||||
webhookToken: string | null;
|
userToken: string | null;
|
||||||
}
|
|
||||||
|
|
||||||
interface ShowCodeAllyState {
|
|
||||||
showIframe: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Component
|
// Component
|
||||||
class ShowCodeAlly extends Component<ShowCodeAllyProps, ShowCodeAllyState> {
|
class ShowCodeAlly extends Component<ShowCodeAllyProps> {
|
||||||
static displayName: string;
|
static displayName: string;
|
||||||
private _container: HTMLElement | null = null;
|
private _container: HTMLElement | null = null;
|
||||||
constructor(props: ShowCodeAllyProps) {
|
|
||||||
super(props);
|
|
||||||
this.state = {
|
|
||||||
showIframe: false
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
componentDidMount(): void {
|
componentDidMount(): void {
|
||||||
const {
|
const {
|
||||||
@ -134,11 +134,9 @@ class ShowCodeAlly extends Component<ShowCodeAllyProps, ShowCodeAllyState> {
|
|||||||
this._container?.focus();
|
this._container?.focus();
|
||||||
}
|
}
|
||||||
|
|
||||||
showIframe = () => {
|
componentWillUnmount() {
|
||||||
this.setState({
|
this.props.hideCodeAlly();
|
||||||
showIframe: true
|
}
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
handleSubmit = ({
|
handleSubmit = ({
|
||||||
showCompletionModal
|
showCompletionModal
|
||||||
@ -153,6 +151,7 @@ class ShowCodeAlly extends Component<ShowCodeAllyProps, ShowCodeAllyState> {
|
|||||||
challenge: { id: challengeId }
|
challenge: { id: challengeId }
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
openCompletionModal,
|
||||||
partiallyCompletedChallenges
|
partiallyCompletedChallenges
|
||||||
} = this.props;
|
} = this.props;
|
||||||
|
|
||||||
@ -170,7 +169,7 @@ class ShowCodeAlly extends Component<ShowCodeAllyProps, ShowCodeAllyState> {
|
|||||||
message: FlashMessages.CompleteProjectFirst
|
message: FlashMessages.CompleteProjectFirst
|
||||||
});
|
});
|
||||||
} else if (showCompletionModal) {
|
} else if (showCompletionModal) {
|
||||||
this.props.openCompletionModal();
|
openCompletionModal();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -201,14 +200,15 @@ class ShowCodeAlly extends Component<ShowCodeAllyProps, ShowCodeAllyState> {
|
|||||||
challengeMeta: { nextChallengePath, prevChallengePath }
|
challengeMeta: { nextChallengePath, prevChallengePath }
|
||||||
},
|
},
|
||||||
partiallyCompletedChallenges,
|
partiallyCompletedChallenges,
|
||||||
|
showCodeAlly,
|
||||||
t,
|
t,
|
||||||
|
tryToShowCodeAlly,
|
||||||
updateSolutionFormValues,
|
updateSolutionFormValues,
|
||||||
webhookToken = null
|
userToken = null
|
||||||
} = this.props;
|
} = this.props;
|
||||||
const { showIframe } = this.state;
|
|
||||||
|
|
||||||
const envVariables = webhookToken
|
const envVariables = userToken
|
||||||
? `&envVariables=CODEROAD_WEBHOOK_TOKEN=${webhookToken}`
|
? `&envVariables=CODEROAD_WEBHOOK_TOKEN=${userToken}`
|
||||||
: '';
|
: '';
|
||||||
|
|
||||||
const isPartiallyCompleted = partiallyCompletedChallenges.some(
|
const isPartiallyCompleted = partiallyCompletedChallenges.some(
|
||||||
@ -219,7 +219,7 @@ class ShowCodeAlly extends Component<ShowCodeAllyProps, ShowCodeAllyState> {
|
|||||||
challenge => challenge.id === challengeId
|
challenge => challenge.id === challengeId
|
||||||
);
|
);
|
||||||
|
|
||||||
return showIframe ? (
|
return showCodeAlly ? (
|
||||||
<LearnLayout>
|
<LearnLayout>
|
||||||
<Helmet title={`${blockName}: ${title} | freeCodeCamp.org`} />
|
<Helmet title={`${blockName}: ${title} | freeCodeCamp.org`} />
|
||||||
<iframe
|
<iframe
|
||||||
@ -251,7 +251,6 @@ class ShowCodeAlly extends Component<ShowCodeAllyProps, ShowCodeAllyState> {
|
|||||||
{title}
|
{title}
|
||||||
</ChallengeTitle>
|
</ChallengeTitle>
|
||||||
<Spacer />
|
<Spacer />
|
||||||
{isSignedIn && <WebhookToken isChallengePage={true} />}
|
|
||||||
<PrismFormatted text={description} />
|
<PrismFormatted text={description} />
|
||||||
<Spacer />
|
<Spacer />
|
||||||
<div className='ca-description'>
|
<div className='ca-description'>
|
||||||
@ -304,7 +303,7 @@ class ShowCodeAlly extends Component<ShowCodeAllyProps, ShowCodeAllyState> {
|
|||||||
<Button
|
<Button
|
||||||
block={true}
|
block={true}
|
||||||
bsStyle='primary'
|
bsStyle='primary'
|
||||||
onClick={this.showIframe}
|
onClick={tryToShowCodeAlly}
|
||||||
>
|
>
|
||||||
{challengeType === challengeTypes.codeAllyCert
|
{challengeType === challengeTypes.codeAllyCert
|
||||||
? t('buttons.click-start-project')
|
? t('buttons.click-start-project')
|
||||||
|
@ -203,8 +203,8 @@ export function postResetProgress(): Promise<void> {
|
|||||||
return post('/account/reset-progress', {});
|
return post('/account/reset-progress', {});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function postWebhookToken(): Promise<void> {
|
export function postUserToken(): Promise<void> {
|
||||||
return post('/user/webhook-token', {});
|
return post('/user/user-token', {});
|
||||||
}
|
}
|
||||||
|
|
||||||
/** PUT **/
|
/** PUT **/
|
||||||
@ -251,6 +251,6 @@ export function putVerifyCert(certSlug: string): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** DELETE **/
|
/** DELETE **/
|
||||||
export function deleteWebhookToken(): Promise<void> {
|
export function deleteUserToken(): Promise<void> {
|
||||||
return deleteRequest('/user/webhook-token', {});
|
return deleteRequest('/user/user-token', {});
|
||||||
}
|
}
|
||||||
|
@ -21,7 +21,6 @@ const toneUrls = {
|
|||||||
[FlashMessages.CertificateMissing]: TRY_AGAIN,
|
[FlashMessages.CertificateMissing]: TRY_AGAIN,
|
||||||
[FlashMessages.CertsPrivate]: TRY_AGAIN,
|
[FlashMessages.CertsPrivate]: TRY_AGAIN,
|
||||||
[FlashMessages.CompleteProjectFirst]: TRY_AGAIN,
|
[FlashMessages.CompleteProjectFirst]: TRY_AGAIN,
|
||||||
[FlashMessages.CreateTokenErr]: TRY_AGAIN,
|
|
||||||
[FlashMessages.DeleteTokenErr]: TRY_AGAIN,
|
[FlashMessages.DeleteTokenErr]: TRY_AGAIN,
|
||||||
[FlashMessages.EmailValid]: CHAL_COMP,
|
[FlashMessages.EmailValid]: CHAL_COMP,
|
||||||
[FlashMessages.HonestFirst]: TRY_AGAIN,
|
[FlashMessages.HonestFirst]: TRY_AGAIN,
|
||||||
@ -39,7 +38,7 @@ const toneUrls = {
|
|||||||
[FlashMessages.ReallyWeird]: TRY_AGAIN,
|
[FlashMessages.ReallyWeird]: TRY_AGAIN,
|
||||||
[FlashMessages.ReportSent]: CHAL_COMP,
|
[FlashMessages.ReportSent]: CHAL_COMP,
|
||||||
[FlashMessages.SigninSuccess]: CHAL_COMP,
|
[FlashMessages.SigninSuccess]: CHAL_COMP,
|
||||||
[FlashMessages.TokenCreated]: CHAL_COMP,
|
[FlashMessages.StartProjectErr]: TRY_AGAIN,
|
||||||
[FlashMessages.TokenDeleted]: CHAL_COMP,
|
[FlashMessages.TokenDeleted]: CHAL_COMP,
|
||||||
[FlashMessages.UpdatedPreferences]: CHAL_COMP,
|
[FlashMessages.UpdatedPreferences]: CHAL_COMP,
|
||||||
[FlashMessages.UsernameNotFound]: TRY_AGAIN,
|
[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 db = client.db('freecodecamp');
|
||||||
const user = db.collection('user');
|
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') {
|
if (process.argv[2] === 'certUser') {
|
||||||
|
dropUserTokens();
|
||||||
user.deleteMany(
|
user.deleteMany(
|
||||||
{
|
{
|
||||||
_id: {
|
_id: {
|
||||||
@ -179,6 +192,7 @@ MongoClient.connect(MONGOHQ_URL, { useNewUrlParser: true }, (err, client) => {
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
|
dropUserTokens();
|
||||||
user.deleteMany(
|
user.deleteMany(
|
||||||
{
|
{
|
||||||
_id: {
|
_id: {
|
||||||
|
Reference in New Issue
Block a user