From 4eeec58c5916761b1aa20124d10dae89683c62ef Mon Sep 17 00:00:00 2001 From: Dane Everitt Date: Sun, 16 Jun 2019 16:57:57 -0700 Subject: [PATCH] Add support for password reset links --- package.json | 2 + .../styles/components/authentication.css | 2 +- resources/assets/styles/components/forms.css | 10 +- .../scripts/api/auth/performPasswordReset.ts | 29 ++++ .../scripts/components/FlashMessageRender.tsx | 18 +- .../components/NetworkErrorMessage.tsx | 13 ++ .../auth/ForgotPasswordContainer.tsx | 15 +- .../components/auth/LoginContainer.tsx | 19 +-- .../auth/ResetPasswordContainer.tsx | 155 ++++++++++++++++++ .../components/forms/OpenInputField.tsx | 16 +- .../scripts/routers/AuthenticationRouter.tsx | 8 +- yarn.lock | 22 +++ 12 files changed, 266 insertions(+), 43 deletions(-) create mode 100644 resources/scripts/api/auth/performPasswordReset.ts create mode 100644 resources/scripts/components/NetworkErrorMessage.tsx create mode 100644 resources/scripts/components/auth/ResetPasswordContainer.tsx diff --git a/package.json b/package.json index f1e43863f..987d66ef3 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/resources/assets/styles/components/authentication.css b/resources/assets/styles/components/authentication.css index 1f99a29f7..2d841fe2e 100644 --- a/resources/assets/styles/components/authentication.css +++ b/resources/assets/styles/components/authentication.css @@ -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; diff --git a/resources/assets/styles/components/forms.css b/resources/assets/styles/components/forms.css index 40f987eba..ffa96c39d 100644 --- a/resources/assets/styles/components/forms.css +++ b/resources/assets/styles/components/forms.css @@ -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; } /** diff --git a/resources/scripts/api/auth/performPasswordReset.ts b/resources/scripts/api/auth/performPasswordReset.ts new file mode 100644 index 000000000..f6263c4fe --- /dev/null +++ b/resources/scripts/api/auth/performPasswordReset.ts @@ -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 => { + 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); + }); +}; diff --git a/resources/scripts/components/FlashMessageRender.tsx b/resources/scripts/components/FlashMessageRender.tsx index 356e536c2..3d2a5ce4d 100644 --- a/resources/scripts/components/FlashMessageRender.tsx +++ b/resources/scripts/components/FlashMessageRender.tsx @@ -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 { return ( { - this.props.flashes.map(flash => ( - - {flash.message} - + this.props.flashes.map((flash, index) => ( + + {index > 0 &&
} + + {flash.message} + +
)) }
- ) + ); } } diff --git a/resources/scripts/components/NetworkErrorMessage.tsx b/resources/scripts/components/NetworkErrorMessage.tsx new file mode 100644 index 000000000..8d4150f2c --- /dev/null +++ b/resources/scripts/components/NetworkErrorMessage.tsx @@ -0,0 +1,13 @@ +import * as React from 'react'; +import MessageBox from '@/components/MessageBox'; + +export default ({ message }: { message: string | undefined | null }) => ( + !message ? + null + : +
+ + {message} + +
+); diff --git a/resources/scripts/components/auth/ForgotPasswordContainer.tsx b/resources/scripts/components/auth/ForgotPasswordContainer.tsx index 4982fdbea..9005c04b9 100644 --- a/resources/scripts/components/auth/ForgotPasswordContainer.tsx +++ b/resources/scripts/components/auth/ForgotPasswordContainer.tsx @@ -58,9 +58,12 @@ class ForgotPasswordContainer extends React.PureComponent { render () { return ( - +
+

+ Request Password Reset +

-
+
{
- +
); } } -const mapStateToProps = (state: ReduxState) => ({ - flashes: state.flashes, -}); - const mapDispatchToProps = { pushFlashMessage, clearAllFlashMessages, }; -export default connect(mapStateToProps, mapDispatchToProps)(ForgotPasswordContainer); +export default connect(null, mapDispatchToProps)(ForgotPasswordContainer); diff --git a/resources/scripts/components/auth/LoginContainer.tsx b/resources/scripts/components/auth/LoginContainer.tsx index f3dbdb1a8..787f60f50 100644 --- a/resources/scripts/components/auth/LoginContainer.tsx +++ b/resources/scripts/components/auth/LoginContainer.tsx @@ -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 ( - {this.state.errorMessage && -
- - {this.state.errorMessage} - -
- } +

+ Login to Continue +

+
-
+
{ disabled={this.state.isLoading} />
-
+
{
Forgot password? diff --git a/resources/scripts/components/auth/ResetPasswordContainer.tsx b/resources/scripts/components/auth/ResetPasswordContainer.tsx new file mode 100644 index 000000000..f49aa3a29 --- /dev/null +++ b/resources/scripts/components/auth/ResetPasswordContainer.tsx @@ -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 & { + pushFlashMessage: typeof pushFlashMessage; + clearAllFlashMessages: typeof clearAllFlashMessages; +}>; + +class ResetPasswordContainer extends React.PureComponent { + 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) => this.setState({ + password: e.target.value, + }); + + onPasswordConfirmChange = (e: React.ChangeEvent) => this.setState({ + passwordConfirm: e.target.value, + }); + + onSubmit = (e: React.FormEvent) => { + 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 ( +
+

+ Reset Password +

+ + +
+ +
+
+ +
+
+ +
+
+ +
+
+ + Return to Login + +
+ +
+ ); + } +} + +const mapDispatchToProps = { + pushFlashMessage, + clearAllFlashMessages, +}; + +export default connect(null, mapDispatchToProps)(ResetPasswordContainer); diff --git a/resources/scripts/components/forms/OpenInputField.tsx b/resources/scripts/components/forms/OpenInputField.tsx index 3e25a9c56..ab0fa0485 100644 --- a/resources/scripts/components/forms/OpenInputField.tsx +++ b/resources/scripts/components/forms/OpenInputField.tsx @@ -4,13 +4,18 @@ import classNames from 'classnames'; type Props = React.InputHTMLAttributes & { label: string; description?: string; + value?: string; }; -export default React.forwardRef(({ className, description, onChange, label, ...props }, ref) => { - const [ value, setValue ] = React.useState(''); +export default React.forwardRef(({ 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(({ 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} /> {description && -

+

{description}

} diff --git a/resources/scripts/routers/AuthenticationRouter.tsx b/resources/scripts/routers/AuthenticationRouter.tsx index 6f59c3511..285725727 100644 --- a/resources/scripts/routers/AuthenticationRouter.tsx +++ b/resources/scripts/routers/AuthenticationRouter.tsx @@ -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 {
-
- -
+ - + +

diff --git a/yarn.lock b/yarn.lock index d5955f2b4..56197c658 100644 --- a/yarn.lock +++ b/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"