feat(client/api): validate ms users (#51372)

Co-authored-by: Muhammed Mustafa <MuhammedElruby@gmail.com>
This commit is contained in:
Tom
2023-08-26 07:57:02 -05:00
committed by GitHub
co-authored by Muhammed Mustafa
parent 0f9ba6e9a5
commit 9a1895d2e3
45 changed files with 927 additions and 332 deletions
@@ -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;
+2 -11
View File
@@ -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) {
+4
View File
@@ -26,6 +26,10 @@ export const actionTypes = createTypes(
'startExam',
'stopExam',
'clearExamResults',
'linkMsUsername',
'unlinkMsUsername',
'setMsUsername',
'setIsProcessing',
'submitComplete',
'updateComplete',
'updateFailed',
+6
View File
@@ -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);
+23 -1
View File
@@ -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 {
+65
View File
@@ -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)
];
}
+1
View File
@@ -123,6 +123,7 @@ export type ChallengeNode = {
owner: string;
type: string;
};
msTrophyId: string;
notes: string;
prerequisites: PrerequisiteChallenge[];
removeComments: boolean;
+8
View File
@@ -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 }))
+10
View File
@@ -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', {});
}
+7 -2
View File
@@ -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
};
+6 -1
View File
@@ -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;