Add support for password reset links

This commit is contained in:
Dane Everitt 2019-06-16 16:57:57 -07:00
parent 54cfe7e981
commit 4eeec58c59
No known key found for this signature in database
GPG key ID: EEA66103B3D71F53
12 changed files with 266 additions and 43 deletions

View file

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

View file

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

View file

@ -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;
}
/**

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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