mirror of
https://github.com/freeCodeCamp/freeCodeCamp.git
synced 2026-05-28 18:26:54 +00:00
feat(client/api): validate ms users (#51372)
Co-authored-by: Muhammed Mustafa <MuhammedElruby@gmail.com>
This commit is contained in:
@@ -423,6 +423,11 @@
|
||||
"type": "hasMany",
|
||||
"model": "UserToken",
|
||||
"foreignKey": "userId"
|
||||
},
|
||||
"msUsernames": {
|
||||
"type": "hasMany",
|
||||
"model": "MsUsername",
|
||||
"foreignKey": "userId"
|
||||
}
|
||||
},
|
||||
"acls": [
|
||||
|
||||
@@ -17,6 +17,7 @@ import fetch from 'node-fetch';
|
||||
import jwt from 'jsonwebtoken';
|
||||
|
||||
import { jwtSecret } from '../../../../config/secrets';
|
||||
import { challengeTypes } from '../../../../config/challenge-types';
|
||||
|
||||
import {
|
||||
fixPartiallyCompletedChallengeItem,
|
||||
@@ -36,8 +37,6 @@ import {
|
||||
validateGeneratedExamSchema,
|
||||
validateUserCompletedExamSchema
|
||||
} from '../utils/exam-schemas';
|
||||
import { isMicrosoftLearnLink } from '../../../../utils/validate';
|
||||
import { getApiUrlFromTrophy } from '../utils/ms-learn-utils';
|
||||
|
||||
const log = debug('fcc:boot:challenges');
|
||||
|
||||
@@ -100,6 +99,14 @@ export default async function bootChallenge(app, done) {
|
||||
|
||||
api.post('/coderoad-challenge-completed', coderoadChallengeCompleted);
|
||||
|
||||
const msTrophyChallengeCompleted = createMsTrophyChallengeCompleted(app);
|
||||
|
||||
api.post(
|
||||
'/ms-trophy-challenge-completed',
|
||||
send200toNonUser,
|
||||
msTrophyChallengeCompleted
|
||||
);
|
||||
|
||||
app.use(api);
|
||||
app.use(router);
|
||||
done();
|
||||
@@ -121,6 +128,10 @@ const savableChallenges = getChallenges()
|
||||
.filter(challenge => challenge.challengeType === 14)
|
||||
.map(challenge => challenge.id);
|
||||
|
||||
const msTrophyChallenges = getChallenges()
|
||||
.filter(challenge => challenge.challengeType === challengeTypes.msTrophy)
|
||||
.map(({ id, msTrophyId }) => ({ id, msTrophyId }));
|
||||
|
||||
export function buildUserUpdate(
|
||||
user,
|
||||
challengeId,
|
||||
@@ -446,25 +457,6 @@ async function projectCompleted(req, res, next) {
|
||||
}
|
||||
}
|
||||
|
||||
const isMSTrophyProject = completedChallenge.challengeType === 18;
|
||||
let isTrophyMissing = false;
|
||||
if (isMSTrophyProject) {
|
||||
if (!isMicrosoftLearnLink(completedChallenge.solution)) {
|
||||
return res.status(403).json({
|
||||
type: 'error',
|
||||
message:
|
||||
'You have not provided the valid links for us to inspect your work.'
|
||||
});
|
||||
}
|
||||
try {
|
||||
const mSLearnAPIUrl = getApiUrlFromTrophy(completedChallenge.solution);
|
||||
isTrophyMissing = mSLearnAPIUrl ? !(await fetch(mSLearnAPIUrl)).ok : true;
|
||||
} catch {
|
||||
isTrophyMissing = true;
|
||||
log(`Error verifying trophy: ${completedChallenge.solution}`);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
// This is an ugly hack to update `user.completedChallenges`
|
||||
await user.getCompletedChallenges$().toPromise();
|
||||
@@ -486,8 +478,7 @@ async function projectCompleted(req, res, next) {
|
||||
return res.json({
|
||||
alreadyCompleted,
|
||||
points: alreadyCompleted ? user.points : user.points + 1,
|
||||
completedDate: completedChallenge.completedDate,
|
||||
...(isMSTrophyProject && { isTrophyMissing })
|
||||
completedDate: completedChallenge.completedDate
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -697,6 +688,77 @@ function createExamChallengeCompleted(app) {
|
||||
};
|
||||
}
|
||||
|
||||
function createMsTrophyChallengeCompleted(app) {
|
||||
const { MsUsername } = app.models;
|
||||
|
||||
return async function msTrophyChallengeCompleted(req, res, next) {
|
||||
const { user, body = {} } = req;
|
||||
const { id = '' } = body;
|
||||
|
||||
try {
|
||||
const msUser = await MsUsername.findOne({
|
||||
where: { userId: user.id }
|
||||
});
|
||||
|
||||
if (!msUser || !msUser.msUsername) {
|
||||
throw new Error('Microsoft username not found.');
|
||||
}
|
||||
|
||||
const { msUsername } = msUser;
|
||||
|
||||
const challenge = msTrophyChallenges.find(
|
||||
challenge => challenge.id === id
|
||||
);
|
||||
|
||||
if (!challenge) {
|
||||
throw new Error('Challenge not found');
|
||||
}
|
||||
|
||||
const { msTrophyId = '' } = challenge;
|
||||
const msTrophyApiUrl = `https://learn.microsoft.com/api/gamestatus/achievements/${msTrophyId}?username=${msUsername}&locale=en-us`;
|
||||
const msApiRes = await fetch(msTrophyApiUrl);
|
||||
|
||||
if (!msApiRes.ok) {
|
||||
throw new Error('Unable to validate trophy');
|
||||
}
|
||||
|
||||
const completedChallenge = pick(body, ['id']);
|
||||
|
||||
completedChallenge.solution = msTrophyApiUrl;
|
||||
completedChallenge.completedDate = Date.now();
|
||||
|
||||
try {
|
||||
await user.getCompletedChallenges$().toPromise();
|
||||
} catch (e) {
|
||||
return next(e);
|
||||
}
|
||||
|
||||
const { alreadyCompleted, updateData } = buildUserUpdate(
|
||||
user,
|
||||
completedChallenge.id,
|
||||
completedChallenge
|
||||
);
|
||||
|
||||
user.updateAttributes(updateData, err => {
|
||||
if (err) {
|
||||
return next(err);
|
||||
}
|
||||
|
||||
return res.json({
|
||||
alreadyCompleted,
|
||||
points: alreadyCompleted ? user.points : user.points + 1,
|
||||
completedDate: completedChallenge.completedDate
|
||||
});
|
||||
});
|
||||
} catch (e) {
|
||||
return res.status(500).json({
|
||||
type: 'error',
|
||||
message: e.message
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
async function saveChallenge(req, res, next) {
|
||||
const user = req.user;
|
||||
const { savedChallenges = [] } = user;
|
||||
|
||||
@@ -2,6 +2,7 @@ import debugFactory from 'debug';
|
||||
import dedent from 'dedent';
|
||||
import { body } from 'express-validator';
|
||||
import { pick } from 'lodash';
|
||||
import fetch from 'node-fetch';
|
||||
|
||||
import {
|
||||
fixCompletedChallengeItem,
|
||||
@@ -22,6 +23,7 @@ import {
|
||||
createDeleteUserToken,
|
||||
encodeUserToken
|
||||
} from '../middlewares/user-token';
|
||||
import { createDeleteMsUsername } from '../middlewares/ms-username';
|
||||
import { deprecatedEndpoint } from '../utils/disabled-endpoints';
|
||||
|
||||
const log = debugFactory('fcc:boot:user');
|
||||
@@ -35,15 +37,24 @@ function bootUser(app) {
|
||||
const postDeleteAccount = createPostDeleteAccount(app);
|
||||
const postUserToken = createPostUserToken(app);
|
||||
const deleteUserToken = createDeleteUserToken(app);
|
||||
const postMsUsername = createPostMsUsername(app);
|
||||
const deleteMsUsername = createDeleteMsUsername(app);
|
||||
|
||||
api.get('/account', sendNonUserToHome, deprecatedEndpoint);
|
||||
api.get('/account/unlink/:social', sendNonUserToHome, getUnlinkSocial);
|
||||
api.get('/user/get-session-user', getSessionUser);
|
||||
api.post('/account/delete', ifNoUser401, deleteUserToken, postDeleteAccount);
|
||||
api.post(
|
||||
'/account/delete',
|
||||
ifNoUser401,
|
||||
deleteUserToken,
|
||||
deleteMsUsername,
|
||||
postDeleteAccount
|
||||
);
|
||||
api.post(
|
||||
'/account/reset-progress',
|
||||
ifNoUser401,
|
||||
deleteUserToken,
|
||||
deleteMsUsername,
|
||||
postResetProgress
|
||||
);
|
||||
api.post(
|
||||
@@ -61,6 +72,14 @@ function bootUser(app) {
|
||||
deleteUserTokenResponse
|
||||
);
|
||||
|
||||
api.post('/user/ms-username', ifNoUser401, postMsUsername);
|
||||
api.delete(
|
||||
'/user/ms-username',
|
||||
ifNoUser401,
|
||||
deleteMsUsername,
|
||||
deleteMsUsernameResponse
|
||||
);
|
||||
|
||||
app.use(api);
|
||||
}
|
||||
|
||||
@@ -93,8 +112,89 @@ function deleteUserTokenResponse(req, res) {
|
||||
return res.send({ userToken: null });
|
||||
}
|
||||
|
||||
function createPostMsUsername(app) {
|
||||
const { MsUsername } = app.models;
|
||||
|
||||
return async function postMsUsername(req, res) {
|
||||
// example msTranscriptUrl: https://learn.microsoft.com/en-us/users/mot01/transcript/8u6awert43q1plo
|
||||
// the last part is the transcript ID
|
||||
// the username is irrelevant, and retrieved from the MS API response
|
||||
|
||||
const { msTranscriptUrl } = req.body;
|
||||
|
||||
if (!msTranscriptUrl) {
|
||||
return res
|
||||
.status(400)
|
||||
.send('Please include a Microsoft transcript URL in request');
|
||||
}
|
||||
|
||||
const msTranscriptId = msTranscriptUrl.split('/').pop();
|
||||
const msTranscriptApiUrl = `https://learn.microsoft.com/api/profiles/transcript/share/${msTranscriptId}`;
|
||||
|
||||
try {
|
||||
const msApiRes = await fetch(msTranscriptApiUrl);
|
||||
|
||||
if (!msApiRes.ok) {
|
||||
res.status(500);
|
||||
throw new Error(
|
||||
'An error occurred trying to get your Microsoft transcript'
|
||||
);
|
||||
}
|
||||
|
||||
const { userName } = await msApiRes.json();
|
||||
|
||||
if (!userName) {
|
||||
res.status(500);
|
||||
throw new Error(
|
||||
'An error occured trying to link your Microsoft account'
|
||||
);
|
||||
}
|
||||
|
||||
// Don't create if username is used by another fCC account
|
||||
const usernameUsed = await MsUsername.findOne({
|
||||
where: { msUsername: userName }
|
||||
});
|
||||
|
||||
if (usernameUsed) {
|
||||
throw new Error('That username is already used');
|
||||
}
|
||||
|
||||
await MsUsername.destroyAll({ userId: req.user.id });
|
||||
|
||||
const ttl = 900 * 24 * 60 * 60 * 1000;
|
||||
const newMsUsername = await MsUsername.create({
|
||||
ttl,
|
||||
userId: req.user.id,
|
||||
msUsername: userName
|
||||
});
|
||||
|
||||
if (!newMsUsername?.id) {
|
||||
res.status(500);
|
||||
throw new Error(
|
||||
'An error occured trying to link your Microsoft account'
|
||||
);
|
||||
}
|
||||
|
||||
return res.json({ msUsername: userName });
|
||||
} catch (e) {
|
||||
log(e);
|
||||
return res.send(e.message);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function deleteMsUsernameResponse(req, res) {
|
||||
if (!req.msUsernameDeleted) {
|
||||
return res
|
||||
.status(500)
|
||||
.send('An error occurred trying to unlink your Microsoft username');
|
||||
}
|
||||
|
||||
return res.send({ msUsername: null });
|
||||
}
|
||||
|
||||
function createReadSessionUser(app) {
|
||||
const { UserToken } = app.models;
|
||||
const { MsUsername, UserToken } = app.models;
|
||||
|
||||
return async function getSessionUser(req, res, next) {
|
||||
const queryUser = req.user;
|
||||
@@ -113,6 +213,20 @@ function createReadSessionUser(app) {
|
||||
return next(e);
|
||||
}
|
||||
|
||||
let msUsername;
|
||||
try {
|
||||
const userId = queryUser?.id;
|
||||
const msUser = userId
|
||||
? await MsUsername.findOne({
|
||||
where: { userId }
|
||||
})
|
||||
: null;
|
||||
|
||||
msUsername = msUser ? msUser.msUsername : undefined;
|
||||
} catch (e) {
|
||||
return next(e);
|
||||
}
|
||||
|
||||
if (!queryUser || !queryUser.toJSON().username) {
|
||||
// TODO: This should return an error status
|
||||
return res.json({ user: {}, result: '' });
|
||||
@@ -154,7 +268,8 @@ function createReadSessionUser(app) {
|
||||
isEmailVerified: !!user.emailVerified,
|
||||
...normaliseUserFields(user),
|
||||
joinDate: user.id.getTimestamp(),
|
||||
userToken: encodedUserToken
|
||||
userToken: encodedUserToken,
|
||||
msUsername
|
||||
}
|
||||
},
|
||||
result: user.username
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import debugFactory from 'debug';
|
||||
const log = debugFactory('fcc:boot:user');
|
||||
|
||||
export function createDeleteMsUsername(app) {
|
||||
const { MsUsername } = app.models;
|
||||
|
||||
return async function deleteMsUsername(req, res, next) {
|
||||
try {
|
||||
await MsUsername.destroyAll({ userId: req.user.id });
|
||||
req.msUsernameDeleted = true;
|
||||
} catch (e) {
|
||||
req.msUsernameDeleted = false;
|
||||
log(
|
||||
`An error occurred deleting Microsoft username for user with id ${req.user.id}`
|
||||
);
|
||||
}
|
||||
|
||||
next();
|
||||
};
|
||||
}
|
||||
@@ -43,6 +43,10 @@
|
||||
"dataSource": "db",
|
||||
"public": false
|
||||
},
|
||||
"MsUsername": {
|
||||
"dataSource": "db",
|
||||
"public": false
|
||||
},
|
||||
"RoleMapping": {
|
||||
"dataSource": "db",
|
||||
"public": false
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"name": "MsUsername",
|
||||
"description": "Microsoft account usernames",
|
||||
"base": "PersistedModel",
|
||||
"idInjection": true,
|
||||
"options": {
|
||||
"validateUpsert": true
|
||||
},
|
||||
"properties": {},
|
||||
"validations": [],
|
||||
"relations": {
|
||||
"user": {
|
||||
"type": "belongsTo",
|
||||
"model": "user",
|
||||
"foreignKey": "userId"
|
||||
}
|
||||
},
|
||||
"acls": [],
|
||||
"methods": {}
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
// TODO: port to new API!
|
||||
|
||||
const MS_LEARN_DOMAIN = 'learn.microsoft.com';
|
||||
const mSLearnRegex = /^\/[^/]+\/training\/achievements\/([^/]+)$/;
|
||||
|
||||
export const getApiUrlFromTrophy = trophyUrlString => {
|
||||
if (!trophyUrlString) return null;
|
||||
|
||||
let mSLearnUrl;
|
||||
try {
|
||||
mSLearnUrl = new URL(trophyUrlString);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (mSLearnUrl.protocol !== 'https:') return null;
|
||||
if (mSLearnUrl.hostname !== MS_LEARN_DOMAIN) return null;
|
||||
const match = mSLearnUrl.pathname.match(mSLearnRegex);
|
||||
if (!match) return null;
|
||||
|
||||
const apiUrl = new URL(
|
||||
`https://${MS_LEARN_DOMAIN}/api/gamestatus/achievements/${match[1]}`
|
||||
);
|
||||
|
||||
apiUrl.searchParams.set('username', mSLearnUrl.searchParams.get('username'));
|
||||
return apiUrl.href;
|
||||
};
|
||||
@@ -1,50 +0,0 @@
|
||||
const { getApiUrlFromTrophy } = require('./ms-learn-utils');
|
||||
|
||||
const validApiUrl =
|
||||
'https://learn.microsoft.com/api/gamestatus/achievements/learn.wwl.get-started-c-sharp-part-1.trophy?username=moT01';
|
||||
const validTrophyUrl =
|
||||
'https://learn.microsoft.com/en-us/training/achievements/learn.wwl.get-started-c-sharp-part-1.trophy?username=moT01&sharingId=E2EF453C1F9208B';
|
||||
|
||||
describe('ms-learn-utils', () => {
|
||||
describe('getApiUrlFromTrophy', () => {
|
||||
it('should return null if the trophy url is empty', () => {
|
||||
expect(getApiUrlFromTrophy('')).toBeNull();
|
||||
});
|
||||
|
||||
it('should return null if the protocol is wrong', () => {
|
||||
expect(getApiUrlFromTrophy('http://learn.microsoft.com')).toBeNull();
|
||||
});
|
||||
|
||||
it('should return null if the domain is wrong', () => {
|
||||
expect(getApiUrlFromTrophy('https://learn.microsoft.co')).toBeNull();
|
||||
});
|
||||
|
||||
it('should return null if the path is incomplete', () => {
|
||||
expect(getApiUrlFromTrophy('https://learn.microsoft.com')).toBeNull();
|
||||
expect(
|
||||
getApiUrlFromTrophy(
|
||||
'https://learn.microsoft.com/en-us/trainin/achievements/learn.wwl.get-started-c-sharp-part-1.trophy?username=moT01'
|
||||
)
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('should add the username as a query param', () => {
|
||||
const username = 'moT01';
|
||||
|
||||
const url = new URL(getApiUrlFromTrophy(validTrophyUrl));
|
||||
|
||||
expect(url.searchParams.get('username')).toBe(username);
|
||||
});
|
||||
|
||||
it('should only add a single query param', () => {
|
||||
const url = new URL(getApiUrlFromTrophy(validTrophyUrl));
|
||||
|
||||
// URLSearchParams.size is only supported in Node 19+
|
||||
expect([...url.searchParams.keys()].length).toBe(1);
|
||||
});
|
||||
|
||||
it('should append the trophy path to the api url', () => {
|
||||
expect(getApiUrlFromTrophy(validTrophyUrl)).toBe(validApiUrl);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -84,6 +84,7 @@ exports.createPages = function createPages({ graphql, actions, reporter }) {
|
||||
}
|
||||
hasEditableBoundaries
|
||||
id
|
||||
msTrophyId
|
||||
order
|
||||
prerequisites {
|
||||
id
|
||||
@@ -289,6 +290,7 @@ exports.createSchemaCustomization = ({ actions }) => {
|
||||
url: String
|
||||
assignments: [String]
|
||||
prerequisites: [PrerequisiteChallenge]
|
||||
msTrophyId: String
|
||||
}
|
||||
type FileContents {
|
||||
fileKey: String
|
||||
|
||||
@@ -84,7 +84,10 @@
|
||||
"exit": "Exit",
|
||||
"finish-exam": "Finish the exam",
|
||||
"finish": "Finish",
|
||||
"submit-exam-results": "Submit my results"
|
||||
"submit-exam-results": "Submit my results",
|
||||
"verify-trophy": "Verify Trophy",
|
||||
"link-account": "Link Account",
|
||||
"unlink-account": "Unlink Account"
|
||||
},
|
||||
"landing": {
|
||||
"big-heading-1": "Learn to code — for free.",
|
||||
@@ -433,6 +436,18 @@
|
||||
"exit": "Are you sure you want to leave the exam? You will lose any progress you have made.",
|
||||
"exit-yes": "Yes, I want to leave the exam",
|
||||
"exit-no": "No, I would like to continue the exam"
|
||||
},
|
||||
"ms": {
|
||||
"link-header": "Link your Microsoft account",
|
||||
"link-signin": "To complete this challenge, you must first link your Microsoft username to your freeCodeCamp account. Sign in to link your Microsoft username.",
|
||||
"linked": "The Microsoft account with username \"{{ msUsername }}\" is currently linked to your freeCodeCamp account. If this is not your Microsoft username, remove the link.",
|
||||
"unlinked": "To complete this challenge, you must first link your Microsoft username to your freeCodeCamp account by following these instructions:",
|
||||
"link-li-1": "Using a browser where you are logged into your Microsoft account, go to <0>https://learn.microsoft.com/users/me/transcript</0>",
|
||||
"link-li-2": "Find and click the \"Share link\" button.",
|
||||
"link-li-3": "If you do not have a transcript link, click the \"Create link\" button to create one.",
|
||||
"link-li-4": "Click the \"Copy link\" button to copy the transcript URL.",
|
||||
"link-li-5": "Paste the URL into the input below and click \"Link Account\".",
|
||||
"transcript-label": "Your Microsoft Transcript Link"
|
||||
}
|
||||
},
|
||||
"donate": {
|
||||
@@ -686,7 +701,6 @@
|
||||
"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.",
|
||||
"ms-trophy-missing": "It looks like the trophy link you provided is not valid. Please check the link and try again.",
|
||||
"timeline-private": "{{username}} has chosen to make their timeline private. They will need to make their timeline public in order for others to be able to view their certification.",
|
||||
"code-saved": "Your code was saved to the database. It will be here when you return.",
|
||||
"code-save-error": "An error occurred trying to save your code.",
|
||||
@@ -694,7 +708,13 @@
|
||||
"challenge-save-too-big": "Sorry, you cannot save your code. Your code is {{user-size}} bytes. We allow a maximum of {{max-size}} bytes. Please make your code smaller and try again or request assistance on https://forum.freecodecamp.org",
|
||||
"challenge-submit-too-big": "Sorry, you cannot submit your code. Your code is {{user-size}} bytes. We allow a maximum of {{max-size}} bytes. Please make your code smaller and try again or request assistance on https://forum.freecodecamp.org",
|
||||
"invalid-update-flag": "You are attempting to access forbidden resources. Please request assistance on https://forum.freecodecamp.org if this is a valid request.",
|
||||
"generate-exam-error": "An error occurred trying to generate your exam."
|
||||
"generate-exam-error": "An error occurred trying to generate your exam.",
|
||||
"ms-trophy-err": "We were unable to verify your trophy from Microsoft's learning platform.",
|
||||
"ms-trophy-verified": "Your trophy from Microsoft's learning platform was verified.",
|
||||
"ms-linked": "Your Microsoft username been linked to your freeCodeCamp account.",
|
||||
"ms-link-err": "An error occurred trying to link your Microsoft username to your freeCodeCamp account.",
|
||||
"ms-unlinked": "The link to your Microsoft username has been removed.",
|
||||
"ms-unlink-err": "An error occurred trying to remove the link to your Microsoft username."
|
||||
},
|
||||
"validation": {
|
||||
"max-characters": "There is a maximum limit of 288 characters, you have {{charsLeft}} left",
|
||||
|
||||
@@ -18,7 +18,12 @@ export enum FlashMessages {
|
||||
IncompleteSteps = 'flash.incomplete-steps',
|
||||
LocalCodeSaved = 'flash.local-code-saved',
|
||||
LocalCodeSaveError = 'flash.local-code-save-error',
|
||||
MSTrophyMissing = 'flash.ms-trophy-missing',
|
||||
MsLinked = 'flash.ms-linked',
|
||||
MsLinkErr = 'flash.ms-link-err',
|
||||
MsTrophyErr = 'flash.ms-trophy-err',
|
||||
MsTrophyVerified = 'flash.ms-trophy-verified',
|
||||
MsUnlinked = 'flash.ms-unlinked',
|
||||
MsUnlinkErr = 'flash.ms-unlink-err',
|
||||
NameNeeded = 'flash.name-needed',
|
||||
None = '',
|
||||
NotEligible = 'flash.not-eligible',
|
||||
|
||||
@@ -15,15 +15,13 @@ import {
|
||||
composeValidators,
|
||||
fCCValidator,
|
||||
httpValidator,
|
||||
pathValidator,
|
||||
microsoftValidator
|
||||
pathValidator
|
||||
} from './form-validators';
|
||||
|
||||
export type FormOptions = {
|
||||
ignored?: string[];
|
||||
isEditorLinkAllowed?: boolean;
|
||||
isLocalLinkAllowed?: boolean;
|
||||
isMicrosoftLearnLink?: boolean;
|
||||
required?: string[];
|
||||
types?: { [key: string]: string };
|
||||
placeholders?: { [key: string]: string };
|
||||
@@ -42,8 +40,7 @@ function FormFields({ formFields, options }: FormFieldsProps): JSX.Element {
|
||||
required = [],
|
||||
types = {},
|
||||
isEditorLinkAllowed = false,
|
||||
isLocalLinkAllowed = false,
|
||||
isMicrosoftLearnLink = false
|
||||
isLocalLinkAllowed = false
|
||||
} = options;
|
||||
|
||||
const nullOrWarning = (
|
||||
@@ -72,7 +69,6 @@ function FormFields({ formFields, options }: FormFieldsProps): JSX.Element {
|
||||
if (!isLocalLinkAllowed) {
|
||||
validators.push(localhostValidator);
|
||||
}
|
||||
if (isMicrosoftLearnLink) validators.push(microsoftValidator);
|
||||
const validationWarning = composeValidators(...validators)(value);
|
||||
const message: string = (error ||
|
||||
validationError ||
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import React from 'react';
|
||||
import { Trans } from 'react-i18next';
|
||||
import { isMicrosoftLearnLink } from '../../../../utils/validate';
|
||||
|
||||
// Matches editor links for: Replit, Glitch, CodeSandbox, GitHub
|
||||
const editorRegex =
|
||||
@@ -20,9 +19,6 @@ function isPathRoot(urlString: string): boolean {
|
||||
|
||||
type Validator = (value: string) => React.ReactElement | null;
|
||||
|
||||
export const microsoftValidator: Validator = value =>
|
||||
!isMicrosoftLearnLink(value) ? <Trans>validation.ms-learn-link</Trans> : null;
|
||||
|
||||
export const editorValidator: Validator = value =>
|
||||
editorRegex.test(value) ? <Trans>validation.editor-url</Trans> : null;
|
||||
|
||||
|
||||
@@ -8,8 +8,7 @@ import {
|
||||
editorValidator,
|
||||
composeValidators,
|
||||
fCCValidator,
|
||||
httpValidator,
|
||||
microsoftValidator
|
||||
httpValidator
|
||||
} from './form-validators';
|
||||
import FormFields, { FormOptions } from './form-fields';
|
||||
|
||||
@@ -36,12 +35,7 @@ function validateFormValues(
|
||||
formValues: FormValues,
|
||||
options: FormOptions
|
||||
): ValidatedValues {
|
||||
const {
|
||||
isEditorLinkAllowed,
|
||||
isLocalLinkAllowed,
|
||||
isMicrosoftLearnLink,
|
||||
types
|
||||
} = options;
|
||||
const { isEditorLinkAllowed, isLocalLinkAllowed, types } = options;
|
||||
const validatedValues: ValidatedValues = {
|
||||
values: {},
|
||||
errors: [],
|
||||
@@ -59,9 +53,6 @@ function validateFormValues(
|
||||
if (!isLocalLinkAllowed) {
|
||||
validators.push(localhostValidator);
|
||||
}
|
||||
if (isMicrosoftLearnLink) {
|
||||
validators.push(microsoftValidator);
|
||||
}
|
||||
|
||||
const nullOrWarning = composeValidators(...validators)(value);
|
||||
if (nullOrWarning) {
|
||||
|
||||
@@ -26,6 +26,10 @@ export const actionTypes = createTypes(
|
||||
'startExam',
|
||||
'stopExam',
|
||||
'clearExamResults',
|
||||
'linkMsUsername',
|
||||
'unlinkMsUsername',
|
||||
'setMsUsername',
|
||||
'setIsProcessing',
|
||||
'submitComplete',
|
||||
'updateComplete',
|
||||
'updateFailed',
|
||||
|
||||
@@ -102,5 +102,11 @@ export const startExam = createAction(actionTypes.startExam);
|
||||
export const stopExam = createAction(actionTypes.stopExam);
|
||||
export const clearExamResults = createAction(actionTypes.clearExamResults);
|
||||
|
||||
export const linkMsUsername = createAction(actionTypes.linkMsUsername);
|
||||
export const unlinkMsUsername = createAction(actionTypes.unlinkMsUsername);
|
||||
export const setMsUsername = createAction(actionTypes.setMsUsername);
|
||||
|
||||
export const setIsProcessing = createAction(actionTypes.setIsProcessing);
|
||||
|
||||
export const closeSignoutModal = createAction(actionTypes.closeSignoutModal);
|
||||
export const openSignoutModal = createAction(actionTypes.openSignoutModal);
|
||||
|
||||
@@ -22,6 +22,7 @@ import { actionTypes as settingsTypes } from './settings/action-types';
|
||||
import { createShowCertSaga } from './show-cert-saga';
|
||||
import updateCompleteEpic from './update-complete-epic';
|
||||
import { createUserTokenSaga } from './user-token-saga';
|
||||
import { createMsUsernameSaga } from './ms-username-saga';
|
||||
|
||||
const defaultFetchState = {
|
||||
pending: true,
|
||||
@@ -50,6 +51,7 @@ const initialState = {
|
||||
completionCount: 0,
|
||||
currentChallengeId: store.get(CURRENT_CHALLENGE_KEY),
|
||||
examInProgress: false,
|
||||
isProcessing: false,
|
||||
showCert: {},
|
||||
showCertFetchState: {
|
||||
...defaultFetchState
|
||||
@@ -88,7 +90,8 @@ export const sagas = [
|
||||
...createShowCertSaga(actionTypes),
|
||||
...createReportUserSaga(actionTypes),
|
||||
...createUserTokenSaga(actionTypes),
|
||||
...createSaveChallengeSaga(actionTypes)
|
||||
...createSaveChallengeSaga(actionTypes),
|
||||
...createMsUsernameSaga(actionTypes)
|
||||
];
|
||||
|
||||
function spreadThePayloadOnUser(state, payload) {
|
||||
@@ -335,6 +338,25 @@ export const reducer = handleActions(
|
||||
}
|
||||
};
|
||||
},
|
||||
[actionTypes.setMsUsername]: (state, { payload }) => {
|
||||
const { appUsername } = state;
|
||||
return {
|
||||
...state,
|
||||
user: {
|
||||
...state.user,
|
||||
[appUsername]: {
|
||||
...state.user[appUsername],
|
||||
msUsername: payload
|
||||
}
|
||||
}
|
||||
};
|
||||
},
|
||||
[actionTypes.setIsProcessing]: (state, { payload }) => {
|
||||
return {
|
||||
...state,
|
||||
isProcessing: payload
|
||||
};
|
||||
},
|
||||
[actionTypes.updateUserToken]: (state, { payload }) => {
|
||||
const { appUsername } = state;
|
||||
return {
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
import { call, put, takeEvery } from 'redux-saga/effects';
|
||||
|
||||
import { createFlashMessage } from '../components/Flash/redux';
|
||||
import { FlashMessages } from '../components/Flash/redux/flash-messages';
|
||||
import { postMsUsername, deleteMsUsername } from '../utils/ajax';
|
||||
import { setMsUsername, setIsProcessing } from './actions';
|
||||
|
||||
const message = {
|
||||
linked: {
|
||||
type: 'success',
|
||||
message: FlashMessages.MsLinked
|
||||
},
|
||||
linkErr: {
|
||||
type: 'danger',
|
||||
message: FlashMessages.MsLinkErr
|
||||
},
|
||||
unlinked: {
|
||||
type: 'info',
|
||||
message: FlashMessages.MsUnlinked
|
||||
},
|
||||
unlinkErr: {
|
||||
type: 'danger',
|
||||
message: FlashMessages.MsUnlinkErr
|
||||
}
|
||||
};
|
||||
|
||||
function* linkMsUsernameSaga({ payload: { msTranscriptUrl } }) {
|
||||
try {
|
||||
const { data } = yield call(postMsUsername, { msTranscriptUrl });
|
||||
|
||||
if (data && Object.prototype.hasOwnProperty.call(data, 'msUsername')) {
|
||||
yield put(setMsUsername(data.msUsername));
|
||||
yield put(setIsProcessing(false));
|
||||
yield put(createFlashMessage(message.linked));
|
||||
} else {
|
||||
yield put(setIsProcessing(false));
|
||||
yield put(createFlashMessage(message.linkErr));
|
||||
}
|
||||
} catch {
|
||||
yield put(setIsProcessing(false));
|
||||
yield put(createFlashMessage(message.linkErr));
|
||||
}
|
||||
}
|
||||
|
||||
function* unlinkMsUsernameSaga() {
|
||||
try {
|
||||
const { data } = yield call(deleteMsUsername);
|
||||
|
||||
if (data && Object.prototype.hasOwnProperty.call(data, 'msUsername')) {
|
||||
yield put(setMsUsername(data.msUsername));
|
||||
yield put(createFlashMessage(message.unlinked));
|
||||
} else {
|
||||
yield put(createFlashMessage(message.unlinkErr));
|
||||
}
|
||||
} catch {
|
||||
yield put(createFlashMessage(message.unlinkErr));
|
||||
}
|
||||
}
|
||||
|
||||
export function createMsUsernameSaga(types) {
|
||||
return [
|
||||
takeEvery(types.linkMsUsername, linkMsUsernameSaga),
|
||||
takeEvery(types.unlinkMsUsername, unlinkMsUsernameSaga)
|
||||
];
|
||||
}
|
||||
@@ -123,6 +123,7 @@ export type ChallengeNode = {
|
||||
owner: string;
|
||||
type: string;
|
||||
};
|
||||
msTrophyId: string;
|
||||
notes: string;
|
||||
prerequisites: PrerequisiteChallenge[];
|
||||
removeComments: boolean;
|
||||
|
||||
@@ -87,6 +87,14 @@ export const examInProgressSelector = state => {
|
||||
|
||||
export const examResultsSelector = state => userSelector(state).examResults;
|
||||
|
||||
export const msUsernameSelector = state => {
|
||||
return userSelector(state).msUsername;
|
||||
};
|
||||
|
||||
export const isProcessingSelector = state => {
|
||||
return state[MainApp].isProcessing;
|
||||
};
|
||||
|
||||
export const userByNameSelector = username => state => {
|
||||
const { user } = state[MainApp];
|
||||
// return initial state empty user empty object instead of empty
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
.link-ms-user-title {
|
||||
text-align: center;
|
||||
font-size: inherit;
|
||||
}
|
||||
|
||||
.link-ms-user-ol li {
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.link-ms-user-ol li:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
Button,
|
||||
FormGroup,
|
||||
ControlLabel,
|
||||
FormControl
|
||||
} from '@freecodecamp/react-bootstrap';
|
||||
import { connect } from 'react-redux';
|
||||
import { bindActionCreators } from 'redux';
|
||||
import type { Dispatch } from 'redux';
|
||||
import { createSelector } from 'reselect';
|
||||
import type { TFunction } from 'i18next';
|
||||
import { Trans, withTranslation } from 'react-i18next';
|
||||
import { Spacer } from '../../../components/helpers';
|
||||
|
||||
import {
|
||||
linkMsUsername,
|
||||
unlinkMsUsername,
|
||||
setIsProcessing
|
||||
} from '../../../redux/actions';
|
||||
import {
|
||||
isSignedInSelector,
|
||||
msUsernameSelector,
|
||||
isProcessingSelector
|
||||
} from '../../../redux/selectors';
|
||||
import Login from '../../../components/Header/components/login';
|
||||
|
||||
import './link-ms-user.css';
|
||||
|
||||
const mapStateToProps = createSelector(
|
||||
isSignedInSelector,
|
||||
msUsernameSelector,
|
||||
isProcessingSelector,
|
||||
(
|
||||
isSignedIn: boolean,
|
||||
msUsername: string | undefined | null,
|
||||
isProcessing: boolean
|
||||
) => ({
|
||||
isSignedIn,
|
||||
msUsername,
|
||||
isProcessing
|
||||
})
|
||||
);
|
||||
|
||||
const mapDispatchToProps = (dispatch: Dispatch) =>
|
||||
bindActionCreators(
|
||||
{
|
||||
linkMsUsername,
|
||||
unlinkMsUsername,
|
||||
setIsProcessing
|
||||
},
|
||||
dispatch
|
||||
);
|
||||
|
||||
interface LinkMsUserProps {
|
||||
isSignedIn: boolean;
|
||||
msUsername: string | undefined | null;
|
||||
linkMsUsername: (arg0: { msTranscriptUrl: string }) => void;
|
||||
unlinkMsUsername: () => void;
|
||||
isProcessing: boolean;
|
||||
setIsProcessing: (arg0: boolean) => void;
|
||||
t: TFunction;
|
||||
}
|
||||
|
||||
export function LinkMsUser({
|
||||
isSignedIn,
|
||||
msUsername,
|
||||
linkMsUsername,
|
||||
unlinkMsUsername,
|
||||
isProcessing,
|
||||
setIsProcessing,
|
||||
t
|
||||
}: LinkMsUserProps): JSX.Element {
|
||||
const [msTranscriptUrl, setMsTranscriptUrl] = useState<string>('');
|
||||
|
||||
function handleLinkUsername(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setIsProcessing(true);
|
||||
linkMsUsername({ msTranscriptUrl });
|
||||
}
|
||||
|
||||
function handleInputChange(e: React.ChangeEvent<HTMLInputElement>) {
|
||||
e.preventDefault();
|
||||
setMsTranscriptUrl(e.target.value);
|
||||
}
|
||||
|
||||
return !isSignedIn ? (
|
||||
<>
|
||||
<h2 className='link-ms-user-title'>{t('learn.ms.link-header')}</h2>
|
||||
<Spacer size='small' />
|
||||
|
||||
<p>{t('learn.ms.link-signin')}</p>
|
||||
<Login />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{msUsername ? (
|
||||
<>
|
||||
<p>{t('learn.ms.linked', { msUsername })}</p>
|
||||
<Button
|
||||
block={true}
|
||||
bsStyle='primary'
|
||||
className='btn-invert'
|
||||
disabled={isProcessing}
|
||||
onClick={unlinkMsUsername}
|
||||
>
|
||||
{t('buttons.unlink-account')}
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<div>
|
||||
<h2 className='link-ms-user-title'>{t('learn.ms.link-header')}</h2>
|
||||
<Spacer size='small' />
|
||||
|
||||
<p>{t('learn.ms.unlinked')}</p>
|
||||
<ol className='link-ms-user-ol'>
|
||||
<li>
|
||||
<Trans i18nKey='learn.ms.link-li-1'>
|
||||
<a
|
||||
href='https://learn.microsoft.com/users/me/transcript'
|
||||
rel='noreferrer'
|
||||
target='_blank'
|
||||
>
|
||||
placeholder
|
||||
</a>
|
||||
</Trans>
|
||||
</li>
|
||||
<li>{t('learn.ms.link-li-2')}</li>
|
||||
<li>{t('learn.ms.link-li-3')}</li>
|
||||
<li>{t('learn.ms.link-li-4')}</li>
|
||||
<li>{t('learn.ms.link-li-5')}</li>
|
||||
</ol>
|
||||
|
||||
<Spacer size='medium' />
|
||||
<form onSubmit={handleLinkUsername}>
|
||||
<FormGroup>
|
||||
<ControlLabel>
|
||||
<strong>{t('learn.ms.transcript-label')}</strong>
|
||||
</ControlLabel>
|
||||
<FormControl
|
||||
type='url'
|
||||
onChange={handleInputChange}
|
||||
placeholder='https://learn.microsoft.com/en-us/users/username/transcript/transcriptId'
|
||||
/>
|
||||
</FormGroup>
|
||||
<Button
|
||||
disabled={isProcessing || msTranscriptUrl.length === 0}
|
||||
block={true}
|
||||
bsStyle='primary'
|
||||
className='btn-invert'
|
||||
onClick={handleLinkUsername}
|
||||
>
|
||||
{t('buttons.link-account')}
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
LinkMsUser.displayName = 'LinkMsUser';
|
||||
|
||||
export default connect(
|
||||
mapStateToProps,
|
||||
mapDispatchToProps
|
||||
)(withTranslation()(LinkMsUser));
|
||||
@@ -0,0 +1,240 @@
|
||||
import { Col, Row, Button } from '@freecodecamp/react-bootstrap';
|
||||
import { graphql } from 'gatsby';
|
||||
import React, { Component } from 'react';
|
||||
import Helmet from 'react-helmet';
|
||||
import type { TFunction } from 'i18next';
|
||||
import { withTranslation } from 'react-i18next';
|
||||
import { connect } from 'react-redux';
|
||||
import { bindActionCreators } from 'redux';
|
||||
import type { Dispatch } from 'redux';
|
||||
import { createSelector } from 'reselect';
|
||||
|
||||
import { Container } from '@freecodecamp/ui';
|
||||
import Spacer from '../../../components/helpers/spacer';
|
||||
import LearnLayout from '../../../components/layouts/learn';
|
||||
import { ChallengeNode, ChallengeMeta } from '../../../redux/prop-types';
|
||||
import ChallengeDescription from '../components/challenge-description';
|
||||
import Hotkeys from '../components/hotkeys';
|
||||
import ChallengeTitle from '../components/challenge-title';
|
||||
import CompletionModal from '../components/completion-modal';
|
||||
import {
|
||||
challengeMounted,
|
||||
updateChallengeMeta,
|
||||
openModal,
|
||||
updateSolutionFormValues,
|
||||
submitChallenge
|
||||
} from '../redux/actions';
|
||||
import { isChallengeCompletedSelector } from '../redux/selectors';
|
||||
import { setIsProcessing } from '../../../redux/actions';
|
||||
import {
|
||||
isProcessingSelector,
|
||||
msUsernameSelector
|
||||
} from '../../../redux/selectors';
|
||||
import LinkMsUser from './link-ms-user';
|
||||
|
||||
// Redux Setup
|
||||
const mapStateToProps = createSelector(
|
||||
isChallengeCompletedSelector,
|
||||
isProcessingSelector,
|
||||
msUsernameSelector,
|
||||
(
|
||||
isChallengeCompleted: boolean,
|
||||
isProcessing: boolean,
|
||||
msUsername: string | undefined | null
|
||||
) => ({
|
||||
isChallengeCompleted,
|
||||
isProcessing,
|
||||
msUsername
|
||||
})
|
||||
);
|
||||
|
||||
const mapDispatchToProps = (dispatch: Dispatch) =>
|
||||
bindActionCreators(
|
||||
{
|
||||
updateChallengeMeta,
|
||||
challengeMounted,
|
||||
updateSolutionFormValues,
|
||||
openCompletionModal: () => openModal('completion'),
|
||||
setIsProcessing,
|
||||
submitChallenge
|
||||
},
|
||||
dispatch
|
||||
);
|
||||
|
||||
// Types
|
||||
interface MsTrophyProps {
|
||||
challengeMounted: (arg0: string) => void;
|
||||
data: { challengeNode: ChallengeNode };
|
||||
isChallengeCompleted: boolean;
|
||||
isProcessing: boolean;
|
||||
setIsProcessing: (arg0: boolean) => void;
|
||||
msUsername: string | undefined | null;
|
||||
openCompletionModal: () => void;
|
||||
pageContext: {
|
||||
challengeMeta: ChallengeMeta;
|
||||
};
|
||||
submitChallenge: () => void;
|
||||
t: TFunction;
|
||||
updateChallengeMeta: (arg0: ChallengeMeta) => void;
|
||||
}
|
||||
|
||||
// Component
|
||||
class MsTrophy extends Component<MsTrophyProps> {
|
||||
static displayName: string;
|
||||
private _container: HTMLElement | null = null;
|
||||
|
||||
constructor(props: MsTrophyProps) {
|
||||
super(props);
|
||||
this.handleSubmit = this.handleSubmit.bind(this);
|
||||
}
|
||||
componentDidMount() {
|
||||
const {
|
||||
challengeMounted,
|
||||
data: {
|
||||
challengeNode: {
|
||||
challenge: { title, challengeType }
|
||||
}
|
||||
},
|
||||
pageContext: { challengeMeta },
|
||||
updateChallengeMeta
|
||||
} = this.props;
|
||||
updateChallengeMeta({
|
||||
...challengeMeta,
|
||||
title,
|
||||
challengeType
|
||||
});
|
||||
challengeMounted(challengeMeta.id);
|
||||
this._container?.focus();
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps: MsTrophyProps): void {
|
||||
const {
|
||||
data: {
|
||||
challengeNode: {
|
||||
challenge: { title: prevTitle }
|
||||
}
|
||||
}
|
||||
} = prevProps;
|
||||
const {
|
||||
challengeMounted,
|
||||
data: {
|
||||
challengeNode: {
|
||||
challenge: { title: currentTitle, challengeType }
|
||||
}
|
||||
},
|
||||
pageContext: { challengeMeta },
|
||||
updateChallengeMeta
|
||||
} = this.props;
|
||||
if (prevTitle !== currentTitle) {
|
||||
updateChallengeMeta({
|
||||
...challengeMeta,
|
||||
title: currentTitle,
|
||||
challengeType
|
||||
});
|
||||
challengeMounted(challengeMeta.id);
|
||||
}
|
||||
}
|
||||
|
||||
handleSubmit = (): void => {
|
||||
const { setIsProcessing, submitChallenge } = this.props;
|
||||
|
||||
setIsProcessing(true);
|
||||
submitChallenge();
|
||||
};
|
||||
|
||||
render() {
|
||||
const {
|
||||
data: {
|
||||
challengeNode: {
|
||||
challenge: {
|
||||
title,
|
||||
description,
|
||||
instructions,
|
||||
superBlock,
|
||||
block,
|
||||
translationPending
|
||||
}
|
||||
}
|
||||
},
|
||||
isChallengeCompleted,
|
||||
isProcessing,
|
||||
msUsername,
|
||||
pageContext: {
|
||||
challengeMeta: { nextChallengePath, prevChallengePath }
|
||||
},
|
||||
t
|
||||
} = this.props;
|
||||
|
||||
const blockNameTitle = `${t(
|
||||
`intro:${superBlock}.blocks.${block}.title`
|
||||
)} - ${title}`;
|
||||
|
||||
return (
|
||||
<Hotkeys
|
||||
innerRef={(c: HTMLElement | null) => (this._container = c)}
|
||||
nextChallengePath={nextChallengePath}
|
||||
prevChallengePath={prevChallengePath}
|
||||
>
|
||||
<LearnLayout>
|
||||
<Helmet
|
||||
title={`${blockNameTitle} | ${t('learn.learn')} | freeCodeCamp.org`}
|
||||
/>
|
||||
<Container>
|
||||
<Row>
|
||||
<Col md={8} mdOffset={2} sm={10} smOffset={1} xs={12}>
|
||||
<Spacer size='medium' />
|
||||
<ChallengeTitle
|
||||
isCompleted={isChallengeCompleted}
|
||||
translationPending={translationPending}
|
||||
>
|
||||
{title}
|
||||
</ChallengeTitle>
|
||||
<ChallengeDescription
|
||||
description={description}
|
||||
instructions={instructions}
|
||||
/>
|
||||
<LinkMsUser />
|
||||
<hr />
|
||||
<Button
|
||||
block={true}
|
||||
bsStyle='primary'
|
||||
className='btn-invert'
|
||||
disabled={!msUsername || isProcessing}
|
||||
onClick={this.handleSubmit}
|
||||
>
|
||||
{t('buttons.verify-trophy')}
|
||||
</Button>
|
||||
<br />
|
||||
<Spacer size='medium' />
|
||||
</Col>
|
||||
<CompletionModal />
|
||||
</Row>
|
||||
</Container>
|
||||
</LearnLayout>
|
||||
</Hotkeys>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
MsTrophy.displayName = 'MsTrophy';
|
||||
|
||||
export default connect(
|
||||
mapStateToProps,
|
||||
mapDispatchToProps
|
||||
)(withTranslation()(MsTrophy));
|
||||
|
||||
export const query = graphql`
|
||||
query MsTrophyChallenge($slug: String!) {
|
||||
challengeNode(challenge: { fields: { slug: { eq: $slug } } }) {
|
||||
challenge {
|
||||
title
|
||||
description
|
||||
instructions
|
||||
challengeType
|
||||
superBlock
|
||||
block
|
||||
translationPending
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
@@ -28,7 +28,6 @@ import { isChallengeCompletedSelector } from '../../redux/selectors';
|
||||
import { getGuideUrl } from '../../utils';
|
||||
import SolutionForm from '../solution-form';
|
||||
import ProjectToolPanel from '../tool-panel';
|
||||
import { challengeTypes } from '../../../../../../config/challenge-types';
|
||||
|
||||
// Redux Setup
|
||||
const mapStateToProps = createSelector(
|
||||
@@ -192,11 +191,9 @@ class Project extends Component<ProjectProps> {
|
||||
onSubmit={this.handleSubmit}
|
||||
updateSolutionForm={updateSolutionFormValues}
|
||||
/>
|
||||
{challengeType !== challengeTypes.msTrophyUrl && (
|
||||
<ProjectToolPanel
|
||||
guideUrl={getGuideUrl({ forumTopicId, title })}
|
||||
/>
|
||||
)}
|
||||
<ProjectToolPanel
|
||||
guideUrl={getGuideUrl({ forumTopicId, title })}
|
||||
/>
|
||||
<br />
|
||||
<Spacer size='medium' />
|
||||
</Col>
|
||||
|
||||
@@ -53,7 +53,6 @@ export class SolutionForm extends Component<SolutionFormProps> {
|
||||
{ name: 'solution', label: t('learn.solution-link') },
|
||||
{ name: 'githubLink', label: t('learn.github-link') }
|
||||
];
|
||||
const msTrophyField = [{ name: 'solution', label: t('learn.ms-link') }];
|
||||
|
||||
const buttonCopy = t('learn.i-completed');
|
||||
|
||||
@@ -64,8 +63,7 @@ export class SolutionForm extends Component<SolutionFormProps> {
|
||||
},
|
||||
required: ['solution'],
|
||||
isEditorLinkAllowed: false,
|
||||
isLocalLinkAllowed: false,
|
||||
isMicrosoftLearnLink: false
|
||||
isLocalLinkAllowed: false
|
||||
};
|
||||
|
||||
let formFields = solutionField;
|
||||
@@ -108,14 +106,6 @@ export class SolutionForm extends Component<SolutionFormProps> {
|
||||
solutionLink = solutionLink + 'https://your-git-repo.url/files';
|
||||
break;
|
||||
|
||||
case challengeTypes.msTrophyUrl:
|
||||
formFields = msTrophyField;
|
||||
options.isMicrosoftLearnLink = true;
|
||||
solutionLink =
|
||||
solutionLink +
|
||||
'https://learn.microsoft.com/en-us/training/achievements/learn.wwl.get-started-c-sharp-part-1.trophy?username=you';
|
||||
break;
|
||||
|
||||
default:
|
||||
formFields = solutionField;
|
||||
solutionLink =
|
||||
|
||||
@@ -13,7 +13,8 @@ import {
|
||||
import { createFlashMessage } from '../../../components/Flash/redux';
|
||||
import {
|
||||
standardErrorMessage,
|
||||
trophyMissingMessage
|
||||
msTrophyError,
|
||||
msTrophyVerified
|
||||
} from '../../../utils/error-messages';
|
||||
import {
|
||||
challengeTypes,
|
||||
@@ -23,6 +24,7 @@ import {
|
||||
import { actionTypes as submitActionTypes } from '../../../redux/action-types';
|
||||
import {
|
||||
allowBlockDonationRequests,
|
||||
setIsProcessing,
|
||||
setRenderStartTime,
|
||||
submitComplete,
|
||||
updateComplete,
|
||||
@@ -50,10 +52,13 @@ import {
|
||||
} from './selectors';
|
||||
|
||||
function postChallenge(update, username) {
|
||||
const {
|
||||
payload: { challengeType }
|
||||
} = update;
|
||||
const saveChallenge = postUpdate$(update).pipe(
|
||||
retry(3),
|
||||
switchMap(({ data }) => {
|
||||
const { savedChallenges, points, isTrophyMissing, examResults } = data;
|
||||
const { savedChallenges, points, type, examResults } = data;
|
||||
const payloadWithClientProperties = {
|
||||
...omit(update.payload, ['files'])
|
||||
};
|
||||
@@ -66,7 +71,7 @@ function postChallenge(update, username) {
|
||||
);
|
||||
}
|
||||
|
||||
const actions = [
|
||||
let actions = [
|
||||
submitComplete({
|
||||
submittedChallenge: {
|
||||
username,
|
||||
@@ -79,9 +84,13 @@ function postChallenge(update, username) {
|
||||
updateComplete(),
|
||||
submitChallengeComplete()
|
||||
];
|
||||
// TODO(Post-MVP): separate endpoint for trophy submission?
|
||||
if (isTrophyMissing)
|
||||
actions.push(createFlashMessage(trophyMissingMessage));
|
||||
|
||||
if (challengeType === challengeTypes.msTrophy && type === 'error') {
|
||||
actions = [createFlashMessage(msTrophyError), submitChallengeError()];
|
||||
} else if (challengeType === challengeTypes.msTrophy) {
|
||||
actions.push(createFlashMessage(msTrophyVerified));
|
||||
}
|
||||
|
||||
return of(...actions);
|
||||
}),
|
||||
catchError(() => of(updateFailed(update), submitChallengeError()))
|
||||
@@ -176,7 +185,8 @@ const submitters = {
|
||||
backend: submitBackendChallenge,
|
||||
'project.frontEnd': submitProject,
|
||||
'project.backEnd': submitProject,
|
||||
exam: submitExam
|
||||
exam: submitExam,
|
||||
msTrophy: submitMsTrophy
|
||||
};
|
||||
|
||||
function submitExam(type, state) {
|
||||
@@ -197,6 +207,22 @@ function submitExam(type, state) {
|
||||
return empty();
|
||||
}
|
||||
|
||||
function submitMsTrophy(type, state) {
|
||||
if (type === actionTypes.submitChallenge) {
|
||||
const { id, challengeType } = challengeMetaSelector(state);
|
||||
|
||||
const { username } = userSelector(state);
|
||||
const challengeInfo = { id, challengeType };
|
||||
|
||||
const update = {
|
||||
endpoint: '/ms-trophy-challenge-completed',
|
||||
payload: challengeInfo
|
||||
};
|
||||
return postChallenge(update, username);
|
||||
}
|
||||
return empty();
|
||||
}
|
||||
|
||||
export default function completionEpic(action$, state$) {
|
||||
return action$.pipe(
|
||||
ofType(actionTypes.submitChallenge),
|
||||
@@ -238,7 +264,9 @@ export default function completionEpic(action$, state$) {
|
||||
action.type === submitActionTypes.submitComplete;
|
||||
|
||||
return submitter(type, state).pipe(
|
||||
concat(of(setIsAdvancing(!lastChallengeInBlock))),
|
||||
concat(
|
||||
of(setIsAdvancing(!lastChallengeInBlock), setIsProcessing(false))
|
||||
),
|
||||
mergeMap(x =>
|
||||
canAllowDonationRequest(state, x)
|
||||
? of(x, allowBlockDonationRequests({ superBlock, block }))
|
||||
|
||||
@@ -271,6 +271,12 @@ export function postUserToken(): Promise<ResponseWithData<void>> {
|
||||
return post('/user/user-token', {});
|
||||
}
|
||||
|
||||
export function postMsUsername(body: {
|
||||
msTranscriptUrl: string;
|
||||
}): Promise<ResponseWithData<void>> {
|
||||
return post('/user/ms-username', body);
|
||||
}
|
||||
|
||||
export function postSaveChallenge(body: {
|
||||
id: string;
|
||||
files: ChallengeFiles;
|
||||
@@ -368,3 +374,7 @@ export function putVerifyCert(
|
||||
export function deleteUserToken(): Promise<ResponseWithData<void>> {
|
||||
return deleteRequest('/user/user-token', {});
|
||||
}
|
||||
|
||||
export function deleteMsUsername(): Promise<ResponseWithData<void>> {
|
||||
return deleteRequest('/user/ms-username', {});
|
||||
}
|
||||
|
||||
@@ -5,9 +5,9 @@ export const standardErrorMessage = {
|
||||
message: FlashMessages.WentWrong
|
||||
};
|
||||
|
||||
export const trophyMissingMessage = {
|
||||
export const msTrophyError = {
|
||||
type: 'danger',
|
||||
message: FlashMessages.MSTrophyMissing
|
||||
message: FlashMessages.MsTrophyErr
|
||||
};
|
||||
|
||||
export const reallyWeirdErrorMessage = {
|
||||
@@ -24,3 +24,8 @@ export const certificateMissingErrorMessage = {
|
||||
type: 'danger',
|
||||
message: FlashMessages.CertificateMissing
|
||||
};
|
||||
|
||||
export const msTrophyVerified = {
|
||||
type: 'success',
|
||||
message: FlashMessages.MsTrophyVerified
|
||||
};
|
||||
|
||||
@@ -63,7 +63,12 @@ const toneUrls = {
|
||||
[FlashMessages.WrongName]: TRY_AGAIN,
|
||||
[FlashMessages.WrongUpdating]: TRY_AGAIN,
|
||||
[FlashMessages.WentWrong]: TRY_AGAIN,
|
||||
[FlashMessages.MSTrophyMissing]: TRY_AGAIN
|
||||
[FlashMessages.MsTrophyErr]: TRY_AGAIN,
|
||||
[FlashMessages.MsTrophyVerified]: CHAL_COMP,
|
||||
[FlashMessages.MsLinked]: CHAL_COMP,
|
||||
[FlashMessages.MsLinkErr]: TRY_AGAIN,
|
||||
[FlashMessages.MsUnlinked]: CHAL_COMP,
|
||||
[FlashMessages.MsUnlinkErr]: TRY_AGAIN
|
||||
} as const;
|
||||
|
||||
type ToneStates = keyof typeof toneUrls;
|
||||
|
||||
@@ -44,6 +44,11 @@ const exam = path.resolve(
|
||||
'../../src/templates/Challenges/exam/show.tsx'
|
||||
);
|
||||
|
||||
const msTrophy = path.resolve(
|
||||
__dirname,
|
||||
'../../src/templates/Challenges/ms-trophy/show.tsx'
|
||||
);
|
||||
|
||||
const views = {
|
||||
backend,
|
||||
classic,
|
||||
@@ -52,7 +57,8 @@ const views = {
|
||||
video,
|
||||
codeAlly,
|
||||
odin,
|
||||
exam
|
||||
exam,
|
||||
msTrophy
|
||||
// quiz: Quiz
|
||||
};
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@ const multifileCertProject = 14;
|
||||
const theOdinProject = 15;
|
||||
const colab = 16;
|
||||
const exam = 17;
|
||||
const msTrophyUrl = 18;
|
||||
const msTrophy = 18;
|
||||
const multipleChoice = 19;
|
||||
const python = 20;
|
||||
|
||||
@@ -41,7 +41,7 @@ export const challengeTypes = {
|
||||
theOdinProject,
|
||||
colab,
|
||||
exam,
|
||||
msTrophyUrl,
|
||||
msTrophy,
|
||||
multipleChoice,
|
||||
python
|
||||
};
|
||||
@@ -92,7 +92,7 @@ export const viewTypes = {
|
||||
[theOdinProject]: 'odin',
|
||||
[colab]: 'frontend',
|
||||
[exam]: 'exam',
|
||||
[msTrophyUrl]: 'frontend',
|
||||
[msTrophy]: 'msTrophy',
|
||||
[multipleChoice]: 'video',
|
||||
[python]: 'modern'
|
||||
};
|
||||
@@ -120,7 +120,7 @@ export const submitTypes = {
|
||||
[theOdinProject]: 'tests',
|
||||
[colab]: 'project.backEnd',
|
||||
[exam]: 'exam',
|
||||
[msTrophyUrl]: 'project.frontEnd',
|
||||
[msTrophy]: 'msTrophy',
|
||||
[multipleChoice]: 'tests',
|
||||
[python]: 'tests'
|
||||
};
|
||||
|
||||
+3
-3
@@ -20,15 +20,15 @@ Which of the following lines of code is a valid use of the conditional operator?
|
||||
|
||||
## --answers--
|
||||
|
||||
`int value = amount >= 10? 10: 20;`
|
||||
`int value = amount >= 10 ? 10 : 20;`
|
||||
|
||||
---
|
||||
|
||||
`int value = amount >= 10: 10? 20;`
|
||||
`int value = amount >= 10 : 10 ? 20;`
|
||||
|
||||
---
|
||||
|
||||
`int value = amount >= 10? 10| 20;`
|
||||
`int value = amount >= 10 ? 10 | 20;`
|
||||
|
||||
## --video-solution--
|
||||
|
||||
|
||||
+3
-13
@@ -3,21 +3,11 @@ id: 647f882207d29547b3bee1c0
|
||||
title: Trophy - Add Logic to C# Console Applications
|
||||
challengeType: 18
|
||||
dashedName: trophy-add-logic-to-c-sharp-console-applications
|
||||
msTrophyId: learn.wwl.get-started-c-sharp-part-3.trophy
|
||||
---
|
||||
|
||||
# --description--
|
||||
|
||||
Now that you've completed all of the "Add Logic to C# Console Applications" modules on Microsoft's learn platform, submit the URL to your trophy below.
|
||||
Now that you've completed all of the "Add Logic to C# Console Applications" challenges, you should have earned a trophy for it on Microsoft's learning platform.
|
||||
|
||||
Follow these instructions to find your trophy URL:
|
||||
|
||||
1. Go to <a href="https://learn.microsoft.com/users/me/achievements#badges-section" target="_blank">https://learn.microsoft.com/users/me/achievements#badges-section</a> using a browser you are logged into Microsoft with
|
||||
1. Find the trophy for "Add Logic to C# Console Applications" and click the "share" icon next to it
|
||||
1. Click the "Copy URL" button
|
||||
1. Paste the URL into the input below
|
||||
|
||||
The URL should look similar to this:
|
||||
|
||||
`https://learn.microsoft.com/en-us/training/achievements/learn.wwl.get-started-c-sharp-part-3.trophy?username=your-username&sharingId=your-sharing-id`
|
||||
|
||||
This trophy is required to qualify to take the certification exam.
|
||||
Link your Microsoft username to your freeCodeCamp account and click the "Verify Trophy" button below to complete the challenge. This trophy is required to qualify to take the certification exam.
|
||||
|
||||
+3
-13
@@ -3,21 +3,11 @@ id: 647f87dc07d29547b3bee1bf
|
||||
title: Trophy - Create and Run Simple C# Console Applications
|
||||
challengeType: 18
|
||||
dashedName: trophy-create-and-run-simple-c-sharp-console-applications
|
||||
msTrophyId: learn.wwl.get-started-c-sharp-part-2.trophy
|
||||
---
|
||||
|
||||
# --description--
|
||||
|
||||
Now that you've completed all of the "Create and Run Simple C# Console Applications" modules on Microsoft's learn platform, submit the URL to your trophy below.
|
||||
Now that you've completed all of the "Create and Run Simple C# Console Applications" challenges, you should have earned a trophy for it on Microsoft's learning platform.
|
||||
|
||||
Follow these instructions to find your trophy URL:
|
||||
|
||||
1. Go to <a href="https://learn.microsoft.com/users/me/achievements#badges-section" target="_blank">https://learn.microsoft.com/users/me/achievements#badges-section</a> using a browser you are logged into Microsoft with
|
||||
1. Find the trophy for "Create and Run Simple C# Console Applications" and click the "share" icon next to it
|
||||
1. Click the "Copy URL" button
|
||||
1. Paste the URL into the input below
|
||||
|
||||
The URL should look similar to this:
|
||||
|
||||
`https://learn.microsoft.com/en-us/training/achievements/learn.wwl.get-started-c-sharp-part-2.trophy?username=your-username&sharingId=your-sharing-id`
|
||||
|
||||
This trophy is required to qualify to take the certification exam.
|
||||
Link your Microsoft username to your freeCodeCamp account and click the "Verify Trophy" button below to complete the challenge. This trophy is required to qualify to take the certification exam.
|
||||
|
||||
+4
-2
@@ -18,9 +18,11 @@ This challenge will be partially completed on Microsoft's learn platform. Follow
|
||||
|
||||
Given the method signature,
|
||||
|
||||
`void Print(string name, string number = "", bool member = false)`,
|
||||
```clike
|
||||
void Print(string name, string number = "", bool member = false)
|
||||
```
|
||||
|
||||
which of the following options correctly uses named and optional arguments?
|
||||
Which of the following options correctly uses named and optional arguments?
|
||||
|
||||
## --answers--
|
||||
|
||||
|
||||
+3
-13
@@ -3,21 +3,11 @@ id: 647f877f07d29547b3bee1be
|
||||
title: Trophy - Create Methods in C# Console Applications
|
||||
challengeType: 18
|
||||
dashedName: trophy-create-methods-in-c-sharp-console-applications
|
||||
msTrophyId: learn.wwl.get-started-c-sharp-part-5.trophy
|
||||
---
|
||||
|
||||
# --description--
|
||||
|
||||
Now that you've completed all of the "Create Methods in C# Console Applications" modules on Microsoft's learn platform, submit the URL to your trophy below.
|
||||
Now that you've completed all of the "Create Methods in C# Console Applications" challenges, you should have earned a trophy for it on Microsoft's learning platform.
|
||||
|
||||
Follow these instructions to find your trophy URL:
|
||||
|
||||
1. Go to <a href="https://learn.microsoft.com/users/me/achievements#badges-section" target="_blank">https://learn.microsoft.com/users/me/achievements#badges-section</a> using a browser you are logged into Microsoft with
|
||||
1. Find the trophy for "Create Methods in C# Console Applications" and click the "share" icon next to it
|
||||
1. Click the "Copy URL" button
|
||||
1. Paste the URL into the input below
|
||||
|
||||
The URL should look similar to this:
|
||||
|
||||
`https://learn.microsoft.com/en-us/training/achievements/learn.wwl.get-started-c-sharp-part-5.trophy?username=your-username&sharingId=your-sharing-id`
|
||||
|
||||
This trophy is required to qualify to take the certification exam.
|
||||
Link your Microsoft username to your freeCodeCamp account and click the "Verify Trophy" button below to complete the challenge. This trophy is required to qualify to take the certification exam.
|
||||
|
||||
+3
-13
@@ -3,21 +3,11 @@ id: 647f86ff07d29547b3bee1bd
|
||||
title: Trophy - Debug C# Console Applications
|
||||
challengeType: 18
|
||||
dashedName: trophy-debug-c-sharp-console-applications
|
||||
msTrophyId: learn.wwl.get-started-c-sharp-part-6.trophy
|
||||
---
|
||||
|
||||
# --description--
|
||||
|
||||
Now that you've completed all of the "Debug C# Console Applications" modules on Microsoft's learn platform, submit the URL to your trophy below.
|
||||
Now that you've completed all of the "Debug C# Console Applications" challenges, you should have earned a trophy for it on Microsoft's learning platform.
|
||||
|
||||
Follow these instructions to find your trophy URL:
|
||||
|
||||
1. Go to <a href="https://learn.microsoft.com/users/me/achievements#badges-section" target="_blank">https://learn.microsoft.com/users/me/achievements#badges-section</a> using a browser you are logged into Microsoft with
|
||||
1. Find the trophy for "Debug C# Console Applications" and click the "share" icon next to it
|
||||
1. Click the "Copy URL" button
|
||||
1. Paste the URL into the input below
|
||||
|
||||
The URL should look similar to this:
|
||||
|
||||
`https://learn.microsoft.com/en-us/training/achievements/learn.wwl.get-started-c-sharp-part-6.trophy?username=your-username&sharingId=your-sharing-id`
|
||||
|
||||
This trophy is required to qualify to take the certification exam.
|
||||
Link your Microsoft username to your freeCodeCamp account and click the "Verify Trophy" button below to complete the challenge. This trophy is required to qualify to take the certification exam.
|
||||
|
||||
+3
-13
@@ -3,21 +3,11 @@ id: 647f867a07d29547b3bee1bc
|
||||
title: Trophy - Work with Variable Data in C# Console Applications
|
||||
challengeType: 18
|
||||
dashedName: trophy-work-with-variable-data-in-c-sharp-console-applications
|
||||
msTrophyId: learn.wwl.get-started-c-sharp-part-4.trophy
|
||||
---
|
||||
|
||||
# --description--
|
||||
|
||||
Now that you've completed all of the "Work with Variable Data in C# Console Applications" modules on Microsoft's learn platform, submit the URL to your trophy below.
|
||||
Now that you've completed all of the "Work with Variable Data in C# Console Applications" challenges, you should have earned a trophy for it on Microsoft's learning platform.
|
||||
|
||||
Follow these instructions to find your trophy URL:
|
||||
|
||||
1. Go to <a href="https://learn.microsoft.com/users/me/achievements#badges-section" target="_blank">https://learn.microsoft.com/users/me/achievements#badges-section</a> using a browser you are logged into Microsoft with
|
||||
1. Find the trophy for "Work with Variable Data in C# Console Applications" and click the "share" icon next to it
|
||||
1. Click the "Copy URL" button
|
||||
1. Paste the URL into the input below
|
||||
|
||||
The URL should look similar to this:
|
||||
|
||||
`https://learn.microsoft.com/en-us/training/achievements/learn.wwl.get-started-c-sharp-part-4.trophy?username=your-username&sharingId=your-sharing-id`
|
||||
|
||||
This trophy is required to qualify to take the certification exam.
|
||||
Link your Microsoft username to your freeCodeCamp account and click the "Verify Trophy" button below to complete the challenge. This trophy is required to qualify to take the certification exam.
|
||||
|
||||
+4
-1
@@ -18,7 +18,10 @@ This challenge will be partially completed on Microsoft's learn platform. Follow
|
||||
|
||||
What is wrong with the following code?
|
||||
|
||||
`int sophiaSum; Console.WriteLine("Sophia: " + sophiaSum);`
|
||||
```clike
|
||||
int sophiaSum;
|
||||
Console.WriteLine("Sophia: " + sophiaSum);
|
||||
```
|
||||
|
||||
## --answers--
|
||||
|
||||
|
||||
+3
-1
@@ -18,7 +18,9 @@ This challenge will be partially completed on Microsoft's learn platform. Follow
|
||||
|
||||
What is the value of the following result?
|
||||
|
||||
`int result = 3 + 1 * 5 / 2;`
|
||||
```clike
|
||||
int result = 3 + 1 * 5 / 2;
|
||||
```
|
||||
|
||||
## --answers--
|
||||
|
||||
|
||||
+3
-3
@@ -20,15 +20,15 @@ Which of the following lines of code correctly uses string interpolation assumin
|
||||
|
||||
## --answers--
|
||||
|
||||
'`Console.WriteLine(@"My value: {value}");`'
|
||||
`Console.WriteLine(@"My value: {value}");`
|
||||
|
||||
---
|
||||
|
||||
'`Console.WriteLine($"My value: {value}");`'
|
||||
`Console.WriteLine($"My value: {value}");`
|
||||
|
||||
---
|
||||
|
||||
'`Console.WriteLine(@"My value: [value]");`'
|
||||
`Console.WriteLine(@"My value: [value]");`
|
||||
|
||||
## --video-solution--
|
||||
|
||||
|
||||
+3
-13
@@ -3,21 +3,11 @@ id: 647f85d407d29547b3bee1bb
|
||||
title: Trophy - Write Your First Code Using C#
|
||||
challengeType: 18
|
||||
dashedName: trophy-write-your-first-code-using-c-sharp
|
||||
msTrophyId: learn.wwl.get-started-c-sharp-part-1.trophy
|
||||
---
|
||||
|
||||
# --description--
|
||||
|
||||
Now that you've completed all of the "Write Your First Code Using C#" modules on Microsoft's learn platform, submit the URL to your trophy below.
|
||||
Now that you've completed all of the "Write Your First Code Using C#" challenges, you should have earned a trophy for it on Microsoft's learning platform.
|
||||
|
||||
Follow these instructions to find your trophy URL:
|
||||
|
||||
1. Go to <a href="https://learn.microsoft.com/users/me/achievements#badges-section" target="_blank">https://learn.microsoft.com/users/me/achievements#badges-section</a> using a browser you are logged into Microsoft with
|
||||
1. Find the trophy for "Write Your First Code Using C#" and click the "share" icon next to it
|
||||
1. Click the "Copy URL" button
|
||||
1. Paste the URL into the input below
|
||||
|
||||
The URL should look similar to this:
|
||||
|
||||
`https://learn.microsoft.com/en-us/training/achievements/learn.wwl.get-started-c-sharp-part-1.trophy?username=your-username&sharingId=your-sharing-id`
|
||||
|
||||
This trophy is required to qualify to take the certification exam.
|
||||
Link your Microsoft username to your freeCodeCamp account and click the "Verify Trophy" button below to complete the challenge. This trophy is required to qualify to take the certification exam.
|
||||
|
||||
@@ -58,6 +58,10 @@ const schema = Joi.object()
|
||||
isComingSoon: Joi.bool(),
|
||||
isLocked: Joi.bool(),
|
||||
isPrivate: Joi.bool(),
|
||||
msTrophyId: Joi.when('challengeType', {
|
||||
is: [challengeTypes.msTrophy],
|
||||
then: Joi.string().required()
|
||||
}),
|
||||
notes: Joi.string().allow(''),
|
||||
order: Joi.number(),
|
||||
prerequisites: Joi.when('challengeType', {
|
||||
|
||||
+1
-64
@@ -3,8 +3,7 @@ import {
|
||||
usernameTooShort,
|
||||
validationSuccess,
|
||||
usernameIsHttpStatusCode,
|
||||
invalidCharError,
|
||||
isMicrosoftLearnLink
|
||||
invalidCharError
|
||||
} from './validate';
|
||||
|
||||
function inRange(num: number, range: number[]) {
|
||||
@@ -57,65 +56,3 @@ describe('isValidUsername', () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
const baseUrl =
|
||||
'https://learn.microsoft.com/en-us/training/achievements/learn.wwl.get-started-c-sharp-part-1.trophy';
|
||||
describe('form-validators', () => {
|
||||
describe('isMicrosoftLearnLink', () => {
|
||||
it('should reject links to domains other than learn.microsoft.com', () => {
|
||||
{
|
||||
expect(isMicrosoftLearnLink('https://lean.microsoft.com')).toBe(false);
|
||||
expect(isMicrosoftLearnLink('https://learn.microsft.com')).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it('should reject links without a sharingId', () => {
|
||||
expect(isMicrosoftLearnLink(`${baseUrl}?username=moT01`)).toBe(false);
|
||||
|
||||
expect(isMicrosoftLearnLink(`${baseUrl}?username=moT01&sharingId=`)).toBe(
|
||||
false
|
||||
);
|
||||
});
|
||||
|
||||
it('should reject links without a username', () => {
|
||||
expect(isMicrosoftLearnLink(`${baseUrl}?sharingId=Whatever`)).toBe(false);
|
||||
expect(isMicrosoftLearnLink(`${baseUrl}?sharingId=123&username=`)).toBe(
|
||||
false
|
||||
);
|
||||
});
|
||||
|
||||
it('should reject links without the /training/achievements/ subpath', () => {
|
||||
expect(
|
||||
isMicrosoftLearnLink(
|
||||
'https://learn.microsoft.com/en-us/achievements/learn.wwl.get-started-c-sharp-part-1.trophy?username=moT01&sharingId=E2EF453C1F9208B8'
|
||||
)
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('should reject links with the wrong trophy subpath', () => {
|
||||
// missing .trophy
|
||||
expect(
|
||||
isMicrosoftLearnLink(
|
||||
'https://learn.microsoft.com/en-us/training/achievements/learn.wwl.get-started-c-sharp-part-1?username=moT01&sharingId=E2EF453C1F9208B8'
|
||||
)
|
||||
).toBe(false);
|
||||
// no number
|
||||
expect(
|
||||
isMicrosoftLearnLink(
|
||||
'https://learn.microsoft.com/en-us/training/achievements/learn.wwl.get-started-c-sharp-part-a.trophy?username=moT01&sharingId=E2EF453C1F9208B8'
|
||||
)
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it.each(['en-us', 'fr-fr', 'lang-country'])(
|
||||
'should accept links with the %s locale',
|
||||
locale => {
|
||||
expect(
|
||||
isMicrosoftLearnLink(
|
||||
`https://learn.microsoft.com/${locale}/training/achievements/learn.wwl.get-started-c-sharp-part-1.trophy?username=moT01&sharingId=E2EF453C1F9208B8`
|
||||
)
|
||||
).toBe(true);
|
||||
}
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -36,21 +36,3 @@ export const isValidUsername = (str: string): Validated => {
|
||||
if (isHttpStatusCode(str)) return usernameIsHttpStatusCode;
|
||||
return validationSuccess;
|
||||
};
|
||||
|
||||
// example link: https://learn.microsoft.com/en-us/training/achievements/learn.wwl.get-started-c-sharp-part-1.trophy?username=moT01&sharingId=E2EF453C1F9208B8
|
||||
export const isMicrosoftLearnLink = (value: string): boolean => {
|
||||
let url;
|
||||
try {
|
||||
url = new URL(value);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
|
||||
const correctDomain = url.hostname === 'learn.microsoft.com';
|
||||
const correctPath = !!url.pathname.match(
|
||||
/^\/[^/]+\/training\/achievements\/learn\.wwl\.get-started-c-sharp-part-\d\.trophy$/
|
||||
);
|
||||
const hasSharingId = !!url.searchParams.get('sharingId');
|
||||
const hasUsername = !!url.searchParams.get('username');
|
||||
return correctDomain && correctPath && hasSharingId && hasUsername;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user