1
0
mirror of https://github.com/flarum/core.git synced 2025-07-21 00:31:17 +02:00

Improve client XHR error handling

The default XHR error handler produce an alert which is appropriate to the response status code. It can be overridden per-request (by specifying the `errorHandler` option) so that the alert can be suppressed or displayed in a different position (e.g. inside a modal).

ref #118
This commit is contained in:
Toby Zerner
2015-10-20 12:48:26 +10:30
parent 0952651cf3
commit f2dbb96e84
26 changed files with 192 additions and 175 deletions

View File

@@ -85,13 +85,12 @@ export default class EditGroupModal extends Modal {
namePlural: this.namePlural(), namePlural: this.namePlural(),
color: this.color(), color: this.color(),
icon: this.icon() icon: this.icon()
}).then( }, {errorHandler: this.onerror.bind(this)})
() => this.hide(), .then(this.hide.bind(this))
(response) => { .catch(() => {
this.loading = false; this.loading = false;
this.handleErrors(response); m.redraw();
} });
);
} }
deleteGroup() { deleteGroup() {

View File

@@ -68,11 +68,8 @@ export default class SettingsModal extends Modal {
this.loading = true; this.loading = true;
saveSettings(this.dirty()).then( saveSettings(this.dirty()).then(
() => this.hide(), this.hide.bind(this),
() => { this.loaded.bind(this)
this.loading = false;
m.redraw();
}
); );
} }
} }

View File

@@ -163,5 +163,6 @@ export default class AvatarEditor extends Component {
*/ */
failure() { failure() {
this.loading = false; this.loading = false;
m.redraw();
} }
} }

View File

@@ -83,16 +83,8 @@ export default class ChangeEmailModal extends Modal {
this.loading = true; this.loading = true;
app.session.user.save({email: this.email()}).then( app.session.user.save({email: this.email()}, {errorHandler: this.onerror.bind(this)})
() => { .then(() => this.success = true)
this.loading = false; .finally(this.loaded.bind(this));
this.success = true;
m.redraw();
},
response => {
this.loading = false;
this.handleErrors(response);
}
);
} }
} }

View File

@@ -42,8 +42,8 @@ export default class ChangePasswordModal extends Modal {
url: app.forum.attribute('apiUrl') + '/forgot', url: app.forum.attribute('apiUrl') + '/forgot',
data: {email: app.session.user.email()} data: {email: app.session.user.email()}
}).then( }).then(
() => this.hide(), this.hide.bind(this),
() => this.loading = false this.loaded.bind(this)
); );
} }
} }

View File

@@ -102,4 +102,12 @@ export default class ComposerBody extends Component {
*/ */
onsubmit() { onsubmit() {
} }
/**
* Stop loading.
*/
loaded() {
this.loading = false;
m.redraw();
}
} }

View File

@@ -109,11 +109,7 @@ export default class DiscussionComposer extends ComposerBody {
app.cache.discussionList.addDiscussion(discussion); app.cache.discussionList.addDiscussion(discussion);
m.route(app.route.discussion(discussion)); m.route(app.route.discussion(discussion));
}, },
response => { this.loaded.bind(this)
this.loading = false;
m.redraw();
app.alertErrors(response.errors);
}
); );
} }
} }

View File

@@ -128,7 +128,7 @@ export default class DiscussionPage extends Page {
// component for the first time on page load, then any calls to m.redraw // component for the first time on page load, then any calls to m.redraw
// will be ineffective and thus any configs (scroll code) will be run // will be ineffective and thus any configs (scroll code) will be run
// before stuff is drawn to the page. // before stuff is drawn to the page.
setTimeout(this.show.bind(this, preloadedDiscussion)); setTimeout(this.show.bind(this, preloadedDiscussion), 0);
} else { } else {
const params = this.requestParams(); const params = this.requestParams();

View File

@@ -64,14 +64,8 @@ export default class EditPostComposer extends ComposerBody {
const data = this.data(); const data = this.data();
this.props.post.save(data).then( this.props.post.save(data).then(
() => { () => app.composer.hide(),
app.composer.hide(); this.loaded.bind(this)
m.redraw();
},
() => {
this.loading = false;
m.redraw();
}
); );
} }
} }

