Remove Truth Social registration
This commit is contained in:
parent
025552d57f
commit
bea2020287
|
@ -114,7 +114,6 @@
|
||||||
"intl-messageformat-parser": "^6.0.0",
|
"intl-messageformat-parser": "^6.0.0",
|
||||||
"intl-pluralrules": "^1.3.1",
|
"intl-pluralrules": "^1.3.1",
|
||||||
"leaflet": "^1.8.0",
|
"leaflet": "^1.8.0",
|
||||||
"libphonenumber-js": "^1.10.8",
|
|
||||||
"line-awesome": "^1.3.0",
|
"line-awesome": "^1.3.0",
|
||||||
"localforage": "^1.10.0",
|
"localforage": "^1.10.0",
|
||||||
"lodash": "^4.7.11",
|
"lodash": "^4.7.11",
|
||||||
|
@ -136,7 +135,6 @@
|
||||||
"react-inlinesvg": "^3.0.0",
|
"react-inlinesvg": "^3.0.0",
|
||||||
"react-intl": "^5.0.0",
|
"react-intl": "^5.0.0",
|
||||||
"react-motion": "^0.5.2",
|
"react-motion": "^0.5.2",
|
||||||
"react-otp-input": "^2.4.0",
|
|
||||||
"react-overlays": "^0.9.0",
|
"react-overlays": "^0.9.0",
|
||||||
"react-popper": "^2.3.0",
|
"react-popper": "^2.3.0",
|
||||||
"react-redux": "^8.0.0",
|
"react-redux": "^8.0.0",
|
||||||
|
|
|
@ -1,427 +0,0 @@
|
||||||
import api from '../api';
|
|
||||||
|
|
||||||
import type { AppDispatch, RootState } from 'soapbox/store';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* LocalStorage 'soapbox:verification'
|
|
||||||
*
|
|
||||||
* {
|
|
||||||
* token: String,
|
|
||||||
* challenges: {
|
|
||||||
* email: Number (0 = incomplete, 1 = complete),
|
|
||||||
* sms: Number,
|
|
||||||
* age: Number
|
|
||||||
* }
|
|
||||||
* }
|
|
||||||
*/
|
|
||||||
const LOCAL_STORAGE_VERIFICATION_KEY = 'soapbox:verification';
|
|
||||||
|
|
||||||
const PEPE_FETCH_INSTANCE_SUCCESS = 'PEPE_FETCH_INSTANCE_SUCCESS';
|
|
||||||
const FETCH_CHALLENGES_SUCCESS = 'FETCH_CHALLENGES_SUCCESS';
|
|
||||||
const FETCH_TOKEN_SUCCESS = 'FETCH_TOKEN_SUCCESS';
|
|
||||||
|
|
||||||
const SET_NEXT_CHALLENGE = 'SET_NEXT_CHALLENGE';
|
|
||||||
const SET_CHALLENGES_COMPLETE = 'SET_CHALLENGES_COMPLETE';
|
|
||||||
const SET_LOADING = 'SET_LOADING';
|
|
||||||
|
|
||||||
const EMAIL: Challenge = 'email';
|
|
||||||
const SMS: Challenge = 'sms';
|
|
||||||
const AGE: Challenge = 'age';
|
|
||||||
|
|
||||||
export type Challenge = 'age' | 'sms' | 'email'
|
|
||||||
|
|
||||||
type Challenges = {
|
|
||||||
email?: 0 | 1
|
|
||||||
sms?: 0 | 1
|
|
||||||
age?: 0 | 1
|
|
||||||
}
|
|
||||||
|
|
||||||
type Verification = {
|
|
||||||
token?: string
|
|
||||||
challenges?: Challenges
|
|
||||||
challengeTypes?: Array<'age' | 'sms' | 'email'>
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Fetch the state of the user's verification in local storage.
|
|
||||||
*/
|
|
||||||
const fetchStoredVerification = (): Verification | null => {
|
|
||||||
try {
|
|
||||||
return JSON.parse(localStorage.getItem(LOCAL_STORAGE_VERIFICATION_KEY) as string);
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Remove the state of the user's verification from local storage.
|
|
||||||
*/
|
|
||||||
const removeStoredVerification = () => {
|
|
||||||
localStorage.removeItem(LOCAL_STORAGE_VERIFICATION_KEY);
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Fetch and return the Registration token for Pepe.
|
|
||||||
*/
|
|
||||||
const fetchStoredToken = () => {
|
|
||||||
try {
|
|
||||||
const verification: Verification | null = fetchStoredVerification();
|
|
||||||
return verification!.token;
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Fetch and return the state of the verification challenges.
|
|
||||||
*/
|
|
||||||
const fetchStoredChallenges = () => {
|
|
||||||
try {
|
|
||||||
const verification: Verification | null = fetchStoredVerification();
|
|
||||||
return verification!.challenges;
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Fetch and return the state of the verification challenge types.
|
|
||||||
*/
|
|
||||||
const fetchStoredChallengeTypes = () => {
|
|
||||||
try {
|
|
||||||
const verification: Verification | null = fetchStoredVerification();
|
|
||||||
return verification!.challengeTypes;
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Update the verification object in local storage.
|
|
||||||
*
|
|
||||||
* @param {*} verification object
|
|
||||||
*/
|
|
||||||
const updateStorage = ({ ...updatedVerification }: Verification) => {
|
|
||||||
const verification = fetchStoredVerification();
|
|
||||||
|
|
||||||
localStorage.setItem(
|
|
||||||
LOCAL_STORAGE_VERIFICATION_KEY,
|
|
||||||
JSON.stringify({ ...verification, ...updatedVerification }),
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Fetch Pepe challenges and registration token
|
|
||||||
*/
|
|
||||||
const fetchVerificationConfig = () =>
|
|
||||||
async(dispatch: AppDispatch) => {
|
|
||||||
await dispatch(fetchPepeInstance());
|
|
||||||
|
|
||||||
dispatch(fetchRegistrationToken());
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Save the challenges in localStorage.
|
|
||||||
*
|
|
||||||
* - If the API removes a challenge after the client has stored it, remove that
|
|
||||||
* challenge from localStorage.
|
|
||||||
* - If the API adds a challenge after the client has stored it, add that
|
|
||||||
* challenge to localStorage.
|
|
||||||
* - Don't overwrite a challenge that has already been completed.
|
|
||||||
* - Update localStorage to the new set of challenges.
|
|
||||||
*/
|
|
||||||
function saveChallenges(challenges: Array<'age' | 'sms' | 'email'>) {
|
|
||||||
const currentChallenges: Challenges = fetchStoredChallenges() || {};
|
|
||||||
|
|
||||||
const challengesToRemove = Object.keys(currentChallenges).filter((currentChallenge) => !challenges.includes(currentChallenge as Challenge)) as Challenge[];
|
|
||||||
challengesToRemove.forEach((challengeToRemove) => delete currentChallenges[challengeToRemove]);
|
|
||||||
|
|
||||||
for (let i = 0; i < challenges.length; i++) {
|
|
||||||
const challengeName = challenges[i];
|
|
||||||
|
|
||||||
if (typeof currentChallenges[challengeName] !== 'number') {
|
|
||||||
currentChallenges[challengeName] = 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
updateStorage({
|
|
||||||
challenges: currentChallenges,
|
|
||||||
challengeTypes: challenges,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Finish a challenge.
|
|
||||||
*/
|
|
||||||
function finishChallenge(challenge: Challenge) {
|
|
||||||
const currentChallenges: Challenges = fetchStoredChallenges() || {};
|
|
||||||
// Set challenge to "complete"
|
|
||||||
currentChallenges[challenge] = 1;
|
|
||||||
|
|
||||||
updateStorage({ challenges: currentChallenges });
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Fetch the next challenge
|
|
||||||
*/
|
|
||||||
const fetchNextChallenge = (): Challenge => {
|
|
||||||
const currentChallenges: Challenges = fetchStoredChallenges() || {};
|
|
||||||
return Object.keys(currentChallenges).find((challenge) => currentChallenges[challenge as Challenge] === 0) as Challenge;
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Dispatch the next challenge or set to complete if all challenges are completed.
|
|
||||||
*/
|
|
||||||
const dispatchNextChallenge = (dispatch: AppDispatch) => {
|
|
||||||
const nextChallenge = fetchNextChallenge();
|
|
||||||
|
|
||||||
if (nextChallenge) {
|
|
||||||
dispatch({ type: SET_NEXT_CHALLENGE, challenge: nextChallenge });
|
|
||||||
} else {
|
|
||||||
dispatch({ type: SET_CHALLENGES_COMPLETE });
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Fetch the challenges and age mininum from Pepe
|
|
||||||
*/
|
|
||||||
const fetchPepeInstance = () =>
|
|
||||||
(dispatch: AppDispatch, getState: () => RootState) => {
|
|
||||||
dispatch({ type: SET_LOADING });
|
|
||||||
|
|
||||||
return api(getState).get('/api/v1/pepe/instance').then(response => {
|
|
||||||
const { challenges, age_minimum: ageMinimum } = response.data;
|
|
||||||
saveChallenges(challenges);
|
|
||||||
const currentChallenge = fetchNextChallenge();
|
|
||||||
|
|
||||||
dispatch({ type: PEPE_FETCH_INSTANCE_SUCCESS, instance: { isReady: true, ...response.data } });
|
|
||||||
|
|
||||||
dispatch({
|
|
||||||
type: FETCH_CHALLENGES_SUCCESS,
|
|
||||||
ageMinimum,
|
|
||||||
currentChallenge,
|
|
||||||
isComplete: !currentChallenge,
|
|
||||||
});
|
|
||||||
})
|
|
||||||
.finally(() => dispatch({ type: SET_LOADING, value: false }));
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Fetch the regristration token from Pepe unless it's already been stored locally
|
|
||||||
*/
|
|
||||||
const fetchRegistrationToken = () =>
|
|
||||||
(dispatch: AppDispatch, getState: () => RootState) => {
|
|
||||||
dispatch({ type: SET_LOADING });
|
|
||||||
|
|
||||||
const token = fetchStoredToken();
|
|
||||||
if (token) {
|
|
||||||
dispatch({
|
|
||||||
type: FETCH_TOKEN_SUCCESS,
|
|
||||||
value: token,
|
|
||||||
});
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return api(getState).post('/api/v1/pepe/registrations')
|
|
||||||
.then(response => {
|
|
||||||
updateStorage({ token: response.data.access_token });
|
|
||||||
|
|
||||||
return dispatch({
|
|
||||||
type: FETCH_TOKEN_SUCCESS,
|
|
||||||
value: response.data.access_token,
|
|
||||||
});
|
|
||||||
})
|
|
||||||
.finally(() => dispatch({ type: SET_LOADING, value: false }));
|
|
||||||
};
|
|
||||||
|
|
||||||
const checkEmailAvailability = (email: string) =>
|
|
||||||
(dispatch: AppDispatch, getState: () => RootState) => {
|
|
||||||
dispatch({ type: SET_LOADING });
|
|
||||||
|
|
||||||
const token = fetchStoredToken();
|
|
||||||
|
|
||||||
return api(getState).get(`/api/v1/pepe/account/exists?email=${email}`, {
|
|
||||||
headers: { Authorization: `Bearer ${token}` },
|
|
||||||
})
|
|
||||||
.catch(() => {})
|
|
||||||
.then(() => dispatch({ type: SET_LOADING, value: false }));
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Send the user's email to Pepe to request confirmation
|
|
||||||
*/
|
|
||||||
const requestEmailVerification = (email: string) =>
|
|
||||||
(dispatch: AppDispatch, getState: () => RootState) => {
|
|
||||||
dispatch({ type: SET_LOADING });
|
|
||||||
|
|
||||||
const token = fetchStoredToken();
|
|
||||||
|
|
||||||
return api(getState).post('/api/v1/pepe/verify_email/request', { email }, {
|
|
||||||
headers: { Authorization: `Bearer ${token}` },
|
|
||||||
})
|
|
||||||
.finally(() => dispatch({ type: SET_LOADING, value: false }));
|
|
||||||
};
|
|
||||||
|
|
||||||
const checkEmailVerification = () =>
|
|
||||||
(dispatch: AppDispatch, getState: () => RootState) => {
|
|
||||||
const token = fetchStoredToken();
|
|
||||||
|
|
||||||
return api(getState).get('/api/v1/pepe/verify_email', {
|
|
||||||
headers: { Authorization: `Bearer ${token}` },
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Confirm the user's email with Pepe
|
|
||||||
*/
|
|
||||||
const confirmEmailVerification = (emailToken: string) =>
|
|
||||||
(dispatch: AppDispatch, getState: () => RootState) => {
|
|
||||||
dispatch({ type: SET_LOADING });
|
|
||||||
|
|
||||||
const token = fetchStoredToken();
|
|
||||||
|
|
||||||
return api(getState).post('/api/v1/pepe/verify_email/confirm', { token: emailToken }, {
|
|
||||||
headers: { Authorization: `Bearer ${token}` },
|
|
||||||
})
|
|
||||||
.then((response) => {
|
|
||||||
updateStorageFromEmailConfirmation(dispatch, response.data.token);
|
|
||||||
})
|
|
||||||
.finally(() => dispatch({ type: SET_LOADING, value: false }));
|
|
||||||
};
|
|
||||||
|
|
||||||
const updateStorageFromEmailConfirmation = (dispatch: AppDispatch, token: string) => {
|
|
||||||
const challengeTypes = fetchStoredChallengeTypes();
|
|
||||||
if (!challengeTypes) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const indexOfEmail = challengeTypes.indexOf('email');
|
|
||||||
const challenges: Challenges = {};
|
|
||||||
challengeTypes?.forEach((challengeType, idx) => {
|
|
||||||
const value = idx <= indexOfEmail ? 1 : 0;
|
|
||||||
challenges[challengeType] = value;
|
|
||||||
});
|
|
||||||
|
|
||||||
updateStorage({ token, challengeTypes, challenges });
|
|
||||||
dispatchNextChallenge(dispatch);
|
|
||||||
};
|
|
||||||
|
|
||||||
const postEmailVerification = () =>
|
|
||||||
(dispatch: AppDispatch) => {
|
|
||||||
finishChallenge(EMAIL);
|
|
||||||
dispatchNextChallenge(dispatch);
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Send the user's phone number to Pepe to request confirmation
|
|
||||||
*/
|
|
||||||
const requestPhoneVerification = (phone: string) =>
|
|
||||||
(dispatch: AppDispatch, getState: () => RootState) => {
|
|
||||||
dispatch({ type: SET_LOADING });
|
|
||||||
|
|
||||||
const token = fetchStoredToken();
|
|
||||||
|
|
||||||
return api(getState).post('/api/v1/pepe/verify_sms/request', { phone }, {
|
|
||||||
headers: { Authorization: `Bearer ${token}` },
|
|
||||||
})
|
|
||||||
.finally(() => dispatch({ type: SET_LOADING, value: false }));
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Send the user's phone number to Pepe to re-request confirmation
|
|
||||||
*/
|
|
||||||
const reRequestPhoneVerification = (phone: string) =>
|
|
||||||
(dispatch: AppDispatch, getState: () => RootState) => {
|
|
||||||
dispatch({ type: SET_LOADING });
|
|
||||||
|
|
||||||
return api(getState).post('/api/v1/pepe/reverify_sms/request', { phone })
|
|
||||||
.finally(() => dispatch({ type: SET_LOADING, value: false }));
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Confirm the user's phone number with Pepe
|
|
||||||
*/
|
|
||||||
const confirmPhoneVerification = (code: string) =>
|
|
||||||
(dispatch: AppDispatch, getState: () => RootState) => {
|
|
||||||
dispatch({ type: SET_LOADING });
|
|
||||||
|
|
||||||
const token = fetchStoredToken();
|
|
||||||
|
|
||||||
return api(getState).post('/api/v1/pepe/verify_sms/confirm', { code }, {
|
|
||||||
headers: { Authorization: `Bearer ${token}` },
|
|
||||||
})
|
|
||||||
.then(() => {
|
|
||||||
finishChallenge(SMS);
|
|
||||||
dispatchNextChallenge(dispatch);
|
|
||||||
})
|
|
||||||
.finally(() => dispatch({ type: SET_LOADING, value: false }));
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Re-Confirm the user's phone number with Pepe
|
|
||||||
*/
|
|
||||||
const reConfirmPhoneVerification = (code: string) =>
|
|
||||||
(dispatch: AppDispatch, getState: () => RootState) => {
|
|
||||||
dispatch({ type: SET_LOADING });
|
|
||||||
|
|
||||||
return api(getState).post('/api/v1/pepe/reverify_sms/confirm', { code })
|
|
||||||
.finally(() => dispatch({ type: SET_LOADING, value: false }));
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Confirm the user's age with Pepe
|
|
||||||
*/
|
|
||||||
const verifyAge = (birthday: Date) =>
|
|
||||||
(dispatch: AppDispatch, getState: () => RootState) => {
|
|
||||||
dispatch({ type: SET_LOADING });
|
|
||||||
|
|
||||||
const token = fetchStoredToken();
|
|
||||||
|
|
||||||
return api(getState).post('/api/v1/pepe/verify_age/confirm', { birthday }, {
|
|
||||||
headers: { Authorization: `Bearer ${token}` },
|
|
||||||
})
|
|
||||||
.then(() => {
|
|
||||||
finishChallenge(AGE);
|
|
||||||
dispatchNextChallenge(dispatch);
|
|
||||||
})
|
|
||||||
.finally(() => dispatch({ type: SET_LOADING, value: false }));
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create the user's account with Pepe
|
|
||||||
*/
|
|
||||||
const createAccount = (username: string, password: string) =>
|
|
||||||
(dispatch: AppDispatch, getState: () => RootState) => {
|
|
||||||
dispatch({ type: SET_LOADING });
|
|
||||||
|
|
||||||
const token = fetchStoredToken();
|
|
||||||
|
|
||||||
return api(getState).post('/api/v1/pepe/accounts', { username, password }, {
|
|
||||||
headers: { Authorization: `Bearer ${token}` },
|
|
||||||
}).finally(() => dispatch({ type: SET_LOADING, value: false }));
|
|
||||||
};
|
|
||||||
|
|
||||||
export {
|
|
||||||
PEPE_FETCH_INSTANCE_SUCCESS,
|
|
||||||
FETCH_CHALLENGES_SUCCESS,
|
|
||||||
FETCH_TOKEN_SUCCESS,
|
|
||||||
LOCAL_STORAGE_VERIFICATION_KEY,
|
|
||||||
SET_CHALLENGES_COMPLETE,
|
|
||||||
SET_LOADING,
|
|
||||||
SET_NEXT_CHALLENGE,
|
|
||||||
checkEmailAvailability,
|
|
||||||
confirmEmailVerification,
|
|
||||||
confirmPhoneVerification,
|
|
||||||
createAccount,
|
|
||||||
fetchStoredChallenges,
|
|
||||||
fetchVerificationConfig,
|
|
||||||
fetchRegistrationToken,
|
|
||||||
removeStoredVerification,
|
|
||||||
requestEmailVerification,
|
|
||||||
checkEmailVerification,
|
|
||||||
postEmailVerification,
|
|
||||||
reConfirmPhoneVerification,
|
|
||||||
requestPhoneVerification,
|
|
||||||
reRequestPhoneVerification,
|
|
||||||
verifyAge,
|
|
||||||
};
|
|
|
@ -38,7 +38,6 @@ export {
|
||||||
MenuList,
|
MenuList,
|
||||||
} from './menu/menu';
|
} from './menu/menu';
|
||||||
export { default as Modal } from './modal/modal';
|
export { default as Modal } from './modal/modal';
|
||||||
export { default as PhoneInput } from './phone-input/phone-input';
|
|
||||||
export { default as Popover } from './popover/popover';
|
export { default as Popover } from './popover/popover';
|
||||||
export { default as Portal } from './portal/portal';
|
export { default as Portal } from './portal/portal';
|
||||||
export { default as ProgressBar } from './progress-bar/progress-bar';
|
export { default as ProgressBar } from './progress-bar/progress-bar';
|
||||||
|
|
|
@ -1,25 +0,0 @@
|
||||||
import React from 'react';
|
|
||||||
|
|
||||||
import { COUNTRY_CODES, CountryCode } from 'soapbox/utils/phone';
|
|
||||||
|
|
||||||
interface ICountryCodeDropdown {
|
|
||||||
countryCode: CountryCode
|
|
||||||
onChange(countryCode: CountryCode): void
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Dropdown menu to select a country code. */
|
|
||||||
const CountryCodeDropdown: React.FC<ICountryCodeDropdown> = ({ countryCode, onChange }) => {
|
|
||||||
return (
|
|
||||||
<select
|
|
||||||
value={countryCode}
|
|
||||||
className='h-full rounded-md border-transparent bg-transparent py-0 pl-3 pr-7 text-base focus:outline-none focus:ring-primary-500 dark:text-white sm:text-sm'
|
|
||||||
onChange={(event) => onChange(event.target.value as any)}
|
|
||||||
>
|
|
||||||
{COUNTRY_CODES.map((code) => (
|
|
||||||
<option value={code} key={code}>+{code}</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default CountryCodeDropdown;
|
|
|
@ -1,81 +0,0 @@
|
||||||
import { parsePhoneNumber, AsYouType } from 'libphonenumber-js';
|
|
||||||
import React, { useState, useEffect } from 'react';
|
|
||||||
|
|
||||||
import { CountryCode } from 'soapbox/utils/phone';
|
|
||||||
|
|
||||||
import Input from '../input/input';
|
|
||||||
|
|
||||||
import CountryCodeDropdown from './country-code-dropdown';
|
|
||||||
|
|
||||||
interface IPhoneInput extends Pick<React.InputHTMLAttributes<HTMLInputElement>, 'required' | 'autoFocus'> {
|
|
||||||
/** E164 phone number. */
|
|
||||||
value?: string
|
|
||||||
/** Change handler which receives the E164 phone string. */
|
|
||||||
onChange?: (phone: string | undefined) => void
|
|
||||||
/** Country code that's selected on mount. */
|
|
||||||
defaultCountryCode?: CountryCode
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Internationalized phone input with country code picker. */
|
|
||||||
const PhoneInput: React.FC<IPhoneInput> = (props) => {
|
|
||||||
const { value, onChange, defaultCountryCode = '1', ...rest } = props;
|
|
||||||
|
|
||||||
const [countryCode, setCountryCode] = useState<CountryCode>(defaultCountryCode);
|
|
||||||
const [nationalNumber, setNationalNumber] = useState<string>('');
|
|
||||||
|
|
||||||
const handleChange: React.ChangeEventHandler<HTMLInputElement> = ({ target }) => {
|
|
||||||
// HACK: AsYouType is not meant to be used this way. But it works!
|
|
||||||
const asYouType = new AsYouType({ defaultCallingCode: countryCode });
|
|
||||||
const formatted = asYouType.input(target.value);
|
|
||||||
|
|
||||||
// If the new value is the same as before, we might be backspacing,
|
|
||||||
// so use the actual event value instead of the formatted value.
|
|
||||||
if (formatted === nationalNumber && target.value !== nationalNumber) {
|
|
||||||
setNationalNumber(target.value);
|
|
||||||
} else {
|
|
||||||
setNationalNumber(formatted);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// When the internal state changes, update the external state.
|
|
||||||
useEffect(() => {
|
|
||||||
if (onChange) {
|
|
||||||
try {
|
|
||||||
const opts = { defaultCallingCode: countryCode, extract: false } as any;
|
|
||||||
const result = parsePhoneNumber(nationalNumber, opts);
|
|
||||||
|
|
||||||
// Throw if the number is invalid, but catch it below.
|
|
||||||
// We'll only ever call `onChange` with a valid E164 string or `undefined`.
|
|
||||||
if (!result.isPossible()) {
|
|
||||||
throw result;
|
|
||||||
}
|
|
||||||
|
|
||||||
onChange(result.format('E.164'));
|
|
||||||
} catch (e) {
|
|
||||||
// The value returned is always a valid E164 string.
|
|
||||||
// If it's not valid, it'll return undefined.
|
|
||||||
onChange(undefined);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [countryCode, nationalNumber]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
handleChange({ target: { value: nationalNumber } } as any);
|
|
||||||
}, [countryCode, nationalNumber]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Input
|
|
||||||
onChange={handleChange}
|
|
||||||
value={nationalNumber}
|
|
||||||
prepend={
|
|
||||||
<CountryCodeDropdown
|
|
||||||
countryCode={countryCode}
|
|
||||||
onChange={setCountryCode}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
{...rest}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default PhoneInput;
|
|
|
@ -13,8 +13,7 @@ import { ScrollContext } from 'react-router-scroll-4';
|
||||||
|
|
||||||
import { loadInstance } from 'soapbox/actions/instance';
|
import { loadInstance } from 'soapbox/actions/instance';
|
||||||
import { fetchMe } from 'soapbox/actions/me';
|
import { fetchMe } from 'soapbox/actions/me';
|
||||||
import { loadSoapboxConfig, getSoapboxConfig } from 'soapbox/actions/soapbox';
|
import { loadSoapboxConfig } from 'soapbox/actions/soapbox';
|
||||||
import { fetchVerificationConfig } from 'soapbox/actions/verification';
|
|
||||||
import * as BuildConfig from 'soapbox/build-config';
|
import * as BuildConfig from 'soapbox/build-config';
|
||||||
import GdprBanner from 'soapbox/components/gdpr-banner';
|
import GdprBanner from 'soapbox/components/gdpr-banner';
|
||||||
import Helmet from 'soapbox/components/helmet';
|
import Helmet from 'soapbox/components/helmet';
|
||||||
|
@ -27,7 +26,6 @@ import BundleContainer from 'soapbox/features/ui/containers/bundle-container';
|
||||||
import {
|
import {
|
||||||
ModalContainer,
|
ModalContainer,
|
||||||
OnboardingWizard,
|
OnboardingWizard,
|
||||||
WaitlistPage,
|
|
||||||
} from 'soapbox/features/ui/util/async-components';
|
} from 'soapbox/features/ui/util/async-components';
|
||||||
import { createGlobals } from 'soapbox/globals';
|
import { createGlobals } from 'soapbox/globals';
|
||||||
import {
|
import {
|
||||||
|
@ -40,7 +38,6 @@ import {
|
||||||
useTheme,
|
useTheme,
|
||||||
useLocale,
|
useLocale,
|
||||||
useInstance,
|
useInstance,
|
||||||
useRegistrationStatus,
|
|
||||||
} from 'soapbox/hooks';
|
} from 'soapbox/hooks';
|
||||||
import MESSAGES from 'soapbox/messages';
|
import MESSAGES from 'soapbox/messages';
|
||||||
import { normalizeSoapboxConfig } from 'soapbox/normalizers';
|
import { normalizeSoapboxConfig } from 'soapbox/normalizers';
|
||||||
|
@ -73,14 +70,6 @@ const loadInitial = () => {
|
||||||
await dispatch(loadInstance());
|
await dispatch(loadInstance());
|
||||||
// Await for configuration
|
// Await for configuration
|
||||||
await dispatch(loadSoapboxConfig());
|
await dispatch(loadSoapboxConfig());
|
||||||
|
|
||||||
const state = getState();
|
|
||||||
const soapboxConfig = getSoapboxConfig(state);
|
|
||||||
const pepeEnabled = soapboxConfig.getIn(['extensions', 'pepe', 'enabled']) === true;
|
|
||||||
|
|
||||||
if (pepeEnabled && !state.me) {
|
|
||||||
await dispatch(fetchVerificationConfig());
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -93,11 +82,9 @@ const SoapboxMount = () => {
|
||||||
const { account } = useOwnAccount();
|
const { account } = useOwnAccount();
|
||||||
const soapboxConfig = useSoapboxConfig();
|
const soapboxConfig = useSoapboxConfig();
|
||||||
const features = useFeatures();
|
const features = useFeatures();
|
||||||
const { pepeEnabled } = useRegistrationStatus();
|
|
||||||
|
|
||||||
const waitlisted = account && account.source?.approved === false;
|
|
||||||
const needsOnboarding = useAppSelector(state => state.onboarding.needsOnboarding);
|
const needsOnboarding = useAppSelector(state => state.onboarding.needsOnboarding);
|
||||||
const showOnboarding = account && !waitlisted && needsOnboarding;
|
const showOnboarding = account && needsOnboarding;
|
||||||
const { redirectRootNoLogin } = soapboxConfig;
|
const { redirectRootNoLogin } = soapboxConfig;
|
||||||
|
|
||||||
// @ts-ignore: I don't actually know what these should be, lol
|
// @ts-ignore: I don't actually know what these should be, lol
|
||||||
|
@ -115,25 +102,6 @@ const SoapboxMount = () => {
|
||||||
/** Render the auth layout or UI. */
|
/** Render the auth layout or UI. */
|
||||||
const renderSwitch = () => (
|
const renderSwitch = () => (
|
||||||
<Switch>
|
<Switch>
|
||||||
<Redirect from='/v1/verify_email/:token' to='/verify/email/:token' />
|
|
||||||
|
|
||||||
{/* Redirect signup route depending on Pepe enablement. */}
|
|
||||||
{/* We should prefer using /signup in components. */}
|
|
||||||
{pepeEnabled ? (
|
|
||||||
<Redirect from='/signup' to='/verify' />
|
|
||||||
) : (
|
|
||||||
<Redirect from='/verify' to='/signup' />
|
|
||||||
)}
|
|
||||||
|
|
||||||
{waitlisted && (
|
|
||||||
<Route render={(props) => (
|
|
||||||
<BundleContainer fetchComponent={WaitlistPage} loading={LoadingScreen}>
|
|
||||||
{(Component) => <Component {...props} account={account} />}
|
|
||||||
</BundleContainer>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{!me && (redirectRootNoLogin
|
{!me && (redirectRootNoLogin
|
||||||
? <Redirect exact from='/' to={redirectRootNoLogin} />
|
? <Redirect exact from='/' to={redirectRootNoLogin} />
|
||||||
: <Route exact path='/' component={PublicLayout} />)}
|
: <Route exact path='/' component={PublicLayout} />)}
|
||||||
|
@ -149,10 +117,6 @@ const SoapboxMount = () => {
|
||||||
<Route exact path='/signup' component={AuthLayout} />
|
<Route exact path='/signup' component={AuthLayout} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{pepeEnabled && (
|
|
||||||
<Route path='/verify' component={AuthLayout} />
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Route path='/reset-password' component={AuthLayout} />
|
<Route path='/reset-password' component={AuthLayout} />
|
||||||
<Route path='/edit-password' component={AuthLayout} />
|
<Route path='/edit-password' component={AuthLayout} />
|
||||||
<Route path='/invite/:token' component={AuthLayout} />
|
<Route path='/invite/:token' component={AuthLayout} />
|
||||||
|
|
|
@ -14,8 +14,6 @@ import RegistrationForm from '../auth-login/components/registration-form';
|
||||||
import ExternalLoginForm from '../external-login/components/external-login-form';
|
import ExternalLoginForm from '../external-login/components/external-login-form';
|
||||||
import Footer from '../public-layout/components/footer';
|
import Footer from '../public-layout/components/footer';
|
||||||
import RegisterInvite from '../register-invite';
|
import RegisterInvite from '../register-invite';
|
||||||
import Verification from '../verification';
|
|
||||||
import EmailPassthru from '../verification/email-passthru';
|
|
||||||
|
|
||||||
const messages = defineMessages({
|
const messages = defineMessages({
|
||||||
register: { id: 'auth_layout.register', defaultMessage: 'Create an account' },
|
register: { id: 'auth_layout.register', defaultMessage: 'Create an account' },
|
||||||
|
@ -65,8 +63,6 @@ const AuthLayout = () => {
|
||||||
{/* If already logged in, redirect home. */}
|
{/* If already logged in, redirect home. */}
|
||||||
{account && <Redirect from='/login' to='/' exact />}
|
{account && <Redirect from='/login' to='/' exact />}
|
||||||
|
|
||||||
<Route exact path='/verify' component={Verification} />
|
|
||||||
<Route exact path='/verify/email/:token' component={EmailPassthru} />
|
|
||||||
<Route exact path='/login/external' component={ExternalLoginForm} />
|
<Route exact path='/login/external' component={ExternalLoginForm} />
|
||||||
<Route exact path='/login/add' component={LoginPage} />
|
<Route exact path='/login/add' component={LoginPage} />
|
||||||
<Route exact path='/login' component={LoginPage} />
|
<Route exact path='/login' component={LoginPage} />
|
||||||
|
|
|
@ -4,8 +4,7 @@ import { Redirect } from 'react-router-dom';
|
||||||
|
|
||||||
import { resetPasswordConfirm } from 'soapbox/actions/security';
|
import { resetPasswordConfirm } from 'soapbox/actions/security';
|
||||||
import { Button, Form, FormActions, FormGroup, Input } from 'soapbox/components/ui';
|
import { Button, Form, FormActions, FormGroup, Input } from 'soapbox/components/ui';
|
||||||
import PasswordIndicator from 'soapbox/features/verification/components/password-indicator';
|
import { useAppDispatch } from 'soapbox/hooks';
|
||||||
import { useAppDispatch, useFeatures } from 'soapbox/hooks';
|
|
||||||
|
|
||||||
const token = new URLSearchParams(window.location.search).get('reset_password_token');
|
const token = new URLSearchParams(window.location.search).get('reset_password_token');
|
||||||
|
|
||||||
|
@ -24,11 +23,9 @@ const Statuses = {
|
||||||
const PasswordResetConfirm = () => {
|
const PasswordResetConfirm = () => {
|
||||||
const intl = useIntl();
|
const intl = useIntl();
|
||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
const { passwordRequirements } = useFeatures();
|
|
||||||
|
|
||||||
const [password, setPassword] = React.useState('');
|
const [password, setPassword] = React.useState('');
|
||||||
const [status, setStatus] = React.useState(Statuses.IDLE);
|
const [status, setStatus] = React.useState(Statuses.IDLE);
|
||||||
const [hasValidPassword, setHasValidPassword] = React.useState<boolean>(passwordRequirements ? false : true);
|
|
||||||
|
|
||||||
const isLoading = status === Statuses.LOADING;
|
const isLoading = status === Statuses.LOADING;
|
||||||
|
|
||||||
|
@ -75,14 +72,10 @@ const PasswordResetConfirm = () => {
|
||||||
onChange={onChange}
|
onChange={onChange}
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{passwordRequirements && (
|
|
||||||
<PasswordIndicator password={password} onChange={setHasValidPassword} />
|
|
||||||
)}
|
|
||||||
</FormGroup>
|
</FormGroup>
|
||||||
|
|
||||||
<FormActions>
|
<FormActions>
|
||||||
<Button type='submit' theme='primary' disabled={isLoading || !hasValidPassword}>
|
<Button type='submit' theme='primary' disabled={isLoading}>
|
||||||
<FormattedMessage id='password_reset.reset' defaultMessage='Reset password' />
|
<FormattedMessage id='password_reset.reset' defaultMessage='Reset password' />
|
||||||
</Button>
|
</Button>
|
||||||
</FormActions>
|
</FormActions>
|
||||||
|
|
|
@ -3,11 +3,9 @@ import { defineMessages, useIntl } from 'react-intl';
|
||||||
|
|
||||||
import { changePassword } from 'soapbox/actions/security';
|
import { changePassword } from 'soapbox/actions/security';
|
||||||
import { Button, Column, Form, FormActions, FormGroup, Input } from 'soapbox/components/ui';
|
import { Button, Column, Form, FormActions, FormGroup, Input } from 'soapbox/components/ui';
|
||||||
import { useAppDispatch, useFeatures } from 'soapbox/hooks';
|
import { useAppDispatch } from 'soapbox/hooks';
|
||||||
import toast from 'soapbox/toast';
|
import toast from 'soapbox/toast';
|
||||||
|
|
||||||
import PasswordIndicator from '../verification/components/password-indicator';
|
|
||||||
|
|
||||||
const messages = defineMessages({
|
const messages = defineMessages({
|
||||||
updatePasswordSuccess: { id: 'security.update_password.success', defaultMessage: 'Password successfully updated.' },
|
updatePasswordSuccess: { id: 'security.update_password.success', defaultMessage: 'Password successfully updated.' },
|
||||||
updatePasswordFail: { id: 'security.update_password.fail', defaultMessage: 'Update password failed.' },
|
updatePasswordFail: { id: 'security.update_password.fail', defaultMessage: 'Update password failed.' },
|
||||||
|
@ -24,11 +22,9 @@ const initialState = { currentPassword: '', newPassword: '', newPasswordConfirma
|
||||||
const EditPassword = () => {
|
const EditPassword = () => {
|
||||||
const intl = useIntl();
|
const intl = useIntl();
|
||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
const { passwordRequirements } = useFeatures();
|
|
||||||
|
|
||||||
const [state, setState] = React.useState(initialState);
|
const [state, setState] = React.useState(initialState);
|
||||||
const [isLoading, setLoading] = React.useState(false);
|
const [isLoading, setLoading] = React.useState(false);
|
||||||
const [hasValidPassword, setHasValidPassword] = React.useState<boolean>(passwordRequirements ? false : true);
|
|
||||||
|
|
||||||
const { currentPassword, newPassword, newPasswordConfirmation } = state;
|
const { currentPassword, newPassword, newPasswordConfirmation } = state;
|
||||||
|
|
||||||
|
@ -73,10 +69,6 @@ const EditPassword = () => {
|
||||||
onChange={handleInputChange}
|
onChange={handleInputChange}
|
||||||
value={newPassword}
|
value={newPassword}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{passwordRequirements && (
|
|
||||||
<PasswordIndicator password={newPassword} onChange={setHasValidPassword} />
|
|
||||||
)}
|
|
||||||
</FormGroup>
|
</FormGroup>
|
||||||
|
|
||||||
<FormGroup labelText={intl.formatMessage(messages.confirmationFieldLabel)}>
|
<FormGroup labelText={intl.formatMessage(messages.confirmationFieldLabel)}>
|
||||||
|
@ -93,7 +85,7 @@ const EditPassword = () => {
|
||||||
{intl.formatMessage(messages.cancel)}
|
{intl.formatMessage(messages.cancel)}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<Button type='submit' theme='primary' disabled={isLoading || !hasValidPassword}>
|
<Button type='submit' theme='primary' disabled={isLoading}>
|
||||||
{intl.formatMessage(messages.submit)}
|
{intl.formatMessage(messages.submit)}
|
||||||
</Button>
|
</Button>
|
||||||
</FormActions>
|
</FormActions>
|
||||||
|
|
|
@ -1,10 +1,9 @@
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
|
||||||
|
import { rememberInstance } from 'soapbox/actions/instance';
|
||||||
|
import { render, screen, rootReducer } from 'soapbox/jest/test-helpers';
|
||||||
|
|
||||||
import LandingPage from '..';
|
import LandingPage from '..';
|
||||||
import { rememberInstance } from '../../../actions/instance';
|
|
||||||
import { SOAPBOX_CONFIG_REMEMBER_SUCCESS } from '../../../actions/soapbox';
|
|
||||||
import { PEPE_FETCH_INSTANCE_SUCCESS } from '../../../actions/verification';
|
|
||||||
import { render, screen, rootReducer, applyActions } from '../../../jest/test-helpers';
|
|
||||||
|
|
||||||
describe('<LandingPage />', () => {
|
describe('<LandingPage />', () => {
|
||||||
it('renders a RegistrationForm for an open Pleroma instance', () => {
|
it('renders a RegistrationForm for an open Pleroma instance', () => {
|
||||||
|
@ -21,7 +20,6 @@ describe('<LandingPage />', () => {
|
||||||
|
|
||||||
expect(screen.queryByTestId('registrations-open')).toBeInTheDocument();
|
expect(screen.queryByTestId('registrations-open')).toBeInTheDocument();
|
||||||
expect(screen.queryByTestId('registrations-closed')).not.toBeInTheDocument();
|
expect(screen.queryByTestId('registrations-closed')).not.toBeInTheDocument();
|
||||||
expect(screen.queryByTestId('registrations-pepe')).not.toBeInTheDocument();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('renders "closed" message for a closed Pleroma instance', () => {
|
it('renders "closed" message for a closed Pleroma instance', () => {
|
||||||
|
@ -38,53 +36,5 @@ describe('<LandingPage />', () => {
|
||||||
|
|
||||||
expect(screen.queryByTestId('registrations-closed')).toBeInTheDocument();
|
expect(screen.queryByTestId('registrations-closed')).toBeInTheDocument();
|
||||||
expect(screen.queryByTestId('registrations-open')).not.toBeInTheDocument();
|
expect(screen.queryByTestId('registrations-open')).not.toBeInTheDocument();
|
||||||
expect(screen.queryByTestId('registrations-pepe')).not.toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('renders Pepe flow if Pepe extension is enabled', () => {
|
|
||||||
|
|
||||||
const state = applyActions(undefined, [{
|
|
||||||
type: SOAPBOX_CONFIG_REMEMBER_SUCCESS,
|
|
||||||
soapboxConfig: {
|
|
||||||
extensions: {
|
|
||||||
pepe: {
|
|
||||||
enabled: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}, {
|
|
||||||
type: PEPE_FETCH_INSTANCE_SUCCESS,
|
|
||||||
instance: {
|
|
||||||
registrations: true,
|
|
||||||
},
|
|
||||||
}], rootReducer);
|
|
||||||
|
|
||||||
render(<LandingPage />, undefined, state);
|
|
||||||
|
|
||||||
expect(screen.queryByTestId('registrations-pepe')).toBeInTheDocument();
|
|
||||||
expect(screen.queryByTestId('registrations-open')).not.toBeInTheDocument();
|
|
||||||
expect(screen.queryByTestId('registrations-closed')).not.toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('renders "closed" message for a Truth Social instance with Pepe closed', () => {
|
|
||||||
|
|
||||||
const state = applyActions(undefined, [{
|
|
||||||
type: rememberInstance.fulfilled.type,
|
|
||||||
payload: {
|
|
||||||
version: '3.4.1 (compatible; TruthSocial 1.0.0)',
|
|
||||||
registrations: false,
|
|
||||||
},
|
|
||||||
}, {
|
|
||||||
type: PEPE_FETCH_INSTANCE_SUCCESS,
|
|
||||||
instance: {
|
|
||||||
registrations: false,
|
|
||||||
},
|
|
||||||
}], rootReducer);
|
|
||||||
|
|
||||||
render(<LandingPage />, undefined, state);
|
|
||||||
|
|
||||||
expect(screen.queryByTestId('registrations-closed')).toBeInTheDocument();
|
|
||||||
expect(screen.queryByTestId('registrations-pepe')).not.toBeInTheDocument();
|
|
||||||
expect(screen.queryByTestId('registrations-open')).not.toBeInTheDocument();
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -4,16 +4,14 @@ import { FormattedMessage } from 'react-intl';
|
||||||
import { prepareRequest } from 'soapbox/actions/consumer-auth';
|
import { prepareRequest } from 'soapbox/actions/consumer-auth';
|
||||||
import Markup from 'soapbox/components/markup';
|
import Markup from 'soapbox/components/markup';
|
||||||
import { Button, Card, CardBody, Stack, Text } from 'soapbox/components/ui';
|
import { Button, Card, CardBody, Stack, Text } from 'soapbox/components/ui';
|
||||||
import VerificationBadge from 'soapbox/components/verification-badge';
|
|
||||||
import RegistrationForm from 'soapbox/features/auth-login/components/registration-form';
|
import RegistrationForm from 'soapbox/features/auth-login/components/registration-form';
|
||||||
import { useAppDispatch, useFeatures, useInstance, useRegistrationStatus, useSoapboxConfig } from 'soapbox/hooks';
|
import { useAppDispatch, useFeatures, useInstance, useSoapboxConfig } from 'soapbox/hooks';
|
||||||
import { capitalize } from 'soapbox/utils/strings';
|
import { capitalize } from 'soapbox/utils/strings';
|
||||||
|
|
||||||
const LandingPage = () => {
|
const LandingPage = () => {
|
||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
const features = useFeatures();
|
const features = useFeatures();
|
||||||
const soapboxConfig = useSoapboxConfig();
|
const soapboxConfig = useSoapboxConfig();
|
||||||
const { pepeEnabled, pepeOpen } = useRegistrationStatus();
|
|
||||||
const instance = useInstance();
|
const instance = useInstance();
|
||||||
|
|
||||||
/** Registrations are closed */
|
/** Registrations are closed */
|
||||||
|
@ -65,34 +63,10 @@ const LandingPage = () => {
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Pepe API registrations are open */
|
|
||||||
const renderPepe = () => {
|
|
||||||
return (
|
|
||||||
<Stack space={3} data-testid='registrations-pepe'>
|
|
||||||
<VerificationBadge className='mx-auto h-16 w-16' />
|
|
||||||
|
|
||||||
<Stack>
|
|
||||||
<Text size='2xl' weight='bold' align='center'>
|
|
||||||
<FormattedMessage id='registrations.get_started' defaultMessage="Let's get started!" />
|
|
||||||
</Text>
|
|
||||||
<Text theme='muted' align='center'>
|
|
||||||
<FormattedMessage id='registrations.tagline' defaultMessage='Social Media Without Discrimination' />
|
|
||||||
</Text>
|
|
||||||
</Stack>
|
|
||||||
|
|
||||||
<Button to='/verify' theme='primary' block>
|
|
||||||
<FormattedMessage id='registrations.create_account' defaultMessage='Create an account' />
|
|
||||||
</Button>
|
|
||||||
</Stack>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Render registration flow depending on features
|
// Render registration flow depending on features
|
||||||
const renderBody = () => {
|
const renderBody = () => {
|
||||||
if (soapboxConfig.authProvider) {
|
if (soapboxConfig.authProvider) {
|
||||||
return renderProvider();
|
return renderProvider();
|
||||||
} else if (pepeEnabled && pepeOpen) {
|
|
||||||
return renderPepe();
|
|
||||||
} else if (features.accountCreation && instance.registrations) {
|
} else if (features.accountCreation && instance.registrations) {
|
||||||
return renderOpen();
|
return renderOpen();
|
||||||
} else {
|
} else {
|
||||||
|
|
|
@ -36,7 +36,6 @@ import {
|
||||||
ReplyMentionsModal,
|
ReplyMentionsModal,
|
||||||
ReportModal,
|
ReportModal,
|
||||||
UnauthorizedModal,
|
UnauthorizedModal,
|
||||||
VerifySmsModal,
|
|
||||||
VideoModal,
|
VideoModal,
|
||||||
} from 'soapbox/features/ui/util/async-components';
|
} from 'soapbox/features/ui/util/async-components';
|
||||||
|
|
||||||
|
@ -82,7 +81,6 @@ const MODAL_COMPONENTS = {
|
||||||
'REPLY_MENTIONS': ReplyMentionsModal,
|
'REPLY_MENTIONS': ReplyMentionsModal,
|
||||||
'REPORT': ReportModal,
|
'REPORT': ReportModal,
|
||||||
'UNAUTHORIZED': UnauthorizedModal,
|
'UNAUTHORIZED': UnauthorizedModal,
|
||||||
'VERIFY_SMS': VerifySmsModal,
|
|
||||||
'VIDEO': VideoModal,
|
'VIDEO': VideoModal,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
@ -1,228 +0,0 @@
|
||||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
|
||||||
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
|
|
||||||
import OtpInput from 'react-otp-input';
|
|
||||||
|
|
||||||
import { verifyCredentials } from 'soapbox/actions/auth';
|
|
||||||
import { closeModal } from 'soapbox/actions/modals';
|
|
||||||
import { reConfirmPhoneVerification, reRequestPhoneVerification } from 'soapbox/actions/verification';
|
|
||||||
import { FormGroup, PhoneInput, Modal, Stack, Text } from 'soapbox/components/ui';
|
|
||||||
import { useAppDispatch, useAppSelector, useInstance } from 'soapbox/hooks';
|
|
||||||
import toast from 'soapbox/toast';
|
|
||||||
import { getAccessToken } from 'soapbox/utils/auth';
|
|
||||||
|
|
||||||
const messages = defineMessages({
|
|
||||||
verificationInvalid: {
|
|
||||||
id: 'sms_verification.invalid',
|
|
||||||
defaultMessage: 'Please enter a valid phone number.',
|
|
||||||
},
|
|
||||||
verificationSuccess: {
|
|
||||||
id: 'sms_verification.success',
|
|
||||||
defaultMessage: 'A verification code has been sent to your phone number.',
|
|
||||||
},
|
|
||||||
verificationFail: {
|
|
||||||
id: 'sms_verification.fail',
|
|
||||||
defaultMessage: 'Failed to send SMS message to your phone number.',
|
|
||||||
},
|
|
||||||
verificationExpired: {
|
|
||||||
id: 'sms_verification.expired',
|
|
||||||
defaultMessage: 'Your SMS token has expired.',
|
|
||||||
},
|
|
||||||
verifySms: {
|
|
||||||
id: 'sms_verification.modal.verify_sms',
|
|
||||||
defaultMessage: 'Verify SMS',
|
|
||||||
},
|
|
||||||
verifyNumber: {
|
|
||||||
id: 'sms_verification.modal.verify_number',
|
|
||||||
defaultMessage: 'Verify phone number',
|
|
||||||
},
|
|
||||||
verifyCode: {
|
|
||||||
id: 'sms_verification.modal.verify_code',
|
|
||||||
defaultMessage: 'Verify code',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
interface IVerifySmsModal {
|
|
||||||
onClose: (type: string) => void
|
|
||||||
}
|
|
||||||
|
|
||||||
enum Statuses {
|
|
||||||
IDLE = 'IDLE',
|
|
||||||
READY = 'READY',
|
|
||||||
REQUESTED = 'REQUESTED',
|
|
||||||
FAIL = 'FAIL',
|
|
||||||
SUCCESS = 'SUCCESS',
|
|
||||||
}
|
|
||||||
|
|
||||||
const VerifySmsModal: React.FC<IVerifySmsModal> = ({ onClose }) => {
|
|
||||||
const dispatch = useAppDispatch();
|
|
||||||
const intl = useIntl();
|
|
||||||
const instance = useInstance();
|
|
||||||
const accessToken = useAppSelector((state) => getAccessToken(state));
|
|
||||||
const isLoading = useAppSelector((state) => state.verification.isLoading);
|
|
||||||
|
|
||||||
const [status, setStatus] = useState<Statuses>(Statuses.IDLE);
|
|
||||||
const [phone, setPhone] = useState<string>();
|
|
||||||
const [verificationCode, setVerificationCode] = useState('');
|
|
||||||
const [requestedAnother, setAlreadyRequestedAnother] = useState(false);
|
|
||||||
|
|
||||||
const isValid = !!phone;
|
|
||||||
|
|
||||||
const onChange = useCallback((phone?: string) => {
|
|
||||||
setPhone(phone);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleSubmit = (event: React.MouseEvent) => {
|
|
||||||
event.preventDefault();
|
|
||||||
|
|
||||||
if (!isValid) {
|
|
||||||
setStatus(Statuses.IDLE);
|
|
||||||
toast.error(intl.formatMessage(messages.verificationInvalid));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
dispatch(reRequestPhoneVerification(phone!)).then(() => {
|
|
||||||
toast.success(
|
|
||||||
intl.formatMessage(messages.verificationSuccess),
|
|
||||||
);
|
|
||||||
})
|
|
||||||
.finally(() => setStatus(Statuses.REQUESTED))
|
|
||||||
.catch(() => {
|
|
||||||
toast.error(intl.formatMessage(messages.verificationFail));
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const resendVerificationCode = (event?: React.MouseEvent<HTMLButtonElement>) => {
|
|
||||||
setAlreadyRequestedAnother(true);
|
|
||||||
handleSubmit(event as React.MouseEvent<HTMLButtonElement>);
|
|
||||||
};
|
|
||||||
|
|
||||||
const onConfirmationClick = (event: any) => {
|
|
||||||
switch (status) {
|
|
||||||
case Statuses.IDLE:
|
|
||||||
setStatus(Statuses.READY);
|
|
||||||
break;
|
|
||||||
case Statuses.READY:
|
|
||||||
handleSubmit(event);
|
|
||||||
break;
|
|
||||||
case Statuses.REQUESTED:
|
|
||||||
submitVerification();
|
|
||||||
break;
|
|
||||||
default: break;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const confirmationText = useMemo(() => {
|
|
||||||
switch (status) {
|
|
||||||
case Statuses.IDLE:
|
|
||||||
return intl.formatMessage(messages.verifySms);
|
|
||||||
case Statuses.READY:
|
|
||||||
return intl.formatMessage(messages.verifyNumber);
|
|
||||||
case Statuses.REQUESTED:
|
|
||||||
return intl.formatMessage(messages.verifyCode);
|
|
||||||
default:
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}, [status]);
|
|
||||||
|
|
||||||
const renderModalBody = () => {
|
|
||||||
switch (status) {
|
|
||||||
case Statuses.IDLE:
|
|
||||||
return (
|
|
||||||
<Text theme='muted'>
|
|
||||||
<FormattedMessage
|
|
||||||
id='sms_verification.modal.verify_help_text'
|
|
||||||
defaultMessage='Verify your phone number to start using {instance}.'
|
|
||||||
values={{
|
|
||||||
instance: instance.title,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Text>
|
|
||||||
);
|
|
||||||
case Statuses.READY:
|
|
||||||
return (
|
|
||||||
<FormGroup labelText={<FormattedMessage id='sms_verification.phone.label' defaultMessage='Phone number' />}>
|
|
||||||
<PhoneInput
|
|
||||||
value={phone}
|
|
||||||
onChange={onChange}
|
|
||||||
required
|
|
||||||
autoFocus
|
|
||||||
/>
|
|
||||||
</FormGroup>
|
|
||||||
);
|
|
||||||
case Statuses.REQUESTED:
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<Text theme='muted' size='sm' align='center'>
|
|
||||||
<FormattedMessage
|
|
||||||
id='sms_verification.modal.enter_code'
|
|
||||||
defaultMessage='We sent you a 6-digit code via SMS. Enter it below.'
|
|
||||||
/>
|
|
||||||
</Text>
|
|
||||||
|
|
||||||
<OtpInput
|
|
||||||
value={verificationCode}
|
|
||||||
onChange={setVerificationCode}
|
|
||||||
numInputs={6}
|
|
||||||
isInputNum
|
|
||||||
shouldAutoFocus
|
|
||||||
isDisabled={isLoading}
|
|
||||||
containerStyle='flex justify-center mt-2 space-x-4'
|
|
||||||
inputStyle='w-10i border-gray-300 rounded-md focus:ring-indigo-500 focus:border-indigo-500'
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
default:
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const submitVerification = () => {
|
|
||||||
if (!accessToken) return;
|
|
||||||
// TODO: handle proper validation from Pepe -- expired vs invalid
|
|
||||||
dispatch(reConfirmPhoneVerification(verificationCode))
|
|
||||||
.then(() => {
|
|
||||||
setStatus(Statuses.SUCCESS);
|
|
||||||
// eslint-disable-next-line promise/catch-or-return
|
|
||||||
dispatch(verifyCredentials(accessToken))
|
|
||||||
.then(() => dispatch(closeModal('VERIFY_SMS')));
|
|
||||||
|
|
||||||
})
|
|
||||||
.catch(() => toast.error(intl.formatMessage(messages.verificationExpired)));
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (verificationCode.length === 6) {
|
|
||||||
submitVerification();
|
|
||||||
}
|
|
||||||
}, [verificationCode]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Modal
|
|
||||||
title={
|
|
||||||
<FormattedMessage
|
|
||||||
id='sms_verification.modal.verify_title'
|
|
||||||
defaultMessage='Verify your phone number'
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
onClose={() => onClose('VERIFY_SMS')}
|
|
||||||
cancelAction={status === Statuses.IDLE ? () => onClose('VERIFY_SMS') : undefined}
|
|
||||||
cancelText='Skip for now'
|
|
||||||
confirmationAction={onConfirmationClick}
|
|
||||||
confirmationText={confirmationText}
|
|
||||||
secondaryAction={status === Statuses.REQUESTED ? resendVerificationCode : undefined}
|
|
||||||
secondaryText={status === Statuses.REQUESTED ? (
|
|
||||||
<FormattedMessage
|
|
||||||
id='sms_verification.modal.resend_code'
|
|
||||||
defaultMessage='Resend verification code?'
|
|
||||||
/>
|
|
||||||
) : undefined}
|
|
||||||
secondaryDisabled={requestedAnother}
|
|
||||||
>
|
|
||||||
<Stack space={4}>
|
|
||||||
{renderModalBody()}
|
|
||||||
</Stack>
|
|
||||||
</Modal>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default VerifySmsModal;
|
|
|
@ -219,11 +219,11 @@ export function ListEditor() {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ListAdder() {
|
export function ListAdder() {
|
||||||
return import(/*webpackChunkName: "features/list_adder" */'../../list-adder');
|
return import('../../list-adder');
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Search() {
|
export function Search() {
|
||||||
return import(/*webpackChunkName: "features/search" */'../../search');
|
return import('../../search');
|
||||||
}
|
}
|
||||||
|
|
||||||
export function LoginPage() {
|
export function LoginPage() {
|
||||||
|
@ -478,24 +478,16 @@ export function OnboardingWizard() {
|
||||||
return import('../../onboarding/onboarding-wizard');
|
return import('../../onboarding/onboarding-wizard');
|
||||||
}
|
}
|
||||||
|
|
||||||
export function WaitlistPage() {
|
|
||||||
return import('../../verification/waitlist-page');
|
|
||||||
}
|
|
||||||
|
|
||||||
export function CompareHistoryModal() {
|
export function CompareHistoryModal() {
|
||||||
return import(/*webpackChunkName: "modals/compare_history_modal" */'../components/modals/compare-history-modal');
|
return import('../components/modals/compare-history-modal');
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AuthTokenList() {
|
export function AuthTokenList() {
|
||||||
return import('../../auth-token-list');
|
return import('../../auth-token-list');
|
||||||
}
|
}
|
||||||
|
|
||||||
export function VerifySmsModal() {
|
|
||||||
return import('../components/modals/verify-sms-modal');
|
|
||||||
}
|
|
||||||
|
|
||||||
export function FamiliarFollowersModal() {
|
export function FamiliarFollowersModal() {
|
||||||
return import(/*webpackChunkName: "modals/familiar_followers_modal" */'../components/modals/familiar-followers-modal');
|
return import('../components/modals/familiar-followers-modal');
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AnnouncementsPanel() {
|
export function AnnouncementsPanel() {
|
||||||
|
@ -503,7 +495,7 @@ export function AnnouncementsPanel() {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Quotes() {
|
export function Quotes() {
|
||||||
return import(/*webpackChunkName: "features/quotes" */'../../quotes');
|
return import('../../quotes');
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ComposeEventModal() {
|
export function ComposeEventModal() {
|
||||||
|
|
|
@ -1,105 +0,0 @@
|
||||||
import { Map as ImmutableMap, Record as ImmutableRecord } from 'immutable';
|
|
||||||
import React from 'react';
|
|
||||||
import { Route, Switch } from 'react-router-dom';
|
|
||||||
|
|
||||||
import { __stub } from 'soapbox/api';
|
|
||||||
|
|
||||||
import { render, screen } from '../../../jest/test-helpers';
|
|
||||||
import Verification from '../index';
|
|
||||||
|
|
||||||
const TestableComponent = () => (
|
|
||||||
<Switch>
|
|
||||||
<Route path='/verify' exact><Verification /></Route>
|
|
||||||
<Route path='/' exact><span data-testid='home'>Homepage</span></Route>
|
|
||||||
</Switch>
|
|
||||||
);
|
|
||||||
|
|
||||||
const renderComponent = (store: any) => render(
|
|
||||||
<TestableComponent />,
|
|
||||||
{},
|
|
||||||
store,
|
|
||||||
{ initialEntries: ['/verify'] },
|
|
||||||
);
|
|
||||||
|
|
||||||
describe('<Verification />', () => {
|
|
||||||
let store: any;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
store = {
|
|
||||||
verification: ImmutableRecord({
|
|
||||||
instance: ImmutableMap({
|
|
||||||
isReady: true,
|
|
||||||
registrations: true,
|
|
||||||
}),
|
|
||||||
ageMinimum: null,
|
|
||||||
currentChallenge: null,
|
|
||||||
isLoading: false,
|
|
||||||
isComplete: false,
|
|
||||||
token: null,
|
|
||||||
})(),
|
|
||||||
};
|
|
||||||
|
|
||||||
__stub(mock => {
|
|
||||||
mock.onGet('/api/v1/pepe/instance')
|
|
||||||
.reply(200, {
|
|
||||||
age_minimum: 18,
|
|
||||||
approval_required: true,
|
|
||||||
challenges: ['age', 'email', 'sms'],
|
|
||||||
});
|
|
||||||
|
|
||||||
mock.onPost('/api/v1/pepe/registrations')
|
|
||||||
.reply(200, {
|
|
||||||
access_token: 'N-dZmNqNSmTutJLsGjZ5AnJL4sLw_y-N3pn2acSqJY8',
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('When registration is closed', () => {
|
|
||||||
it('successfully redirects to the homepage', () => {
|
|
||||||
const verification = store.verification.setIn(['instance', 'registrations'], false);
|
|
||||||
store.verification = verification;
|
|
||||||
|
|
||||||
renderComponent(store);
|
|
||||||
expect(screen.getByTestId('home')).toHaveTextContent('Homepage');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('When verification is complete', () => {
|
|
||||||
it('successfully renders the Registration component', () => {
|
|
||||||
const verification = store.verification.set('isComplete', true);
|
|
||||||
store.verification = verification;
|
|
||||||
|
|
||||||
renderComponent(store);
|
|
||||||
expect(screen.getByRole('heading')).toHaveTextContent('Register your account');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('Switching verification steps', () => {
|
|
||||||
it('successfully renders the Birthday step', () => {
|
|
||||||
const verification = store.verification.set('currentChallenge', 'age');
|
|
||||||
store.verification = verification;
|
|
||||||
|
|
||||||
renderComponent(store);
|
|
||||||
|
|
||||||
expect(screen.getByRole('heading')).toHaveTextContent('Enter your birth date');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('successfully renders the Email step', () => {
|
|
||||||
const verification = store.verification.set('currentChallenge', 'email');
|
|
||||||
store.verification = verification;
|
|
||||||
|
|
||||||
renderComponent(store);
|
|
||||||
|
|
||||||
expect(screen.getByRole('heading')).toHaveTextContent('Enter your email address');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('successfully renders the SMS step', () => {
|
|
||||||
const verification = store.verification.set('currentChallenge', 'sms');
|
|
||||||
store.verification = verification;
|
|
||||||
|
|
||||||
renderComponent(store);
|
|
||||||
|
|
||||||
expect(screen.getByRole('heading')).toHaveTextContent('Enter your phone number');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
|
@ -1,117 +0,0 @@
|
||||||
import React from 'react';
|
|
||||||
|
|
||||||
import { __stub } from 'soapbox/api';
|
|
||||||
|
|
||||||
import { fireEvent, render, screen, waitFor } from '../../../jest/test-helpers';
|
|
||||||
import Registration from '../registration';
|
|
||||||
|
|
||||||
describe('<Registration />', () => {
|
|
||||||
it('renders', () => {
|
|
||||||
render(<Registration />);
|
|
||||||
|
|
||||||
expect(screen.getByRole('heading')).toHaveTextContent(/register your account/i);
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('with valid data', () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
__stub(mock => {
|
|
||||||
mock.onPost('/api/v1/pepe/accounts').reply(200, {});
|
|
||||||
mock.onPost('/api/v1/apps').reply(200, {});
|
|
||||||
mock.onPost('/oauth/token').reply(200, {});
|
|
||||||
mock.onGet('/api/v1/accounts/verify_credentials').reply(200, { id: '123' });
|
|
||||||
mock.onGet('/api/v1/instance').reply(200, {});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('handles successful submission', async() => {
|
|
||||||
render(<Registration />);
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
fireEvent.submit(screen.getByTestId('button'), { preventDefault: () => {} });
|
|
||||||
});
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(screen.getByTestId('toast')).toHaveTextContent(/welcome to/i);
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(screen.queryAllByRole('heading')).toHaveLength(0);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('with invalid data', () => {
|
|
||||||
it('handles 422 errors', async() => {
|
|
||||||
__stub(mock => {
|
|
||||||
mock.onPost('/api/v1/pepe/accounts').reply(
|
|
||||||
422, {
|
|
||||||
error: 'user_taken',
|
|
||||||
},
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
render(<Registration />);
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
fireEvent.submit(screen.getByTestId('button'), { preventDefault: () => {} });
|
|
||||||
});
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(screen.getByTestId('toast')).toHaveTextContent(/this username has already been taken/i);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('handles 422 errors with messages', async() => {
|
|
||||||
__stub(mock => {
|
|
||||||
mock.onPost('/api/v1/pepe/accounts').reply(
|
|
||||||
422, {
|
|
||||||
error: 'user_vip',
|
|
||||||
message: 'This username is unavailable.',
|
|
||||||
},
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
render(<Registration />);
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
fireEvent.submit(screen.getByTestId('button'), { preventDefault: () => {} });
|
|
||||||
});
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(screen.getByTestId('toast')).toHaveTextContent(/this username is unavailable/i);
|
|
||||||
});
|
|
||||||
|
|
||||||
});
|
|
||||||
|
|
||||||
it('handles generic errors', async() => {
|
|
||||||
__stub(mock => {
|
|
||||||
mock.onPost('/api/v1/pepe/accounts').reply(500, {});
|
|
||||||
});
|
|
||||||
|
|
||||||
render(<Registration />);
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
fireEvent.submit(screen.getByTestId('button'), { preventDefault: () => {} });
|
|
||||||
});
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(screen.getByTestId('toast')).toHaveTextContent(/failed to register your account/i);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('validations', () => {
|
|
||||||
it('should undisable button with valid password', async() => {
|
|
||||||
render(<Registration />);
|
|
||||||
|
|
||||||
expect(screen.getByTestId('button')).toBeDisabled();
|
|
||||||
fireEvent.change(screen.getByTestId('password-input'), { target: { value: 'Password' } });
|
|
||||||
expect(screen.getByTestId('button')).not.toBeDisabled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should disable button with invalid password', async() => {
|
|
||||||
render(<Registration />);
|
|
||||||
|
|
||||||
fireEvent.change(screen.getByTestId('password-input'), { target: { value: 'Passwor' } });
|
|
||||||
expect(screen.getByTestId('button')).toBeDisabled();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
|
@ -1,72 +0,0 @@
|
||||||
import React, { useEffect, useMemo } from 'react';
|
|
||||||
import { defineMessages, useIntl } from 'react-intl';
|
|
||||||
|
|
||||||
import { Stack } from 'soapbox/components/ui';
|
|
||||||
import ValidationCheckmark from 'soapbox/components/validation-checkmark';
|
|
||||||
|
|
||||||
const messages = defineMessages({
|
|
||||||
minimumCharacters: {
|
|
||||||
id: 'registration.validation.minimum_characters',
|
|
||||||
defaultMessage: '8 characters',
|
|
||||||
},
|
|
||||||
capitalLetter: {
|
|
||||||
id: 'registration.validation.capital_letter',
|
|
||||||
defaultMessage: '1 capital letter',
|
|
||||||
},
|
|
||||||
lowercaseLetter: {
|
|
||||||
id: 'registration.validation.lowercase_letter',
|
|
||||||
defaultMessage: '1 lowercase letter',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const hasUppercaseCharacter = (string: string) => {
|
|
||||||
for (let i = 0; i < string.length; i++) {
|
|
||||||
if (string.charAt(i) === string.charAt(i).toUpperCase() && string.charAt(i).match(/[a-z]/i)) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
};
|
|
||||||
|
|
||||||
const hasLowercaseCharacter = (string: string) => {
|
|
||||||
return string.toUpperCase() !== string;
|
|
||||||
};
|
|
||||||
|
|
||||||
interface IPasswordIndicator {
|
|
||||||
onChange(isValid: boolean): void
|
|
||||||
password: string
|
|
||||||
}
|
|
||||||
|
|
||||||
const PasswordIndicator = ({ onChange, password }: IPasswordIndicator) => {
|
|
||||||
const intl = useIntl();
|
|
||||||
|
|
||||||
const meetsLengthRequirements = useMemo(() => password.length >= 8, [password]);
|
|
||||||
const meetsCapitalLetterRequirements = useMemo(() => hasUppercaseCharacter(password), [password]);
|
|
||||||
const meetsLowercaseLetterRequirements = useMemo(() => hasLowercaseCharacter(password), [password]);
|
|
||||||
const hasValidPassword = meetsLengthRequirements && meetsCapitalLetterRequirements && meetsLowercaseLetterRequirements;
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
onChange(hasValidPassword);
|
|
||||||
}, [hasValidPassword]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Stack className='mt-2' space={1}>
|
|
||||||
<ValidationCheckmark
|
|
||||||
isValid={meetsLengthRequirements}
|
|
||||||
text={intl.formatMessage(messages.minimumCharacters)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<ValidationCheckmark
|
|
||||||
isValid={meetsCapitalLetterRequirements}
|
|
||||||
text={intl.formatMessage(messages.capitalLetter)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<ValidationCheckmark
|
|
||||||
isValid={meetsLowercaseLetterRequirements}
|
|
||||||
text={intl.formatMessage(messages.lowercaseLetter)}
|
|
||||||
/>
|
|
||||||
</Stack>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default PasswordIndicator;
|
|
|
@ -1,167 +0,0 @@
|
||||||
import React from 'react';
|
|
||||||
import { defineMessages, useIntl } from 'react-intl';
|
|
||||||
import { useHistory, useParams } from 'react-router-dom';
|
|
||||||
|
|
||||||
import { confirmEmailVerification } from 'soapbox/actions/verification';
|
|
||||||
import { Icon, Spinner, Stack, Text } from 'soapbox/components/ui';
|
|
||||||
import { useAppDispatch, useAppSelector } from 'soapbox/hooks';
|
|
||||||
import toast from 'soapbox/toast';
|
|
||||||
|
|
||||||
import { ChallengeTypes } from './index';
|
|
||||||
|
|
||||||
import type { AxiosError } from 'axios';
|
|
||||||
|
|
||||||
const Statuses = {
|
|
||||||
IDLE: 'IDLE',
|
|
||||||
SUCCESS: 'SUCCESS',
|
|
||||||
GENERIC_FAIL: 'GENERIC_FAIL',
|
|
||||||
TOKEN_NOT_FOUND: 'TOKEN_NOT_FOUND',
|
|
||||||
TOKEN_EXPIRED: 'TOKEN_EXPIRED',
|
|
||||||
};
|
|
||||||
|
|
||||||
const messages = defineMessages({
|
|
||||||
emailConfirmedHeading: { id: 'email_passthru.confirmed.heading', defaultMessage: 'Email Confirmed!' },
|
|
||||||
emailConfirmedBody: { id: 'email_passthru.confirmed.body', defaultMessage: 'Close this tab and continue the registration process on the {bold} from which you sent this email confirmation.' },
|
|
||||||
genericFailHeading: { id: 'email_passthru.generic_fail.heading', defaultMessage: 'Something Went Wrong' },
|
|
||||||
genericFailBody: { id: 'email_passthru.generic_fail.body', defaultMessage: 'Please request a new email confirmation.' },
|
|
||||||
tokenNotFoundHeading: { id: 'email_passthru.token_not_found.heading', defaultMessage: 'Invalid Token' },
|
|
||||||
tokenNotFoundBody: { id: 'email_passthru.token_not_found.body', defaultMessage: 'Your email token was not found. Please request a new email confirmation from the {bold} from which you sent this email confirmation.' },
|
|
||||||
tokenExpiredHeading: { id: 'email_passthru.token_expired.heading', defaultMessage: 'Token Expired' },
|
|
||||||
tokenExpiredBody: { id: 'email_passthru.token_expired.body', defaultMessage: 'Your email token has expired. Please request a new email confirmation from the {bold} from which you sent this email confirmation.' },
|
|
||||||
emailConfirmed: { id: 'email_passthru.success', defaultMessage: 'Your email has been verified!' },
|
|
||||||
genericFail: { id: 'email_passthru.fail.generic', defaultMessage: 'Unable to confirm your email' },
|
|
||||||
tokenExpired: { id: 'email_passthru.fail.expired', defaultMessage: 'Your email token has expired' },
|
|
||||||
tokenNotFound: { id: 'email_passthru.fail.not_found', defaultMessage: 'Your email token is invalid.' },
|
|
||||||
invalidToken: { id: 'email_passthru.fail.invalid_token', defaultMessage: 'Your token is invalid' },
|
|
||||||
});
|
|
||||||
|
|
||||||
const Success = () => {
|
|
||||||
const intl = useIntl();
|
|
||||||
const history = useHistory();
|
|
||||||
const currentChallenge = useAppSelector((state) => state.verification.currentChallenge as ChallengeTypes);
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
|
||||||
// Bypass the user straight to the next step.
|
|
||||||
if (currentChallenge === ChallengeTypes.SMS) {
|
|
||||||
history.push('/verify');
|
|
||||||
}
|
|
||||||
}, [currentChallenge]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Stack space={4} alignItems='center'>
|
|
||||||
<Icon src={require('@tabler/icons/circle-check.svg')} className='h-10 w-10 text-primary-600 dark:text-primary-400' />
|
|
||||||
<Text size='3xl' weight='semibold' align='center'>
|
|
||||||
{intl.formatMessage(messages.emailConfirmedHeading)}
|
|
||||||
</Text>
|
|
||||||
<Text theme='muted' align='center'>
|
|
||||||
{intl.formatMessage(messages.emailConfirmedBody, { bold: <Text tag='span' weight='medium'>same device</Text> })}
|
|
||||||
</Text>
|
|
||||||
</Stack>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const GenericFail = () => {
|
|
||||||
const intl = useIntl();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Stack space={4} alignItems='center'>
|
|
||||||
<Icon src={require('@tabler/icons/circle-x.svg')} className='h-10 w-10 text-danger-600' />
|
|
||||||
<Text size='3xl' weight='semibold' align='center'>
|
|
||||||
{intl.formatMessage(messages.genericFailHeading)}
|
|
||||||
</Text>
|
|
||||||
<Text theme='muted' align='center'>
|
|
||||||
{intl.formatMessage(messages.genericFailBody)}
|
|
||||||
</Text>
|
|
||||||
</Stack>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const TokenNotFound = () => {
|
|
||||||
const intl = useIntl();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Stack space={4} alignItems='center'>
|
|
||||||
<Icon src={require('@tabler/icons/circle-x.svg')} className='h-10 w-10 text-danger-600' />
|
|
||||||
<Text size='3xl' weight='semibold' align='center'>
|
|
||||||
{intl.formatMessage(messages.tokenNotFoundHeading)}
|
|
||||||
</Text>
|
|
||||||
<Text theme='muted' align='center'>
|
|
||||||
{intl.formatMessage(messages.tokenNotFoundBody, { bold: <Text tag='span' weight='medium'>same device</Text> })}
|
|
||||||
|
|
||||||
</Text>
|
|
||||||
</Stack>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const TokenExpired = () => {
|
|
||||||
const intl = useIntl();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Stack space={4} alignItems='center'>
|
|
||||||
<Icon src={require('@tabler/icons/circle-x.svg')} className='h-10 w-10 text-danger-600' />
|
|
||||||
<Text size='3xl' weight='semibold' align='center'>
|
|
||||||
{intl.formatMessage(messages.tokenExpiredHeading)}
|
|
||||||
</Text>
|
|
||||||
<Text theme='muted' align='center'>
|
|
||||||
{intl.formatMessage(messages.tokenExpiredBody, { bold: <Text tag='span' weight='medium'>same device</Text> })}
|
|
||||||
</Text>
|
|
||||||
</Stack>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const EmailPassThru = () => {
|
|
||||||
const { token } = useParams<{ token: string }>();
|
|
||||||
|
|
||||||
const dispatch = useAppDispatch();
|
|
||||||
const intl = useIntl();
|
|
||||||
|
|
||||||
const [status, setStatus] = React.useState(Statuses.IDLE);
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
|
||||||
if (token) {
|
|
||||||
dispatch(confirmEmailVerification(token))
|
|
||||||
.then(() => {
|
|
||||||
setStatus(Statuses.SUCCESS);
|
|
||||||
toast.success(intl.formatMessage(messages.emailConfirmed));
|
|
||||||
})
|
|
||||||
.catch((error: AxiosError<any>) => {
|
|
||||||
const errorKey = error?.response?.data?.error;
|
|
||||||
let message = intl.formatMessage(messages.genericFail);
|
|
||||||
|
|
||||||
if (errorKey) {
|
|
||||||
switch (errorKey) {
|
|
||||||
case 'token_expired':
|
|
||||||
message = intl.formatMessage(messages.tokenExpired);
|
|
||||||
setStatus(Statuses.TOKEN_EXPIRED);
|
|
||||||
break;
|
|
||||||
case 'token_not_found':
|
|
||||||
message = intl.formatMessage(messages.tokenNotFound);
|
|
||||||
message = intl.formatMessage(messages.invalidToken);
|
|
||||||
setStatus(Statuses.TOKEN_NOT_FOUND);
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
setStatus(Statuses.GENERIC_FAIL);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
toast.error(message);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}, [token]);
|
|
||||||
|
|
||||||
switch (status) {
|
|
||||||
case Statuses.SUCCESS:
|
|
||||||
return <Success />;
|
|
||||||
case Statuses.TOKEN_EXPIRED:
|
|
||||||
return <TokenExpired />;
|
|
||||||
case Statuses.TOKEN_NOT_FOUND:
|
|
||||||
return <TokenNotFound />;
|
|
||||||
case Statuses.GENERIC_FAIL:
|
|
||||||
return <GenericFail />;
|
|
||||||
default:
|
|
||||||
return <Spinner />;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export default EmailPassThru;
|
|
|
@ -1,56 +0,0 @@
|
||||||
import React from 'react';
|
|
||||||
import { Redirect } from 'react-router-dom';
|
|
||||||
|
|
||||||
import { fetchVerificationConfig } from 'soapbox/actions/verification';
|
|
||||||
import { useAppDispatch, useAppSelector } from 'soapbox/hooks';
|
|
||||||
|
|
||||||
import Registration from './registration';
|
|
||||||
import AgeVerification from './steps/age-verification';
|
|
||||||
import EmailVerification from './steps/email-verification';
|
|
||||||
import SmsVerification from './steps/sms-verification';
|
|
||||||
|
|
||||||
export enum ChallengeTypes {
|
|
||||||
EMAIL = 'email',
|
|
||||||
SMS = 'sms',
|
|
||||||
AGE = 'age',
|
|
||||||
}
|
|
||||||
|
|
||||||
const verificationSteps = {
|
|
||||||
email: EmailVerification,
|
|
||||||
sms: SmsVerification,
|
|
||||||
age: AgeVerification,
|
|
||||||
};
|
|
||||||
|
|
||||||
const Verification = () => {
|
|
||||||
const dispatch = useAppDispatch();
|
|
||||||
|
|
||||||
const isInstanceReady = useAppSelector((state) => state.verification.instance.get('isReady') === true);
|
|
||||||
const isRegistrationOpen = useAppSelector(state => state.verification.instance.get('registrations') === true);
|
|
||||||
const currentChallenge = useAppSelector((state) => state.verification.currentChallenge as ChallengeTypes);
|
|
||||||
const isVerificationComplete = useAppSelector((state) => state.verification.isComplete);
|
|
||||||
const StepToRender = verificationSteps[currentChallenge];
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
|
||||||
dispatch(fetchVerificationConfig());
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
if (isInstanceReady && !isRegistrationOpen) {
|
|
||||||
return <Redirect to='/' />;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isVerificationComplete) {
|
|
||||||
return (
|
|
||||||
<Registration />
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!currentChallenge) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<StepToRender />
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default Verification;
|
|
|
@ -1,161 +0,0 @@
|
||||||
import React from 'react';
|
|
||||||
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
|
|
||||||
import { Redirect } from 'react-router-dom';
|
|
||||||
|
|
||||||
import { logIn, verifyCredentials } from 'soapbox/actions/auth';
|
|
||||||
import { fetchInstance } from 'soapbox/actions/instance';
|
|
||||||
import { startOnboarding } from 'soapbox/actions/onboarding';
|
|
||||||
import { createAccount, removeStoredVerification } from 'soapbox/actions/verification';
|
|
||||||
import { Button, Form, FormGroup, Input, Text } from 'soapbox/components/ui';
|
|
||||||
import { useAppDispatch, useAppSelector, useInstance, useSoapboxConfig } from 'soapbox/hooks';
|
|
||||||
import toast from 'soapbox/toast';
|
|
||||||
import { getRedirectUrl } from 'soapbox/utils/redirect';
|
|
||||||
|
|
||||||
import PasswordIndicator from './components/password-indicator';
|
|
||||||
|
|
||||||
import type { AxiosError } from 'axios';
|
|
||||||
|
|
||||||
const messages = defineMessages({
|
|
||||||
success: { id: 'registrations.success', defaultMessage: 'Welcome to {siteTitle}!' },
|
|
||||||
usernameLabel: { id: 'registrations.username.label', defaultMessage: 'Your username' },
|
|
||||||
usernameHint: { id: 'registrations.username.hint', defaultMessage: 'May only contain A-Z, 0-9, and underscores' },
|
|
||||||
usernameTaken: { id: 'registrations.unprocessable_entity', defaultMessage: 'This username has already been taken.' },
|
|
||||||
passwordLabel: { id: 'registrations.password.label', defaultMessage: 'Password' },
|
|
||||||
error: { id: 'registrations.error', defaultMessage: 'Failed to register your account.' },
|
|
||||||
});
|
|
||||||
|
|
||||||
const initialState = {
|
|
||||||
username: '',
|
|
||||||
password: '',
|
|
||||||
};
|
|
||||||
|
|
||||||
const Registration = () => {
|
|
||||||
const dispatch = useAppDispatch();
|
|
||||||
const intl = useIntl();
|
|
||||||
const instance = useInstance();
|
|
||||||
const soapboxConfig = useSoapboxConfig();
|
|
||||||
const { links } = soapboxConfig;
|
|
||||||
|
|
||||||
const isLoading = useAppSelector((state) => state.verification.isLoading as boolean);
|
|
||||||
|
|
||||||
const [state, setState] = React.useState(initialState);
|
|
||||||
const [shouldRedirect, setShouldRedirect] = React.useState<boolean>(false);
|
|
||||||
const [hasValidPassword, setHasValidPassword] = React.useState<boolean>(false);
|
|
||||||
const { username, password } = state;
|
|
||||||
|
|
||||||
const handleSubmit: React.FormEventHandler = React.useCallback((event) => {
|
|
||||||
event.preventDefault();
|
|
||||||
|
|
||||||
dispatch(createAccount(username, password))
|
|
||||||
.then(() => dispatch(logIn(username, password)))
|
|
||||||
.then(({ access_token }: any) => dispatch(verifyCredentials(access_token)))
|
|
||||||
.then(() => dispatch(fetchInstance()))
|
|
||||||
.then(() => {
|
|
||||||
setShouldRedirect(true);
|
|
||||||
removeStoredVerification();
|
|
||||||
dispatch(startOnboarding());
|
|
||||||
toast.success(
|
|
||||||
intl.formatMessage(messages.success, { siteTitle: instance.title }),
|
|
||||||
);
|
|
||||||
})
|
|
||||||
.catch((errorResponse: AxiosError<{ error: string, message: string }>) => {
|
|
||||||
const error = errorResponse.response?.data?.error;
|
|
||||||
|
|
||||||
if (error) {
|
|
||||||
toast.error(errorResponse.response?.data?.message || intl.formatMessage(messages.usernameTaken));
|
|
||||||
} else {
|
|
||||||
toast.error(intl.formatMessage(messages.error));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}, [username, password]);
|
|
||||||
|
|
||||||
const handleInputChange: React.ChangeEventHandler<HTMLInputElement> = React.useCallback((event) => {
|
|
||||||
event.persist();
|
|
||||||
|
|
||||||
setState((prevState) => ({ ...prevState, [event.target.name]: event.target.value }));
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
if (shouldRedirect) {
|
|
||||||
const redirectUri = getRedirectUrl();
|
|
||||||
return <Redirect to={redirectUri} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<div className='-mx-4 mb-4 border-b border-solid border-gray-200 pb-4 dark:border-gray-800 sm:-mx-10 sm:pb-10'>
|
|
||||||
<h1 className='text-center text-2xl font-bold'>
|
|
||||||
<FormattedMessage id='registration.header' defaultMessage='Register your account' />
|
|
||||||
</h1>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className='mx-auto space-y-4 sm:w-2/3 sm:pt-10 md:w-1/2'>
|
|
||||||
<Form onSubmit={handleSubmit}>
|
|
||||||
<FormGroup labelText={intl.formatMessage(messages.usernameLabel)} hintText={intl.formatMessage(messages.usernameHint)}>
|
|
||||||
<Input
|
|
||||||
name='username'
|
|
||||||
type='text'
|
|
||||||
value={username}
|
|
||||||
onChange={handleInputChange}
|
|
||||||
required
|
|
||||||
icon={require('@tabler/icons/at.svg')}
|
|
||||||
placeholder='LibertyForAll'
|
|
||||||
/>
|
|
||||||
</FormGroup>
|
|
||||||
|
|
||||||
<FormGroup labelText={intl.formatMessage(messages.passwordLabel)}>
|
|
||||||
<Input
|
|
||||||
name='password'
|
|
||||||
type='password'
|
|
||||||
value={password}
|
|
||||||
onChange={handleInputChange}
|
|
||||||
required
|
|
||||||
data-testid='password-input'
|
|
||||||
/>
|
|
||||||
|
|
||||||
<PasswordIndicator password={password} onChange={setHasValidPassword} />
|
|
||||||
</FormGroup>
|
|
||||||
|
|
||||||
<div className='space-y-2 text-center'>
|
|
||||||
<Button
|
|
||||||
block
|
|
||||||
theme='primary'
|
|
||||||
type='submit'
|
|
||||||
disabled={isLoading || !hasValidPassword}
|
|
||||||
>
|
|
||||||
<FormattedMessage id='header.register.label' defaultMessage='Register' />
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
{(links.get('termsOfService') && links.get('privacyPolicy')) ? (
|
|
||||||
<Text theme='muted' size='xs'>
|
|
||||||
<FormattedMessage
|
|
||||||
id='registration.acceptance'
|
|
||||||
defaultMessage='By registering, you agree to the {terms} and {privacy}.'
|
|
||||||
values={{
|
|
||||||
terms: (
|
|
||||||
<a href={links.get('termsOfService')} target='_blank' className='text-primary-600 hover:underline dark:text-primary-400'>
|
|
||||||
<FormattedMessage
|
|
||||||
id='registration.tos'
|
|
||||||
defaultMessage='Terms of Service'
|
|
||||||
/>
|
|
||||||
</a>
|
|
||||||
),
|
|
||||||
privacy: (
|
|
||||||
<a href={links.get('privacyPolicy')} target='_blank' className='text-primary-600 hover:underline dark:text-primary-400'>
|
|
||||||
<FormattedMessage
|
|
||||||
id='registration.privacy'
|
|
||||||
defaultMessage='Privacy Policy'
|
|
||||||
/>
|
|
||||||
</a>
|
|
||||||
),
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Text>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
</Form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default Registration;
|
|
|
@ -1,53 +0,0 @@
|
||||||
import userEvent from '@testing-library/user-event';
|
|
||||||
import { Map as ImmutableMap } from 'immutable';
|
|
||||||
import React from 'react';
|
|
||||||
|
|
||||||
import { __stub } from 'soapbox/api';
|
|
||||||
import { fireEvent, render, screen } from 'soapbox/jest/test-helpers';
|
|
||||||
|
|
||||||
import AgeVerification from '../age-verification';
|
|
||||||
|
|
||||||
describe('<AgeVerification />', () => {
|
|
||||||
let store: any;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
store = {
|
|
||||||
verification: ImmutableMap({
|
|
||||||
ageMinimum: 13,
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
|
|
||||||
__stub(mock => {
|
|
||||||
mock.onPost('/api/v1/pepe/verify_age/confirm')
|
|
||||||
.reply(200, {});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('successfully renders the Birthday step', async() => {
|
|
||||||
render(
|
|
||||||
<AgeVerification />,
|
|
||||||
{},
|
|
||||||
store,
|
|
||||||
);
|
|
||||||
expect(screen.getByRole('heading')).toHaveTextContent('Enter your birth date');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('selects a date', async() => {
|
|
||||||
render(
|
|
||||||
<AgeVerification />,
|
|
||||||
{},
|
|
||||||
store,
|
|
||||||
);
|
|
||||||
|
|
||||||
await userEvent.selectOptions(
|
|
||||||
screen.getByTestId('datepicker-year'),
|
|
||||||
screen.getByRole('option', { name: '2020' }),
|
|
||||||
);
|
|
||||||
|
|
||||||
fireEvent.submit(
|
|
||||||
screen.getByRole('button'), {
|
|
||||||
preventDefault: () => {},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
|
@ -1,68 +0,0 @@
|
||||||
import userEvent from '@testing-library/user-event';
|
|
||||||
import React from 'react';
|
|
||||||
|
|
||||||
import { __stub } from 'soapbox/api';
|
|
||||||
import { fireEvent, render, screen, waitFor } from 'soapbox/jest/test-helpers';
|
|
||||||
|
|
||||||
import EmailVerification from '../email-verification';
|
|
||||||
|
|
||||||
describe('<EmailVerification />', () => {
|
|
||||||
it('successfully renders the Email step', async() => {
|
|
||||||
render(<EmailVerification />);
|
|
||||||
expect(screen.getByRole('heading')).toHaveTextContent('Enter your email address');
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('with valid data', () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
__stub(mock => {
|
|
||||||
mock.onPost('/api/v1/pepe/verify_email/request')
|
|
||||||
.reply(200, {});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('successfully submits', async() => {
|
|
||||||
render(<EmailVerification />);
|
|
||||||
|
|
||||||
await userEvent.type(screen.getByLabelText('E-mail address'), 'foo@bar.com{enter}');
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
fireEvent.submit(
|
|
||||||
screen.getByTestId('button'), {
|
|
||||||
preventDefault: () => {},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(screen.getByTestId('button')).toHaveTextContent('Resend verification email');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('with invalid data', () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
__stub(mock => {
|
|
||||||
mock.onPost('/api/v1/pepe/verify_email/request')
|
|
||||||
.reply(422, {
|
|
||||||
error: 'email_taken',
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('renders errors', async() => {
|
|
||||||
render(<EmailVerification />);
|
|
||||||
|
|
||||||
await userEvent.type(screen.getByLabelText('E-mail address'), 'foo@bar.com{enter}');
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
fireEvent.submit(
|
|
||||||
screen.getByTestId('button'), {
|
|
||||||
preventDefault: () => {},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(screen.getByTestId('form-group-error')).toHaveTextContent('is taken');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
|
@ -1,120 +0,0 @@
|
||||||
import userEvent from '@testing-library/user-event';
|
|
||||||
import React from 'react';
|
|
||||||
import { act } from 'react-dom/test-utils';
|
|
||||||
import { toast } from 'react-hot-toast';
|
|
||||||
|
|
||||||
import { __stub } from 'soapbox/api';
|
|
||||||
import { fireEvent, render, screen, waitFor } from 'soapbox/jest/test-helpers';
|
|
||||||
|
|
||||||
import SmsVerification from '../sms-verification';
|
|
||||||
|
|
||||||
describe('<SmsVerification />', () => {
|
|
||||||
it('successfully renders the SMS step', async() => {
|
|
||||||
render(<SmsVerification />);
|
|
||||||
expect(screen.getByRole('heading')).toHaveTextContent('Enter your phone number');
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('with valid data', () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
__stub(mock => {
|
|
||||||
mock.onPost('/api/v1/pepe/verify_sms/request').reply(200, {});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('successfully submits', async() => {
|
|
||||||
__stub(mock => {
|
|
||||||
mock.onPost('/api/v1/pepe/verify_sms/confirm').reply(200, {});
|
|
||||||
});
|
|
||||||
|
|
||||||
render(<SmsVerification />);
|
|
||||||
|
|
||||||
await userEvent.type(screen.getByLabelText('Phone number'), '+1 (555) 555-5555');
|
|
||||||
await waitFor(() => {
|
|
||||||
fireEvent.submit(
|
|
||||||
screen.getByRole('button', { name: 'Next' }), {
|
|
||||||
preventDefault: () => {},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(screen.getByRole('heading')).toHaveTextContent('Verification code');
|
|
||||||
expect(screen.getByTestId('toast')).toHaveTextContent('A verification code has been sent to your phone number.');
|
|
||||||
});
|
|
||||||
|
|
||||||
act(() => {
|
|
||||||
toast.remove();
|
|
||||||
});
|
|
||||||
|
|
||||||
await userEvent.type(screen.getByLabelText('Please enter verification code. Digit 1'), '1');
|
|
||||||
await userEvent.type(screen.getByLabelText('Digit 2'), '2');
|
|
||||||
await userEvent.type(screen.getByLabelText('Digit 3'), '3');
|
|
||||||
await userEvent.type(screen.getByLabelText('Digit 4'), '4');
|
|
||||||
await userEvent.type(screen.getByLabelText('Digit 5'), '5');
|
|
||||||
await userEvent.type(screen.getByLabelText('Digit 6'), '6');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('handle expired tokens', async() => {
|
|
||||||
__stub(mock => {
|
|
||||||
mock.onPost('/api/v1/pepe/verify_sms/confirm').reply(422, {});
|
|
||||||
});
|
|
||||||
|
|
||||||
render(<SmsVerification />);
|
|
||||||
|
|
||||||
await userEvent.type(screen.getByLabelText('Phone number'), '+1 (555) 555-5555');
|
|
||||||
await waitFor(() => {
|
|
||||||
fireEvent.submit(
|
|
||||||
screen.getByRole('button', { name: 'Next' }), {
|
|
||||||
preventDefault: () => {},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(screen.getByRole('heading')).toHaveTextContent('Verification code');
|
|
||||||
expect(screen.getByTestId('toast')).toHaveTextContent('A verification code has been sent to your phone number.');
|
|
||||||
});
|
|
||||||
|
|
||||||
act(() => {
|
|
||||||
toast.remove();
|
|
||||||
});
|
|
||||||
|
|
||||||
await userEvent.type(screen.getByLabelText('Please enter verification code. Digit 1'), '1');
|
|
||||||
await userEvent.type(screen.getByLabelText('Digit 2'), '2');
|
|
||||||
await userEvent.type(screen.getByLabelText('Digit 3'), '3');
|
|
||||||
await userEvent.type(screen.getByLabelText('Digit 4'), '4');
|
|
||||||
await userEvent.type(screen.getByLabelText('Digit 5'), '5');
|
|
||||||
await userEvent.type(screen.getByLabelText('Digit 6'), '6');
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(screen.getByTestId('toast')).toHaveTextContent('Your SMS token has expired.');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('with invalid data', () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
__stub(mock => {
|
|
||||||
mock.onPost('/api/v1/pepe/verify_sms/request')
|
|
||||||
.reply(422, {});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('renders errors', async() => {
|
|
||||||
render(<SmsVerification />);
|
|
||||||
|
|
||||||
await userEvent.type(screen.getByLabelText('Phone number'), '+1 (555) 555-5555');
|
|
||||||
await waitFor(() => {
|
|
||||||
fireEvent.submit(
|
|
||||||
screen.getByRole('button', { name: 'Next' }), {
|
|
||||||
preventDefault: () => {},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(screen.getByTestId('toast')).toHaveTextContent('Failed to send SMS message to your phone number.');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
|
@ -1,84 +0,0 @@
|
||||||
import React from 'react';
|
|
||||||
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
|
|
||||||
|
|
||||||
import { verifyAge } from 'soapbox/actions/verification';
|
|
||||||
import { Button, Datepicker, Form, Text } from 'soapbox/components/ui';
|
|
||||||
import { useAppDispatch, useAppSelector, useInstance } from 'soapbox/hooks';
|
|
||||||
import toast from 'soapbox/toast';
|
|
||||||
|
|
||||||
const messages = defineMessages({
|
|
||||||
fail: {
|
|
||||||
id: 'age_verification.fail',
|
|
||||||
defaultMessage: 'You must be {ageMinimum, plural, one {# year} other {# years}} old or older.',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
function meetsAgeMinimum(birthday: Date, ageMinimum: number) {
|
|
||||||
const month = birthday.getUTCMonth();
|
|
||||||
const day = birthday.getUTCDate();
|
|
||||||
const year = birthday.getUTCFullYear();
|
|
||||||
|
|
||||||
return new Date(year + ageMinimum, month, day) <= new Date();
|
|
||||||
}
|
|
||||||
|
|
||||||
const AgeVerification = () => {
|
|
||||||
const intl = useIntl();
|
|
||||||
const dispatch = useAppDispatch();
|
|
||||||
const instance = useInstance();
|
|
||||||
|
|
||||||
const isLoading = useAppSelector((state) => state.verification.isLoading) as boolean;
|
|
||||||
const ageMinimum = useAppSelector((state) => state.verification.ageMinimum) as any;
|
|
||||||
|
|
||||||
const [date, setDate] = React.useState<Date>();
|
|
||||||
const isValid = typeof date === 'object';
|
|
||||||
|
|
||||||
const onChange = React.useCallback((date: Date) => setDate(date), []);
|
|
||||||
|
|
||||||
const handleSubmit: React.FormEventHandler = React.useCallback((event) => {
|
|
||||||
event.preventDefault();
|
|
||||||
|
|
||||||
const birthday = new Date(date!);
|
|
||||||
|
|
||||||
if (meetsAgeMinimum(birthday, ageMinimum)) {
|
|
||||||
dispatch(verifyAge(birthday));
|
|
||||||
} else {
|
|
||||||
toast.error(intl.formatMessage(messages.fail, { ageMinimum }));
|
|
||||||
}
|
|
||||||
}, [date, ageMinimum]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<div className='-mx-4 mb-4 border-b border-solid border-gray-200 pb-4 dark:border-gray-800 sm:-mx-10 sm:pb-10'>
|
|
||||||
<h1 className='text-center text-2xl font-bold'>
|
|
||||||
<FormattedMessage id='age_verification.header' defaultMessage='Enter your birth date' />
|
|
||||||
</h1>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className='mx-auto sm:pt-10 md:w-2/3'>
|
|
||||||
<Form onSubmit={handleSubmit}>
|
|
||||||
<Datepicker onChange={onChange} />
|
|
||||||
|
|
||||||
<Text theme='muted' size='sm'>
|
|
||||||
<FormattedMessage
|
|
||||||
id='age_verification.body'
|
|
||||||
defaultMessage='{siteTitle} requires users to be at least {ageMinimum, plural, one {# year} other {# years}} old to access its platform. Anyone under the age of {ageMinimum, plural, one {# year} other {# years}} old cannot access this platform.'
|
|
||||||
values={{
|
|
||||||
siteTitle: instance.title,
|
|
||||||
ageMinimum,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
</Text>
|
|
||||||
|
|
||||||
<div className='text-center'>
|
|
||||||
<Button block theme='primary' type='submit' disabled={isLoading || !isValid}>
|
|
||||||
<FormattedMessage id='onboarding.next' defaultMessage='Next' />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</Form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default AgeVerification;
|
|
|
@ -1,146 +0,0 @@
|
||||||
import { AxiosError } from 'axios';
|
|
||||||
import React from 'react';
|
|
||||||
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
|
|
||||||
|
|
||||||
import { checkEmailVerification, postEmailVerification, requestEmailVerification } from 'soapbox/actions/verification';
|
|
||||||
import Icon from 'soapbox/components/icon';
|
|
||||||
import { Button, Form, FormGroup, Input, Text } from 'soapbox/components/ui';
|
|
||||||
import { useAppDispatch, useAppSelector } from 'soapbox/hooks';
|
|
||||||
import toast from 'soapbox/toast';
|
|
||||||
|
|
||||||
const messages = defineMessages({
|
|
||||||
verificationSuccess: { id: 'email_verification.success', defaultMessage: 'Verification email sent successfully.' },
|
|
||||||
verificationFail: { id: 'email_verification.fail', defaultMessage: 'Failed to request email verification.' },
|
|
||||||
verificationFailTakenAlert: { id: 'email_verifilcation.exists', defaultMessage: 'This email has already been taken.' },
|
|
||||||
verificationFailTaken: { id: 'email_verification.taken', defaultMessage: 'is taken' },
|
|
||||||
emailLabel: { id: 'email_verification.email.label', defaultMessage: 'E-mail address' },
|
|
||||||
});
|
|
||||||
|
|
||||||
const Statuses = {
|
|
||||||
IDLE: 'IDLE',
|
|
||||||
REQUESTED: 'REQUESTED',
|
|
||||||
FAIL: 'FAIL',
|
|
||||||
};
|
|
||||||
|
|
||||||
const EMAIL_REGEX = /^[^@\s]+@[^@\s]+$/;
|
|
||||||
|
|
||||||
interface IEmailSent {
|
|
||||||
handleSubmit: React.FormEventHandler
|
|
||||||
}
|
|
||||||
|
|
||||||
const EmailSent: React.FC<IEmailSent> = ({ handleSubmit }) => {
|
|
||||||
const dispatch = useAppDispatch();
|
|
||||||
|
|
||||||
const checkEmailConfirmation = () => {
|
|
||||||
dispatch(checkEmailVerification())
|
|
||||||
.then(() => dispatch(postEmailVerification()))
|
|
||||||
.catch(() => null);
|
|
||||||
};
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
|
||||||
const intervalId = setInterval(() => checkEmailConfirmation(), 2500);
|
|
||||||
|
|
||||||
return () => clearInterval(intervalId);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className='mx-auto flex flex-col items-center justify-center sm:pt-10'>
|
|
||||||
<Icon src={require('@tabler/icons/send.svg')} className='mb-5 h-12 w-12 text-primary-600 dark:text-primary-400' />
|
|
||||||
|
|
||||||
<div className='mb-4 space-y-2 text-center'>
|
|
||||||
<Text weight='bold' size='3xl'>We sent you an email</Text>
|
|
||||||
<Text theme='muted'>Click on the link in the email to validate your email.</Text>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Button theme='tertiary' onClick={handleSubmit}>Resend verification email</Button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const EmailVerification = () => {
|
|
||||||
const intl = useIntl();
|
|
||||||
const dispatch = useAppDispatch();
|
|
||||||
|
|
||||||
const isLoading = useAppSelector((state) => state.verification.isLoading) as boolean;
|
|
||||||
|
|
||||||
const [email, setEmail] = React.useState('');
|
|
||||||
const [status, setStatus] = React.useState(Statuses.IDLE);
|
|
||||||
const [errors, setErrors] = React.useState<Array<string>>([]);
|
|
||||||
|
|
||||||
const isValid = email.length > 0 && EMAIL_REGEX.test(email);
|
|
||||||
|
|
||||||
const onChange: React.ChangeEventHandler<HTMLInputElement> = React.useCallback((event) => {
|
|
||||||
setEmail(event.target.value);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleSubmit: React.FormEventHandler = React.useCallback((event) => {
|
|
||||||
event.preventDefault();
|
|
||||||
setErrors([]);
|
|
||||||
|
|
||||||
submitEmailForVerification();
|
|
||||||
}, [email]);
|
|
||||||
|
|
||||||
const submitEmailForVerification = () => {
|
|
||||||
return dispatch(requestEmailVerification((email)))
|
|
||||||
.then(() => {
|
|
||||||
setStatus(Statuses.REQUESTED);
|
|
||||||
|
|
||||||
toast.success(intl.formatMessage(messages.verificationSuccess));
|
|
||||||
})
|
|
||||||
.catch((error: AxiosError) => {
|
|
||||||
const errorMessage = (error.response?.data as any)?.error;
|
|
||||||
const isEmailTaken = errorMessage === 'email_taken';
|
|
||||||
let message = intl.formatMessage(messages.verificationFail);
|
|
||||||
|
|
||||||
if (isEmailTaken) {
|
|
||||||
message = intl.formatMessage(messages.verificationFailTakenAlert);
|
|
||||||
} else if (errorMessage) {
|
|
||||||
message = errorMessage;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isEmailTaken) {
|
|
||||||
setErrors([intl.formatMessage(messages.verificationFailTaken)]);
|
|
||||||
}
|
|
||||||
|
|
||||||
toast.error(message);
|
|
||||||
setStatus(Statuses.FAIL);
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
if (status === Statuses.REQUESTED) {
|
|
||||||
return <EmailSent handleSubmit={handleSubmit} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<div className='-mx-4 mb-4 border-b border-solid border-gray-200 pb-4 dark:border-gray-800 sm:-mx-10 sm:pb-10'>
|
|
||||||
<h1 className='text-center text-2xl font-bold'>
|
|
||||||
<FormattedMessage id='email_verification.header' defaultMessage='Enter your email address' />
|
|
||||||
</h1>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className='mx-auto sm:w-2/3 sm:pt-10 md:w-1/2'>
|
|
||||||
<Form onSubmit={handleSubmit}>
|
|
||||||
<FormGroup labelText={intl.formatMessage(messages.emailLabel)} errors={errors}>
|
|
||||||
<Input
|
|
||||||
type='email'
|
|
||||||
value={email}
|
|
||||||
name='email'
|
|
||||||
onChange={onChange}
|
|
||||||
placeholder='you@email.com'
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</FormGroup>
|
|
||||||
|
|
||||||
<div className='text-center'>
|
|
||||||
<Button block theme='primary' type='submit' disabled={isLoading || !isValid}>
|
|
||||||
<FormattedMessage id='onboarding.next' defaultMessage='Next' />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</Form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default EmailVerification;
|
|
|
@ -1,151 +0,0 @@
|
||||||
import { AxiosError } from 'axios';
|
|
||||||
import React from 'react';
|
|
||||||
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
|
|
||||||
import OtpInput from 'react-otp-input';
|
|
||||||
|
|
||||||
import { confirmPhoneVerification, requestPhoneVerification } from 'soapbox/actions/verification';
|
|
||||||
import { Button, Form, FormGroup, PhoneInput, Text } from 'soapbox/components/ui';
|
|
||||||
import { useAppDispatch, useAppSelector } from 'soapbox/hooks';
|
|
||||||
import toast from 'soapbox/toast';
|
|
||||||
|
|
||||||
const messages = defineMessages({
|
|
||||||
verificationInvalid: { id: 'sms_verification.invalid', defaultMessage: 'Please enter a valid phone number.' },
|
|
||||||
verificationSuccess: { id: 'sms_verification.success', defaultMessage: 'A verification code has been sent to your phone number.' },
|
|
||||||
verificationFail: { id: 'sms_verification.fail', defaultMessage: 'Failed to send SMS message to your phone number.' },
|
|
||||||
verificationExpired: { id: 'sms_verification.expired', defaultMessage: 'Your SMS token has expired.' },
|
|
||||||
phoneLabel: { id: 'sms_verification.phone.label', defaultMessage: 'Phone number' },
|
|
||||||
});
|
|
||||||
|
|
||||||
const Statuses = {
|
|
||||||
IDLE: 'IDLE',
|
|
||||||
REQUESTED: 'REQUESTED',
|
|
||||||
FAIL: 'FAIL',
|
|
||||||
};
|
|
||||||
|
|
||||||
const SmsVerification = () => {
|
|
||||||
const intl = useIntl();
|
|
||||||
const dispatch = useAppDispatch();
|
|
||||||
|
|
||||||
const isLoading = useAppSelector((state) => state.verification.isLoading) as boolean;
|
|
||||||
|
|
||||||
const [phone, setPhone] = React.useState<string>();
|
|
||||||
const [status, setStatus] = React.useState(Statuses.IDLE);
|
|
||||||
const [verificationCode, setVerificationCode] = React.useState('');
|
|
||||||
const [requestedAnother, setAlreadyRequestedAnother] = React.useState(false);
|
|
||||||
|
|
||||||
const isValid = !!phone;
|
|
||||||
|
|
||||||
const onChange = React.useCallback((phone?: string) => {
|
|
||||||
setPhone(phone);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleSubmit: React.FormEventHandler = React.useCallback((event) => {
|
|
||||||
event.preventDefault();
|
|
||||||
|
|
||||||
if (!isValid) {
|
|
||||||
setStatus(Statuses.IDLE);
|
|
||||||
toast.error(intl.formatMessage(messages.verificationInvalid));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
dispatch(requestPhoneVerification(phone!)).then(() => {
|
|
||||||
toast.success(intl.formatMessage(messages.verificationSuccess));
|
|
||||||
setStatus(Statuses.REQUESTED);
|
|
||||||
}).catch((error: AxiosError) => {
|
|
||||||
const message = (error.response?.data as any)?.message || intl.formatMessage(messages.verificationFail);
|
|
||||||
|
|
||||||
toast.error(message);
|
|
||||||
setStatus(Statuses.FAIL);
|
|
||||||
});
|
|
||||||
}, [phone, isValid]);
|
|
||||||
|
|
||||||
const resendVerificationCode: React.MouseEventHandler = React.useCallback((event) => {
|
|
||||||
setAlreadyRequestedAnother(true);
|
|
||||||
handleSubmit(event);
|
|
||||||
}, [isValid]);
|
|
||||||
|
|
||||||
const submitVerification = () => {
|
|
||||||
// TODO: handle proper validation from Pepe -- expired vs invalid
|
|
||||||
dispatch(confirmPhoneVerification(verificationCode))
|
|
||||||
.catch(() => {
|
|
||||||
toast.error(intl.formatMessage(messages.verificationExpired));
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
|
||||||
if (verificationCode.length === 6) {
|
|
||||||
submitVerification();
|
|
||||||
}
|
|
||||||
}, [verificationCode]);
|
|
||||||
|
|
||||||
if (status === Statuses.REQUESTED) {
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<div className='-mx-4 mb-4 border-b border-solid border-gray-200 pb-4 dark:border-gray-800 sm:-mx-10 sm:pb-10'>
|
|
||||||
<h1 className='text-center text-2xl font-bold'>
|
|
||||||
<FormattedMessage id='sms_verification.sent.header' defaultMessage='Verification code' />
|
|
||||||
</h1>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className='mx-auto space-y-4 sm:w-2/3 sm:pt-10 md:w-1/2'>
|
|
||||||
<Text theme='muted' size='sm' align='center'>
|
|
||||||
<FormattedMessage id='sms_verification.sent.body' defaultMessage='We sent you a 6-digit code via SMS. Enter it below.' />
|
|
||||||
</Text>
|
|
||||||
|
|
||||||
<OtpInput
|
|
||||||
value={verificationCode}
|
|
||||||
onChange={setVerificationCode}
|
|
||||||
numInputs={6}
|
|
||||||
isInputNum
|
|
||||||
shouldAutoFocus
|
|
||||||
isDisabled={isLoading}
|
|
||||||
containerStyle='flex justify-center mt-2 space-x-4'
|
|
||||||
inputStyle='w-10i border-gray-300 dark:bg-gray-800 dark:border-gray-800 rounded-md focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500'
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className='text-center'>
|
|
||||||
<Button
|
|
||||||
size='sm'
|
|
||||||
type='button'
|
|
||||||
theme='tertiary'
|
|
||||||
onClick={resendVerificationCode}
|
|
||||||
disabled={requestedAnother}
|
|
||||||
>
|
|
||||||
<FormattedMessage id='sms_verification.sent.actions.resend' defaultMessage='Resend verification code?' />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<div className='-mx-4 mb-4 border-b border-solid border-gray-200 pb-4 dark:border-gray-800 sm:-mx-10 sm:pb-10'>
|
|
||||||
<h1 className='text-center text-2xl font-bold'>
|
|
||||||
<FormattedMessage id='sms_verification.header' defaultMessage='Enter your phone number' />
|
|
||||||
</h1>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className='mx-auto sm:w-2/3 sm:pt-10 md:w-1/2'>
|
|
||||||
<Form onSubmit={handleSubmit}>
|
|
||||||
<FormGroup labelText={intl.formatMessage(messages.phoneLabel)}>
|
|
||||||
<PhoneInput
|
|
||||||
value={phone}
|
|
||||||
onChange={onChange}
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</FormGroup>
|
|
||||||
|
|
||||||
<div className='text-center'>
|
|
||||||
<Button block theme='primary' type='submit' disabled={isLoading || !isValid}>
|
|
||||||
<FormattedMessage id='onboarding.next' defaultMessage='Next' />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</Form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export { SmsVerification as default };
|
|
|
@ -1,79 +0,0 @@
|
||||||
import React, { useEffect } from 'react';
|
|
||||||
import { FormattedMessage } from 'react-intl';
|
|
||||||
import { Link } from 'react-router-dom';
|
|
||||||
|
|
||||||
import { logOut } from 'soapbox/actions/auth';
|
|
||||||
import { openModal } from 'soapbox/actions/modals';
|
|
||||||
import LandingGradient from 'soapbox/components/landing-gradient';
|
|
||||||
import SiteLogo from 'soapbox/components/site-logo';
|
|
||||||
import { Button, Stack, Text } from 'soapbox/components/ui';
|
|
||||||
import { useAppDispatch, useInstance, useOwnAccount } from 'soapbox/hooks';
|
|
||||||
|
|
||||||
const WaitlistPage = () => {
|
|
||||||
const dispatch = useAppDispatch();
|
|
||||||
const instance = useInstance();
|
|
||||||
|
|
||||||
const { account: me } = useOwnAccount();
|
|
||||||
const isSmsVerified = me?.source?.sms_verified ?? true;
|
|
||||||
|
|
||||||
const onClickLogOut: React.MouseEventHandler = (event) => {
|
|
||||||
event.preventDefault();
|
|
||||||
dispatch(logOut());
|
|
||||||
};
|
|
||||||
|
|
||||||
const openVerifySmsModal = () => dispatch(openModal('VERIFY_SMS'));
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!isSmsVerified) {
|
|
||||||
openVerifySmsModal();
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<LandingGradient />
|
|
||||||
|
|
||||||
<main className='relative mx-auto flex h-screen max-w-7xl flex-col px-2 sm:px-6 lg:px-8'>
|
|
||||||
<header className='relative flex h-16 justify-between'>
|
|
||||||
<div className='relative flex flex-1 items-stretch justify-center'>
|
|
||||||
<Link to='/' className='flex shrink-0 cursor-pointer items-center'>
|
|
||||||
<SiteLogo alt='Logo' className='h-7' />
|
|
||||||
</Link>
|
|
||||||
|
|
||||||
<div className='absolute inset-y-0 right-0 flex items-center pr-2'>
|
|
||||||
<Button onClick={onClickLogOut} theme='primary' to='/logout'>
|
|
||||||
<FormattedMessage id='navigation_bar.logout' defaultMessage='Logout' />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<div className='-mt-16 flex h-full flex-col items-center justify-center'>
|
|
||||||
<div className='max-w-xl'>
|
|
||||||
<Stack space={4}>
|
|
||||||
<img src='/instance/images/waitlist.png' className='mx-auto h-32 w-32' alt='Waitlisted' />
|
|
||||||
|
|
||||||
<Stack space={2}>
|
|
||||||
<Text size='lg' theme='muted' align='center' weight='medium'>
|
|
||||||
<FormattedMessage
|
|
||||||
id='waitlist.body'
|
|
||||||
defaultMessage='Welcome back to {title}! You were previously placed on our waitlist. Please verify your phone number to receive immediate access to your account!'
|
|
||||||
values={{ title: instance.title }}
|
|
||||||
/>
|
|
||||||
</Text>
|
|
||||||
|
|
||||||
<div className='text-center'>
|
|
||||||
<Button onClick={openVerifySmsModal} theme='primary'>
|
|
||||||
<FormattedMessage id='waitlist.actions.verify_number' defaultMessage='Verify phone number' />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</Stack>
|
|
||||||
</Stack>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default WaitlistPage;
|
|
|
@ -1,46 +0,0 @@
|
||||||
import { storeClosed, storeOpen, storePepeClosed, storePepeOpen } from 'soapbox/jest/mock-stores';
|
|
||||||
import { renderHook } from 'soapbox/jest/test-helpers';
|
|
||||||
|
|
||||||
import { useRegistrationStatus } from '../useRegistrationStatus';
|
|
||||||
|
|
||||||
describe('useRegistrationStatus()', () => {
|
|
||||||
test('Registrations open', () => {
|
|
||||||
const { result } = renderHook(useRegistrationStatus, undefined, storeOpen);
|
|
||||||
|
|
||||||
expect(result.current).toMatchObject({
|
|
||||||
isOpen: true,
|
|
||||||
pepeEnabled: false,
|
|
||||||
pepeOpen: false,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test('Registrations closed', () => {
|
|
||||||
const { result } = renderHook(useRegistrationStatus, undefined, storeClosed);
|
|
||||||
|
|
||||||
expect(result.current).toMatchObject({
|
|
||||||
isOpen: false,
|
|
||||||
pepeEnabled: false,
|
|
||||||
pepeOpen: false,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test('Registrations closed, Pepe enabled & open', () => {
|
|
||||||
const { result } = renderHook(useRegistrationStatus, undefined, storePepeOpen);
|
|
||||||
|
|
||||||
expect(result.current).toMatchObject({
|
|
||||||
isOpen: true,
|
|
||||||
pepeEnabled: true,
|
|
||||||
pepeOpen: true,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test('Registrations closed, Pepe enabled & closed', () => {
|
|
||||||
const { result } = renderHook(useRegistrationStatus, undefined, storePepeClosed);
|
|
||||||
|
|
||||||
expect(result.current).toMatchObject({
|
|
||||||
isOpen: false,
|
|
||||||
pepeEnabled: true,
|
|
||||||
pepeOpen: false,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
|
@ -1,22 +1,12 @@
|
||||||
import { useAppSelector } from './useAppSelector';
|
|
||||||
import { useFeatures } from './useFeatures';
|
import { useFeatures } from './useFeatures';
|
||||||
import { useInstance } from './useInstance';
|
import { useInstance } from './useInstance';
|
||||||
import { useSoapboxConfig } from './useSoapboxConfig';
|
|
||||||
|
|
||||||
export const useRegistrationStatus = () => {
|
export const useRegistrationStatus = () => {
|
||||||
const instance = useInstance();
|
const instance = useInstance();
|
||||||
const features = useFeatures();
|
const features = useFeatures();
|
||||||
const soapboxConfig = useSoapboxConfig();
|
|
||||||
|
|
||||||
const pepeOpen = useAppSelector(state => state.verification.instance.get('registrations') === true);
|
|
||||||
const pepeEnabled = soapboxConfig.getIn(['extensions', 'pepe', 'enabled']) === true;
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
/** Registrations are open, either through Pepe or traditional account creation. */
|
/** Registrations are open, either through Pepe or traditional account creation. */
|
||||||
isOpen: (features.accountCreation && instance.registrations) || (pepeEnabled && pepeOpen),
|
isOpen: features.accountCreation && instance.registrations,
|
||||||
/** Whether Pepe is open. */
|
|
||||||
pepeOpen,
|
|
||||||
/** Whether Pepe is enabled. */
|
|
||||||
pepeEnabled,
|
|
||||||
};
|
};
|
||||||
};
|
};
|
|
@ -1,5 +1,3 @@
|
||||||
import { fromJS } from 'immutable';
|
|
||||||
|
|
||||||
import alexJson from 'soapbox/__fixtures__/pleroma-account.json';
|
import alexJson from 'soapbox/__fixtures__/pleroma-account.json';
|
||||||
import { normalizeInstance } from 'soapbox/normalizers';
|
import { normalizeInstance } from 'soapbox/normalizers';
|
||||||
|
|
||||||
|
@ -11,20 +9,6 @@ const storeOpen = { instance: normalizeInstance({ registrations: true }) };
|
||||||
/** Store with registrations closed. */
|
/** Store with registrations closed. */
|
||||||
const storeClosed = { instance: normalizeInstance({ registrations: false }) };
|
const storeClosed = { instance: normalizeInstance({ registrations: false }) };
|
||||||
|
|
||||||
/** Store with registrations closed, and Pepe enabled & open. */
|
|
||||||
const storePepeOpen = {
|
|
||||||
instance: normalizeInstance({ registrations: false }),
|
|
||||||
soapbox: fromJS({ extensions: { pepe: { enabled: true } } }),
|
|
||||||
verification: { instance: fromJS({ registrations: true }) },
|
|
||||||
};
|
|
||||||
|
|
||||||
/** Store with registrations closed, and Pepe enabled & closed. */
|
|
||||||
const storePepeClosed = {
|
|
||||||
instance: normalizeInstance({ registrations: false }),
|
|
||||||
soapbox: fromJS({ extensions: { pepe: { enabled: true } } }),
|
|
||||||
verification: { instance: fromJS({ registrations: false }) },
|
|
||||||
};
|
|
||||||
|
|
||||||
/** Store with a logged-in user. */
|
/** Store with a logged-in user. */
|
||||||
const storeLoggedIn = {
|
const storeLoggedIn = {
|
||||||
me: alexJson.id,
|
me: alexJson.id,
|
||||||
|
@ -36,7 +20,5 @@ const storeLoggedIn = {
|
||||||
export {
|
export {
|
||||||
storeOpen,
|
storeOpen,
|
||||||
storeClosed,
|
storeClosed,
|
||||||
storePepeOpen,
|
|
||||||
storePepeClosed,
|
|
||||||
storeLoggedIn,
|
storeLoggedIn,
|
||||||
};
|
};
|
|
@ -64,7 +64,6 @@ import timelines from './timelines';
|
||||||
import trending_statuses from './trending-statuses';
|
import trending_statuses from './trending-statuses';
|
||||||
import trends from './trends';
|
import trends from './trends';
|
||||||
import user_lists from './user-lists';
|
import user_lists from './user-lists';
|
||||||
import verification from './verification';
|
|
||||||
|
|
||||||
const reducers = {
|
const reducers = {
|
||||||
accounts_meta,
|
accounts_meta,
|
||||||
|
@ -127,7 +126,6 @@ const reducers = {
|
||||||
trending_statuses,
|
trending_statuses,
|
||||||
trends,
|
trends,
|
||||||
user_lists,
|
user_lists,
|
||||||
verification,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Build a default state from all reducers: it has the key and `undefined`
|
// Build a default state from all reducers: it has the key and `undefined`
|
||||||
|
|
|
@ -1,51 +0,0 @@
|
||||||
import { Map as ImmutableMap, Record as ImmutableRecord, fromJS } from 'immutable';
|
|
||||||
|
|
||||||
import {
|
|
||||||
PEPE_FETCH_INSTANCE_SUCCESS,
|
|
||||||
FETCH_CHALLENGES_SUCCESS,
|
|
||||||
FETCH_TOKEN_SUCCESS,
|
|
||||||
SET_CHALLENGES_COMPLETE,
|
|
||||||
SET_LOADING,
|
|
||||||
SET_NEXT_CHALLENGE,
|
|
||||||
Challenge,
|
|
||||||
} from '../actions/verification';
|
|
||||||
|
|
||||||
import type { AnyAction } from 'redux';
|
|
||||||
|
|
||||||
const ReducerRecord = ImmutableRecord({
|
|
||||||
ageMinimum: null as string | null,
|
|
||||||
currentChallenge: null as Challenge | null,
|
|
||||||
isLoading: false,
|
|
||||||
isComplete: false as boolean | null,
|
|
||||||
token: null as string | null,
|
|
||||||
instance: ImmutableMap<string, any>(),
|
|
||||||
});
|
|
||||||
|
|
||||||
export default function verification(state = ReducerRecord(), action: AnyAction) {
|
|
||||||
switch (action.type) {
|
|
||||||
case PEPE_FETCH_INSTANCE_SUCCESS:
|
|
||||||
return state.set('instance', ImmutableMap(fromJS(action.instance)));
|
|
||||||
case FETCH_CHALLENGES_SUCCESS:
|
|
||||||
return state
|
|
||||||
.set('ageMinimum', action.ageMinimum)
|
|
||||||
.set('currentChallenge', action.currentChallenge)
|
|
||||||
.set('isLoading', false)
|
|
||||||
.set('isComplete', action.isComplete);
|
|
||||||
case FETCH_TOKEN_SUCCESS:
|
|
||||||
return state
|
|
||||||
.set('isLoading', false)
|
|
||||||
.set('token', action.value);
|
|
||||||
case SET_CHALLENGES_COMPLETE:
|
|
||||||
return state
|
|
||||||
.set('isLoading', false)
|
|
||||||
.set('isComplete', true);
|
|
||||||
case SET_NEXT_CHALLENGE:
|
|
||||||
return state
|
|
||||||
.set('currentChallenge', action.challenge)
|
|
||||||
.set('isLoading', false);
|
|
||||||
case SET_LOADING:
|
|
||||||
return state.set('isLoading', typeof action.value === 'boolean' ? action.value : true);
|
|
||||||
default:
|
|
||||||
return state;
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -734,14 +734,6 @@ const getInstanceFeatures = (instance: Instance) => {
|
||||||
*/
|
*/
|
||||||
paginatedContext: v.software === TRUTHSOCIAL,
|
paginatedContext: v.software === TRUTHSOCIAL,
|
||||||
|
|
||||||
/**
|
|
||||||
* Require minimum password requirements.
|
|
||||||
* - 8 characters
|
|
||||||
* - 1 uppercase
|
|
||||||
* - 1 lowercase
|
|
||||||
*/
|
|
||||||
passwordRequirements: v.software === TRUTHSOCIAL,
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Displays a form to follow a user when logged out.
|
* Displays a form to follow a user when logged out.
|
||||||
* @see POST /main/ostatus
|
* @see POST /main/ostatus
|
||||||
|
|
10
yarn.lock
10
yarn.lock
|
@ -5905,11 +5905,6 @@ li@^1.3.0:
|
||||||
resolved "https://registry.yarnpkg.com/li/-/li-1.3.0.tgz#22c59bcaefaa9a8ef359cf759784e4bf106aea1b"
|
resolved "https://registry.yarnpkg.com/li/-/li-1.3.0.tgz#22c59bcaefaa9a8ef359cf759784e4bf106aea1b"
|
||||||
integrity sha512-z34TU6GlMram52Tss5mt1m//ifRIpKH5Dqm7yUVOdHI+BQCs9qGPHFaCUTIzsWX7edN30aa2WrPwR7IO10FHaw==
|
integrity sha512-z34TU6GlMram52Tss5mt1m//ifRIpKH5Dqm7yUVOdHI+BQCs9qGPHFaCUTIzsWX7edN30aa2WrPwR7IO10FHaw==
|
||||||
|
|
||||||
libphonenumber-js@^1.10.8:
|
|
||||||
version "1.10.8"
|
|
||||||
resolved "https://registry.yarnpkg.com/libphonenumber-js/-/libphonenumber-js-1.10.8.tgz#21925db0f16d4f1553dff2bbc62afdaeb03f21f0"
|
|
||||||
integrity sha512-MGgHrKRGE7sg7y0DikHybRDgTXcYv4HL+WwhDm5UAiChCNb5tcy5OEaU8XTTt5bDBwhZGCJNxoGMVBpZ4RfhIg==
|
|
||||||
|
|
||||||
lie@3.1.1:
|
lie@3.1.1:
|
||||||
version "3.1.1"
|
version "3.1.1"
|
||||||
resolved "https://registry.yarnpkg.com/lie/-/lie-3.1.1.tgz#9a436b2cc7746ca59de7a41fa469b3efb76bd87e"
|
resolved "https://registry.yarnpkg.com/lie/-/lie-3.1.1.tgz#9a436b2cc7746ca59de7a41fa469b3efb76bd87e"
|
||||||
|
@ -7330,11 +7325,6 @@ react-onclickoutside@^6.12.0:
|
||||||
resolved "https://registry.yarnpkg.com/react-onclickoutside/-/react-onclickoutside-6.12.1.tgz#92dddd28f55e483a1838c5c2930e051168c1e96b"
|
resolved "https://registry.yarnpkg.com/react-onclickoutside/-/react-onclickoutside-6.12.1.tgz#92dddd28f55e483a1838c5c2930e051168c1e96b"
|
||||||
integrity sha512-a5Q7CkWznBRUWPmocCvE8b6lEYw1s6+opp/60dCunhO+G6E4tDTO2Sd2jKE+leEnnrLAE2Wj5DlDHNqj5wPv1Q==
|
integrity sha512-a5Q7CkWznBRUWPmocCvE8b6lEYw1s6+opp/60dCunhO+G6E4tDTO2Sd2jKE+leEnnrLAE2Wj5DlDHNqj5wPv1Q==
|
||||||
|
|
||||||
react-otp-input@^2.4.0:
|
|
||||||
version "2.4.0"
|
|
||||||
resolved "https://registry.yarnpkg.com/react-otp-input/-/react-otp-input-2.4.0.tgz#0f0a3de1d8c8d564e2e4fbe5d6b7b56e29e3a6e6"
|
|
||||||
integrity sha512-AIgl7u4sS9BTNCxX1xlaS5fPWay/Zml8Ho5LszXZKXrH1C/TiFsTQGmtl13UecQYO3mSF3HUzG2rrDf0sjEFmg==
|
|
||||||
|
|
||||||
react-overlays@^0.9.0:
|
react-overlays@^0.9.0:
|
||||||
version "0.9.3"
|
version "0.9.3"
|
||||||
resolved "https://registry.yarnpkg.com/react-overlays/-/react-overlays-0.9.3.tgz#5bac8c1e9e7e057a125181dee2d784864dd62902"
|
resolved "https://registry.yarnpkg.com/react-overlays/-/react-overlays-0.9.3.tgz#5bac8c1e9e7e057a125181dee2d784864dd62902"
|
||||||
|
|
Loading…
Reference in New Issue