Add support for password reset links
This commit is contained in:
parent
54cfe7e981
commit
4eeec58c59
|
@ -10,6 +10,7 @@
|
|||
"feather-icons": "^4.10.0",
|
||||
"jquery": "^3.3.1",
|
||||
"lodash": "^4.17.11",
|
||||
"query-string": "^6.7.0",
|
||||
"react": "^16.8.6",
|
||||
"react-dom": "^16.8.6",
|
||||
"react-hot-loader": "^4.9.0",
|
||||
|
@ -30,6 +31,7 @@
|
|||
"@types/classnames": "^2.2.8",
|
||||
"@types/feather-icons": "^4.7.0",
|
||||
"@types/lodash": "^4.14.119",
|
||||
"@types/query-string": "^6.3.0",
|
||||
"@types/react": "^16.8.19",
|
||||
"@types/react-dom": "^16.8.4",
|
||||
"@types/react-router-dom": "^4.3.3",
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
.login-box {
|
||||
@apply .bg-white .shadow-lg .rounded-lg .pt-10 .px-8 .pb-6 .mb-4;
|
||||
@apply .bg-white .shadow-lg .rounded-lg .p-6;
|
||||
|
||||
@screen xsx {
|
||||
@apply .rounded-none;
|
||||
|
|
|
@ -17,11 +17,11 @@ input[type=number] {
|
|||
* is input and then sinks back down into the field if left empty.
|
||||
*/
|
||||
.input-open {
|
||||
@apply .w-full .px-3 .relative;
|
||||
@apply .w-full .relative;
|
||||
}
|
||||
|
||||
.input-open > .input {
|
||||
@apply .appearance-none .block .w-full .text-neutral-800 .border-b-2 .border-neutral-200 .py-3 .mb-3;
|
||||
.input-open > .input, .input-open > .input:disabled {
|
||||
@apply .appearance-none .block .w-full .text-neutral-800 .border-b-2 .border-neutral-200 .py-3 .px-2 .bg-white;
|
||||
|
||||
&:focus {
|
||||
@apply .border-primary-400;
|
||||
|
@ -40,9 +40,9 @@ input[type=number] {
|
|||
}
|
||||
|
||||
.input-open > label {
|
||||
@apply .block .uppercase .tracking-wide .text-neutral-500 .text-xs .mb-2 .absolute;
|
||||
@apply .block .uppercase .tracking-wide .text-neutral-500 .text-xs .mb-2 .px-2 .absolute;
|
||||
top: 14px;
|
||||
transition: transform 200ms ease-out;
|
||||
transition: padding 200ms linear, transform 200ms ease-out;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -0,0 +1,29 @@
|
|||
import http from '@/api/http';
|
||||
|
||||
interface Data {
|
||||
token: string;
|
||||
password: string;
|
||||
passwordConfirmation: string;
|
||||
}
|
||||
|
||||
interface PasswordResetResponse {
|
||||
redirectTo?: string | null;
|
||||
sendToLogin: boolean;
|
||||
}
|
||||
|
||||
export default (email: string, data: Data): Promise<PasswordResetResponse> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
http.post('/auth/password/reset', {
|
||||
email,
|
||||
token: data.token,
|
||||
password: data.password,
|
||||
// eslint-disable-next-line @typescript-eslint/camelcase
|
||||
password_confirmation: data.passwordConfirmation,
|
||||
})
|
||||
.then(response => resolve({
|
||||
redirectTo: response.data.redirect_to,
|
||||
sendToLogin: response.data.send_to_login,
|
||||
}))
|
||||
.catch(reject);
|
||||
});
|
||||
};
|
|
@ -4,6 +4,7 @@ import { connect } from 'react-redux';
|
|||
import MessageBox from '@/components/MessageBox';
|
||||
|
||||
type Props = Readonly<{
|
||||
spacerClass?: string;
|
||||
flashes: FlashMessage[];
|
||||
}>;
|
||||
|
||||
|
@ -16,18 +17,17 @@ class FlashMessageRender extends React.PureComponent<Props> {
|
|||
return (
|
||||
<React.Fragment>
|
||||
{
|
||||
this.props.flashes.map(flash => (
|
||||
<MessageBox
|
||||
key={flash.id || flash.type + flash.message}
|
||||
type={flash.type}
|
||||
title={flash.title}
|
||||
>
|
||||
{flash.message}
|
||||
</MessageBox>
|
||||
this.props.flashes.map((flash, index) => (
|
||||
<React.Fragment key={flash.id || flash.type + flash.message}>
|
||||
{index > 0 && <div className={this.props.spacerClass || 'mt-2'}></div>}
|
||||
<MessageBox type={flash.type} title={flash.title}>
|
||||
{flash.message}
|
||||
</MessageBox>
|
||||
</React.Fragment>
|
||||
))
|
||||
}
|
||||
</React.Fragment>
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -0,0 +1,13 @@
|
|||
import * as React from 'react';
|
||||
import MessageBox from '@/components/MessageBox';
|
||||
|
||||
export default ({ message }: { message: string | undefined | null }) => (
|
||||
!message ?
|
||||
null
|
||||
:
|
||||
<div className={'mb-4'}>
|
||||
<MessageBox type={'error'} title={'Error'}>
|
||||
{message}
|
||||
</MessageBox>
|
||||
</div>
|
||||
);
|
|
@ -58,9 +58,12 @@ class ForgotPasswordContainer extends React.PureComponent<Props, State> {
|
|||
|
||||
render () {
|
||||
return (
|
||||
<React.Fragment>
|
||||
<div>
|
||||
<h2 className={'text-center text-neutral-100 font-medium py-4'}>
|
||||
Request Password Reset
|
||||
</h2>
|
||||
<form className={'login-box'} onSubmit={this.handleSubmission}>
|
||||
<div className={'-mx-3'}>
|
||||
<div className={'mt-3'}>
|
||||
<OpenInputField
|
||||
ref={this.emailField}
|
||||
id={'email'}
|
||||
|
@ -93,18 +96,14 @@ class ForgotPasswordContainer extends React.PureComponent<Props, State> {
|
|||
</Link>
|
||||
</div>
|
||||
</form>
|
||||
</React.Fragment>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const mapStateToProps = (state: ReduxState) => ({
|
||||
flashes: state.flashes,
|
||||
});
|
||||
|
||||
const mapDispatchToProps = {
|
||||
pushFlashMessage,
|
||||
clearAllFlashMessages,
|
||||
};
|
||||
|
||||
export default connect(mapStateToProps, mapDispatchToProps)(ForgotPasswordContainer);
|
||||
export default connect(null, mapDispatchToProps)(ForgotPasswordContainer);
|
||||
|
|
|
@ -3,7 +3,7 @@ import OpenInputField from '@/components/forms/OpenInputField';
|
|||
import { Link } from 'react-router-dom';
|
||||
import login from '@/api/auth/login';
|
||||
import { httpErrorToHuman } from '@/api/http';
|
||||
import MessageBox from '@/components/MessageBox';
|
||||
import NetworkErrorMessage from '@/components/NetworkErrorMessage';
|
||||
|
||||
type State = Readonly<{
|
||||
errorMessage?: string;
|
||||
|
@ -52,15 +52,12 @@ export default class LoginContainer extends React.PureComponent<{}, State> {
|
|||
render () {
|
||||
return (
|
||||
<React.Fragment>
|
||||
{this.state.errorMessage &&
|
||||
<div className={'mb-4'}>
|
||||
<MessageBox type={'error'} title={'Error'}>
|
||||
{this.state.errorMessage}
|
||||
</MessageBox>
|
||||
</div>
|
||||
}
|
||||
<h2 className={'text-center text-neutral-100 font-medium py-4'}>
|
||||
Login to Continue
|
||||
</h2>
|
||||
<NetworkErrorMessage message={this.state.errorMessage}/>
|
||||
<form className={'login-box'} onSubmit={this.submit}>
|
||||
<div className={'-mx-3'}>
|
||||
<div className={'mt-3'}>
|
||||
<OpenInputField
|
||||
autoFocus={true}
|
||||
label={'Username or Email'}
|
||||
|
@ -71,7 +68,7 @@ export default class LoginContainer extends React.PureComponent<{}, State> {
|
|||
disabled={this.state.isLoading}
|
||||
/>
|
||||
</div>
|
||||
<div className={'-mx-3 mt-6'}>
|
||||
<div className={'mt-6'}>
|
||||
<OpenInputField
|
||||
label={'Password'}
|
||||
type={'password'}
|
||||
|
@ -96,7 +93,7 @@ export default class LoginContainer extends React.PureComponent<{}, State> {
|
|||
</div>
|
||||
<div className={'mt-6 text-center'}>
|
||||
<Link
|
||||
to={'/forgot-password'}
|
||||
to={'/password'}
|
||||
className={'text-xs text-neutral-500 tracking-wide no-underline uppercase hover:text-neutral-600'}
|
||||
>
|
||||
Forgot password?
|
||||
|
|
|
@ -0,0 +1,155 @@
|
|||
import * as React from 'react';
|
||||
import OpenInputField from '@/components/forms/OpenInputField';
|
||||
import { RouteComponentProps } from 'react-router';
|
||||
import { parse } from 'query-string';
|
||||
import { Link } from 'react-router-dom';
|
||||
import NetworkErrorMessage from '@/components/NetworkErrorMessage';
|
||||
import performPasswordReset from '@/api/auth/performPasswordReset';
|
||||
import { httpErrorToHuman } from '@/api/http';
|
||||
import { connect } from 'react-redux';
|
||||
import { pushFlashMessage, clearAllFlashMessages } from '@/redux/actions/flash';
|
||||
|
||||
type State = Readonly<{
|
||||
email?: string;
|
||||
password?: string;
|
||||
passwordConfirm?: string;
|
||||
isLoading: boolean;
|
||||
errorMessage?: string;
|
||||
}>;
|
||||
|
||||
type Props = Readonly<RouteComponentProps<{ token: string }> & {
|
||||
pushFlashMessage: typeof pushFlashMessage;
|
||||
clearAllFlashMessages: typeof clearAllFlashMessages;
|
||||
}>;
|
||||
|
||||
class ResetPasswordContainer extends React.PureComponent<Props, State> {
|
||||
state: State = {
|
||||
isLoading: false,
|
||||
};
|
||||
|
||||
componentDidMount () {
|
||||
const parsed = parse(this.props.location.search);
|
||||
|
||||
this.setState({ email: parsed.email as string || undefined });
|
||||
}
|
||||
|
||||
canSubmit () {
|
||||
if (!this.state.password || !this.state.email) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return this.state.password.length >= 8 && this.state.password === this.state.passwordConfirm;
|
||||
}
|
||||
|
||||
onPasswordChange = (e: React.ChangeEvent<HTMLInputElement>) => this.setState({
|
||||
password: e.target.value,
|
||||
});
|
||||
|
||||
onPasswordConfirmChange = (e: React.ChangeEvent<HTMLInputElement>) => this.setState({
|
||||
passwordConfirm: e.target.value,
|
||||
});
|
||||
|
||||
onSubmit = (e: React.FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
|
||||
const { password, passwordConfirm, email } = this.state;
|
||||
if (!password || !email || !passwordConfirm) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.props.clearAllFlashMessages();
|
||||
this.setState({ isLoading: true }, () => {
|
||||
performPasswordReset(email, {
|
||||
token: this.props.match.params.token,
|
||||
password: password,
|
||||
passwordConfirmation: passwordConfirm,
|
||||
})
|
||||
.then(response => {
|
||||
if (response.redirectTo) {
|
||||
// @ts-ignore
|
||||
window.location = response.redirectTo;
|
||||
return;
|
||||
}
|
||||
|
||||
this.props.pushFlashMessage({
|
||||
type: 'success',
|
||||
message: 'Your password has been reset, please login to continue.',
|
||||
});
|
||||
this.props.history.push('/login');
|
||||
})
|
||||
.catch(error => {
|
||||
console.error(error);
|
||||
this.setState({ errorMessage: httpErrorToHuman(error) });
|
||||
})
|
||||
.then(() => this.setState({ isLoading: false }));
|
||||
});
|
||||
};
|
||||
|
||||
render () {
|
||||
return (
|
||||
<div>
|
||||
<h2 className={'text-center text-neutral-100 font-medium py-4'}>
|
||||
Reset Password
|
||||
</h2>
|
||||
<NetworkErrorMessage message={this.state.errorMessage}/>
|
||||
<form className={'login-box'} onSubmit={this.onSubmit}>
|
||||
<div className={'mt-3'}>
|
||||
<OpenInputField
|
||||
label={'Email'}
|
||||
value={this.state.email || ''}
|
||||
disabled
|
||||
/>
|
||||
</div>
|
||||
<div className={'mt-6'}>
|
||||
<OpenInputField
|
||||
autoFocus={true}
|
||||
label={'New Password'}
|
||||
description={'Passwords must be at least 8 characters in length.'}
|
||||
type={'password'}
|
||||
required={true}
|
||||
id={'password'}
|
||||
onChange={this.onPasswordChange}
|
||||
/>
|
||||
</div>
|
||||
<div className={'mt-6'}>
|
||||
<OpenInputField
|
||||
label={'Confirm New Password'}
|
||||
type={'password'}
|
||||
required={true}
|
||||
id={'password-confirm'}
|
||||
onChange={this.onPasswordConfirmChange}
|
||||
/>
|
||||
</div>
|
||||
<div className={'mt-6'}>
|
||||
<button
|
||||
type={'submit'}
|
||||
className={'btn btn-primary btn-jumbo'}
|
||||
disabled={this.state.isLoading || !this.canSubmit()}
|
||||
>
|
||||
{this.state.isLoading ?
|
||||
<span className={'spinner white'}> </span>
|
||||
:
|
||||
'Reset Password'
|
||||
}
|
||||
</button>
|
||||
</div>
|
||||
<div className={'mt-6 text-center'}>
|
||||
<Link
|
||||
to={'/login'}
|
||||
className={'text-xs text-neutral-500 tracking-wide no-underline uppercase hover:text-neutral-600'}
|
||||
>
|
||||
Return to Login
|
||||
</Link>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const mapDispatchToProps = {
|
||||
pushFlashMessage,
|
||||
clearAllFlashMessages,
|
||||
};
|
||||
|
||||
export default connect(null, mapDispatchToProps)(ResetPasswordContainer);
|
|
@ -4,13 +4,18 @@ import classNames from 'classnames';
|
|||
type Props = React.InputHTMLAttributes<HTMLInputElement> & {
|
||||
label: string;
|
||||
description?: string;
|
||||
value?: string;
|
||||
};
|
||||
|
||||
export default React.forwardRef<HTMLInputElement, Props>(({ className, description, onChange, label, ...props }, ref) => {
|
||||
const [ value, setValue ] = React.useState('');
|
||||
export default React.forwardRef<HTMLInputElement, Props>(({ className, description, onChange, label, value, ...props }, ref) => {
|
||||
const [ stateValue, setStateValue ] = React.useState(value);
|
||||
|
||||
if (value !== stateValue) {
|
||||
setStateValue(value);
|
||||
}
|
||||
|
||||
const classes = classNames('input open-label', {
|
||||
'has-content': value && value.length > 0,
|
||||
'has-content': stateValue && stateValue.length > 0,
|
||||
});
|
||||
|
||||
return (
|
||||
|
@ -19,16 +24,17 @@ export default React.forwardRef<HTMLInputElement, Props>(({ className, descripti
|
|||
ref={ref}
|
||||
className={classes}
|
||||
onChange={e => {
|
||||
setValue(e.target.value);
|
||||
setStateValue(e.target.value);
|
||||
if (onChange) {
|
||||
onChange(e);
|
||||
}
|
||||
}}
|
||||
value={typeof value !== 'undefined' ? (stateValue || '') : undefined}
|
||||
{...props}
|
||||
/>
|
||||
<label htmlFor={props.id}>{label}</label>
|
||||
{description &&
|
||||
<p className={'text-xs text-neutral-500'}>
|
||||
<p className={'mt-2 text-xs text-neutral-500'}>
|
||||
{description}
|
||||
</p>
|
||||
}
|
||||
|
|
|
@ -4,6 +4,7 @@ import LoginContainer from '@/components/auth/LoginContainer';
|
|||
import { CSSTransition, TransitionGroup } from 'react-transition-group';
|
||||
import ForgotPasswordContainer from '@/components/auth/ForgotPasswordContainer';
|
||||
import FlashMessageRender from '@/components/FlashMessageRender';
|
||||
import ResetPasswordContainer from '@/components/auth/ResetPasswordContainer';
|
||||
|
||||
export default class AuthenticationRouter extends React.PureComponent {
|
||||
render () {
|
||||
|
@ -14,12 +15,11 @@ export default class AuthenticationRouter extends React.PureComponent {
|
|||
<TransitionGroup className={'route-transition-group mt-32'}>
|
||||
<CSSTransition key={location.key} timeout={150} classNames={'fade'}>
|
||||
<section>
|
||||
<div className={'mb-2'}>
|
||||
<FlashMessageRender/>
|
||||
</div>
|
||||
<FlashMessageRender/>
|
||||
<Switch location={location}>
|
||||
<Route path={'/login'} component={LoginContainer}/>
|
||||
<Route path={'/forgot-password'} component={ForgotPasswordContainer}/>
|
||||
<Route path={'/password'} component={ForgotPasswordContainer} exact/>
|
||||
<Route path={'/password/reset/:token'} component={ResetPasswordContainer}/>
|
||||
<Route path={'/checkpoint'}/>
|
||||
</Switch>
|
||||
<p className={'text-center text-neutral-500 text-xs'}>
|
||||
|
|
22
yarn.lock
22
yarn.lock
|
@ -766,6 +766,12 @@
|
|||
version "15.7.1"
|
||||
resolved "https://registry.yarnpkg.com/@types/prop-types/-/prop-types-15.7.1.tgz#f1a11e7babb0c3cad68100be381d1e064c68f1f6"
|
||||
|
||||
"@types/query-string@^6.3.0":
|
||||
version "6.3.0"
|
||||
resolved "https://registry.yarnpkg.com/@types/query-string/-/query-string-6.3.0.tgz#b6fa172a01405abcaedac681118e78429d62ea39"
|
||||
dependencies:
|
||||
query-string "*"
|
||||
|
||||
"@types/react-dom@^16.8.4":
|
||||
version "16.8.4"
|
||||
resolved "https://registry.yarnpkg.com/@types/react-dom/-/react-dom-16.8.4.tgz#7fb7ba368857c7aa0f4e4511c4710ca2c5a12a88"
|
||||
|
@ -6064,6 +6070,14 @@ qs@6.5.2:
|
|||
version "6.5.2"
|
||||
resolved "https://registry.yarnpkg.com/qs/-/qs-6.5.2.tgz#cb3ae806e8740444584ef154ce8ee98d403f3e36"
|
||||
|
||||
query-string@*, query-string@^6.7.0:
|
||||
version "6.7.0"
|
||||
resolved "https://registry.yarnpkg.com/query-string/-/query-string-6.7.0.tgz#7e92bf8525140cf8c5ebf500f26716b0de5b7023"
|
||||
dependencies:
|
||||
decode-uri-component "^0.2.0"
|
||||
split-on-first "^1.0.0"
|
||||
strict-uri-encode "^2.0.0"
|
||||
|
||||
querystring-es3@^0.2.0:
|
||||
version "0.2.1"
|
||||
resolved "https://registry.yarnpkg.com/querystring-es3/-/querystring-es3-0.2.1.tgz#9ec61f79049875707d69414596fd907a4d711e73"
|
||||
|
@ -6918,6 +6932,10 @@ spdy@^4.0.0:
|
|||
select-hose "^2.0.0"
|
||||
spdy-transport "^3.0.0"
|
||||
|
||||
split-on-first@^1.0.0:
|
||||
version "1.1.0"
|
||||
resolved "https://registry.yarnpkg.com/split-on-first/-/split-on-first-1.1.0.tgz#f610afeee3b12bce1d0c30425e76398b78249a5f"
|
||||
|
||||
split-string@^3.0.1, split-string@^3.0.2:
|
||||
version "3.1.0"
|
||||
resolved "https://registry.yarnpkg.com/split-string/-/split-string-3.1.0.tgz#7cb09dda3a86585705c64b39a6466038682e8fe2"
|
||||
|
@ -6987,6 +7005,10 @@ stream-shift@^1.0.0:
|
|||
version "1.0.0"
|
||||
resolved "https://registry.yarnpkg.com/stream-shift/-/stream-shift-1.0.0.tgz#d5c752825e5367e786f78e18e445ea223a155952"
|
||||
|
||||
strict-uri-encode@^2.0.0:
|
||||
version "2.0.0"
|
||||
resolved "https://registry.yarnpkg.com/strict-uri-encode/-/strict-uri-encode-2.0.0.tgz#b9c7330c7042862f6b142dc274bbcc5866ce3546"
|
||||
|
||||
string-width@^1.0.1:
|
||||
version "1.0.2"
|
||||
resolved "https://registry.yarnpkg.com/string-width/-/string-width-1.0.2.tgz#118bdf5b8cdc51a2a7e70d211e07e2b0b9b107d3"
|
||||
|
|
Loading…
Reference in New Issue