Merge pull request #144 from edx/server-error-reset-password

handle api failures gracefully
This commit is contained in:
Uzair Rasheed
2021-02-18 13:29:12 +05:00
committed by GitHub
10 changed files with 252 additions and 54 deletions

View File

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

View File

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

View File

@@ -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({

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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