View File

@@ -102,11 +102,7 @@ export default class EditUserModal extends Modal {
); );
} }
onsubmit(e) { data() {
e.preventDefault();
this.loading = true;
const groups = Object.keys(this.groups) const groups = Object.keys(this.groups)
.filter(id => this.groups[id]()) .filter(id => this.groups[id]())
.map(id => app.store.getById('groups', id)); .map(id => app.store.getById('groups', id));
@@ -121,12 +117,19 @@ export default class EditUserModal extends Modal {
data.password = this.password(); data.password = this.password();
} }
this.props.user.save(data).then( return data;
() => this.hide(), }
response => {
onsubmit(e) {
e.preventDefault();
this.loading = true;
this.props.user.save(this.data(), {errorHandler: this.onerror.bind(this)})
.then(this.hide.bind(this))
.catch(() => {
this.loading = false; this.loading = false;
this.handleErrors(response); m.redraw();
} });
);
} }
} }

View File

@@ -85,24 +85,12 @@ export default class ForgotPasswordModal extends Modal {
app.request({ app.request({
method: 'POST', method: 'POST',
url: app.forum.attribute('apiUrl') + '/forgot', url: app.forum.attribute('apiUrl') + '/forgot',
data: {email: this.email()}, data: {email: this.email()}
handlers: { })
404: () => { .then(() => {
this.alert = new Alert({type: 'warning', message: 'That email wasn\'t found in our database.'});
throw new Error();
}
}
}).then(
() => {
this.loading = false;
this.success = true; this.success = true;
this.alert = null; this.alert = null;
m.redraw(); })
}, .finally(this.loaded.bind(this));
response => {
this.loading = false;
this.handleErrors(response);
}
);
} }
} }

View File

@@ -124,25 +124,25 @@ export default class LogInModal extends Modal {
const email = this.email(); const email = this.email();
const password = this.password(); const password = this.password();
app.session.login(email, password).then( app.session.login(email, password, {errorHandler: this.onerror.bind(this)})
null, .catch(this.loaded.bind(this));
response => {
this.loading = false;
if (response && response.code === 'confirm_email') {
this.alert = Alert.component({
children: app.trans('core.forum.log_in_confirmation_required_message', {email: response.email})
});
} else {
this.alert = Alert.component({
type: 'error',
children: app.trans('core.forum.log_in_invalid_login_message')
});
} }
m.redraw(); onerror(error) {
this.onready(); switch (error.status) {
case 401:
error.alert.props.children = app.trans('core.forum.log_in_confirmation_required_message', {email: error.response.emailConfirmationRequired});
delete error.alert.props.type;
break;
case 404:
error.alert.props.children = app.trans('core.forum.log_in_invalid_login_message');
break;
default:
// no default
} }
);
super.onerror(error);
} }
} }

View File

