Allow waitlisted users to verify their SMS

This commit is contained in:
Justin 2022-05-18 14:08:08 -04:00
parent 5323c8a160
commit 54ef361bcc
10 changed files with 267 additions and 9 deletions

Binary file not shown.

View file

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

View file

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

View file

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

View file

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

View file

@ -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');
}

View file

@ -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({