From 9a1895d2e366d58a3f3af42c0e83ff25b7306626 Mon Sep 17 00:00:00 2001 From: Tom <20648924+moT01@users.noreply.github.com> Date: Sat, 26 Aug 2023 07:57:02 -0500 Subject: [PATCH] feat(client/api): validate ms users (#51372) Co-authored-by: Muhammed Mustafa --- api-server/src/common/models/user.json | 5 + api-server/src/server/boot/challenge.js | 108 ++++++-- api-server/src/server/boot/user.js | 121 ++++++++- .../src/server/middlewares/ms-username.js | 20 ++ api-server/src/server/model-config.json | 4 + api-server/src/server/models/ms-username.json | 20 ++ api-server/src/server/utils/ms-learn-utils.js | 27 -- .../src/server/utils/ms-learn-utils.test.js | 50 ---- client/gatsby-node.js | 2 + client/i18n/locales/english/translations.json | 26 +- .../components/Flash/redux/flash-messages.ts | 7 +- .../components/formHelpers/form-fields.tsx | 8 +- .../formHelpers/form-validators.tsx | 4 - client/src/components/formHelpers/form.tsx | 13 +- client/src/redux/action-types.js | 4 + client/src/redux/actions.js | 6 + client/src/redux/index.js | 24 +- client/src/redux/ms-username-saga.js | 65 +++++ client/src/redux/prop-types.ts | 1 + client/src/redux/selectors.js | 8 + .../Challenges/ms-trophy/link-ms-user.css | 12 + .../Challenges/ms-trophy/link-ms-user.tsx | 167 ++++++++++++ .../templates/Challenges/ms-trophy/show.tsx | 240 ++++++++++++++++++ .../Challenges/projects/frontend/show.tsx | 9 +- .../Challenges/projects/solution-form.tsx | 12 +- .../Challenges/redux/completion-epic.js | 44 +++- client/src/utils/ajax.ts | 10 + client/src/utils/error-messages.ts | 9 +- client/src/utils/tone/index.ts | 7 +- client/utils/gatsby/challenge-page-creator.js | 8 +- config/challenge-types.ts | 8 +- ...xpressions-to-make-decisions-in-c-sharp.md | 6 +- ...d-logic-to-c-sharp-console-applications.md | 16 +- ...run-simple-c-sharp-console-applications.md | 16 +- .../create-c-sharp-methods-with-parameters.md | 6 +- ...methods-in-c-sharp-console-applications.md | 16 +- ...ophy-debug-c-sharp-console-applications.md | 16 +- ...le-data-in-c-sharp-console-applications.md | 16 +- ...ject-calculate-and-print-student-grades.md | 5 +- ...-basic-operations-on-numbers-in-c-sharp.md | 4 +- ...form-basic-string-formatting-in-c-sharp.md | 6 +- ...phy-write-your-first-code-using-c-sharp.md | 16 +- curriculum/schema/challenge-schema.js | 4 + utils/validate.test.ts | 65 +---- utils/validate.ts | 18 -- 45 files changed, 927 insertions(+), 332 deletions(-) create mode 100644 api-server/src/server/middlewares/ms-username.js create mode 100644 api-server/src/server/models/ms-username.json delete mode 100644 api-server/src/server/utils/ms-learn-utils.js delete mode 100644 api-server/src/server/utils/ms-learn-utils.test.js create mode 100644 client/src/redux/ms-username-saga.js create mode 100644 client/src/templates/Challenges/ms-trophy/link-ms-user.css create mode 100644 client/src/templates/Challenges/ms-trophy/link-ms-user.tsx create mode 100644 client/src/templates/Challenges/ms-trophy/show.tsx diff --git a/api-server/src/common/models/user.json b/api-server/src/common/models/user.json index 06a58635d79..ecb01b0e96e 100644 --- a/api-server/src/common/models/user.json +++ b/api-server/src/common/models/user.json @@ -423,6 +423,11 @@ "type": "hasMany", "model": "UserToken", "foreignKey": "userId" + }, + "msUsernames": { + "type": "hasMany", + "model": "MsUsername", + "foreignKey": "userId" } }, "acls": [ diff --git a/api-server/src/server/boot/challenge.js b/api-server/src/server/boot/challenge.js index b8e1a116fe7..b30910869ba 100644 --- a/api-server/src/server/boot/challenge.js +++ b/api-server/src/server/boot/challenge.js @@ -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; diff --git a/api-server/src/server/boot/user.js b/api-server/src/server/boot/user.js index 7f76564b8b8..bf5cd2dabb5 100644 --- a/api-server/src/server/boot/user.js +++ b/api-server/src/server/boot/user.js @@ -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 diff --git a/api-server/src/server/middlewares/ms-username.js b/api-server/src/server/middlewares/ms-username.js new file mode 100644 index 00000000000..710bf187e32 --- /dev/null +++ b/api-server/src/server/middlewares/ms-username.js @@ -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(); + }; +} diff --git a/api-server/src/server/model-config.json b/api-server/src/server/model-config.json index d7720094385..76270b42cef 100644 --- a/api-server/src/server/model-config.json +++ b/api-server/src/server/model-config.json @@ -43,6 +43,10 @@ "dataSource": "db", "public": false }, + "MsUsername": { + "dataSource": "db", + "public": false + }, "RoleMapping": { "dataSource": "db", "public": false diff --git a/api-server/src/server/models/ms-username.json b/api-server/src/server/models/ms-username.json new file mode 100644 index 00000000000..4c3cc583a70 --- /dev/null +++ b/api-server/src/server/models/ms-username.json @@ -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": {} +} diff --git a/api-server/src/server/utils/ms-learn-utils.js b/api-server/src/server/utils/ms-learn-utils.js deleted file mode 100644 index 0f13b66eae4..00000000000 --- a/api-server/src/server/utils/ms-learn-utils.js +++ /dev/null @@ -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; -}; diff --git a/api-server/src/server/utils/ms-learn-utils.test.js b/api-server/src/server/utils/ms-learn-utils.test.js deleted file mode 100644 index fa942428234..00000000000 --- a/api-server/src/server/utils/ms-learn-utils.test.js +++ /dev/null @@ -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); - }); - }); -}); diff --git a/client/gatsby-node.js b/client/gatsby-node.js index 5b72129beca..be4bedf1dca 100644 --- a/client/gatsby-node.js +++ b/client/gatsby-node.js @@ -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 diff --git a/client/i18n/locales/english/translations.json b/client/i18n/locales/english/translations.json index 00e1372f5b8..c9d8688ddfe 100644 --- a/client/i18n/locales/english/translations.json +++ b/client/i18n/locales/english/translations.json @@ -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", + "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", diff --git a/client/src/components/Flash/redux/flash-messages.ts b/client/src/components/Flash/redux/flash-messages.ts index 4c9f4d1259b..8487e4eff1f 100644 --- a/client/src/components/Flash/redux/flash-messages.ts +++ b/client/src/components/Flash/redux/flash-messages.ts @@ -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', diff --git a/client/src/components/formHelpers/form-fields.tsx b/client/src/components/formHelpers/form-fields.tsx index ad871e3966e..e138d028b58 100644 --- a/client/src/components/formHelpers/form-fields.tsx +++ b/client/src/components/formHelpers/form-fields.tsx @@ -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 || diff --git a/client/src/components/formHelpers/form-validators.tsx b/client/src/components/formHelpers/form-validators.tsx index 3dfddec3cfc..53d95087d4f 100644 --- a/client/src/components/formHelpers/form-validators.tsx +++ b/client/src/components/formHelpers/form-validators.tsx @@ -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) ? validation.ms-learn-link : null; - export const editorValidator: Validator = value => editorRegex.test(value) ? validation.editor-url : null; diff --git a/client/src/components/formHelpers/form.tsx b/client/src/components/formHelpers/form.tsx index ad98380acde..182b0ba800f 100644 --- a/client/src/components/formHelpers/form.tsx +++ b/client/src/components/formHelpers/form.tsx @@ -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) { diff --git a/client/src/redux/action-types.js b/client/src/redux/action-types.js index 24a8fef7477..eb76b6168c0 100644 --- a/client/src/redux/action-types.js +++ b/client/src/redux/action-types.js @@ -26,6 +26,10 @@ export const actionTypes = createTypes( 'startExam', 'stopExam', 'clearExamResults', + 'linkMsUsername', + 'unlinkMsUsername', + 'setMsUsername', + 'setIsProcessing', 'submitComplete', 'updateComplete', 'updateFailed', diff --git a/client/src/redux/actions.js b/client/src/redux/actions.js index 1d1309b85a2..19b27272c35 100644 --- a/client/src/redux/actions.js +++ b/client/src/redux/actions.js @@ -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); diff --git a/client/src/redux/index.js b/client/src/redux/index.js index 87b7696b801..5eb2ea6f2cd 100644 --- a/client/src/redux/index.js +++ b/client/src/redux/index.js @@ -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 { diff --git a/client/src/redux/ms-username-saga.js b/client/src/redux/ms-username-saga.js new file mode 100644 index 00000000000..843fd8a3395 --- /dev/null +++ b/client/src/redux/ms-username-saga.js @@ -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) + ]; +} diff --git a/client/src/redux/prop-types.ts b/client/src/redux/prop-types.ts index 5ca91a6cc25..e6f55f8e5f5 100644 --- a/client/src/redux/prop-types.ts +++ b/client/src/redux/prop-types.ts @@ -123,6 +123,7 @@ export type ChallengeNode = { owner: string; type: string; }; + msTrophyId: string; notes: string; prerequisites: PrerequisiteChallenge[]; removeComments: boolean; diff --git a/client/src/redux/selectors.js b/client/src/redux/selectors.js index f2cdca663ba..3e1ad582c8b 100644 --- a/client/src/redux/selectors.js +++ b/client/src/redux/selectors.js @@ -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 diff --git a/client/src/templates/Challenges/ms-trophy/link-ms-user.css b/client/src/templates/Challenges/ms-trophy/link-ms-user.css new file mode 100644 index 00000000000..c59bf7a65db --- /dev/null +++ b/client/src/templates/Challenges/ms-trophy/link-ms-user.css @@ -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; +} diff --git a/client/src/templates/Challenges/ms-trophy/link-ms-user.tsx b/client/src/templates/Challenges/ms-trophy/link-ms-user.tsx new file mode 100644 index 00000000000..60c5109c041 --- /dev/null +++ b/client/src/templates/Challenges/ms-trophy/link-ms-user.tsx @@ -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(''); + + function handleLinkUsername(e: React.FormEvent) { + e.preventDefault(); + setIsProcessing(true); + linkMsUsername({ msTranscriptUrl }); + } + + function handleInputChange(e: React.ChangeEvent) { + e.preventDefault(); + setMsTranscriptUrl(e.target.value); + } + + return !isSignedIn ? ( + <> +

{t('learn.ms.link-header')}

+ + +

{t('learn.ms.link-signin')}

+ + + ) : ( + <> + {msUsername ? ( + <> +

{t('learn.ms.linked', { msUsername })}

+ + + ) : ( +
+

{t('learn.ms.link-header')}

+ + +

{t('learn.ms.unlinked')}

+
    +
  1. + + + placeholder + + +
  2. +
  3. {t('learn.ms.link-li-2')}
  4. +
  5. {t('learn.ms.link-li-3')}
  6. +
  7. {t('learn.ms.link-li-4')}
  8. +
  9. {t('learn.ms.link-li-5')}
  10. +
+ + +
+ + + {t('learn.ms.transcript-label')} + + + + +
+
+ )} + + ); +} + +LinkMsUser.displayName = 'LinkMsUser'; + +export default connect( + mapStateToProps, + mapDispatchToProps +)(withTranslation()(LinkMsUser)); diff --git a/client/src/templates/Challenges/ms-trophy/show.tsx b/client/src/templates/Challenges/ms-trophy/show.tsx new file mode 100644 index 00000000000..8ed3689bbd9 --- /dev/null +++ b/client/src/templates/Challenges/ms-trophy/show.tsx @@ -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 { + 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 ( + (this._container = c)} + nextChallengePath={nextChallengePath} + prevChallengePath={prevChallengePath} + > + + + + + + + + {title} + + + +
+ +
+ + + +
+
+
+
+ ); + } +} + +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 + } + } + } +`; diff --git a/client/src/templates/Challenges/projects/frontend/show.tsx b/client/src/templates/Challenges/projects/frontend/show.tsx index f120121a0a1..2f12cbc12aa 100644 --- a/client/src/templates/Challenges/projects/frontend/show.tsx +++ b/client/src/templates/Challenges/projects/frontend/show.tsx @@ -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 { onSubmit={this.handleSubmit} updateSolutionForm={updateSolutionFormValues} /> - {challengeType !== challengeTypes.msTrophyUrl && ( - - )} +
diff --git a/client/src/templates/Challenges/projects/solution-form.tsx b/client/src/templates/Challenges/projects/solution-form.tsx index 12ae2ae23cd..50979e35774 100644 --- a/client/src/templates/Challenges/projects/solution-form.tsx +++ b/client/src/templates/Challenges/projects/solution-form.tsx @@ -53,7 +53,6 @@ export class SolutionForm extends Component { { 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 { }, required: ['solution'], isEditorLinkAllowed: false, - isLocalLinkAllowed: false, - isMicrosoftLearnLink: false + isLocalLinkAllowed: false }; let formFields = solutionField; @@ -108,14 +106,6 @@ export class SolutionForm extends Component { 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 = diff --git a/client/src/templates/Challenges/redux/completion-epic.js b/client/src/templates/Challenges/redux/completion-epic.js index e93873c8bf0..da73bbedb89 100644 --- a/client/src/templates/Challenges/redux/completion-epic.js +++ b/client/src/templates/Challenges/redux/completion-epic.js @@ -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 })) diff --git a/client/src/utils/ajax.ts b/client/src/utils/ajax.ts index 7e92fbc1f74..314a986bba9 100644 --- a/client/src/utils/ajax.ts +++ b/client/src/utils/ajax.ts @@ -271,6 +271,12 @@ export function postUserToken(): Promise> { return post('/user/user-token', {}); } +export function postMsUsername(body: { + msTranscriptUrl: string; +}): Promise> { + 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> { return deleteRequest('/user/user-token', {}); } + +export function deleteMsUsername(): Promise> { + return deleteRequest('/user/ms-username', {}); +} diff --git a/client/src/utils/error-messages.ts b/client/src/utils/error-messages.ts index 238d479d5be..9d755a0085e 100644 --- a/client/src/utils/error-messages.ts +++ b/client/src/utils/error-messages.ts @@ -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 +}; diff --git a/client/src/utils/tone/index.ts b/client/src/utils/tone/index.ts index 7cf38103f97..179c6a45c6b 100644 --- a/client/src/utils/tone/index.ts +++ b/client/src/utils/tone/index.ts @@ -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; diff --git a/client/utils/gatsby/challenge-page-creator.js b/client/utils/gatsby/challenge-page-creator.js index 19f03e964f6..8e8f5bc37ce 100644 --- a/client/utils/gatsby/challenge-page-creator.js +++ b/client/utils/gatsby/challenge-page-creator.js @@ -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 }; diff --git a/config/challenge-types.ts b/config/challenge-types.ts index caecbfc4332..b9cceeb2fc8 100644 --- a/config/challenge-types.ts +++ b/config/challenge-types.ts @@ -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' }; diff --git a/curriculum/challenges/english/19-foundational-c-sharp-with-microsoft/add-logic-to-c-sharp-console-applications/evaluate-boolean-expressions-to-make-decisions-in-c-sharp.md b/curriculum/challenges/english/19-foundational-c-sharp-with-microsoft/add-logic-to-c-sharp-console-applications/evaluate-boolean-expressions-to-make-decisions-in-c-sharp.md index 3d48e953996..47a0c486d1c 100644 --- a/curriculum/challenges/english/19-foundational-c-sharp-with-microsoft/add-logic-to-c-sharp-console-applications/evaluate-boolean-expressions-to-make-decisions-in-c-sharp.md +++ b/curriculum/challenges/english/19-foundational-c-sharp-with-microsoft/add-logic-to-c-sharp-console-applications/evaluate-boolean-expressions-to-make-decisions-in-c-sharp.md @@ -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-- diff --git a/curriculum/challenges/english/19-foundational-c-sharp-with-microsoft/add-logic-to-c-sharp-console-applications/trophy-add-logic-to-c-sharp-console-applications.md b/curriculum/challenges/english/19-foundational-c-sharp-with-microsoft/add-logic-to-c-sharp-console-applications/trophy-add-logic-to-c-sharp-console-applications.md index 24f758c359d..709027461cd 100644 --- a/curriculum/challenges/english/19-foundational-c-sharp-with-microsoft/add-logic-to-c-sharp-console-applications/trophy-add-logic-to-c-sharp-console-applications.md +++ b/curriculum/challenges/english/19-foundational-c-sharp-with-microsoft/add-logic-to-c-sharp-console-applications/trophy-add-logic-to-c-sharp-console-applications.md @@ -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 https://learn.microsoft.com/users/me/achievements#badges-section 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. diff --git a/curriculum/challenges/english/19-foundational-c-sharp-with-microsoft/create-and-run-simple-c-sharp-console-applications/trophy-create-and-run-simple-c-sharp-console-applications.md b/curriculum/challenges/english/19-foundational-c-sharp-with-microsoft/create-and-run-simple-c-sharp-console-applications/trophy-create-and-run-simple-c-sharp-console-applications.md index 79faabc35ec..1f2d2911618 100644 --- a/curriculum/challenges/english/19-foundational-c-sharp-with-microsoft/create-and-run-simple-c-sharp-console-applications/trophy-create-and-run-simple-c-sharp-console-applications.md +++ b/curriculum/challenges/english/19-foundational-c-sharp-with-microsoft/create-and-run-simple-c-sharp-console-applications/trophy-create-and-run-simple-c-sharp-console-applications.md @@ -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 https://learn.microsoft.com/users/me/achievements#badges-section 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. diff --git a/curriculum/challenges/english/19-foundational-c-sharp-with-microsoft/create-methods-in-c-sharp-console-applications/create-c-sharp-methods-with-parameters.md b/curriculum/challenges/english/19-foundational-c-sharp-with-microsoft/create-methods-in-c-sharp-console-applications/create-c-sharp-methods-with-parameters.md index d0ea7e05106..deef4a96635 100644 --- a/curriculum/challenges/english/19-foundational-c-sharp-with-microsoft/create-methods-in-c-sharp-console-applications/create-c-sharp-methods-with-parameters.md +++ b/curriculum/challenges/english/19-foundational-c-sharp-with-microsoft/create-methods-in-c-sharp-console-applications/create-c-sharp-methods-with-parameters.md @@ -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-- diff --git a/curriculum/challenges/english/19-foundational-c-sharp-with-microsoft/create-methods-in-c-sharp-console-applications/trophy-create-methods-in-c-sharp-console-applications.md b/curriculum/challenges/english/19-foundational-c-sharp-with-microsoft/create-methods-in-c-sharp-console-applications/trophy-create-methods-in-c-sharp-console-applications.md index 1ccee355dd3..a2fa4877ce6 100644 --- a/curriculum/challenges/english/19-foundational-c-sharp-with-microsoft/create-methods-in-c-sharp-console-applications/trophy-create-methods-in-c-sharp-console-applications.md +++ b/curriculum/challenges/english/19-foundational-c-sharp-with-microsoft/create-methods-in-c-sharp-console-applications/trophy-create-methods-in-c-sharp-console-applications.md @@ -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 https://learn.microsoft.com/users/me/achievements#badges-section 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. diff --git a/curriculum/challenges/english/19-foundational-c-sharp-with-microsoft/debug-c-sharp-console-applications/trophy-debug-c-sharp-console-applications.md b/curriculum/challenges/english/19-foundational-c-sharp-with-microsoft/debug-c-sharp-console-applications/trophy-debug-c-sharp-console-applications.md index ade2bde35ed..0ad0629cd57 100644 --- a/curriculum/challenges/english/19-foundational-c-sharp-with-microsoft/debug-c-sharp-console-applications/trophy-debug-c-sharp-console-applications.md +++ b/curriculum/challenges/english/19-foundational-c-sharp-with-microsoft/debug-c-sharp-console-applications/trophy-debug-c-sharp-console-applications.md @@ -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 https://learn.microsoft.com/users/me/achievements#badges-section 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. diff --git a/curriculum/challenges/english/19-foundational-c-sharp-with-microsoft/work-with-variable-data-in-c-sharp-console-applications/trophy-work-with-variable-data-in-c-sharp-console-applications.md b/curriculum/challenges/english/19-foundational-c-sharp-with-microsoft/work-with-variable-data-in-c-sharp-console-applications/trophy-work-with-variable-data-in-c-sharp-console-applications.md index 03b7a62a073..d556dae8bb6 100644 --- a/curriculum/challenges/english/19-foundational-c-sharp-with-microsoft/work-with-variable-data-in-c-sharp-console-applications/trophy-work-with-variable-data-in-c-sharp-console-applications.md +++ b/curriculum/challenges/english/19-foundational-c-sharp-with-microsoft/work-with-variable-data-in-c-sharp-console-applications/trophy-work-with-variable-data-in-c-sharp-console-applications.md @@ -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 https://learn.microsoft.com/users/me/achievements#badges-section 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. diff --git a/curriculum/challenges/english/19-foundational-c-sharp-with-microsoft/write-your-first-code-using-c-sharp/guided-project-calculate-and-print-student-grades.md b/curriculum/challenges/english/19-foundational-c-sharp-with-microsoft/write-your-first-code-using-c-sharp/guided-project-calculate-and-print-student-grades.md index 8e2307255b1..d7980f1b170 100644 --- a/curriculum/challenges/english/19-foundational-c-sharp-with-microsoft/write-your-first-code-using-c-sharp/guided-project-calculate-and-print-student-grades.md +++ b/curriculum/challenges/english/19-foundational-c-sharp-with-microsoft/write-your-first-code-using-c-sharp/guided-project-calculate-and-print-student-grades.md @@ -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-- diff --git a/curriculum/challenges/english/19-foundational-c-sharp-with-microsoft/write-your-first-code-using-c-sharp/perform-basic-operations-on-numbers-in-c-sharp.md b/curriculum/challenges/english/19-foundational-c-sharp-with-microsoft/write-your-first-code-using-c-sharp/perform-basic-operations-on-numbers-in-c-sharp.md index 9b11808405d..80398319ef8 100644 --- a/curriculum/challenges/english/19-foundational-c-sharp-with-microsoft/write-your-first-code-using-c-sharp/perform-basic-operations-on-numbers-in-c-sharp.md +++ b/curriculum/challenges/english/19-foundational-c-sharp-with-microsoft/write-your-first-code-using-c-sharp/perform-basic-operations-on-numbers-in-c-sharp.md @@ -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-- diff --git a/curriculum/challenges/english/19-foundational-c-sharp-with-microsoft/write-your-first-code-using-c-sharp/perform-basic-string-formatting-in-c-sharp.md b/curriculum/challenges/english/19-foundational-c-sharp-with-microsoft/write-your-first-code-using-c-sharp/perform-basic-string-formatting-in-c-sharp.md index 82ef2c897b0..177487051ef 100644 --- a/curriculum/challenges/english/19-foundational-c-sharp-with-microsoft/write-your-first-code-using-c-sharp/perform-basic-string-formatting-in-c-sharp.md +++ b/curriculum/challenges/english/19-foundational-c-sharp-with-microsoft/write-your-first-code-using-c-sharp/perform-basic-string-formatting-in-c-sharp.md @@ -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-- diff --git a/curriculum/challenges/english/19-foundational-c-sharp-with-microsoft/write-your-first-code-using-c-sharp/trophy-write-your-first-code-using-c-sharp.md b/curriculum/challenges/english/19-foundational-c-sharp-with-microsoft/write-your-first-code-using-c-sharp/trophy-write-your-first-code-using-c-sharp.md index fa0299b12b6..4a0d15770c2 100644 --- a/curriculum/challenges/english/19-foundational-c-sharp-with-microsoft/write-your-first-code-using-c-sharp/trophy-write-your-first-code-using-c-sharp.md +++ b/curriculum/challenges/english/19-foundational-c-sharp-with-microsoft/write-your-first-code-using-c-sharp/trophy-write-your-first-code-using-c-sharp.md @@ -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 https://learn.microsoft.com/users/me/achievements#badges-section 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. diff --git a/curriculum/schema/challenge-schema.js b/curriculum/schema/challenge-schema.js index 69bc8e84830..ee9ee91ad92 100644 --- a/curriculum/schema/challenge-schema.js +++ b/curriculum/schema/challenge-schema.js @@ -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', { diff --git a/utils/validate.test.ts b/utils/validate.test.ts index 40626bc39a7..9ed026e2ef5 100644 --- a/utils/validate.test.ts +++ b/utils/validate.test.ts @@ -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); - } - ); - }); -}); diff --git a/utils/validate.ts b/utils/validate.ts index dd4e01439a8..12a1fa76019 100644 --- a/utils/validate.ts +++ b/utils/validate.ts @@ -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; -};