@@ -115,13 +115,12 @@ export default class NotificationList extends Component {
this.loading = true; this.loading = true;
m.redraw(); m.redraw();
app.store.find('notifications').then(notifications => { app.store.find('notifications')
.then(notifications => {
app.session.user.pushAttributes({newNotificationsCount: 0}); app.session.user.pushAttributes({newNotificationsCount: 0});
app.cache.notifications = notifications.sort((a, b) => b.time() - a.time()); app.cache.notifications = notifications.sort((a, b) => b.time() - a.time());
})
this.loading = false; .finally(this.loaded.bind(this));
m.redraw();
});
} }
/** /**

View File

@@ -36,7 +36,8 @@ export default class ReplyComposer extends ComposerBody {
items.add('title', ( items.add('title', (
<h3> <h3>
{icon('reply')}{' '}<a href={app.route.discussion(discussion)} config={m.route}>{discussion.title()}</a> {icon('reply')} {' '}
<a href={app.route.discussion(discussion)} config={m.route}>{discussion.title()}</a>
</h3> </h3>
)); ));
@@ -93,11 +94,7 @@ export default class ReplyComposer extends ComposerBody {
app.composer.hide(); app.composer.hide();
}, },
response => { this.loaded.bind(this)
this.loading = false;
m.redraw();
app.alertErrors(response.errors);
}
); );
} }
} }

View File

@@ -178,7 +178,8 @@ export default class SignUpModal extends Modal {
app.request({ app.request({
url: app.forum.attribute('baseUrl') + '/register', url: app.forum.attribute('baseUrl') + '/register',
method: 'POST', method: 'POST',
data data,
errorHandler: this.onerror.bind(this)
}).then( }).then(
payload => { payload => {
const user = app.store.pushPayload(payload); const user = app.store.pushPayload(payload);
@@ -190,14 +191,10 @@ export default class SignUpModal extends Modal {
window.location.reload(); window.location.reload();
} else { } else {
this.welcomeUser = user; this.welcomeUser = user;
this.loading = false; this.loaded();
m.redraw();
} }
}, },
response => { this.loaded.bind(this)
this.loading = false;
this.handleErrors(response);
}
); );
} }

View File

@@ -91,10 +91,7 @@ export default class UserBio extends Component {
if (user.bio() !== value) { if (user.bio() !== value) {
this.loading = true; this.loading = true;
user.save({bio: value}).then(() => { user.save({bio: value}).finally(this.loaded.bind(this));
this.loading = false;
m.redraw();
});
} }
this.editing = false; this.editing = false;

View File

@@ -206,10 +206,14 @@ export default class App {
try { try {
return JSON.parse(responseText); return JSON.parse(responseText);
} catch (e) { } catch (e) {
throw new RequestError(e.message, responseText); throw new RequestError(500, responseText);
} }
}); });
options.errorHandler = options.errorHandler || (error => {
throw error;
});
// When extracting the data from the response, we can check the server // When extracting the data from the response, we can check the server
// response code and show an error message to the user if something's gone // response code and show an error message to the user if something's gone
// awry. // awry.
@@ -225,26 +229,56 @@ export default class App {
const status = xhr.status; const status = xhr.status;
if (status >= 500 && status <= 599) { if (status < 200 || status > 299) {
throw new RequestError('Internal Server Error', responseText); throw new RequestError(status, responseText, xhr);
} }
return responseText; return responseText;
}; };
this.alerts.dismiss(this.requestErrorAlert); if (this.requestError) this.requestError.hideAlert();
// Now make the request. If it's a failure, inspect the error that was // Now make the request. If it's a failure, inspect the error that was
// returned and show an alert containing its contents. // returned and show an alert containing its contents.
return m.request(options).then(null, error => { return m.request(options).then(null, error => {
if (error instanceof RequestError) { this.requestError = error;
this.alerts.show(this.requestErrorAlert = new Alert({
let children;
switch (error.status) {
case 422:
children = error.response.errors
.map(error => [error.detail, <br/>])
.reduce((a, b) => a.concat(b), [])
.slice(0, -1);
break;
case 401:
case 403:
children = 'You do not have permission to do that.';
break;
case 404:
case 410:
children = 'The requested resource was not found.';
break;
default:
children = 'Oops! Something went wrong. Please reload the page and try again.';
}
error.alert = new Alert({
type: 'error', type: 'error',
children: 'Oops! Something went wrong. Please reload the page and try again.', children,
controls: app.forum.attribute('debug') ? [ controls: app.forum.attribute('debug') ? [
<Button className="Button Button--link" onclick={this.showDebug.bind(this, error)}>Debug</Button> <Button className="Button Button--link" onclick={this.showDebug.bind(this, error)}>Debug</Button>
] : undefined ] : undefined
})); });
try {
options.errorHandler(error);
} catch (error) {
this.alerts.show(error.alert);
} }
throw error; throw error;
@@ -264,17 +298,19 @@ export default class App {
/** /**
* Show alert error messages for each error returned in an API response. * Show alert error messages for each error returned in an API response.
* *
* @param {Array} errors * @param {Object} response
* @public * @public
*/ */
alertErrors(errors) { alertErrors(response) {
errors.forEach(error => { if (response.errors) {
response.errors.forEach(error => {
this.alerts.show(new Alert({ this.alerts.show(new Alert({
type: 'error', type: 'error',
children: error.detail children: error.detail
})); }));
}); });
} }
}
/** /**
* Construct a URL to the route with the given name. * Construct a URL to the route with the given name.

View File

@@ -117,10 +117,11 @@ export default class Model {
* *
* @param {Object} attributes The attributes to save. If a 'relationships' key * @param {Object} attributes The attributes to save. If a 'relationships' key
* exists, it will be extracted and relationships will also be saved. * exists, it will be extracted and relationships will also be saved.
* @param {Object} [options]
* @return {Promise} * @return {Promise}
* @public * @public
*/ */
save(attributes) { save(attributes, options = {}) {
const data = { const data = {
type: this.data.type, type: this.data.type,
id: this.data.id, id: this.data.id,
@@ -153,11 +154,11 @@ export default class Model {
this.pushData(data); this.pushData(data);
return app.request({ return app.request(Object.assign({
method: this.exists ? 'PATCH' : 'POST', method: this.exists ? 'PATCH' : 'POST',
url: app.forum.attribute('apiUrl') + this.apiEndpoint(), url: app.forum.attribute('apiUrl') + this.apiEndpoint(),
data: {data} data: {data}
}).then( }, options)).then(
// If everything went well, we'll make sure the store knows that this // If everything went well, we'll make sure the store knows that this
// model exists now (if it didn't already), and we'll push the data that // model exists now (if it didn't already), and we'll push the data that
// the API returned into the store. // the API returned into the store.
@@ -181,17 +182,18 @@ export default class Model {
* Send a request to delete the resource. * Send a request to delete the resource.
* *
* @param {Object} data Data to send along with the DELETE request. * @param {Object} data Data to send along with the DELETE request.
* @param {Object} [options]
* @return {Promise} * @return {Promise}
* @public * @public
*/ */
delete(data) { delete(data, options = {}) {
if (!this.exists) return m.deferred.resolve().promise; if (!this.exists) return m.deferred.resolve().promise;
return app.request({ return app.request(Object.assign({
method: 'DELETE', method: 'DELETE',
url: app.forum.attribute('apiUrl') + this.apiEndpoint(), url: app.forum.attribute('apiUrl') + this.apiEndpoint(),
data data
}).then(() => { }, options)).then(() => {
this.exists = false; this.exists = false;
this.store.remove(this); this.store.remove(this);
}); });

View File

@@ -26,15 +26,16 @@ export default class Session {
* *
* @param {String} identification The username/email. * @param {String} identification The username/email.
* @param {String} password * @param {String} password
* @param {Object} [options]
* @return {Promise} * @return {Promise}
* @public * @public
*/ */
login(identification, password) { login(identification, password, options = {}) {
return app.request({ return app.request(Object.assign({
method: 'POST', method: 'POST',
url: app.forum.attribute('baseUrl') + '/login', url: app.forum.attribute('baseUrl') + '/login',
data: {identification, password} data: {identification, password}
}) }, options))
.then(() => window.location.reload()); .then(() => window.location.reload());
} }

View File

@@ -79,10 +79,11 @@ export default class Store {
* Alternatively, if an object is passed, it will be handled as the * Alternatively, if an object is passed, it will be handled as the
* `query` parameter. * `query` parameter.
* @param {Object} [query] * @param {Object} [query]
* @param {Object} [options]
* @return {Promise} * @return {Promise}
* @public * @public
*/ */
find(type, id, query = {}) { find(type, id, query = {}, options = {}) {
let data = query; let data = query;
let url = app.forum.attribute('apiUrl') + '/' + type; let url = app.forum.attribute('apiUrl') + '/' + type;
@@ -94,11 +95,11 @@ export default class Store {
url += '/' + id; url += '/' + id;
} }
return app.request({ return app.request(Object.assign({
method: 'GET', method: 'GET',
url, url,
data data
}).then(this.pushPayload.bind(this)); }, options)).then(this.pushPayload.bind(this));
} }
/** /**

View File

@@ -109,27 +109,28 @@ export default class Modal extends Component {
} }
/** /**
* Show an alert describing errors returned from the API, and give focus to * Stop loading.
*/
loaded() {
this.loading = false;
m.redraw();
}
/**
* Show an alert describing an error returned from the API, and give focus to
* the first relevant field. * the first relevant field.
* *
* @param {Object} response * @param {RequestError} error
*/ */
handleErrors(response) { onerror(error) {
const errors = response && response.errors; this.alert = error.alert;
if (errors) {
this.alert = new Alert({
type: 'error',
children: errors.map((error, k) => [error.detail, k < errors.length - 1 ? m('br') : ''])
});
}
m.redraw(); m.redraw();
if (errors) { if (error.status === 422 && error.response.errors) {
this.$('form [name=' + errors[0].source.pointer.replace('/data/attributes/', '') + ']').select(); this.$('form [name=' + error.response.errors[0].source.pointer.replace('/data/attributes/', '') + ']').select();
} else { } else {
this.$('form :input:first').select(); this.onready();
} }
} }
} }

View File

@@ -5,10 +5,6 @@ export default class RequestErrorModal extends Modal {
return 'RequestErrorModal Modal--large'; return 'RequestErrorModal Modal--large';
} }
title() {
return this.props.error.message;
}
content() { content() {
let responseText; let responseText;

View File

@@ -1,6 +1,15 @@
export default class RequestError { export default class RequestError {
constructor(message, responseText) { constructor(status, responseText, xhr) {
this.message = message; this.status = status;
this.responseText = responseText; this.responseText = responseText;
this.xhr = xhr;
try {
this.response = JSON.parse(responseText);
} catch (e) {
this.response = null;
}
this.alert = null;
} }
} }

View File

@@ -12,11 +12,11 @@ namespace Flarum\Api\Controller;
use Flarum\Api\Command\GenerateAccessToken; use Flarum\Api\Command\GenerateAccessToken;
use Flarum\Core\Repository\UserRepository; use Flarum\Core\Repository\UserRepository;
use Flarum\Core\Exception\PermissionDeniedException;
use Flarum\Event\UserEmailChangeWasRequested; use Flarum\Event\UserEmailChangeWasRequested;
use Flarum\Http\Controller\ControllerInterface; use Flarum\Http\Controller\ControllerInterface;
use Illuminate\Contracts\Bus\Dispatcher as BusDispatcher; use Illuminate\Contracts\Bus\Dispatcher as BusDispatcher;
use Illuminate\Contracts\Events\Dispatcher as EventDispatcher; use Illuminate\Contracts\Events\Dispatcher as EventDispatcher;
use Illuminate\Database\Eloquent\ModelNotFoundException;
use Psr\Http\Message\ServerRequestInterface; use Psr\Http\Message\ServerRequestInterface;
use Zend\Diactoros\Response\JsonResponse; use Zend\Diactoros\Response\JsonResponse;
@@ -62,16 +62,13 @@ class TokenController implements ControllerInterface
$user = $this->users->findByIdentification($identification); $user = $this->users->findByIdentification($identification);
if (! $user || ! $user->checkPassword($password)) { if (! $user || ! $user->checkPassword($password)) {
throw new PermissionDeniedException; throw new ModelNotFoundException;
} }
if (! $user->is_activated) { if (! $user->is_activated) {
$this->events->fire(new UserEmailChangeWasRequested($user, $user->email)); $this->events->fire(new UserEmailChangeWasRequested($user, $user->email));
return new JsonResponse([ return new JsonResponse(['emailConfirmationRequired' => $user->email], 401);
'code' => 'confirm_email',
'email' => $user->email
], 401);
} }
$token = $this->bus->dispatch( $token = $this->bus->dispatch(

View File

@@ -63,6 +63,7 @@ class EditUserHandler
$attributes = array_get($data, 'attributes', []); $attributes = array_get($data, 'attributes', []);
$relationships = array_get($data, 'relationships', []); $relationships = array_get($data, 'relationships', []);
$validate = [];
if (isset($attributes['username'])) { if (isset($attributes['username'])) {
$this->assertPermission($canEdit); $this->assertPermission($canEdit);
@@ -72,6 +73,10 @@ class EditUserHandler
if (isset($attributes['email'])) { if (isset($attributes['email'])) {
if ($isSelf) { if ($isSelf) {
$user->requestEmailChange($attributes['email']); $user->requestEmailChange($attributes['email']);
if ($attributes['email'] !== $user->email) {
$validate['email'] = $attributes['email'];
}
} else { } else {
$this->assertPermission($canEdit); $this->assertPermission($canEdit);
$user->changeEmail($attributes['email']); $user->changeEmail($attributes['email']);
@@ -81,6 +86,8 @@ class EditUserHandler
if (isset($attributes['password'])) { if (isset($attributes['password'])) {
$this->assertPermission($canEdit); $this->assertPermission($canEdit);
$user->changePassword($attributes['password']); $user->changePassword($attributes['password']);
$validate['password'] = $attributes['password'];
} }
if (isset($attributes['bio'])) { if (isset($attributes['bio'])) {
@@ -127,7 +134,7 @@ class EditUserHandler
new UserWillBeSaved($user, $actor, $data) new UserWillBeSaved($user, $actor, $data)
); );
$this->validator->assertValid(array_merge($user->getDirty(), array_only($attributes, ['password', 'email']))); $this->validator->assertValid(array_merge($user->getDirty(), $validate));
$user->save(); $user->save();

View File

@@ -55,12 +55,11 @@ class LoginController implements ControllerInterface
$actor = $request->getAttribute('actor'); $actor = $request->getAttribute('actor');
$params = array_only($request->getParsedBody(), ['identification', 'password']); $params = array_only($request->getParsedBody(), ['identification', 'password']);
$data = json_decode($this->apiClient->send($controller, $actor, [], $params)->getBody()); $response = $this->apiClient->send($controller, $actor, [], $params);
if ($response->getStatusCode() === 200) {
$data = json_decode($response->getBody());
// TODO: The client needs to pass through exceptions(?) or the whole
// response so we can look at the response code. For now if there isn't
// any useful data we just assume it's a 401.
if (isset($data->userId)) {
// Extend the token's expiry to 2 weeks so that we can set a // Extend the token's expiry to 2 weeks so that we can set a
// remember cookie // remember cookie
AccessToken::where('id', $data->token)->update(['expires_at' => new DateTime('+2 weeks')]); AccessToken::where('id', $data->token)->update(['expires_at' => new DateTime('+2 weeks')]);
@@ -68,11 +67,11 @@ class LoginController implements ControllerInterface
event(new UserLoggedIn($this->users->findOrFail($data->userId), $data->token)); event(new UserLoggedIn($this->users->findOrFail($data->userId), $data->token));
return $this->withRememberCookie( return $this->withRememberCookie(
new JsonResponse($data), $response,
$data->token $data->token
); );
} else { } else {
return new EmptyResponse(401); return $response;
} }
} }
} }