From 90129818f250e90246f00c8448d05fff3c7f4659 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Fri, 6 May 2022 16:51:36 -0500 Subject: [PATCH 01/18] RegistrationForm: convert to tsx --- app/soapbox/components/showable_password.tsx | 9 + .../components/registration_form.js | 377 ------------------ .../components/registration_form.tsx | 348 ++++++++++++++++ app/soapbox/features/forms/index.tsx | 15 + 4 files changed, 372 insertions(+), 377 deletions(-) delete mode 100644 app/soapbox/features/auth_login/components/registration_form.js create mode 100644 app/soapbox/features/auth_login/components/registration_form.tsx diff --git a/app/soapbox/components/showable_password.tsx b/app/soapbox/components/showable_password.tsx index 40835decd..7d7858fdc 100644 --- a/app/soapbox/components/showable_password.tsx +++ b/app/soapbox/components/showable_password.tsx @@ -14,8 +14,17 @@ interface IShowablePassword { label?: React.ReactNode, className?: string, hint?: React.ReactNode, + placeholder?: string, error?: boolean, onToggleVisibility?: () => void, + autoComplete?: string, + autoCorrect?: string, + autoCapitalize?: string, + name?: string, + required?: boolean, + onChange?: React.ChangeEventHandler, + onBlur?: React.ChangeEventHandler, + value?: string, } const ShowablePassword: React.FC = (props) => { diff --git a/app/soapbox/features/auth_login/components/registration_form.js b/app/soapbox/features/auth_login/components/registration_form.js deleted file mode 100644 index 898a1b7bd..000000000 --- a/app/soapbox/features/auth_login/components/registration_form.js +++ /dev/null @@ -1,377 +0,0 @@ -import { CancelToken } from 'axios'; -import { Map as ImmutableMap } from 'immutable'; -import { debounce } from 'lodash'; -import PropTypes from 'prop-types'; -import React from 'react'; -import ImmutablePropTypes from 'react-immutable-proptypes'; -import ImmutablePureComponent from 'react-immutable-pure-component'; -import { injectIntl, FormattedMessage, defineMessages } from 'react-intl'; -import { connect } from 'react-redux'; -import { Link, withRouter } from 'react-router-dom'; -import { v4 as uuidv4 } from 'uuid'; - -import { accountLookup } from 'soapbox/actions/accounts'; -import { register, verifyCredentials } from 'soapbox/actions/auth'; -import { openModal } from 'soapbox/actions/modals'; -import { getSettings } from 'soapbox/actions/settings'; -import BirthdayInput from 'soapbox/components/birthday_input'; -import ShowablePassword from 'soapbox/components/showable_password'; -import CaptchaField from 'soapbox/features/auth_login/components/captcha'; -import { - SimpleForm, - SimpleInput, - TextInput, - SimpleTextarea, - Checkbox, -} from 'soapbox/features/forms'; -import { getFeatures } from 'soapbox/utils/features'; - -const messages = defineMessages({ - username: { id: 'registration.fields.username_placeholder', defaultMessage: 'Username' }, - username_hint: { id: 'registration.fields.username_hint', defaultMessage: 'Only letters, numbers, and underscores are allowed.' }, - email: { id: 'registration.fields.email_placeholder', defaultMessage: 'E-Mail address' }, - password: { id: 'registration.fields.password_placeholder', defaultMessage: 'Password' }, - confirm: { id: 'registration.fields.confirm_placeholder', defaultMessage: 'Password (again)' }, - agreement: { id: 'registration.agreement', defaultMessage: 'I agree to the {tos}.' }, - tos: { id: 'registration.tos', defaultMessage: 'Terms of Service' }, - close: { id: 'registration.confirmation_modal.close', defaultMessage: 'Close' }, - newsletter: { id: 'registration.newsletter', defaultMessage: 'Subscribe to newsletter.' }, - needsConfirmationHeader: { id: 'confirmations.register.needs_confirmation.header', defaultMessage: 'Confirmation needed' }, - needsApprovalHeader: { id: 'confirmations.register.needs_approval.header', defaultMessage: 'Approval needed' }, -}); - -const mapStateToProps = (state, props) => ({ - instance: state.get('instance'), - locale: getSettings(state).get('locale'), - needsConfirmation: state.getIn(['instance', 'pleroma', 'metadata', 'account_activation_required']), - needsApproval: state.getIn(['instance', 'approval_required']), - supportsEmailList: getFeatures(state.get('instance')).emailList, - supportsAccountLookup: getFeatures(state.get('instance')).accountLookup, - birthdayRequired: state.getIn(['instance', 'pleroma', 'metadata', 'birthday_required']), -}); - -export default @connect(mapStateToProps) -@injectIntl -@withRouter -class RegistrationForm extends ImmutablePureComponent { - - static propTypes = { - intl: PropTypes.object.isRequired, - instance: ImmutablePropTypes.map, - locale: PropTypes.string, - needsConfirmation: PropTypes.bool, - needsApproval: PropTypes.bool, - supportsEmailList: PropTypes.bool, - supportsAccountLookup: PropTypes.bool, - inviteToken: PropTypes.string, - birthdayRequired: PropTypes.bool, - history: PropTypes.object, - } - - state = { - captchaLoading: true, - submissionLoading: false, - params: ImmutableMap(), - captchaIdempotencyKey: uuidv4(), - usernameUnavailable: false, - passwordConfirmation: '', - passwordMismatch: false, - } - - source = CancelToken.source(); - - refreshCancelToken = () => { - this.source.cancel(); - this.source = CancelToken.source(); - return this.source; - } - - setParams = map => { - this.setState({ params: this.state.params.merge(ImmutableMap(map)) }); - } - - onInputChange = e => { - this.setParams({ [e.target.name]: e.target.value }); - } - - onUsernameChange = e => { - this.setParams({ username: e.target.value }); - this.setState({ usernameUnavailable: false }); - this.source.cancel(); - - this.usernameAvailable(e.target.value); - } - - onCheckboxChange = e => { - this.setParams({ [e.target.name]: e.target.checked }); - } - - onPasswordChange = e => { - const password = e.target.value; - const { passwordConfirmation } = this.state; - this.onInputChange(e); - - if (password === passwordConfirmation) { - this.setState({ passwordMismatch: false }); - } - } - - onPasswordConfirmChange = e => { - const password = this.state.params.get('password', ''); - const passwordConfirmation = e.target.value; - this.setState({ passwordConfirmation }); - - if (password === passwordConfirmation) { - this.setState({ passwordMismatch: false }); - } - } - - onPasswordConfirmBlur = e => { - this.setState({ passwordMismatch: !this.passwordsMatch() }); - } - - onBirthdayChange = birthday => { - this.setState({ - birthday, - }); - } - - launchModal = () => { - const { dispatch, intl, needsConfirmation, needsApproval } = this.props; - - const message = (<> - {needsConfirmation &&

- {this.state.params.get('email')} }} - />

} - {needsApproval &&

-

} - ); - - dispatch(openModal('CONFIRM', { - icon: require('@tabler/icons/icons/check.svg'), - heading: needsConfirmation - ? intl.formatMessage(messages.needsConfirmationHeader) - : needsApproval - ? intl.formatMessage(messages.needsApprovalHeader) - : undefined, - message, - confirm: intl.formatMessage(messages.close), - })); - } - - postRegisterAction = ({ access_token }) => { - const { dispatch, needsConfirmation, needsApproval, history } = this.props; - - if (needsConfirmation || needsApproval) { - return this.launchModal(); - } else { - return dispatch(verifyCredentials(access_token)).then(() => { - history.push('/'); - }); - } - } - - passwordsMatch = () => { - const { params, passwordConfirmation } = this.state; - return params.get('password', '') === passwordConfirmation; - } - - usernameAvailable = debounce(username => { - const { dispatch, supportsAccountLookup } = this.props; - - if (!supportsAccountLookup) return; - - const source = this.refreshCancelToken(); - - dispatch(accountLookup(username, source.token)) - .then(account => { - this.setState({ usernameUnavailable: !!account }); - }) - .catch((error) => { - if (error.response?.status === 404) { - this.setState({ usernameUnavailable: false }); - } - }); - - }, 1000, { trailing: true }); - - onSubmit = e => { - const { dispatch, inviteToken } = this.props; - const { birthday } = this.state; - - if (!this.passwordsMatch()) { - this.setState({ passwordMismatch: true }); - return; - } - - const params = this.state.params.withMutations(params => { - // Locale for confirmation email - params.set('locale', this.props.locale); - - // Pleroma invites - if (inviteToken) { - params.set('token', inviteToken); - } - - if (birthday) { - params.set('birthday', new Date(birthday.getTime() - (birthday.getTimezoneOffset() * 60000)).toISOString().slice(0, 10)); - } - }); - - this.setState({ submissionLoading: true }); - - dispatch(register(params.toJS())) - .then(this.postRegisterAction) - .catch(error => { - this.setState({ submissionLoading: false }); - this.refreshCaptcha(); - }); - } - - onCaptchaClick = e => { - this.refreshCaptcha(); - } - - onFetchCaptcha = captcha => { - this.setState({ captchaLoading: false }); - this.setParams({ - captcha_token: captcha.get('token'), - captcha_answer_data: captcha.get('answer_data'), - }); - } - - onFetchCaptchaFail = error => { - this.setState({ captchaLoading: false }); - } - - refreshCaptcha = () => { - this.setState({ captchaIdempotencyKey: uuidv4() }); - this.setParams({ captcha_solution: '' }); - } - - render() { - const { instance, intl, supportsEmailList, birthdayRequired } = this.props; - const { params, usernameUnavailable, passwordConfirmation, passwordMismatch, birthday } = this.state; - const isLoading = this.state.captchaLoading || this.state.submissionLoading; - - return ( - -
-
-
- {usernameUnavailable && ( -
- -
- )} - - - {passwordMismatch && ( -
- -
- )} - - - {birthdayRequired && - } - {instance.get('approval_required') && - } - hint={} - name='reason' - maxLength={500} - onChange={this.onInputChange} - value={params.get('reason', '')} - required - />} -
- -
- {intl.formatMessage(messages.tos)} })} - name='agreement' - onChange={this.onCheckboxChange} - checked={params.get('agreement', false)} - required - /> - {supportsEmailList && } -
-
- -
-
-
-
- ); - } - -} diff --git a/app/soapbox/features/auth_login/components/registration_form.tsx b/app/soapbox/features/auth_login/components/registration_form.tsx new file mode 100644 index 000000000..ccbd64c04 --- /dev/null +++ b/app/soapbox/features/auth_login/components/registration_form.tsx @@ -0,0 +1,348 @@ +import axios from 'axios'; +import { Map as ImmutableMap } from 'immutable'; +import { debounce } from 'lodash'; +import React, { useState, useRef } from 'react'; +import { useIntl, FormattedMessage, defineMessages } from 'react-intl'; +import { Link, useHistory } from 'react-router-dom'; +import { v4 as uuidv4 } from 'uuid'; + +import { accountLookup } from 'soapbox/actions/accounts'; +import { register, verifyCredentials } from 'soapbox/actions/auth'; +import { openModal } from 'soapbox/actions/modals'; +import BirthdayInput from 'soapbox/components/birthday_input'; +import ShowablePassword from 'soapbox/components/showable_password'; +import CaptchaField from 'soapbox/features/auth_login/components/captcha'; +import { + SimpleForm, + SimpleInput, + TextInput, + SimpleTextarea, + Checkbox, +} from 'soapbox/features/forms'; +import { useAppSelector, useAppDispatch, useSettings, useFeatures } from 'soapbox/hooks'; + +const messages = defineMessages({ + username: { id: 'registration.fields.username_placeholder', defaultMessage: 'Username' }, + username_hint: { id: 'registration.fields.username_hint', defaultMessage: 'Only letters, numbers, and underscores are allowed.' }, + email: { id: 'registration.fields.email_placeholder', defaultMessage: 'E-Mail address' }, + password: { id: 'registration.fields.password_placeholder', defaultMessage: 'Password' }, + confirm: { id: 'registration.fields.confirm_placeholder', defaultMessage: 'Password (again)' }, + agreement: { id: 'registration.agreement', defaultMessage: 'I agree to the {tos}.' }, + tos: { id: 'registration.tos', defaultMessage: 'Terms of Service' }, + close: { id: 'registration.confirmation_modal.close', defaultMessage: 'Close' }, + newsletter: { id: 'registration.newsletter', defaultMessage: 'Subscribe to newsletter.' }, + needsConfirmationHeader: { id: 'confirmations.register.needs_confirmation.header', defaultMessage: 'Confirmation needed' }, + needsApprovalHeader: { id: 'confirmations.register.needs_approval.header', defaultMessage: 'Approval needed' }, +}); + +interface IRegistrationForm { + inviteToken?: string, +} + +/** Allows the user to sign up for the website. */ +const RegistrationForm: React.FC = ({ inviteToken }) => { + const intl = useIntl(); + const history = useHistory(); + const dispatch = useAppDispatch(); + + const settings = useSettings(); + const features = useFeatures(); + const instance = useAppSelector(state => state.instance); + + const locale = settings.get('locale'); + const needsConfirmation = !!instance.pleroma.getIn(['metadata', 'account_activation_required']); + const needsApproval = instance.approval_required; + const supportsEmailList = features.emailList; + const supportsAccountLookup = features.accountLookup; + const birthdayRequired = instance.pleroma.getIn(['metadata', 'birthday_required']); + + const [captchaLoading, setCaptchaLoading] = useState(true); + const [submissionLoading, setSubmissionLoading] = useState(false); + const [params, setParams] = useState(ImmutableMap()); + const [captchaIdempotencyKey, setCaptchaIdempotencyKey] = useState(uuidv4()); + const [usernameUnavailable, setUsernameUnavailable] = useState(false); + const [passwordConfirmation, setPasswordConfirmation] = useState(''); + const [passwordMismatch, setPasswordMismatch] = useState(false); + const [birthday, setBirthday] = useState(undefined); + + const source = useRef(axios.CancelToken.source()); + + const refreshCancelToken = () => { + source.current.cancel(); + source.current = axios.CancelToken.source(); + return source.current; + }; + + const updateParams = (map: any) => { + setParams(params.merge(ImmutableMap(map))); + }; + + const onInputChange: React.ChangeEventHandler = e => { + updateParams({ [e.target.name]: e.target.value }); + }; + + const onUsernameChange: React.ChangeEventHandler = e => { + updateParams({ username: e.target.value }); + setUsernameUnavailable(false); + source.current.cancel(); + + usernameAvailable(e.target.value); + }; + + const onCheckboxChange: React.ChangeEventHandler = e => { + updateParams({ [e.target.name]: e.target.checked }); + }; + + const onPasswordChange: React.ChangeEventHandler = e => { + const password = e.target.value; + onInputChange(e); + + if (password === passwordConfirmation) { + setPasswordMismatch(false); + } + }; + + const onPasswordConfirmChange: React.ChangeEventHandler = e => { + const password = params.get('password', ''); + const passwordConfirmation = e.target.value; + setPasswordConfirmation(passwordConfirmation); + + if (password === passwordConfirmation) { + setPasswordMismatch(false); + } + }; + + const onPasswordConfirmBlur: React.ChangeEventHandler = () => { + setPasswordMismatch(!passwordsMatch()); + }; + + const onBirthdayChange = (newBirthday: Date) => { + setBirthday(newBirthday); + }; + + const launchModal = () => { + const message = (<> + {needsConfirmation &&

+ {params.get('email')} }} + />

} + {needsApproval &&

+

} + ); + + dispatch(openModal('CONFIRM', { + icon: require('@tabler/icons/icons/check.svg'), + heading: needsConfirmation + ? intl.formatMessage(messages.needsConfirmationHeader) + : needsApproval + ? intl.formatMessage(messages.needsApprovalHeader) + : undefined, + message, + confirm: intl.formatMessage(messages.close), + })); + }; + + const postRegisterAction = ({ access_token }: any) => { + if (needsConfirmation || needsApproval) { + return launchModal(); + } else { + return dispatch(verifyCredentials(access_token)).then(() => { + history.push('/'); + }); + } + }; + + const passwordsMatch = () => { + return params.get('password', '') === passwordConfirmation; + }; + + const usernameAvailable = debounce(username => { + if (!supportsAccountLookup) return; + + const source = refreshCancelToken(); + + dispatch(accountLookup(username, source.token)) + .then(account => { + setUsernameUnavailable(!!account); + }) + .catch((error) => { + if (error.response?.status === 404) { + setUsernameUnavailable(false); + } + }); + + }, 1000, { trailing: true }); + + const onSubmit: React.FormEventHandler = () => { + if (!passwordsMatch()) { + setPasswordMismatch(true); + return; + } + + const normalParams = params.withMutations(params => { + // Locale for confirmation email + params.set('locale', locale); + + // Pleroma invites + if (inviteToken) { + params.set('token', inviteToken); + } + + if (birthday) { + params.set('birthday', new Date(birthday.getTime() - (birthday.getTimezoneOffset() * 60000)).toISOString().slice(0, 10)); + } + }); + + setSubmissionLoading(true); + + dispatch(register(normalParams.toJS())) + .then(postRegisterAction) + .catch(() => { + setSubmissionLoading(false); + refreshCaptcha(); + }); + }; + + const onCaptchaClick: React.MouseEventHandler = () => { + refreshCaptcha(); + }; + + const onFetchCaptcha = (captcha: ImmutableMap) => { + setCaptchaLoading(false); + updateParams({ + captcha_token: captcha.get('token'), + captcha_answer_data: captcha.get('answer_data'), + }); + }; + + const onFetchCaptchaFail = () => { + setCaptchaLoading(false); + }; + + const refreshCaptcha = () => { + setCaptchaIdempotencyKey(uuidv4()); + updateParams({ captcha_solution: '' }); + }; + + const isLoading = captchaLoading || submissionLoading; + + return ( + +
+
+
+ {usernameUnavailable && ( +
+ +
+ )} + + + {passwordMismatch && ( +
+ +
+ )} + + + {birthdayRequired && + } + {instance.get('approval_required') && + } + hint={} + name='reason' + maxLength={500} + onChange={onInputChange} + value={params.get('reason', '')} + required + />} +
+ +
+ {intl.formatMessage(messages.tos)} })} + name='agreement' + onChange={onCheckboxChange} + checked={params.get('agreement', false)} + required + /> + {supportsEmailList && } +
+
+ +
+
+
+
+ ); +}; + +export default RegistrationForm; diff --git a/app/soapbox/features/forms/index.tsx b/app/soapbox/features/forms/index.tsx index 79495c6cf..c458cd6b1 100644 --- a/app/soapbox/features/forms/index.tsx +++ b/app/soapbox/features/forms/index.tsx @@ -85,6 +85,10 @@ interface ISimpleInput { name?: string, placeholder?: string, value?: string | number, + autoComplete?: string, + autoCorrect?: string, + autoCapitalize?: string, + required?: boolean, } export const SimpleInput: React.FC = (props) => { @@ -104,6 +108,9 @@ interface ISimpleTextarea { value?: string, onChange?: React.ChangeEventHandler, rows?: number, + name?: string, + maxLength?: number, + required?: boolean, } export const SimpleTextarea: React.FC = (props) => { @@ -161,6 +168,7 @@ interface ICheckbox { name?: string, checked?: boolean, onChange?: React.ChangeEventHandler, + required?: boolean, } export const Checkbox: React.FC = (props) => ( @@ -240,8 +248,15 @@ interface ITextInput { name?: string, onChange?: React.ChangeEventHandler, label?: React.ReactNode, + hint?: React.ReactNode, placeholder?: string, value?: string, + autoComplete?: string, + autoCorrect?: string, + autoCapitalize?: string, + pattern?: string, + error?: boolean, + required?: boolean, } export const TextInput: React.FC = props => ( From 165ddc91bdb6c089098480ad458e63e363bac1bb Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Fri, 6 May 2022 17:13:00 -0500 Subject: [PATCH 02/18] RegistrationForm: use ui form inputs --- app/soapbox/components/ui/input/input.tsx | 4 +- .../components/registration_form.tsx | 88 ++++++++++--------- 2 files changed, 48 insertions(+), 44 deletions(-) diff --git a/app/soapbox/components/ui/input/input.tsx b/app/soapbox/components/ui/input/input.tsx index 75ab74601..20c7804ae 100644 --- a/app/soapbox/components/ui/input/input.tsx +++ b/app/soapbox/components/ui/input/input.tsx @@ -11,7 +11,7 @@ const messages = defineMessages({ hidePassword: { id: 'input.password.hide_password', defaultMessage: 'Hide password' }, }); -interface IInput extends Pick, 'maxLength' | 'onChange' | 'type' | 'autoComplete' | 'autoCorrect' | 'autoCapitalize' | 'required' | 'disabled' | 'onClick' | 'readOnly' | 'min' | 'pattern'> { +interface IInput extends Pick, 'maxLength' | 'onChange' | 'onBlur' | 'type' | 'autoComplete' | 'autoCorrect' | 'autoCapitalize' | 'required' | 'disabled' | 'onClick' | 'readOnly' | 'min' | 'pattern'> { /** Put the cursor into the input on mount. */ autoFocus?: boolean, /** The initial text in the input. */ @@ -32,6 +32,8 @@ interface IInput extends Pick, 'maxL onChange?: (event: React.ChangeEvent) => void, /** HTML input type. */ type: 'text' | 'number' | 'email' | 'tel' | 'password', + /** Whether to display the input in red. */ + hasError?: boolean, } /** Form input element. */ diff --git a/app/soapbox/features/auth_login/components/registration_form.tsx b/app/soapbox/features/auth_login/components/registration_form.tsx index ccbd64c04..65c3c3e6d 100644 --- a/app/soapbox/features/auth_login/components/registration_form.tsx +++ b/app/soapbox/features/auth_login/components/registration_form.tsx @@ -10,15 +10,9 @@ import { accountLookup } from 'soapbox/actions/accounts'; import { register, verifyCredentials } from 'soapbox/actions/auth'; import { openModal } from 'soapbox/actions/modals'; import BirthdayInput from 'soapbox/components/birthday_input'; -import ShowablePassword from 'soapbox/components/showable_password'; +import { Form, FormGroup, Input, Textarea } from 'soapbox/components/ui'; import CaptchaField from 'soapbox/features/auth_login/components/captcha'; -import { - SimpleForm, - SimpleInput, - TextInput, - SimpleTextarea, - Checkbox, -} from 'soapbox/features/forms'; +import { Checkbox } from 'soapbox/features/forms'; import { useAppSelector, useAppDispatch, useSettings, useFeatures } from 'soapbox/hooks'; const messages = defineMessages({ @@ -232,8 +226,8 @@ const RegistrationForm: React.FC = ({ inviteToken }) => { const isLoading = captchaLoading || submissionLoading; return ( - -
+
+
{usernameUnavailable && ( @@ -241,23 +235,25 @@ const RegistrationForm: React.FC = ({ inviteToken }) => {
)} - - + + + = ({ inviteToken }) => {
)} - - {birthdayRequired && @@ -299,16 +297,20 @@ const RegistrationForm: React.FC = ({ inviteToken }) => { onChange={onBirthdayChange} required />} - {instance.get('approval_required') && - } - hint={} - name='reason' - maxLength={500} - onChange={onInputChange} - value={params.get('reason', '')} - required - />} + {needsApproval && ( + } + hintText={} + > +