Merge pull request #144 from edx/server-error-reset-password
handle api failures gracefully
This commit is contained in:
@@ -8,12 +8,16 @@ const APIFailureMessage = (props) => {
|
||||
const { intl, header } = props;
|
||||
|
||||
return (
|
||||
<Alert id="internal-server-error" variant="danger">
|
||||
<Alert.Heading>
|
||||
{header}
|
||||
</Alert.Heading>
|
||||
{intl.formatMessage(messages['internal.server.error.message'])}
|
||||
</Alert>
|
||||
<div className="d-flex justify-content-center m-4">
|
||||
<div className="d-flex flex-column mw-500">
|
||||
<Alert id="internal-server-error" variant="danger">
|
||||
<Alert.Heading>
|
||||
{header}
|
||||
</Alert.Heading>
|
||||
{intl.formatMessage(messages['internal.server.error.message'])}
|
||||
</Alert>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ export { default as ThirdPartyAuthAlert } from './ThirdPartyAuthAlert';
|
||||
export { default as InstitutionLogistration } from './InstitutionLogistration';
|
||||
export { RenderInstitutionButton } from './InstitutionLogistration';
|
||||
export { default as AuthnValidationFormGroup } from './AuthnValidationFormGroup';
|
||||
export { default as APIFailureMessage } from './APIFailureMessage';
|
||||
export { default as reducer } from './data/reducers';
|
||||
export { default as saga } from './data/sagas';
|
||||
export { storeName } from './data/selectors';
|
||||
|
||||
@@ -17,8 +17,10 @@ import InvalidTokenMessage from './InvalidToken';
|
||||
import ResetSuccessMessage from './ResetSuccess';
|
||||
import {
|
||||
AuthnValidationFormGroup,
|
||||
APIFailureMessage,
|
||||
} from '../common-components';
|
||||
import Spinner from './Spinner';
|
||||
import { INTERNAL_SERVER_ERROR } from '../data/constants';
|
||||
|
||||
const ResetPasswordPage = (props) => {
|
||||
const { intl } = props;
|
||||
@@ -32,7 +34,7 @@ const ResetPasswordPage = (props) => {
|
||||
const [bannerErrorMessage, setbannerErrorMessage] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
if (props.status === 'failure' && props.errors) {
|
||||
if (props.status === 'failure' && props.errors !== INTERNAL_SERVER_ERROR) {
|
||||
setbannerErrorMessage(props.errors);
|
||||
setvalidationMessage(props.errors);
|
||||
setPasswordValidValue(false);
|
||||
@@ -95,19 +97,27 @@ const ResetPasswordPage = (props) => {
|
||||
props.resetPassword(formPayload, props.token, params);
|
||||
};
|
||||
|
||||
if (props.token_status === 'pending') {
|
||||
if (props.status === 'token-pending') {
|
||||
const { token } = props.match.params;
|
||||
if (token) {
|
||||
props.validateToken(token);
|
||||
return <Spinner />;
|
||||
}
|
||||
} else if (props.token_status === 'invalid') {
|
||||
} else if (props.status === 'invalid' && props.errors === INTERNAL_SERVER_ERROR) {
|
||||
return (
|
||||
<APIFailureMessage header={intl.formatMessage(messages['reset.password.token.validation.sever.error'])} />
|
||||
);
|
||||
} else if (props.status === 'invalid') {
|
||||
return <InvalidTokenMessage />;
|
||||
} else if (props.status === 'success') {
|
||||
return <ResetSuccessMessage />;
|
||||
} else {
|
||||
return (
|
||||
<>
|
||||
|
||||
{props.status === 'failure' && props.errors === INTERNAL_SERVER_ERROR ? (
|
||||
<APIFailureMessage header={intl.formatMessage(messages['reset.password.request.server.error'])} />
|
||||
) : null}
|
||||
<div id="main" className="d-flex justify-content-center m-4">
|
||||
<div className="d-flex flex-column mw-500">
|
||||
{bannerErrorMessage ? (
|
||||
@@ -168,7 +178,6 @@ const ResetPasswordPage = (props) => {
|
||||
|
||||
ResetPasswordPage.defaultProps = {
|
||||
status: null,
|
||||
token_status: null,
|
||||
token: null,
|
||||
match: null,
|
||||
errors: null,
|
||||
@@ -178,7 +187,6 @@ ResetPasswordPage.propTypes = {
|
||||
intl: intlShape.isRequired,
|
||||
resetPassword: PropTypes.func.isRequired,
|
||||
validateToken: PropTypes.func.isRequired,
|
||||
token_status: PropTypes.string,
|
||||
token: PropTypes.string,
|
||||
match: PropTypes.shape({
|
||||
params: PropTypes.shape({
|
||||
|
||||
@@ -18,9 +18,9 @@ export const validateTokenSuccess = (tokenStatus, token) => ({
|
||||
payload: { tokenStatus, token },
|
||||
});
|
||||
|
||||
export const validateTokenFailure = (tokenStatus) => ({
|
||||
export const validateTokenFailure = errors => ({
|
||||
type: VALIDATE_TOKEN.FAILURE,
|
||||
payload: { tokenStatus },
|
||||
payload: { errors },
|
||||
});
|
||||
|
||||
// Reset Password
|
||||
|
||||
@@ -1,29 +1,24 @@
|
||||
import { RESET_PASSWORD, VALIDATE_TOKEN } from './actions';
|
||||
|
||||
export const defaultState = {
|
||||
status: null,
|
||||
token_status: 'pending',
|
||||
status: 'token-pending',
|
||||
token: null,
|
||||
errors: null,
|
||||
};
|
||||
|
||||
const reducer = (state = defaultState, action = null) => {
|
||||
switch (action.type) {
|
||||
case VALIDATE_TOKEN.BEGIN:
|
||||
return {
|
||||
...state,
|
||||
token_status: 'pending',
|
||||
};
|
||||
case VALIDATE_TOKEN.SUCCESS:
|
||||
return {
|
||||
...state,
|
||||
token_status: 'valid',
|
||||
status: 'valid',
|
||||
token: action.payload.token,
|
||||
};
|
||||
case VALIDATE_TOKEN.FAILURE:
|
||||
return {
|
||||
...state,
|
||||
token_status: 'invalid',
|
||||
status: 'invalid',
|
||||
errors: action.payload.errors,
|
||||
};
|
||||
case RESET_PASSWORD.BEGIN:
|
||||
return {
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
} from './actions';
|
||||
|
||||
import { validateToken, resetPassword } from './service';
|
||||
import { INTERNAL_SERVER_ERROR } from '../../data/constants';
|
||||
|
||||
// Services
|
||||
export function* handleValidateToken(action) {
|
||||
@@ -27,7 +28,7 @@ export function* handleValidateToken(action) {
|
||||
yield put(validateTokenFailure(isValid));
|
||||
}
|
||||
} catch (err) {
|
||||
yield put(validateTokenFailure(err));
|
||||
yield put(validateTokenFailure(INTERNAL_SERVER_ERROR));
|
||||
logError(err);
|
||||
}
|
||||
}
|
||||
@@ -45,7 +46,7 @@ export function* handleResetPassword(action) {
|
||||
yield put(resetPasswordFailure(resetErrors));
|
||||
}
|
||||
} catch (err) {
|
||||
yield put(resetPasswordFailure(err));
|
||||
yield put(resetPasswordFailure(INTERNAL_SERVER_ERROR));
|
||||
logError(err);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,10 +4,13 @@ import {
|
||||
resetPasswordBegin,
|
||||
resetPasswordSuccess,
|
||||
resetPasswordFailure,
|
||||
validateTokenBegin,
|
||||
validateTokenFailure,
|
||||
} from '../actions';
|
||||
import { handleResetPassword } from '../sagas';
|
||||
import { handleResetPassword, handleValidateToken } from '../sagas';
|
||||
import * as api from '../service';
|
||||
import initializeMockLogging from '../../../setupTest';
|
||||
import { INTERNAL_SERVER_ERROR } from '../../../data/constants';
|
||||
|
||||
const { loggingService } = initializeMockLogging();
|
||||
|
||||
@@ -60,7 +63,35 @@ describe('handleResetPassword', () => {
|
||||
);
|
||||
|
||||
expect(resetPassword).toHaveBeenCalledTimes(1);
|
||||
expect(dispatched).toEqual([resetPasswordBegin(), resetPasswordFailure()]);
|
||||
expect(dispatched).toEqual([resetPasswordBegin(), resetPasswordFailure(INTERNAL_SERVER_ERROR)]);
|
||||
resetPassword.mockClear();
|
||||
});
|
||||
});
|
||||
|
||||
describe('handleValidateToken', () => {
|
||||
const params = {
|
||||
payload: {
|
||||
token: 'token',
|
||||
params: {},
|
||||
},
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
loggingService.logError.mockReset();
|
||||
});
|
||||
|
||||
it('check server error on api failure', async () => {
|
||||
const validateToken = jest.spyOn(api, 'validateToken')
|
||||
.mockImplementation(() => Promise.reject());
|
||||
|
||||
const dispatched = [];
|
||||
await runSaga(
|
||||
{ dispatch: (action) => dispatched.push(action) },
|
||||
handleValidateToken,
|
||||
params,
|
||||
);
|
||||
|
||||
expect(validateToken).toHaveBeenCalledTimes(1);
|
||||
expect(dispatched).toEqual([validateTokenBegin(), validateTokenFailure(INTERNAL_SERVER_ERROR)]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -61,6 +61,16 @@ const messages = defineMessages({
|
||||
defaultMessage: 'We couldn\'t reset your password.',
|
||||
description: 'Heading that appears above error message when user submits empty form.',
|
||||
},
|
||||
'reset.password.request.server.error': {
|
||||
id: 'reset.password.request.server.error',
|
||||
defaultMessage: 'Failed to reset password',
|
||||
description: 'Failed to reset password error message heading.',
|
||||
},
|
||||
'reset.password.token.validation.sever.error': {
|
||||
id: 'reset.password.token.validation.sever.error',
|
||||
defaultMessage: 'Token validation failure',
|
||||
description: 'Failed to validate reset password token error message.',
|
||||
},
|
||||
});
|
||||
|
||||
export default messages;
|
||||
|
||||
@@ -8,8 +8,10 @@ import { IntlProvider, injectIntl } from '@edx/frontend-platform/i18n';
|
||||
import CookiePolicyBanner from '@edx/frontend-component-cookie-policy-banner';
|
||||
import * as auth from '@edx/frontend-platform/auth';
|
||||
import { resetPassword } from '../data/actions';
|
||||
import { APIFailureMessage } from '../../common-components';
|
||||
|
||||
import ResetPasswordPage from '../ResetPasswordPage';
|
||||
import { INTERNAL_SERVER_ERROR } from '../../data/constants';
|
||||
|
||||
jest.mock('@edx/frontend-platform/auth');
|
||||
|
||||
@@ -241,4 +243,29 @@ describe('ResetPasswordPage', () => {
|
||||
const resetPasswordPage = mount(reduxWrapper(<IntlResetPasswordPage {...props} />));
|
||||
expect(resetPasswordPage.find(<CookiePolicyBanner />)).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should display error banner on server error', () => {
|
||||
const bannerMessage = 'Failed to reset passwordAn error has occurred. Try refreshing the page, or check your Internet connection.';
|
||||
props = {
|
||||
...props,
|
||||
status: 'failure',
|
||||
errors: INTERNAL_SERVER_ERROR,
|
||||
};
|
||||
|
||||
const resetPasswordPage = mount(reduxWrapper(<IntlResetPasswordPage {...props} />));
|
||||
resetPasswordPage.find('button.btn-primary').simulate('click');
|
||||
|
||||
resetPasswordPage.update();
|
||||
expect(resetPasswordPage.find('#internal-server-error').first().text()).toEqual(bannerMessage);
|
||||
});
|
||||
|
||||
it('check api failure banner rendered', () => {
|
||||
props = {
|
||||
...props,
|
||||
status: 'invalid',
|
||||
errors: INTERNAL_SERVER_ERROR,
|
||||
};
|
||||
const resetPasswordPage = mount(reduxWrapper(<IntlResetPasswordPage {...props} />));
|
||||
expect(resetPasswordPage.find(<APIFailureMessage />)).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,38 +3,86 @@
|
||||
exports[`ResetPasswordPage should match invalid token message section snapshot 1`] = `
|
||||
<div
|
||||
className="d-flex justify-content-center m-4"
|
||||
id="main"
|
||||
>
|
||||
<div
|
||||
className="d-flex flex-column mw-500"
|
||||
>
|
||||
<div
|
||||
className="fade alert alert-danger show"
|
||||
role="alert"
|
||||
<form
|
||||
className=""
|
||||
>
|
||||
<div
|
||||
className="alert-heading h4"
|
||||
<h3
|
||||
className="mt-3"
|
||||
>
|
||||
|
||||
Invalid Password Reset Link
|
||||
</div>
|
||||
<span>
|
||||
This password reset link is invalid. It may have been used already. To reset your password, go to the
|
||||
<a
|
||||
className="alert-link"
|
||||
href="/login"
|
||||
Reset your password
|
||||
</h3>
|
||||
<p
|
||||
className="mb-4"
|
||||
>
|
||||
Enter and confirm your new password.
|
||||
</p>
|
||||
<div
|
||||
className="form-group w-100"
|
||||
>
|
||||
<span />
|
||||
<input
|
||||
aria-describedby=""
|
||||
className="form-control"
|
||||
id="reset-password-input"
|
||||
name="new-password1"
|
||||
onBlur={[Function]}
|
||||
onChange={[Function]}
|
||||
onClick={[Function]}
|
||||
onKeyDown={[Function]}
|
||||
onFocus={[Function]}
|
||||
placeholder="New Password"
|
||||
required={true}
|
||||
type="password"
|
||||
value=""
|
||||
/>
|
||||
<span />
|
||||
</div>
|
||||
<div
|
||||
className="form-group w-100"
|
||||
>
|
||||
<span />
|
||||
<input
|
||||
aria-describedby=""
|
||||
className="form-control"
|
||||
id="confirm-password-input"
|
||||
name="new-password2"
|
||||
onBlur={[Function]}
|
||||
onChange={[Function]}
|
||||
onClick={[Function]}
|
||||
onFocus={[Function]}
|
||||
placeholder="Confirm Password"
|
||||
required={true}
|
||||
type="password"
|
||||
value=""
|
||||
/>
|
||||
<span />
|
||||
<strong
|
||||
className="invalid-feedback"
|
||||
id="confirm-password-input-invalid-feedback"
|
||||
>
|
||||
sign-in
|
||||
</a>
|
||||
page and select
|
||||
<strong>
|
||||
|
||||
Forgot Password
|
||||
|
||||
Passwords do not match.
|
||||
</strong>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
aria-disabled={false}
|
||||
aria-live="assertive"
|
||||
className="pgn__stateful-btn pgn__stateful-btn-state-null btn-primary btn btn-primary"
|
||||
disabled={false}
|
||||
onClick={[Function]}
|
||||
onMouseDown={[Function]}
|
||||
type="submit"
|
||||
>
|
||||
<span
|
||||
className="d-flex align-items-center justify-content-center"
|
||||
>
|
||||
Reset my password
|
||||
</span>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
@@ -274,14 +322,87 @@ exports[`ResetPasswordPage should match successful reset message section snapsho
|
||||
|
||||
exports[`ResetPasswordPage show spinner component during token validation 1`] = `
|
||||
<div
|
||||
className="container position-absolute h-90"
|
||||
className="d-flex justify-content-center m-4"
|
||||
id="main"
|
||||
>
|
||||
<div
|
||||
className="d-flex justify-content-center align-items-center h-90"
|
||||
className="d-flex flex-column mw-500"
|
||||
>
|
||||
<div
|
||||
className="spinner-border text-primary"
|
||||
/>
|
||||
<form
|
||||
className=""
|
||||
>
|
||||
<h3
|
||||
className="mt-3"
|
||||
>
|
||||
Reset your password
|
||||
</h3>
|
||||
<p
|
||||
className="mb-4"
|
||||
>
|
||||
Enter and confirm your new password.
|
||||
</p>
|
||||
<div
|
||||
className="form-group w-100"
|
||||
>
|
||||
<span />
|
||||
<input
|
||||
aria-describedby=""
|
||||
className="form-control"
|
||||
id="reset-password-input"
|
||||
name="new-password1"
|
||||
onBlur={[Function]}
|
||||
onChange={[Function]}
|
||||
onClick={[Function]}
|
||||
onFocus={[Function]}
|
||||
placeholder="New Password"
|
||||
required={true}
|
||||
type="password"
|
||||
value=""
|
||||
/>
|
||||
<span />
|
||||
</div>
|
||||
<div
|
||||
className="form-group w-100"
|
||||
>
|
||||
<span />
|
||||
<input
|
||||
aria-describedby=""
|
||||
className="form-control"
|
||||
id="confirm-password-input"
|
||||
name="new-password2"
|
||||
onBlur={[Function]}
|
||||
onChange={[Function]}
|
||||
onClick={[Function]}
|
||||
onFocus={[Function]}
|
||||
placeholder="Confirm Password"
|
||||
required={true}
|
||||
type="password"
|
||||
value=""
|
||||
/>
|
||||
<span />
|
||||
<strong
|
||||
className="invalid-feedback"
|
||||
id="confirm-password-input-invalid-feedback"
|
||||
>
|
||||
Passwords do not match.
|
||||
</strong>
|
||||
</div>
|
||||
<button
|
||||
aria-disabled={false}
|
||||
aria-live="assertive"
|
||||
className="pgn__stateful-btn pgn__stateful-btn-state-null btn-primary btn btn-primary"
|
||||
disabled={false}
|
||||
onClick={[Function]}
|
||||
onMouseDown={[Function]}
|
||||
type="submit"
|
||||
>
|
||||
<span
|
||||
className="d-flex align-items-center justify-content-center"
|
||||
>
|
||||
Reset my password
|
||||
</span>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
Reference in New Issue
Block a user