diff --git a/client/i18n/locales/english/translations.json b/client/i18n/locales/english/translations.json index ef9cfd61e51..ed73a5cfd91 100644 --- a/client/i18n/locales/english/translations.json +++ b/client/i18n/locales/english/translations.json @@ -450,7 +450,8 @@ "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" + "transcript-label": "Your Microsoft Transcript Link", + "invalid-transcript": "Your transcript link is not correct, it should have the following form: <1>https://learn.microsoft.com/LOCALE/users/USERNAME/transcript/ID - check the UPPERCASE items in your link are correct." } }, "donate": { diff --git a/client/src/templates/Challenges/ms-trophy/link-ms-user.tsx b/client/src/templates/Challenges/ms-trophy/link-ms-user.tsx index 60c5109c041..78b20e579bd 100644 --- a/client/src/templates/Challenges/ms-trophy/link-ms-user.tsx +++ b/client/src/templates/Challenges/ms-trophy/link-ms-user.tsx @@ -3,16 +3,17 @@ import { Button, FormGroup, ControlLabel, - FormControl + FormControl, + HelpBlock } from '@freecodecamp/react-bootstrap'; -import { connect } from 'react-redux'; +import { ConnectedProps, 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 { Trans, useTranslation } from 'react-i18next'; +import { Spacer } from '../../../components/helpers'; +import { isMicrosoftTranscriptLink } from '../../../../../utils/validate'; import { linkMsUsername, unlinkMsUsername, @@ -27,7 +28,7 @@ import Login from '../../../components/Header/components/login'; import './link-ms-user.css'; -const mapStateToProps = createSelector( +const mapState = createSelector( isSignedInSelector, msUsernameSelector, isProcessingSelector, @@ -42,7 +43,7 @@ const mapStateToProps = createSelector( }) ); -const mapDispatchToProps = (dispatch: Dispatch) => +const mapDispatch = (dispatch: Dispatch) => bindActionCreators( { linkMsUsername, @@ -52,26 +53,20 @@ const mapDispatchToProps = (dispatch: Dispatch) => dispatch ); -interface LinkMsUserProps { - isSignedIn: boolean; - msUsername: string | undefined | null; - linkMsUsername: (arg0: { msTranscriptUrl: string }) => void; - unlinkMsUsername: () => void; - isProcessing: boolean; - setIsProcessing: (arg0: boolean) => void; - t: TFunction; -} +const connector = connect(mapState, mapDispatch); -export function LinkMsUser({ +type Props = ConnectedProps; + +function LinkMsUser({ isSignedIn, msUsername, linkMsUsername, unlinkMsUsername, isProcessing, - setIsProcessing, - t -}: LinkMsUserProps): JSX.Element { - const [msTranscriptUrl, setMsTranscriptUrl] = useState(''); + setIsProcessing +}: Props): JSX.Element { + const { t } = useTranslation(); + const [msTranscriptUrl, setMsTranscriptUrl] = useState(''); function handleLinkUsername(e: React.FormEvent) { e.preventDefault(); @@ -84,6 +79,11 @@ export function LinkMsUser({ setMsTranscriptUrl(e.target.value); } + const isValid = isMicrosoftTranscriptLink(msTranscriptUrl); + const isPristine = msTranscriptUrl === ''; + const isDisabled = isProcessing || !isValid; + const showWarning = !isPristine && !isValid; + return !isSignedIn ? ( <>

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

@@ -133,7 +133,7 @@ export function LinkMsUser({
- + {t('learn.ms.transcript-label')} @@ -144,7 +144,7 @@ export function LinkMsUser({ /> + {showWarning && ( + + + placeholder placeholder placeholder + + + )} )} @@ -161,7 +168,4 @@ export function LinkMsUser({ LinkMsUser.displayName = 'LinkMsUser'; -export default connect( - mapStateToProps, - mapDispatchToProps -)(withTranslation()(LinkMsUser)); +export default connector(LinkMsUser); diff --git a/utils/validate.test.ts b/utils/validate.test.ts index 9ed026e2ef5..7ad84ab327c 100644 --- a/utils/validate.test.ts +++ b/utils/validate.test.ts @@ -3,7 +3,8 @@ import { usernameTooShort, validationSuccess, usernameIsHttpStatusCode, - invalidCharError + invalidCharError, + isMicrosoftTranscriptLink } from './validate'; function inRange(num: number, range: number[]) { @@ -56,3 +57,60 @@ describe('isValidUsername', () => { } }); }); + +const baseUrl = 'https://learn.microsoft.com/'; + +describe('isMicrosoftTranscriptLink', () => { + it('should reject links to domains other than learn.microsoft.com', () => { + { + expect(isMicrosoftTranscriptLink('https://lean.microsoft.com')).toBe( + false + ); + expect(isMicrosoftTranscriptLink('https://learn.microsft.com')).toBe( + false + ); + } + }); + + it('should reject links without a username', () => { + expect(isMicrosoftTranscriptLink(`${baseUrl}/en-us/users/`)).toBe(false); + }); + + it('should reject links without a unique id', () => { + expect( + isMicrosoftTranscriptLink(`${baseUrl}/en-us/users/moT01/transcript`) + ).toBe(false); + }); + + it('should reject links with anything after the unique id', () => { + expect( + isMicrosoftTranscriptLink( + `${baseUrl}/en-us/users/moT01/transcript/any-id/more-stuff` + ) + ).toBe(false); + }); + + it('should reject the placeholder link', () => { + expect( + isMicrosoftTranscriptLink( + 'https://learn.microsoft.com/LOCALE/users/USERNAME/transcript/ID' + ) + ).toBe(false); + expect( + isMicrosoftTranscriptLink( + 'https://learn.microsoft.com/LOCALE/users/USERNAME/transcript/ID/' + ) + ).toBe(false); + }); + + it.each(['en-us', 'fr-fr', 'lang-country'])( + 'should accept links with the %s locale', + locale => { + expect( + isMicrosoftTranscriptLink( + `https://learn.microsoft.com/${locale}/users/moT01/transcript/any-id` + ) + ).toBe(true); + } + ); +}); diff --git a/utils/validate.ts b/utils/validate.ts index 12a1fa76019..ca8034174fe 100644 --- a/utils/validate.ts +++ b/utils/validate.ts @@ -36,3 +36,23 @@ export const isValidUsername = (str: string): Validated => { if (isHttpStatusCode(str)) return usernameIsHttpStatusCode; return validationSuccess; }; + +// link template: +// https://learn.microsoft.com/LOCALE/users/USERNAME/transcript/ID +export const isMicrosoftTranscriptLink = (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( + /^\/[^/]+\/users\/[^/]+\/transcript\/[^/]+$/ + ); + const notPlaceholder = !url.pathname.match( + '/LOCALE/users/USERNAME/transcript/ID' + ); + return correctDomain && correctPath && notPlaceholder; +};