Allow waitlisted users to verify their SMS

environments/review-review-rcmeyv/deployments/1
Justin 2022-05-18 14:08:08 -04:00
rodzic 5323c8a160
commit 54ef361bcc
10 zmienionych plików z 324 dodań i 19 usunięć

Wyświetl plik

@ -323,6 +323,20 @@ function requestPhoneVerification(phone) {
}; };
} }
/**
* Send the user's phone number to Pepe to re-request confirmation
* @param {string} phone
* @returns {promise}
*/
function reRequestPhoneVerification(phone) {
return (dispatch, getState) => {
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 * Confirm the user's phone number with Pepe
* @param {string} code * @param {string} code
@ -345,6 +359,20 @@ function confirmPhoneVerification(code) {
}; };
} }
/**
* Re-Confirm the user's phone number with Pepe
* @param {string} code
* @returns {promise}
*/
function reConfirmPhoneVerification(code) {
return (dispatch, getState) => {
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 * Confirm the user's age with Pepe
* @param {date} birthday * @param {date} birthday
@ -404,6 +432,8 @@ export {
requestEmailVerification, requestEmailVerification,
checkEmailVerification, checkEmailVerification,
postEmailVerification, postEmailVerification,
reConfirmPhoneVerification,
requestPhoneVerification, requestPhoneVerification,
reRequestPhoneVerification,
verifyAge, verifyAge,
}; };

Wyświetl plik

@ -33,7 +33,7 @@ interface IModal {
/** Position of the close button. */ /** Position of the close button. */
closePosition?: 'left' | 'right', closePosition?: 'left' | 'right',
/** Callback when the modal is confirmed. */ /** Callback when the modal is confirmed. */
confirmationAction?: () => void, confirmationAction?: (event?: React.MouseEvent<HTMLButtonElement>) => void,
/** Whether the confirmation button is disabled. */ /** Whether the confirmation button is disabled. */
confirmationDisabled?: boolean, confirmationDisabled?: boolean,
/** Confirmation button text. */ /** Confirmation button text. */
@ -43,9 +43,10 @@ interface IModal {
/** Callback when the modal is closed. */ /** Callback when the modal is closed. */
onClose?: () => void, onClose?: () => void,
/** Callback when the secondary action is chosen. */ /** Callback when the secondary action is chosen. */
secondaryAction?: () => void, secondaryAction?: (event?: React.MouseEvent<HTMLButtonElement>) => void,
/** Secondary button text. */ /** Secondary button text. */
secondaryText?: React.ReactNode, secondaryText?: React.ReactNode,
secondaryDisabled?: boolean,
/** Don't focus the "confirm" button on mount. */ /** Don't focus the "confirm" button on mount. */
skipFocus?: boolean, skipFocus?: boolean,
/** Title text for the modal. */ /** Title text for the modal. */
@ -66,6 +67,7 @@ const Modal: React.FC<IModal> = ({
confirmationTheme, confirmationTheme,
onClose, onClose,
secondaryAction, secondaryAction,
secondaryDisabled = false,
secondaryText, secondaryText,
skipFocus = false, skipFocus = false,
title, title,
@ -128,6 +130,7 @@ const Modal: React.FC<IModal> = ({
<Button <Button
theme='secondary' theme='secondary'
onClick={secondaryAction} onClick={secondaryAction}
disabled={secondaryDisabled}
> >
{secondaryText} {secondaryText}
</Button> </Button>

Wyświetl plik

@ -18,6 +18,7 @@ import AuthLayout from 'soapbox/features/auth_layout';
import OnboardingWizard from 'soapbox/features/onboarding/onboarding-wizard'; import OnboardingWizard from 'soapbox/features/onboarding/onboarding-wizard';
import PublicLayout from 'soapbox/features/public_layout'; import PublicLayout from 'soapbox/features/public_layout';
import NotificationsContainer from 'soapbox/features/ui/containers/notifications_container'; import NotificationsContainer from 'soapbox/features/ui/containers/notifications_container';
import { ModalContainer } from 'soapbox/features/ui/util/async-components';
import WaitlistPage from 'soapbox/features/verification/waitlist_page'; import WaitlistPage from 'soapbox/features/verification/waitlist_page';
import { createGlobals } from 'soapbox/globals'; import { createGlobals } from 'soapbox/globals';
import { useAppSelector, useAppDispatch, useOwnAccount, useFeatures, useSoapboxConfig, useSettings, useSystemTheme } from 'soapbox/hooks'; import { useAppSelector, useAppDispatch, useOwnAccount, useFeatures, useSoapboxConfig, useSettings, useSystemTheme } from 'soapbox/hooks';
@ -29,6 +30,7 @@ import { checkOnboardingStatus } from '../actions/onboarding';
import { preload } from '../actions/preload'; import { preload } from '../actions/preload';
import ErrorBoundary from '../components/error_boundary'; import ErrorBoundary from '../components/error_boundary';
import UI from '../features/ui'; import UI from '../features/ui';
import BundleContainer from '../features/ui/containers/bundle_container';
import { store } from '../store'; import { store } from '../store';
/** Ensure the given locale exists in our codebase */ /** Ensure the given locale exists in our codebase */
@ -96,7 +98,7 @@ const SoapboxMount = () => {
MESSAGES[locale]().then(messages => { MESSAGES[locale]().then(messages => {
setMessages(messages); setMessages(messages);
setLocaleLoading(false); setLocaleLoading(false);
}).catch(() => {}); }).catch(() => { });
}, [locale]); }, [locale]);
// Load initial data from the API // Load initial data from the API
@ -172,7 +174,13 @@ const SoapboxMount = () => {
)} )}
{waitlisted && ( {waitlisted && (
<Route render={(props) => <WaitlistPage {...props} account={account} />} /> <>
<Route render={(props) => <WaitlistPage {...props} account={account} />} />
<BundleContainer fetchComponent={ModalContainer}>
{Component => <Component />}
</BundleContainer>
</>
)} )}
{!me && (singleUserMode {!me && (singleUserMode

Wyświetl plik

@ -3,6 +3,8 @@ import { HotKeys } from 'react-hotkeys';
import { FormattedMessage, useIntl } from 'react-intl'; import { FormattedMessage, useIntl } from 'react-intl';
import { useHistory } from 'react-router-dom'; import { useHistory } from 'react-router-dom';
import { useAppSelector } from 'soapbox/hooks';
import Icon from '../../../components/icon'; import Icon from '../../../components/icon';
import Permalink from '../../../components/permalink'; import Permalink from '../../../components/permalink';
import { HStack, Text, Emoji } from '../../../components/ui'; import { HStack, Text, Emoji } from '../../../components/ui';
@ -50,6 +52,7 @@ const icons: Record<NotificationType, string> = {
move: require('@tabler/icons/icons/briefcase.svg'), move: require('@tabler/icons/icons/briefcase.svg'),
'pleroma:chat_mention': require('@tabler/icons/icons/messages.svg'), 'pleroma:chat_mention': require('@tabler/icons/icons/messages.svg'),
'pleroma:emoji_reaction': require('@tabler/icons/icons/mood-happy.svg'), 'pleroma:emoji_reaction': require('@tabler/icons/icons/mood-happy.svg'),
user_approved: require('@tabler/icons/icons/user-plus.svg'),
}; };
const messages: Record<NotificationType, { id: string, defaultMessage: string }> = { const messages: Record<NotificationType, { id: string, defaultMessage: string }> = {
@ -93,16 +96,20 @@ const messages: Record<NotificationType, { id: string, defaultMessage: string }>
id: 'notification.pleroma:emoji_reaction', id: 'notification.pleroma:emoji_reaction',
defaultMessage: '{name} reacted to your post', defaultMessage: '{name} reacted to your post',
}, },
user_approved: {
id: 'notification.user_approved',
defaultMessage: 'Welcome to {instance}!',
},
}; };
const buildMessage = (type: NotificationType, account: Account, targetName?: string): JSX.Element => { const buildMessage = (type: NotificationType, account: Account, targetName: string, instanceTitle: string): JSX.Element => {
const link = buildLink(account); const link = buildLink(account);
return ( return (
<FormattedMessageFixed <FormattedMessageFixed
id={messages[type].id} id={messages[type].id}
defaultMessage={messages[type].defaultMessage} defaultMessage={messages[type].defaultMessage}
values={{ name: link, targetName }} values={{ name: link, targetName, instance: instanceTitle }}
/> />
); );
}; };
@ -128,6 +135,7 @@ const Notification: React.FC<INotificaton> = (props) => {
const history = useHistory(); const history = useHistory();
const intl = useIntl(); const intl = useIntl();
const instance = useAppSelector((state) => state.instance);
const type = notification.type; const type = notification.type;
const { account, status } = notification; const { account, status } = notification;
@ -216,6 +224,7 @@ const Notification: React.FC<INotificaton> = (props) => {
switch (type) { switch (type) {
case 'follow': case 'follow':
case 'follow_request': case 'follow_request':
case 'user_approved':
return account && typeof account === 'object' ? ( return account && typeof account === 'object' ? (
<AccountContainer <AccountContainer
id={account.id} id={account.id}
@ -239,7 +248,7 @@ const Notification: React.FC<INotificaton> = (props) => {
case 'pleroma:emoji_reaction': case 'pleroma:emoji_reaction':
return status && typeof status === 'object' ? ( return status && typeof status === 'object' ? (
<StatusContainer <StatusContainer
// @ts-ignore // @ts-ignore
id={status.id} id={status.id}
withDismiss withDismiss
hidden={hidden} hidden={hidden}
@ -259,7 +268,7 @@ const Notification: React.FC<INotificaton> = (props) => {
const targetName = notification.target && typeof notification.target === 'object' ? notification.target.acct : ''; const targetName = notification.target && typeof notification.target === 'object' ? notification.target.acct : '';
const message: React.ReactNode = type && account && typeof account === 'object' ? buildMessage(type, account, targetName) : null; const message: React.ReactNode = type && account && typeof account === 'object' ? buildMessage(type, account, targetName, instance.title) : null;
return ( return (
<HotKeys handlers={getHandlers()} data-testid='notification'> <HotKeys handlers={getHandlers()} data-testid='notification'>

Wyświetl plik

@ -30,6 +30,7 @@ import {
BirthdaysModal, BirthdaysModal,
AccountNoteModal, AccountNoteModal,
CompareHistoryModal, CompareHistoryModal,
VerifySmsModal,
} from 'soapbox/features/ui/util/async-components'; } from 'soapbox/features/ui/util/async-components';
import BundleContainer from '../containers/bundle_container'; import BundleContainer from '../containers/bundle_container';
@ -66,6 +67,7 @@ const MODAL_COMPONENTS = {
'BIRTHDAYS': BirthdaysModal, 'BIRTHDAYS': BirthdaysModal,
'ACCOUNT_NOTE': AccountNoteModal, 'ACCOUNT_NOTE': AccountNoteModal,
'COMPARE_HISTORY': CompareHistoryModal, 'COMPARE_HISTORY': CompareHistoryModal,
'VERIFY_SMS': VerifySmsModal,
}; };
export default class ModalRoot extends React.PureComponent { export default class ModalRoot extends React.PureComponent {

Wyświetl plik

@ -0,0 +1,233 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { useIntl } from 'react-intl';
import OtpInput from 'react-otp-input';
import { verifyCredentials } from 'soapbox/actions/auth';
import { closeModal } from 'soapbox/actions/modals';
import snackbar from 'soapbox/actions/snackbar';
import { reConfirmPhoneVerification, reRequestPhoneVerification } from 'soapbox/actions/verification';
import { FormGroup, Input, Modal, Stack, Text } from 'soapbox/components/ui';
import { validPhoneNumberRegex } from 'soapbox/features/verification/steps/sms-verification';
import { useAppDispatch, useAppSelector } from 'soapbox/hooks';
import { getAccessToken } from 'soapbox/utils/auth';
import { formatPhoneNumber } from 'soapbox/utils/phone';
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 accessToken = useAppSelector((state) => getAccessToken(state));
const title = useAppSelector((state) => state.instance.title);
const isLoading = useAppSelector((state) => state.verification.get('isLoading') as boolean);
const [status, setStatus] = useState<Statuses>(Statuses.IDLE);
const [phone, setPhone] = useState<string>('');
const [verificationCode, setVerificationCode] = useState('');
const [requestedAnother, setAlreadyRequestedAnother] = useState(false);
const isValid = validPhoneNumberRegex.test(phone);
const onChange = useCallback((event: React.ChangeEvent<HTMLInputElement>) => {
const formattedPhone = formatPhoneNumber(event.target.value);
setPhone(formattedPhone);
}, []);
const handleSubmit = (event: React.MouseEvent) => {
event.preventDefault();
if (!isValid) {
setStatus(Statuses.IDLE);
dispatch(
snackbar.error(
intl.formatMessage({
id: 'sms_verification.invalid',
defaultMessage: 'Please enter a valid phone number.',
}),
),
);
return;
}
dispatch(reRequestPhoneVerification(phone)).then(() => {
dispatch(
snackbar.success(
intl.formatMessage({
id: 'sms_verification.success',
defaultMessage: 'A verification code has been sent to your phone number.',
}),
),
);
})
.finally(() => setStatus(Statuses.REQUESTED))
.catch(() => {
dispatch(
snackbar.error(
intl.formatMessage({
id: 'sms_verification.fail',
defaultMessage: 'Failed to send SMS message to your phone number.',
}),
),
);
});
};
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({
id: 'sms_verification.modal.verify_sms',
defaultMessage: 'Verify SMS',
});
case Statuses.READY:
return intl.formatMessage({
id: 'sms_verification.modal.verify_number',
defaultMessage: 'Verify phone number',
});
case Statuses.REQUESTED:
return intl.formatMessage({
id: 'sms_verification.modal.verify_code',
defaultMessage: 'Verify code',
});
default:
return null;
}
}, [status]);
const renderModalBody = () => {
switch (status) {
case Statuses.IDLE:
return (
<Text theme='muted'>
{intl.formatMessage({
id: 'sms_verification.modal.verify_help_text',
defaultMessage: 'Verify your phone number to start using {instance}.',
}, {
instance: title,
})}
</Text>
);
case Statuses.READY:
return (
<FormGroup labelText='Phone Number'>
<Input
type='text'
value={phone}
onChange={onChange}
required
autoFocus
/>
</FormGroup>
);
case Statuses.REQUESTED:
return (
<>
<Text theme='muted' size='sm' align='center'>
{intl.formatMessage({
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 = () => {
// 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(() => dispatch(
snackbar.error(
intl.formatMessage({
id: 'sms_verification.invalid',
defaultMessage: 'Your SMS token has expired.',
}),
),
));
};
useEffect(() => {
if (verificationCode.length === 6) {
submitVerification();
}
}, [verificationCode]);
return (
<Modal
title={
intl.formatMessage({
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 ? intl.formatMessage({
id: 'sms_verification.modal.resend_code',
defaultMessage: 'Resend verification code?',
}) : undefined}
secondaryDisabled={requestedAnother}
>
<Stack space={4}>
{renderModalBody()}
</Stack>
</Modal>
);
};
export default VerifySmsModal;

Wyświetl plik

@ -501,3 +501,7 @@ export function CompareHistoryModal() {
export function AuthTokenList() { export function AuthTokenList() {
return import(/* webpackChunkName: "features/auth_token_list" */'../../auth_token_list'); return import(/* webpackChunkName: "features/auth_token_list" */'../../auth_token_list');
} }
export function VerifySmsModal() {
return import(/* webpackChunkName: "features/ui" */'../components/modals/verify-sms-modal');
}

Wyświetl plik

@ -1,7 +1,7 @@
import * as React from 'react'; import * as React from 'react';
import { useIntl } from 'react-intl'; import { useIntl } from 'react-intl';
import OtpInput from 'react-otp-input'; import OtpInput from 'react-otp-input';
import { useDispatch, useSelector } from 'react-redux'; import { useDispatch, useSelector } from 'react-redux';
import snackbar from 'soapbox/actions/snackbar'; import snackbar from 'soapbox/actions/snackbar';
import { confirmPhoneVerification, requestPhoneVerification } from 'soapbox/actions/verification'; import { confirmPhoneVerification, requestPhoneVerification } from 'soapbox/actions/verification';
@ -167,4 +167,4 @@ const SmsVerification = () => {
}; };
export default SmsVerification; export { SmsVerification as default, validPhoneNumberRegex };

Wyświetl plik

@ -1,13 +1,15 @@
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import React from 'react'; import React, { useEffect } from 'react';
import { useIntl } from 'react-intl'; import { useIntl } from 'react-intl';
import { useDispatch } from 'react-redux'; import { useDispatch } from 'react-redux';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { openModal } from 'soapbox/actions/modals';
import LandingGradient from 'soapbox/components/landing-gradient'; import LandingGradient from 'soapbox/components/landing-gradient';
import SiteLogo from 'soapbox/components/site-logo'; import SiteLogo from 'soapbox/components/site-logo';
import BundleContainer from 'soapbox/features/ui/containers/bundle_container'; import BundleContainer from 'soapbox/features/ui/containers/bundle_container';
import { NotificationsContainer } from 'soapbox/features/ui/util/async-components'; import { NotificationsContainer } from 'soapbox/features/ui/util/async-components';
import { useAppSelector, useOwnAccount } from 'soapbox/hooks';
import { logOut } from '../../actions/auth'; import { logOut } from '../../actions/auth';
import { Button, Stack, Text } from '../../components/ui'; import { Button, Stack, Text } from '../../components/ui';
@ -15,12 +17,24 @@ import { Button, Stack, Text } from '../../components/ui';
const WaitlistPage = ({ account }) => { const WaitlistPage = ({ account }) => {
const dispatch = useDispatch(); const dispatch = useDispatch();
const intl = useIntl(); const intl = useIntl();
const title = useAppSelector((state) => state.instance.title);
const me = useOwnAccount();
const isSmsVerified = me.getIn(['source', 'sms_verified']);
const onClickLogOut = (event) => { const onClickLogOut = (event) => {
event.preventDefault(); event.preventDefault();
dispatch(logOut(intl)); dispatch(logOut(intl));
}; };
const openVerifySmsModal = () => dispatch(openModal('VERIFY_SMS'));
useEffect(() => {
if (!isSmsVerified) {
openVerifySmsModal();
}
}, []);
return ( return (
<div> <div>
<LandingGradient /> <LandingGradient />
@ -41,19 +55,20 @@ const WaitlistPage = ({ account }) => {
</header> </header>
<div className='-mt-16 flex flex-col justify-center items-center h-full'> <div className='-mt-16 flex flex-col justify-center items-center h-full'>
<div className='max-w-2xl'> <div className='max-w-xl'>
<Stack space={4}> <Stack space={4}>
<img src='/instance/images/waitlist.png' className='mx-auto w-32 h-32' alt='Waitlisted' /> <img src='/instance/images/waitlist.png' className='mx-auto w-32 h-32' alt='Waitlisted' />
<Stack space={2}> <Stack space={2}>
<Text size='2xl' align='center' weight='bold'>
@{account.acct} has been created successfully!
</Text>
<Text size='lg' theme='muted' align='center' weight='medium'> <Text size='lg' theme='muted' align='center' weight='medium'>
Due to massive demand, we have placed you on our waitlist. Welcome back to {title}! You were previously placed on our
We love you, and you're not just another number to us. waitlist. Please verify your phone number to receive
We are working to get you on our platform. Stay tuned! immediate access to your account!
</Text> </Text>
<div className='text-center'>
<Button onClick={openVerifySmsModal} theme='primary'>Verify phone number</Button>
</div>
</Stack> </Stack>
</Stack> </Stack>
</div> </div>

Wyświetl plik

@ -21,7 +21,8 @@ export type NotificationType =
| 'status' | 'status'
| 'move' | 'move'
| 'pleroma:chat_mention' | 'pleroma:chat_mention'
| 'pleroma:emoji_reaction'; | 'pleroma:emoji_reaction'
| 'user_approved';
// https://docs.joinmastodon.org/entities/notification/ // https://docs.joinmastodon.org/entities/notification/
export const NotificationRecord = ImmutableRecord({ export const NotificationRecord = ImmutableRecord({