Merge branch 'registration' into 'develop'
Registration improvements See merge request soapbox-pub/soapbox-fe!1325
This commit is contained in:
commit
d520f4d837
|
@ -1,58 +0,0 @@
|
||||||
import classNames from 'classnames';
|
|
||||||
import React, { useState } from 'react';
|
|
||||||
import { defineMessages, useIntl } from 'react-intl';
|
|
||||||
|
|
||||||
import IconButton from 'soapbox/components/icon_button';
|
|
||||||
import { InputContainer, LabelInputContainer } from 'soapbox/features/forms';
|
|
||||||
|
|
||||||
const messages = defineMessages({
|
|
||||||
showPassword: { id: 'forms.show_password', defaultMessage: 'Show password' },
|
|
||||||
hidePassword: { id: 'forms.hide_password', defaultMessage: 'Hide password' },
|
|
||||||
});
|
|
||||||
|
|
||||||
interface IShowablePassword {
|
|
||||||
label?: React.ReactNode,
|
|
||||||
className?: string,
|
|
||||||
hint?: React.ReactNode,
|
|
||||||
error?: boolean,
|
|
||||||
onToggleVisibility?: () => void,
|
|
||||||
}
|
|
||||||
|
|
||||||
const ShowablePassword: React.FC<IShowablePassword> = (props) => {
|
|
||||||
const intl = useIntl();
|
|
||||||
const [revealed, setRevealed] = useState(false);
|
|
||||||
|
|
||||||
const { hint, error, label, className, ...rest } = props;
|
|
||||||
|
|
||||||
const toggleReveal = () => {
|
|
||||||
if (props.onToggleVisibility) {
|
|
||||||
props.onToggleVisibility();
|
|
||||||
} else {
|
|
||||||
setRevealed(!revealed);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const revealButton = (
|
|
||||||
<IconButton
|
|
||||||
src={revealed ? require('@tabler/icons/icons/eye-off.svg') : require('@tabler/icons/icons/eye.svg')}
|
|
||||||
onClick={toggleReveal}
|
|
||||||
title={intl.formatMessage(revealed ? messages.hidePassword : messages.showPassword)}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<InputContainer {...props} extraClass={classNames('showable-password', className)}>
|
|
||||||
{label ? (
|
|
||||||
<LabelInputContainer label={label}>
|
|
||||||
<input {...rest} type={revealed ? 'text' : 'password'} />
|
|
||||||
{revealButton}
|
|
||||||
</LabelInputContainer>
|
|
||||||
) : (<>
|
|
||||||
<input {...rest} type={revealed ? 'text' : 'password'} />
|
|
||||||
{revealButton}
|
|
||||||
</>)}
|
|
||||||
</InputContainer>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default ShowablePassword;
|
|
|
@ -0,0 +1,17 @@
|
||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
interface ICheckbox extends Pick<React.InputHTMLAttributes<HTMLInputElement>, 'disabled' | 'id' | 'name' | 'onChange' | 'checked' | 'required'> { }
|
||||||
|
|
||||||
|
/** A pretty checkbox input. */
|
||||||
|
const Checkbox = React.forwardRef<HTMLInputElement, ICheckbox>((props, ref) => {
|
||||||
|
return (
|
||||||
|
<input
|
||||||
|
{...props}
|
||||||
|
ref={ref}
|
||||||
|
type='checkbox'
|
||||||
|
className='focus:ring-indigo-500 h-4 w-4 text-indigo-600 border-gray-300 rounded'
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
export default Checkbox;
|
|
@ -1,6 +1,10 @@
|
||||||
import React, { useMemo } from 'react';
|
import React, { useMemo } from 'react';
|
||||||
import { v4 as uuidv4 } from 'uuid';
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
|
|
||||||
|
import Checkbox from '../checkbox/checkbox';
|
||||||
|
import HStack from '../hstack/hstack';
|
||||||
|
import Stack from '../stack/stack';
|
||||||
|
|
||||||
interface IFormGroup {
|
interface IFormGroup {
|
||||||
/** Input label message. */
|
/** Input label message. */
|
||||||
labelText?: React.ReactNode,
|
labelText?: React.ReactNode,
|
||||||
|
@ -10,17 +14,56 @@ interface IFormGroup {
|
||||||
errors?: string[]
|
errors?: string[]
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Input element with label and hint. */
|
/** Input container with label. Renders the child. */
|
||||||
const FormGroup: React.FC<IFormGroup> = (props) => {
|
const FormGroup: React.FC<IFormGroup> = (props) => {
|
||||||
const { children, errors = [], labelText, hintText } = props;
|
const { children, errors = [], labelText, hintText } = props;
|
||||||
const formFieldId: string = useMemo(() => `field-${uuidv4()}`, []);
|
const formFieldId: string = useMemo(() => `field-${uuidv4()}`, []);
|
||||||
const inputChildren = React.Children.toArray(children);
|
const inputChildren = React.Children.toArray(children);
|
||||||
|
const hasError = errors?.length > 0;
|
||||||
|
|
||||||
let firstChild;
|
let firstChild;
|
||||||
if (React.isValidElement(inputChildren[0])) {
|
if (React.isValidElement(inputChildren[0])) {
|
||||||
firstChild = React.cloneElement(
|
firstChild = React.cloneElement(
|
||||||
inputChildren[0],
|
inputChildren[0],
|
||||||
{ id: formFieldId },
|
{ id: formFieldId, hasError },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const isCheckboxFormGroup = firstChild?.type === Checkbox;
|
||||||
|
|
||||||
|
if (isCheckboxFormGroup) {
|
||||||
|
return (
|
||||||
|
<HStack alignItems='start' space={2}>
|
||||||
|
{firstChild}
|
||||||
|
|
||||||
|
<Stack>
|
||||||
|
{labelText && (
|
||||||
|
<label
|
||||||
|
htmlFor={formFieldId}
|
||||||
|
data-testid='form-group-label'
|
||||||
|
className='-mt-0.5 block text-sm font-medium text-gray-700 dark:text-gray-400'
|
||||||
|
>
|
||||||
|
{labelText}
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{hasError && (
|
||||||
|
<div>
|
||||||
|
<p
|
||||||
|
data-testid='form-group-error'
|
||||||
|
className='mt-0.5 text-xs text-danger-900 bg-danger-200 rounded-md inline-block px-2 py-1 relative form-error'
|
||||||
|
>
|
||||||
|
{errors.join(', ')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{hintText && (
|
||||||
|
<p data-testid='form-group-hint' className='mt-0.5 text-xs text-gray-400'>
|
||||||
|
{hintText}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
</HStack>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -40,7 +83,7 @@ const FormGroup: React.FC<IFormGroup> = (props) => {
|
||||||
{firstChild}
|
{firstChild}
|
||||||
{inputChildren.filter((_, i) => i !== 0)}
|
{inputChildren.filter((_, i) => i !== 0)}
|
||||||
|
|
||||||
{errors?.length > 0 && (
|
{hasError && (
|
||||||
<p
|
<p
|
||||||
data-testid='form-group-error'
|
data-testid='form-group-error'
|
||||||
className='mt-0.5 text-xs text-danger-900 bg-danger-200 rounded-md inline-block px-2 py-1 relative form-error'
|
className='mt-0.5 text-xs text-danger-900 bg-danger-200 rounded-md inline-block px-2 py-1 relative form-error'
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
export { default as Avatar } from './avatar/avatar';
|
export { default as Avatar } from './avatar/avatar';
|
||||||
export { default as Button } from './button/button';
|
export { default as Button } from './button/button';
|
||||||
export { Card, CardBody, CardHeader, CardTitle } from './card/card';
|
export { Card, CardBody, CardHeader, CardTitle } from './card/card';
|
||||||
|
export { default as Checkbox } from './checkbox/checkbox';
|
||||||
export { default as Column } from './column/column';
|
export { default as Column } from './column/column';
|
||||||
export { default as Counter } from './counter/counter';
|
export { default as Counter } from './counter/counter';
|
||||||
export { default as Emoji } from './emoji/emoji';
|
export { default as Emoji } from './emoji/emoji';
|
||||||
|
|
|
@ -11,7 +11,7 @@ const messages = defineMessages({
|
||||||
hidePassword: { id: 'input.password.hide_password', defaultMessage: 'Hide password' },
|
hidePassword: { id: 'input.password.hide_password', defaultMessage: 'Hide password' },
|
||||||
});
|
});
|
||||||
|
|
||||||
interface IInput extends Pick<React.InputHTMLAttributes<HTMLInputElement>, 'maxLength' | 'onChange' | 'type' | 'autoComplete' | 'autoCorrect' | 'autoCapitalize' | 'required' | 'disabled' | 'onClick' | 'readOnly' | 'min' | 'pattern'> {
|
interface IInput extends Pick<React.InputHTMLAttributes<HTMLInputElement>, 'maxLength' | 'onChange' | 'onBlur' | 'type' | 'autoComplete' | 'autoCorrect' | 'autoCapitalize' | 'required' | 'disabled' | 'onClick' | 'readOnly' | 'min' | 'pattern'> {
|
||||||
/** Put the cursor into the input on mount. */
|
/** Put the cursor into the input on mount. */
|
||||||
autoFocus?: boolean,
|
autoFocus?: boolean,
|
||||||
/** The initial text in the input. */
|
/** The initial text in the input. */
|
||||||
|
@ -32,6 +32,8 @@ interface IInput extends Pick<React.InputHTMLAttributes<HTMLInputElement>, 'maxL
|
||||||
onChange?: (event: React.ChangeEvent<HTMLInputElement>) => void,
|
onChange?: (event: React.ChangeEvent<HTMLInputElement>) => void,
|
||||||
/** HTML input type. */
|
/** HTML input type. */
|
||||||
type: 'text' | 'number' | 'email' | 'tel' | 'password',
|
type: 'text' | 'number' | 'email' | 'tel' | 'password',
|
||||||
|
/** Whether to display the input in red. */
|
||||||
|
hasError?: boolean,
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Form input element. */
|
/** Form input element. */
|
||||||
|
@ -39,7 +41,7 @@ const Input = React.forwardRef<HTMLInputElement, IInput>(
|
||||||
(props, ref) => {
|
(props, ref) => {
|
||||||
const intl = useIntl();
|
const intl = useIntl();
|
||||||
|
|
||||||
const { type = 'text', icon, className, outerClassName, ...filteredProps } = props;
|
const { type = 'text', icon, className, outerClassName, hasError, ...filteredProps } = props;
|
||||||
|
|
||||||
const [revealed, setRevealed] = React.useState(false);
|
const [revealed, setRevealed] = React.useState(false);
|
||||||
|
|
||||||
|
@ -65,6 +67,7 @@ const Input = React.forwardRef<HTMLInputElement, IInput>(
|
||||||
'dark:bg-slate-800 block w-full sm:text-sm border-gray-300 dark:border-gray-600 rounded-md focus:ring-indigo-500 focus:border-indigo-500':
|
'dark:bg-slate-800 block w-full sm:text-sm border-gray-300 dark:border-gray-600 rounded-md focus:ring-indigo-500 focus:border-indigo-500':
|
||||||
true,
|
true,
|
||||||
'pr-7': isPassword,
|
'pr-7': isPassword,
|
||||||
|
'text-red-600 border-red-600': hasError,
|
||||||
'pl-8': typeof icon !== 'undefined',
|
'pl-8': typeof icon !== 'undefined',
|
||||||
}, className)}
|
}, className)}
|
||||||
/>
|
/>
|
||||||
|
|
|
@ -1,125 +0,0 @@
|
||||||
import { Map as ImmutableMap } from 'immutable';
|
|
||||||
import PropTypes from 'prop-types';
|
|
||||||
import React from 'react';
|
|
||||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
|
||||||
import { FormattedMessage } from 'react-intl';
|
|
||||||
import { connect } from 'react-redux';
|
|
||||||
|
|
||||||
import { fetchCaptcha } from 'soapbox/actions/auth';
|
|
||||||
import { TextInput } from 'soapbox/features/forms';
|
|
||||||
|
|
||||||
const noOp = () => {};
|
|
||||||
|
|
||||||
export default @connect()
|
|
||||||
class CaptchaField extends React.Component {
|
|
||||||
|
|
||||||
static propTypes = {
|
|
||||||
onChange: PropTypes.func,
|
|
||||||
onFetch: PropTypes.func,
|
|
||||||
onFetchFail: PropTypes.func,
|
|
||||||
onClick: PropTypes.func,
|
|
||||||
dispatch: PropTypes.func,
|
|
||||||
refreshInterval: PropTypes.number,
|
|
||||||
idempotencyKey: PropTypes.string,
|
|
||||||
}
|
|
||||||
|
|
||||||
static defaultProps = {
|
|
||||||
onChange: noOp,
|
|
||||||
onFetch: noOp,
|
|
||||||
onFetchFail: noOp,
|
|
||||||
onClick: noOp,
|
|
||||||
refreshInterval: 5*60*1000, // 5 minutes, Pleroma default
|
|
||||||
}
|
|
||||||
|
|
||||||
state = {
|
|
||||||
captcha: ImmutableMap(),
|
|
||||||
refresh: undefined,
|
|
||||||
}
|
|
||||||
|
|
||||||
startRefresh = () => {
|
|
||||||
const { refreshInterval } = this.props;
|
|
||||||
if (refreshInterval) {
|
|
||||||
const refresh = setInterval(this.fetchCaptcha, refreshInterval);
|
|
||||||
this.setState({ refresh });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
endRefresh = () => {
|
|
||||||
clearInterval(this.state.refresh);
|
|
||||||
}
|
|
||||||
|
|
||||||
forceRefresh = () => {
|
|
||||||
this.fetchCaptcha();
|
|
||||||
this.endRefresh();
|
|
||||||
this.startRefresh();
|
|
||||||
}
|
|
||||||
|
|
||||||
fetchCaptcha = () => {
|
|
||||||
const { dispatch, onFetch, onFetchFail } = this.props;
|
|
||||||
dispatch(fetchCaptcha()).then(response => {
|
|
||||||
const captcha = ImmutableMap(response.data);
|
|
||||||
this.setState({ captcha });
|
|
||||||
onFetch(captcha);
|
|
||||||
}).catch(error => {
|
|
||||||
onFetchFail(error);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
componentDidMount() {
|
|
||||||
this.fetchCaptcha();
|
|
||||||
this.startRefresh(); // Refresh periodically
|
|
||||||
}
|
|
||||||
|
|
||||||
componentWillUnmount() {
|
|
||||||
this.endRefresh();
|
|
||||||
}
|
|
||||||
|
|
||||||
componentDidUpdate(prevProps) {
|
|
||||||
if (this.props.idempotencyKey !== prevProps.idempotencyKey) {
|
|
||||||
this.forceRefresh();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
render() {
|
|
||||||
const { captcha } = this.state;
|
|
||||||
const { onChange, onClick, ...props } = this.props;
|
|
||||||
|
|
||||||
switch(captcha.get('type')) {
|
|
||||||
case 'native':
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<p>{<FormattedMessage id='registration.captcha.hint' defaultMessage='Click the image to get a new captcha' />}</p>
|
|
||||||
<NativeCaptchaField captcha={captcha} onChange={onChange} onClick={onClick} {...props} />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
case 'none':
|
|
||||||
default:
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
export const NativeCaptchaField = ({ captcha, onChange, onClick, name, value }) => (
|
|
||||||
<div className='captcha' >
|
|
||||||
<img alt='captcha' src={captcha.get('url')} onClick={onClick} />
|
|
||||||
<TextInput
|
|
||||||
placeholder='Enter the pictured text'
|
|
||||||
name={name}
|
|
||||||
value={value}
|
|
||||||
autoComplete='off'
|
|
||||||
autoCorrect='off'
|
|
||||||
autoCapitalize='off'
|
|
||||||
onChange={onChange}
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
NativeCaptchaField.propTypes = {
|
|
||||||
captcha: ImmutablePropTypes.map.isRequired,
|
|
||||||
onChange: PropTypes.func,
|
|
||||||
onClick: PropTypes.func,
|
|
||||||
name: PropTypes.string,
|
|
||||||
value: PropTypes.string,
|
|
||||||
};
|
|
|
@ -0,0 +1,132 @@
|
||||||
|
import { Map as ImmutableMap } from 'immutable';
|
||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { useIntl, defineMessages, FormattedMessage } from 'react-intl';
|
||||||
|
|
||||||
|
import { fetchCaptcha } from 'soapbox/actions/auth';
|
||||||
|
import { Stack, Text, Input } from 'soapbox/components/ui';
|
||||||
|
import { useAppDispatch } from 'soapbox/hooks';
|
||||||
|
|
||||||
|
const noOp = () => {};
|
||||||
|
|
||||||
|
const messages = defineMessages({
|
||||||
|
placeholder: { id: 'registration.captcha.placeholder', defaultMessage: 'Enter the pictured text' },
|
||||||
|
});
|
||||||
|
|
||||||
|
interface ICaptchaField {
|
||||||
|
name?: string,
|
||||||
|
value: string,
|
||||||
|
onChange?: React.ChangeEventHandler<HTMLInputElement>,
|
||||||
|
onFetch?: (captcha: ImmutableMap<string, any>) => void,
|
||||||
|
onFetchFail?: (error: Error) => void,
|
||||||
|
onClick?: React.MouseEventHandler,
|
||||||
|
refreshInterval?: number,
|
||||||
|
idempotencyKey: string,
|
||||||
|
}
|
||||||
|
|
||||||
|
const CaptchaField: React.FC<ICaptchaField> = ({
|
||||||
|
name,
|
||||||
|
value,
|
||||||
|
onChange = noOp,
|
||||||
|
onFetch = noOp,
|
||||||
|
onFetchFail = noOp,
|
||||||
|
onClick = noOp,
|
||||||
|
refreshInterval = 5*60*1000, // 5 minutes, Pleroma default
|
||||||
|
idempotencyKey,
|
||||||
|
}) => {
|
||||||
|
const dispatch = useAppDispatch();
|
||||||
|
|
||||||
|
const [captcha, setCaptcha] = useState(ImmutableMap<string, any>());
|
||||||
|
const [refresh, setRefresh] = useState<NodeJS.Timer | undefined>(undefined);
|
||||||
|
|
||||||
|
const getCaptcha = () => {
|
||||||
|
dispatch(fetchCaptcha()).then(response => {
|
||||||
|
const captcha = ImmutableMap<string, any>(response.data);
|
||||||
|
setCaptcha(captcha);
|
||||||
|
onFetch(captcha);
|
||||||
|
}).catch((error: Error) => {
|
||||||
|
onFetchFail(error);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const startRefresh = () => {
|
||||||
|
if (refreshInterval) {
|
||||||
|
const newRefresh = setInterval(getCaptcha, refreshInterval);
|
||||||
|
setRefresh(newRefresh);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const endRefresh = () => {
|
||||||
|
if (refresh) {
|
||||||
|
clearInterval(refresh);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
getCaptcha();
|
||||||
|
endRefresh();
|
||||||
|
startRefresh(); // Refresh periodically
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
endRefresh();
|
||||||
|
};
|
||||||
|
}, [idempotencyKey]);
|
||||||
|
|
||||||
|
switch(captcha.get('type')) {
|
||||||
|
case 'native':
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<Text>
|
||||||
|
<FormattedMessage id='registration.captcha.hint' defaultMessage='Click the image to get a new captcha' />
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<NativeCaptchaField
|
||||||
|
captcha={captcha}
|
||||||
|
onChange={onChange}
|
||||||
|
onClick={onClick}
|
||||||
|
name={name}
|
||||||
|
value={value}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
case 'none':
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
interface INativeCaptchaField {
|
||||||
|
captcha: ImmutableMap<string, any>,
|
||||||
|
onChange: React.ChangeEventHandler<HTMLInputElement>,
|
||||||
|
onClick: React.MouseEventHandler,
|
||||||
|
name?: string,
|
||||||
|
value: string,
|
||||||
|
}
|
||||||
|
|
||||||
|
const NativeCaptchaField: React.FC<INativeCaptchaField> = ({ captcha, onChange, onClick, name, value }) => {
|
||||||
|
const intl = useIntl();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack space={2}>
|
||||||
|
<div className='flex items-center justify-center bg-white w-full border border-solid border-gray-300 dark:border-gray-600 rounded-md'>
|
||||||
|
<img alt='captcha' src={captcha.get('url')} onClick={onClick} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Input
|
||||||
|
type='text'
|
||||||
|
placeholder={intl.formatMessage(messages.placeholder)}
|
||||||
|
name={name}
|
||||||
|
value={value}
|
||||||
|
autoComplete='off'
|
||||||
|
autoCorrect='off'
|
||||||
|
autoCapitalize='off'
|
||||||
|
onChange={onChange}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export {
|
||||||
|
CaptchaField as default,
|
||||||
|
NativeCaptchaField,
|
||||||
|
};
|
|
@ -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 && <p>
|
|
||||||
<FormattedMessage
|
|
||||||
id='confirmations.register.needs_confirmation'
|
|
||||||
defaultMessage='Please check your inbox at {email} for confirmation instructions. You will need to verify your email address to continue.'
|
|
||||||
values={{ email: <strong>{this.state.params.get('email')}</strong> }}
|
|
||||||
/></p>}
|
|
||||||
{needsApproval && <p>
|
|
||||||
<FormattedMessage
|
|
||||||
id='confirmations.register.needs_approval'
|
|
||||||
defaultMessage='Your account will be manually approved by an admin. Please be patient while we review your details.'
|
|
||||||
/></p>}
|
|
||||||
</>);
|
|
||||||
|
|
||||||
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 (
|
|
||||||
<SimpleForm onSubmit={this.onSubmit} data-testid='registrations-open'>
|
|
||||||
<fieldset disabled={isLoading}>
|
|
||||||
<div className='simple_form__overlay-area'>
|
|
||||||
<div className='fields-group'>
|
|
||||||
{usernameUnavailable && (
|
|
||||||
<div className='error'>
|
|
||||||
<FormattedMessage id='registration.username_unavailable' defaultMessage='Username is already taken.' />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<TextInput
|
|
||||||
placeholder={intl.formatMessage(messages.username)}
|
|
||||||
name='username'
|
|
||||||
hint={intl.formatMessage(messages.username_hint)}
|
|
||||||
autoComplete='off'
|
|
||||||
autoCorrect='off'
|
|
||||||
autoCapitalize='off'
|
|
||||||
pattern='^[a-zA-Z\d_-]+'
|
|
||||||
onChange={this.onUsernameChange}
|
|
||||||
value={params.get('username', '')}
|
|
||||||
error={usernameUnavailable}
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
<SimpleInput
|
|
||||||
placeholder={intl.formatMessage(messages.email)}
|
|
||||||
name='email'
|
|
||||||
type='email'
|
|
||||||
autoComplete='off'
|
|
||||||
autoCorrect='off'
|
|
||||||
autoCapitalize='off'
|
|
||||||
onChange={this.onInputChange}
|
|
||||||
value={params.get('email', '')}
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
{passwordMismatch && (
|
|
||||||
<div className='error'>
|
|
||||||
<FormattedMessage id='registration.password_mismatch' defaultMessage="Passwords don't match." />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<ShowablePassword
|
|
||||||
placeholder={intl.formatMessage(messages.password)}
|
|
||||||
name='password'
|
|
||||||
autoComplete='off'
|
|
||||||
autoCorrect='off'
|
|
||||||
autoCapitalize='off'
|
|
||||||
onChange={this.onPasswordChange}
|
|
||||||
value={params.get('password', '')}
|
|
||||||
error={passwordMismatch === true}
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
<ShowablePassword
|
|
||||||
placeholder={intl.formatMessage(messages.confirm)}
|
|
||||||
name='password_confirmation'
|
|
||||||
autoComplete='off'
|
|
||||||
autoCorrect='off'
|
|
||||||
autoCapitalize='off'
|
|
||||||
onChange={this.onPasswordConfirmChange}
|
|
||||||
onBlur={this.onPasswordConfirmBlur}
|
|
||||||
value={passwordConfirmation}
|
|
||||||
error={passwordMismatch === true}
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
{birthdayRequired &&
|
|
||||||
<BirthdayInput
|
|
||||||
value={birthday}
|
|
||||||
onChange={this.onBirthdayChange}
|
|
||||||
required
|
|
||||||
/>}
|
|
||||||
{instance.get('approval_required') &&
|
|
||||||
<SimpleTextarea
|
|
||||||
label={<FormattedMessage id='registration.reason' defaultMessage='Why do you want to join?' />}
|
|
||||||
hint={<FormattedMessage id='registration.reason_hint' defaultMessage='This will help us review your application' />}
|
|
||||||
name='reason'
|
|
||||||
maxLength={500}
|
|
||||||
onChange={this.onInputChange}
|
|
||||||
value={params.get('reason', '')}
|
|
||||||
required
|
|
||||||
/>}
|
|
||||||
</div>
|
|
||||||
<CaptchaField
|
|
||||||
onFetch={this.onFetchCaptcha}
|
|
||||||
onFetchFail={this.onFetchCaptchaFail}
|
|
||||||
onChange={this.onInputChange}
|
|
||||||
onClick={this.onCaptchaClick}
|
|
||||||
idempotencyKey={this.state.captchaIdempotencyKey}
|
|
||||||
name='captcha_solution'
|
|
||||||
value={params.get('captcha_solution', '')}
|
|
||||||
/>
|
|
||||||
<div className='fields-group'>
|
|
||||||
<Checkbox
|
|
||||||
label={intl.formatMessage(messages.agreement, { tos: <Link to='/about/tos' target='_blank' key={0}>{intl.formatMessage(messages.tos)}</Link> })}
|
|
||||||
name='agreement'
|
|
||||||
onChange={this.onCheckboxChange}
|
|
||||||
checked={params.get('agreement', false)}
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
{supportsEmailList && <Checkbox
|
|
||||||
label={intl.formatMessage(messages.newsletter)}
|
|
||||||
name='accepts_email_list'
|
|
||||||
onChange={this.onCheckboxChange}
|
|
||||||
checked={params.get('accepts_email_list', false)}
|
|
||||||
/>}
|
|
||||||
</div>
|
|
||||||
<div className='actions'>
|
|
||||||
<button name='button' type='submit' className='btn button button-primary'>
|
|
||||||
<FormattedMessage id='registration.sign_up' defaultMessage='Sign up' />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</fieldset>
|
|
||||||
</SimpleForm>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
|
@ -0,0 +1,356 @@
|
||||||
|
import axios from 'axios';
|
||||||
|
import { Map as ImmutableMap } from 'immutable';
|
||||||
|
import { debounce } from 'lodash';
|
||||||
|
import React, { useState, useRef, useCallback } 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 { Checkbox, Form, FormGroup, FormActions, Button, Input, Textarea } from 'soapbox/components/ui';
|
||||||
|
import CaptchaField from 'soapbox/features/auth_login/components/captcha';
|
||||||
|
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.' },
|
||||||
|
usernameUnavailable: { id: 'registration.username_unavailable', defaultMessage: 'Username is already taken.' },
|
||||||
|
email: { id: 'registration.fields.email_placeholder', defaultMessage: 'E-Mail address' },
|
||||||
|
password: { id: 'registration.fields.password_placeholder', defaultMessage: 'Password' },
|
||||||
|
passwordMismatch: { id: 'registration.password_mismatch', defaultMessage: 'Passwords don\'t match.' },
|
||||||
|
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<IRegistrationForm> = ({ 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<string, any>());
|
||||||
|
const [captchaIdempotencyKey, setCaptchaIdempotencyKey] = useState(uuidv4());
|
||||||
|
const [usernameUnavailable, setUsernameUnavailable] = useState(false);
|
||||||
|
const [passwordConfirmation, setPasswordConfirmation] = useState('');
|
||||||
|
const [passwordMismatch, setPasswordMismatch] = useState(false);
|
||||||
|
const [birthday, setBirthday] = useState<Date | undefined>(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<HTMLInputElement | HTMLTextAreaElement> = e => {
|
||||||
|
updateParams({ [e.target.name]: e.target.value });
|
||||||
|
};
|
||||||
|
|
||||||
|
const onUsernameChange: React.ChangeEventHandler<HTMLInputElement> = e => {
|
||||||
|
updateParams({ username: e.target.value });
|
||||||
|
setUsernameUnavailable(false);
|
||||||
|
source.current.cancel();
|
||||||
|
|
||||||
|
usernameAvailable(e.target.value);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onCheckboxChange: React.ChangeEventHandler<HTMLInputElement> = e => {
|
||||||
|
updateParams({ [e.target.name]: e.target.checked });
|
||||||
|
};
|
||||||
|
|
||||||
|
const onPasswordChange: React.ChangeEventHandler<HTMLInputElement> = e => {
|
||||||
|
const password = e.target.value;
|
||||||
|
onInputChange(e);
|
||||||
|
|
||||||
|
if (password === passwordConfirmation) {
|
||||||
|
setPasswordMismatch(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onPasswordConfirmChange: React.ChangeEventHandler<HTMLInputElement> = e => {
|
||||||
|
const password = params.get('password', '');
|
||||||
|
const passwordConfirmation = e.target.value;
|
||||||
|
setPasswordConfirmation(passwordConfirmation);
|
||||||
|
|
||||||
|
if (password === passwordConfirmation) {
|
||||||
|
setPasswordMismatch(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onPasswordConfirmBlur: React.ChangeEventHandler<HTMLInputElement> = () => {
|
||||||
|
setPasswordMismatch(!passwordsMatch());
|
||||||
|
};
|
||||||
|
|
||||||
|
const onBirthdayChange = (newBirthday: Date) => {
|
||||||
|
setBirthday(newBirthday);
|
||||||
|
};
|
||||||
|
|
||||||
|
const launchModal = () => {
|
||||||
|
const message = (<>
|
||||||
|
{needsConfirmation && <p>
|
||||||
|
<FormattedMessage
|
||||||
|
id='confirmations.register.needs_confirmation'
|
||||||
|
defaultMessage='Please check your inbox at {email} for confirmation instructions. You will need to verify your email address to continue.'
|
||||||
|
values={{ email: <strong>{params.get('email')}</strong> }}
|
||||||
|
/></p>}
|
||||||
|
{needsApproval && <p>
|
||||||
|
<FormattedMessage
|
||||||
|
id='confirmations.register.needs_approval'
|
||||||
|
defaultMessage='Your account will be manually approved by an admin. Please be patient while we review your details.'
|
||||||
|
/></p>}
|
||||||
|
</>);
|
||||||
|
|
||||||
|
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 = useCallback(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<string, any>) => {
|
||||||
|
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 (
|
||||||
|
<Form onSubmit={onSubmit} data-testid='registrations-open'>
|
||||||
|
<fieldset disabled={isLoading} className='space-y-3'>
|
||||||
|
<FormGroup
|
||||||
|
hintText={intl.formatMessage(messages.username_hint)}
|
||||||
|
errors={usernameUnavailable ? [intl.formatMessage(messages.usernameUnavailable)] : undefined}
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
type='text'
|
||||||
|
name='username'
|
||||||
|
placeholder={intl.formatMessage(messages.username)}
|
||||||
|
autoComplete='off'
|
||||||
|
autoCorrect='off'
|
||||||
|
autoCapitalize='off'
|
||||||
|
pattern='^[a-zA-Z\d_-]+'
|
||||||
|
onChange={onUsernameChange}
|
||||||
|
value={params.get('username', '')}
|
||||||
|
hasError={usernameUnavailable}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</FormGroup>
|
||||||
|
|
||||||
|
<Input
|
||||||
|
type='email'
|
||||||
|
name='email'
|
||||||
|
placeholder={intl.formatMessage(messages.email)}
|
||||||
|
autoComplete='off'
|
||||||
|
autoCorrect='off'
|
||||||
|
autoCapitalize='off'
|
||||||
|
onChange={onInputChange}
|
||||||
|
value={params.get('email', '')}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Input
|
||||||
|
type='password'
|
||||||
|
name='password'
|
||||||
|
placeholder={intl.formatMessage(messages.password)}
|
||||||
|
autoComplete='off'
|
||||||
|
autoCorrect='off'
|
||||||
|
autoCapitalize='off'
|
||||||
|
onChange={onPasswordChange}
|
||||||
|
value={params.get('password', '')}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormGroup
|
||||||
|
errors={passwordMismatch ? [intl.formatMessage(messages.passwordMismatch)] : undefined}
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
type='password'
|
||||||
|
name='password_confirmation'
|
||||||
|
placeholder={intl.formatMessage(messages.confirm)}
|
||||||
|
autoComplete='off'
|
||||||
|
autoCorrect='off'
|
||||||
|
autoCapitalize='off'
|
||||||
|
onChange={onPasswordConfirmChange}
|
||||||
|
onBlur={onPasswordConfirmBlur}
|
||||||
|
value={passwordConfirmation}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</FormGroup>
|
||||||
|
|
||||||
|
{birthdayRequired && (
|
||||||
|
<BirthdayInput
|
||||||
|
value={birthday}
|
||||||
|
onChange={onBirthdayChange}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{needsApproval && (
|
||||||
|
<FormGroup
|
||||||
|
labelText={<FormattedMessage id='registration.reason' defaultMessage='Why do you want to join?' />}
|
||||||
|
hintText={<FormattedMessage id='registration.reason_hint' defaultMessage='This will help us review your application' />}
|
||||||
|
>
|
||||||
|
<Textarea
|
||||||
|
name='reason'
|
||||||
|
maxLength={500}
|
||||||
|
onChange={onInputChange}
|
||||||
|
value={params.get('reason', '')}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</FormGroup>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<CaptchaField
|
||||||
|
onFetch={onFetchCaptcha}
|
||||||
|
onFetchFail={onFetchCaptchaFail}
|
||||||
|
onChange={onInputChange}
|
||||||
|
onClick={onCaptchaClick}
|
||||||
|
idempotencyKey={captchaIdempotencyKey}
|
||||||
|
name='captcha_solution'
|
||||||
|
value={params.get('captcha_solution', '')}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormGroup
|
||||||
|
labelText={intl.formatMessage(messages.agreement, { tos: <Link to='/about/tos' target='_blank' key={0}>{intl.formatMessage(messages.tos)}</Link> })}
|
||||||
|
>
|
||||||
|
<Checkbox
|
||||||
|
name='agreement'
|
||||||
|
onChange={onCheckboxChange}
|
||||||
|
checked={params.get('agreement', false)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</FormGroup>
|
||||||
|
|
||||||
|
{supportsEmailList && (
|
||||||
|
<FormGroup labelText={intl.formatMessage(messages.newsletter)}>
|
||||||
|
<Checkbox
|
||||||
|
name='accepts_email_list'
|
||||||
|
onChange={onCheckboxChange}
|
||||||
|
checked={params.get('accepts_email_list', false)}
|
||||||
|
/>
|
||||||
|
</FormGroup>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<FormActions>
|
||||||
|
<Button type='submit'>
|
||||||
|
<FormattedMessage id='registration.sign_up' defaultMessage='Sign up' />
|
||||||
|
</Button>
|
||||||
|
</FormActions>
|
||||||
|
</fieldset>
|
||||||
|
</Form>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default RegistrationForm;
|
|
@ -85,6 +85,10 @@ interface ISimpleInput {
|
||||||
name?: string,
|
name?: string,
|
||||||
placeholder?: string,
|
placeholder?: string,
|
||||||
value?: string | number,
|
value?: string | number,
|
||||||
|
autoComplete?: string,
|
||||||
|
autoCorrect?: string,
|
||||||
|
autoCapitalize?: string,
|
||||||
|
required?: boolean,
|
||||||
}
|
}
|
||||||
|
|
||||||
export const SimpleInput: React.FC<ISimpleInput> = (props) => {
|
export const SimpleInput: React.FC<ISimpleInput> = (props) => {
|
||||||
|
@ -104,6 +108,9 @@ interface ISimpleTextarea {
|
||||||
value?: string,
|
value?: string,
|
||||||
onChange?: React.ChangeEventHandler<HTMLTextAreaElement>,
|
onChange?: React.ChangeEventHandler<HTMLTextAreaElement>,
|
||||||
rows?: number,
|
rows?: number,
|
||||||
|
name?: string,
|
||||||
|
maxLength?: number,
|
||||||
|
required?: boolean,
|
||||||
}
|
}
|
||||||
|
|
||||||
export const SimpleTextarea: React.FC<ISimpleTextarea> = (props) => {
|
export const SimpleTextarea: React.FC<ISimpleTextarea> = (props) => {
|
||||||
|
@ -161,6 +168,7 @@ interface ICheckbox {
|
||||||
name?: string,
|
name?: string,
|
||||||
checked?: boolean,
|
checked?: boolean,
|
||||||
onChange?: React.ChangeEventHandler<HTMLInputElement>,
|
onChange?: React.ChangeEventHandler<HTMLInputElement>,
|
||||||
|
required?: boolean,
|
||||||
}
|
}
|
||||||
|
|
||||||
export const Checkbox: React.FC<ICheckbox> = (props) => (
|
export const Checkbox: React.FC<ICheckbox> = (props) => (
|
||||||
|
@ -240,8 +248,15 @@ interface ITextInput {
|
||||||
name?: string,
|
name?: string,
|
||||||
onChange?: React.ChangeEventHandler,
|
onChange?: React.ChangeEventHandler,
|
||||||
label?: React.ReactNode,
|
label?: React.ReactNode,
|
||||||
|
hint?: React.ReactNode,
|
||||||
placeholder?: string,
|
placeholder?: string,
|
||||||
value?: string,
|
value?: string,
|
||||||
|
autoComplete?: string,
|
||||||
|
autoCorrect?: string,
|
||||||
|
autoCapitalize?: string,
|
||||||
|
pattern?: string,
|
||||||
|
error?: boolean,
|
||||||
|
required?: boolean,
|
||||||
}
|
}
|
||||||
|
|
||||||
export const TextInput: React.FC<ITextInput> = props => (
|
export const TextInput: React.FC<ITextInput> = props => (
|
||||||
|
|
|
@ -81,7 +81,7 @@ const LandingPage = () => {
|
||||||
</Stack>
|
</Stack>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className='hidden lg:block sm:mt-24 lg:mt-0 lg:col-span-6 self-center'>
|
<div className='sm:mt-24 lg:mt-0 lg:col-span-6 self-center'>
|
||||||
<Card size='xl' variant='rounded' className='sm:max-w-md sm:w-full sm:mx-auto'>
|
<Card size='xl' variant='rounded' className='sm:max-w-md sm:w-full sm:mx-auto'>
|
||||||
<CardBody>
|
<CardBody>
|
||||||
{renderBody()}
|
{renderBody()}
|
||||||
|
|
|
@ -65,10 +65,9 @@ code {
|
||||||
position: relative;
|
position: relative;
|
||||||
|
|
||||||
.label_input > label {
|
.label_input > label {
|
||||||
@apply text-black dark:text-white;
|
@apply text-sm font-medium text-gray-700 dark:text-gray-400;
|
||||||
font-family: inherit;
|
font-family: inherit;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
padding-top: 5px;
|
|
||||||
display: block;
|
display: block;
|
||||||
width: auto;
|
width: auto;
|
||||||
}
|
}
|
||||||
|
@ -84,7 +83,7 @@ code {
|
||||||
|
|
||||||
input[type="checkbox"] {
|
input[type="checkbox"] {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 3px;
|
top: 1px;
|
||||||
left: 0;
|
left: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
Loading…
Reference in New Issue