Merge branch 'waitlist-improvements' into 'develop'

Allow waitlisted users to verify their SMS

See merge request soapbox-pub/soapbox-fe!1422
environments/review-review-rcmeyv/deployments/1
Justin 2 years ago
commit 3ceb3a1d01

@ -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
* @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
* @param {date} birthday
@ -404,6 +432,8 @@ export {
requestEmailVerification,
checkEmailVerification,
postEmailVerification,
reConfirmPhoneVerification,
requestPhoneVerification,
reRequestPhoneVerification,
verifyAge,
};

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

@ -18,6 +18,7 @@ import AuthLayout from 'soapbox/features/auth_layout';
import OnboardingWizard from 'soapbox/features/onboarding/onboarding-wizard';
import PublicLayout from 'soapbox/features/public_layout';
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 { createGlobals } from 'soapbox/globals';
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 ErrorBoundary from '../components/error_boundary';
import UI from '../features/ui';
import BundleContainer from '../features/ui/containers/bundle_container';
import { store } from '../store';
/** Ensure the given locale exists in our codebase */
@ -96,7 +98,7 @@ const SoapboxMount = () => {
MESSAGES[locale]().then(messages => {
setMessages(messages);
setLocaleLoading(false);
}).catch(() => {});
}).catch(() => { });
}, [locale]);
// Load initial data from the API
@ -172,7 +174,13 @@ const SoapboxMount = () => {
)}
{waitlisted && (
<Route render={(props) => <WaitlistPage {...props} account={account} />} />
<>
<Route render={(props) => <WaitlistPage {...props} account={account} />} />
<BundleContainer fetchComponent={ModalContainer}>
{Component => <Component />}
</BundleContainer>
</>
)}
{!me && (singleUserMode

@ -3,6 +3,8 @@ import { HotKeys } from 'react-hotkeys';
import { FormattedMessage, useIntl } from 'react-intl';
import { useHistory } from 'react-router-dom';
import { useAppSelector } from 'soapbox/hooks';
import Icon from '../../../components/icon';
import Permalink from '../../../components/permalink';
import { HStack, Text, Emoji } from '../../../components/ui';
@ -50,6 +52,7 @@ const icons: Record<NotificationType, string> = {
move: require('@tabler/icons/icons/briefcase.svg'),
'pleroma:chat_mention': require('@tabler/icons/icons/messages.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 }> = {
@ -93,16 +96,20 @@ const messages: Record<NotificationType, { id: string, defaultMessage: string }>
id: 'notification.pleroma:emoji_reaction',
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);
return (
<FormattedMessageFixed
id={messages[type].id}
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 intl = useIntl();
const instance = useAppSelector((state) => state.instance);
const type = notification.type;
const { account, status } = notification;
@ -216,6 +224,7 @@ const Notification: React.FC<INotificaton> = (props) => {
switch (type) {
case 'follow':
case 'follow_request':
case 'user_approved':
return account && typeof account === 'object' ? (
<AccountContainer
id={account.id}
@ -239,7 +248,7 @@ const Notification: React.FC<INotificaton> = (props) => {
case 'pleroma:emoji_reaction':
return status && typeof status === 'object' ? (
<StatusContainer
// @ts-ignore
// @ts-ignore
id={status.id}
withDismiss
hidden={hidden}
@ -259,7 +268,7 @@ const Notification: React.FC<INotificaton> = (props) => {
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 (
<HotKeys handlers={getHandlers()} data-testid='notification'>

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

@ -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;

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

@ -1,7 +1,7 @@
import * as React from 'react';
import { useIntl } from 'react-intl';
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 { confirmPhoneVerification, requestPhoneVerification } from 'soapbox/actions/verification';
@ -167,4 +167,4 @@ const SmsVerification = () => {
};
export default SmsVerification;
export { SmsVerification as default, validPhoneNumberRegex };

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

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

Loading…
Cancel
Save