From 8800176749f69a72313fdd57d9c9d4134f54e859 Mon Sep 17 00:00:00 2001 From: Changaco Date: Fri, 24 May 2024 09:38:23 +0200 Subject: [PATCH 1/4] drop obsolete JavaScript code for identity documents --- js/10-base.js | 1 - js/identity-docs.js | 136 -------------------------------------------- 2 files changed, 137 deletions(-) delete mode 100644 js/identity-docs.js diff --git a/js/10-base.js b/js/10-base.js index 439461a6ca..6ef02d2b94 100644 --- a/js/10-base.js +++ b/js/10-base.js @@ -32,7 +32,6 @@ Liberapay.init = function() { Liberapay.auto_tail_log(); Liberapay.charts.init(); - Liberapay.identity_docs_init(); Liberapay.lookup.init(); Liberapay.s3_uploader_init(); Liberapay.stripe_init(); diff --git a/js/identity-docs.js b/js/identity-docs.js deleted file mode 100644 index e40a79dbb2..0000000000 --- a/js/identity-docs.js +++ /dev/null @@ -1,136 +0,0 @@ - -Liberapay.identity_docs_init = function () { - - var $form = $('#identity-form-2'); - if ($form.length === 0) return; - var form = $form.get(0); - var $form_submit_button = $('#identity-form-2 button').filter(':not([type]), [type="submit"]'); - var $inputs = $form.find(':not(:disabled)').filter(function () { - return $(this).parents('.fine-uploader').length == 0 - }); - - var uploaders = []; - $('.fine-uploader').each(function () { - // https://docs.fineuploader.com/api/options.html - var uploader = new qq.FineUploader({ - element: this, - template: document.getElementById('qq-template'), - autoUpload: false, - request: { - endpoint: $form.data('upload-url'), - params: { - action: 'add_page', - csrf_token: Liberapay.getCookie('csrf_token'), - }, - }, - validation: { - allowedExtensions: $form.data('allowed-extensions').split(', '), - sizeLimit: $form.data('doc-max-size'), - }, - display: { - fileSizeOnSubmit: true, - }, - text: { - fileInputTitle: '', - }, - callbacks: { - onAllComplete: function (successes, failures) { - if (successes.length > 0 && failures.length == 0) { - validate_doc(uploader, uploader._options.request.params.doc_id) - } - }, - onSubmitted: function () { - $form_submit_button.prop('disabled', false); - }, - }, - }); - uploader._doc_type_ = $(this).attr('name'); - uploaders.push(uploader); - }); - - function create_doc(uploader, doc_type) { - jQuery.ajax({ - url: uploader._options.request.endpoint, - type: 'POST', - data: {action: 'create_doc', 'doc_type': doc_type}, - dataType: 'json', - success: function (data) { - uploader._options.request.params.doc_id = data.doc_id; - uploader.uploadStoredFiles(); - }, - error: [ - function () { $inputs.prop('disabled', false); }, - Liberapay.error, - ], - }); - } - - function validate_doc(uploader, doc_id) { - jQuery.ajax({ - url: uploader._options.request.endpoint, - type: 'POST', - data: {action: 'validate_doc', 'doc_id': doc_id}, - dataType: 'json', - success: function (data) { - uploader._allComplete_ = true; - var allComplete = true; - $.each(uploaders, function () { - if (!this._allComplete_) { - allComplete = false; - } - }); - if (allComplete === true) { - window.location.href = window.location.href; - } - }, - error: [ - function () { $inputs.prop('disabled', false); }, - Liberapay.error, - ], - }); - } - - function submit(e, confirmed) { - e.preventDefault(); - if (!confirmed && form.reportValidity && form.reportValidity() == false) return; - var data = $form.serializeArray(); - $inputs.prop('disabled', true); - jQuery.ajax({ - url: '', - type: 'POST', - data: data, - dataType: 'json', - success: function (data) { - $inputs.prop('disabled', false); - if (data.confirm) { - if (window.confirm(data.confirm)) { - $form.append(''); - return submit(e, true); - }; - return; - } - var count = 0; - $.each(uploaders, function (i, uploader) { - if (uploader._storedIds.length !== 0) { - count += uploader._storedIds.length; - if (uploader._options.request.params.doc_id) { - uploader.uploadStoredFiles(); - } else { - create_doc(uploader, uploader._doc_type_); - } - } - }); - if (count == 0) { - window.location.href = window.location.href; - } - }, - error: [ - function () { $inputs.prop('disabled', false); }, - Liberapay.error, - ], - }); - } - $form.submit(submit); - $form_submit_button.click(submit); - -}; From 20ecd1cf8de918073f3faa9020d03ccd3b113f27 Mon Sep 17 00:00:00 2001 From: Changaco Date: Sat, 25 May 2024 17:46:52 +0200 Subject: [PATCH 2/4] drop unused jQuery method `.center()` --- js/10-base.js | 17 ----------------- 1 file changed, 17 deletions(-) diff --git a/js/10-base.js b/js/10-base.js index 6ef02d2b94..c760766d7b 100644 --- a/js/10-base.js +++ b/js/10-base.js @@ -174,20 +174,3 @@ Liberapay.jsonml = function(jsonml) { return node; }; - -(function($) { - return $.fn.center = function(position) { - return this.each(function() { - var e = $(this); - var pos = e.css('position'); - if (pos != 'absolute' && pos != 'fixed' || position && pos != position) { - e.css('position', position || 'absolute'); - } - e.css({ - left: '50%', - top: '50%', - margin: '-' + (e.innerHeight() / 2) + 'px 0 0 -' + (e.innerWidth() / 2) + 'px' - }); - }); - }; -})(jQuery); From a9dc356b7ec011121ac85ebe91e0246dd058ef00 Mon Sep 17 00:00:00 2001 From: Changaco Date: Sat, 22 Jun 2024 10:46:48 +0200 Subject: [PATCH 3/4] stop expecting the `X-Requested-With` header --- liberapay/testing/__init__.py | 9 +++++---- liberapay/utils/__init__.py | 5 +++-- tests/py/test_communities.py | 24 ++++++++++++------------ tests/py/test_email.py | 2 +- tests/py/test_newsletters.py | 12 ++++++------ tests/py/test_pages.py | 2 +- tests/py/test_payday.py | 4 ++-- tests/py/test_tip_json.py | 4 ++-- tests/py/test_tips_json.py | 2 +- www/%username/elsewhere/delete.spt | 4 +--- www/%username/identity.spt | 2 +- www/%username/news/%action.spt | 8 ++------ www/%username/tip.spt | 2 +- www/for/%name/%action.spt | 9 +++------ www/sign-out.spt | 12 ++---------- 15 files changed, 43 insertions(+), 58 deletions(-) diff --git a/liberapay/testing/__init__.py b/liberapay/testing/__init__.py index 846bbdffcc..7432d8a3f3 100644 --- a/liberapay/testing/__init__.py +++ b/liberapay/testing/__init__.py @@ -86,8 +86,9 @@ def build_wsgi_environ(self, method, *a, **kw): return environ def hit(self, method, url, *a, **kw): - if kw.pop('xhr', False): - kw['HTTP_X_REQUESTED_WITH'] = b'XMLHttpRequest' + if kw.pop('json', False): + kw['HTTP_ACCEPT'] = b'application/json' + kw.setdefault('raise_immediately', False) # prevent tell_sentry from reraising errors sentry_reraise = kw.pop('sentry_reraise', True) @@ -449,9 +450,9 @@ def make_invoice(self, sender, addressee, amount, status): 'description': 'lorem ipsum', 'details': '', } - r = self.client.PxST( + r = self.client.POST( '/~%s/invoices/new' % addressee.id, auth_as=sender, - data=invoice_data, xhr=True, + data=invoice_data, json=True, ) assert r.code == 200, r.text invoice_id = json.loads(r.text)['invoice_id'] diff --git a/liberapay/utils/__init__.py b/liberapay/utils/__init__.py index 96c5c5470d..b27a804ee2 100644 --- a/liberapay/utils/__init__.py +++ b/liberapay/utils/__init__.py @@ -188,8 +188,9 @@ def form_post_success(state, msg='', redirect_url=None): """This function is meant to be called after a successful form POST. """ request, response = state['request'], state['response'] - if request.headers.get(b'X-Requested-With') == b'XMLHttpRequest': - raise response.json({"msg": msg} if msg else {}) + if request.headers.get(b'Accept', b'').startswith(b'application/json'): + _ = state['_'] + raise response.json({"msg": msg or _("The changes have been saved.")}) else: if not redirect_url: redirect_url = request.body.get('back_to') or request.line.uri.decoded diff --git a/tests/py/test_communities.py b/tests/py/test_communities.py index f8233415b0..73286945ab 100644 --- a/tests/py/test_communities.py +++ b/tests/py/test_communities.py @@ -54,16 +54,16 @@ def test_post_bad_id_returns_400(self): assert response.code == 400 def test_joining_and_leaving_community(self): - response = self.client.PxST('/alice/edit/communities', + response = self.client.POST('/alice/edit/communities', {'do': 'join:'+self.c_id}, - auth_as=self.alice, xhr=True) + auth_as=self.alice, json=True) r = json.loads(response.text) - assert r == {} + assert isinstance(r, dict) - response = self.client.PxST('/alice/edit/communities', + response = self.client.POST('/alice/edit/communities', {'do': 'leave:'+self.c_id}, - auth_as=self.alice, xhr=True) + auth_as=self.alice, json=True) communities = self.alice.get_communities() assert len(communities) == 0 @@ -84,39 +84,39 @@ def test_post_bad_name_returns_404(self): def test_subscribe_and_unsubscribe(self): # Subscribe - response = self.client.POST('/for/test/subscribe', auth_as=self.bob, xhr=True) + response = self.client.POST('/for/test/subscribe', auth_as=self.bob, json=True) assert response.code == 200 p = self.community.participant.refetch() assert p.nsubscribers == 1 # Subscribe again, shouldn't do anything - response = self.client.POST('/for/test/subscribe', auth_as=self.bob, xhr=True) + response = self.client.POST('/for/test/subscribe', auth_as=self.bob, json=True) assert response.code == 200 p = self.community.participant.refetch() assert p.nsubscribers == 1 # Unsubscribe - self.client.POST('/for/test/unsubscribe', auth_as=self.bob, xhr=True) + self.client.POST('/for/test/unsubscribe', auth_as=self.bob, json=True) communities = self.bob.get_communities() assert len(communities) == 0 def test_subscribe_and_unsubscribe_as_anon(self): - response = self.client.POST('/for/test/subscribe', xhr=True, raise_immediately=False) + response = self.client.POST('/for/test/subscribe', json=True) assert response.code == 403 - response = self.client.POST('/for/test/unsubscribe', xhr=True, raise_immediately=False) + response = self.client.POST('/for/test/unsubscribe', json=True) assert response.code == 403 def test_join_and_leave(self): with self.assertRaises(AuthRequired): self.client.POST('/for/test/join') - self.client.POST('/for/test/join', auth_as=self.bob, xhr=True) + self.client.POST('/for/test/join', auth_as=self.bob, json=True) communities = self.bob.get_communities() assert len(communities) == 1 - self.client.POST('/for/test/leave', auth_as=self.bob, xhr=True) + self.client.POST('/for/test/leave', auth_as=self.bob, json=True) communities = self.bob.get_communities() assert len(communities) == 0 diff --git a/tests/py/test_email.py b/tests/py/test_email.py index 75abc05898..fa713d5caa 100644 --- a/tests/py/test_email.py +++ b/tests/py/test_email.py @@ -25,7 +25,7 @@ def setUp(self): def hit_email_spt(self, action, address, auth_as='alice', expected_code=200): data = {action: address} - headers = {'HTTP_ACCEPT_LANGUAGE': 'en', 'HTTP_X_REQUESTED_WITH': 'XMLHttpRequest'} + headers = {'HTTP_ACCEPT_LANGUAGE': 'en', 'HTTP_ACCEPT': 'application/json'} auth_as = self.alice if auth_as == 'alice' else auth_as r = self.client.POST( '/alice/emails/', data, diff --git a/tests/py/test_newsletters.py b/tests/py/test_newsletters.py index e9e41621cf..4ef30c7944 100644 --- a/tests/py/test_newsletters.py +++ b/tests/py/test_newsletters.py @@ -9,17 +9,17 @@ def setUp(self): self.bob = self.make_participant('bob') def test_subscribe_and_unsubscribe(self): - r = self.client.POST('/alice/news/subscribe', auth_as=self.bob, xhr=True) + r = self.client.POST('/alice/news/subscribe', auth_as=self.bob, json=True) assert r.code == 200 - r = self.client.POST('/alice/news/unsubscribe', auth_as=self.bob, xhr=True) + r = self.client.POST('/alice/news/unsubscribe', auth_as=self.bob, json=True) assert r.code == 200 def test_subscribe_and_unsubscribe_as_anon(self): - r = self.client.POST('/alice/news/subscribe', xhr=True, raise_immediately=False) + r = self.client.POST('/alice/news/subscribe', json=True) assert r.code == 403 - r = self.client.POST('/alice/news/unsubscribe', xhr=True, raise_immediately=False) + r = self.client.POST('/alice/news/unsubscribe', json=True) assert r.code == 403 def test_unsubscribe_and_subscribe_with_token(self): @@ -27,10 +27,10 @@ def test_unsubscribe_and_subscribe_with_token(self): assert self.alice.check_subscription_status(self.bob) is True unsubscribe_url = '/~{publisher}/news/unsubscribe?id={id}&token={token}'.format(**subscription._asdict()) - r = self.client.POST(unsubscribe_url, xhr=True) + r = self.client.POST(unsubscribe_url, json=True) assert r.code == 200 assert self.alice.check_subscription_status(self.bob) is False subscribe_url = unsubscribe_url.replace('/unsubscribe', '/subscribe') - r = self.client.POST(subscribe_url, xhr=True) + r = self.client.POST(subscribe_url, json=True) assert r.code == 200 diff --git a/tests/py/test_pages.py b/tests/py/test_pages.py index a0180803b3..78d2652bb1 100644 --- a/tests/py/test_pages.py +++ b/tests/py/test_pages.py @@ -102,7 +102,7 @@ def test_sign_out_expires_session(self): def test_sign_out_doesnt_redirect_xhr(self): alice = self.make_participant('alice') - response = self.client.PxST('/sign-out.html', auth_as=alice, xhr=True) + response = self.client.POST('/sign-out.html', auth_as=alice, json=True) assert response.code == 200 def test_giving_page(self): diff --git a/tests/py/test_payday.py b/tests/py/test_payday.py index 39c360a7f6..07105b43cc 100644 --- a/tests/py/test_payday.py +++ b/tests/py/test_payday.py @@ -377,8 +377,8 @@ def test_payday_notifies_participants(self): self.make_payin_and_transfer(janet_card, self.david, EUR('4.50')) self.make_payin_and_transfer(janet_card, self.homer, EUR('3.50')) self.make_payin_and_transfer(janet_card, team, EUR('25.00')) - self.client.PxST('/homer/emails/', auth_as=self.homer, - data={'events': 'income', 'income': ''}, xhr=True) + self.client.POST('/homer/emails/', auth_as=self.homer, + data={'events': 'income', 'income': ''}, json=True) self.db.run(""" UPDATE scheduled_payins SET ctime = ctime - interval '12 hours' diff --git a/tests/py/test_tip_json.py b/tests/py/test_tip_json.py index f194661d37..30e2041e03 100644 --- a/tests/py/test_tip_json.py +++ b/tests/py/test_tip_json.py @@ -11,7 +11,7 @@ def tip(self, tipper, tippee, amount, period='weekly', raise_immediately=True): "/%s/tip.json" % tippee, data, auth_as=tipper, - xhr=True, + json=True, raise_immediately=raise_immediately, ) @@ -96,7 +96,7 @@ def test_set_tip_standard_amount(self): "/bob/tip.json", {'selected_amount': '1.00'}, auth_as=alice, - xhr=True, + json=True, ) assert r.code == 200 r_data = json.loads(r.text) diff --git a/tests/py/test_tips_json.py b/tests/py/test_tips_json.py index 64d60c242f..2d4c7d363d 100644 --- a/tests/py/test_tips_json.py +++ b/tests/py/test_tips_json.py @@ -53,7 +53,7 @@ def test_get_response_with_tips(self): response = self.client.POST('/test_tippee1/tip.json', {'amount': '1.00', 'period': 'weekly'}, auth_as=test_tipper, - xhr=True, + json=True, ) data = json.loads(response.text) assert response.code == 200 diff --git a/www/%username/elsewhere/delete.spt b/www/%username/elsewhere/delete.spt index 39c605ff5e..be5d194bf9 100644 --- a/www/%username/elsewhere/delete.spt +++ b/www/%username/elsewhere/delete.spt @@ -11,10 +11,8 @@ user_id = request.body["user_id"] participant.delete_elsewhere(platform, domain, user_id) -if request.headers.get(b'X-Requested-With') != b'XMLHttpRequest': - response.redirect('..') - [---] text/html +% do response.redirect('..') [---] application/json via json_dump {} diff --git a/www/%username/identity.spt b/www/%username/identity.spt index 8955686385..7e05a5cbe6 100644 --- a/www/%username/identity.spt +++ b/www/%username/identity.spt @@ -58,7 +58,7 @@ if request.method == 'POST': success = _("Your identity information has been updated.") form_post_success(state, msg=success) - if error and request.headers.get(b'X-Requested-With') == b'XMLHttpRequest': + if error and request.headers.get(b'Accept', b'').startswith(b'application/json'): raise response.error(400, error) else: diff --git a/www/%username/news/%action.spt b/www/%username/news/%action.spt index 93b74e931d..daa503bf41 100644 --- a/www/%username/news/%action.spt +++ b/www/%username/news/%action.spt @@ -1,7 +1,7 @@ from liberapay.exceptions import AuthRequired, LoginRequired from liberapay.models.participant import Participant from liberapay.security.crypto import constant_time_compare -from liberapay.utils import get_participant +from liberapay.utils import form_post_success, get_participant [---] @@ -42,8 +42,7 @@ if request.method == 'POST': subscriber.upsert_subscription(subscribe, publisher.id) - if request.headers.get(b'X-Requested-With') != b'XMLHttpRequest': - response.redirect(back_to) + form_post_success(state, redirect_url=back_to) title = _("Subscribe") if subscribe else _("Unsubscribe") @@ -66,6 +65,3 @@ title = _("Subscribe") if subscribe else _("Unsubscribe") {{ _("← Go back") }} % endblock - -[---] application/json -{} diff --git a/www/%username/tip.spt b/www/%username/tip.spt index bb179f3455..384a2cbde2 100644 --- a/www/%username/tip.spt +++ b/www/%username/tip.spt @@ -78,7 +78,7 @@ if request.method == 'POST': messages = PLEDGE_MESSAGES if out['is_pledge'] else DONATION_MESSAGES msg = messages[out['period']] out["msg"] = _(msg, out['periodic_amount'], tippee_name) - if request.headers.get(b'X-Requested-With') != b'XMLHttpRequest': + if output.media_type != 'application/json': back_to = request.body.get('back_to') or tipper.path('giving/') back_to += '&' if '?' in back_to else '?' back_to += 'success=' + b64encode_s(out["msg"]) diff --git a/www/for/%name/%action.spt b/www/for/%name/%action.spt index 6635b15b6c..8345c5c35b 100644 --- a/www/for/%name/%action.spt +++ b/www/for/%name/%action.spt @@ -1,4 +1,4 @@ -from liberapay.utils import get_community +from liberapay.utils import form_post_success, get_community [---] @@ -18,8 +18,8 @@ elif action in ('join', 'leave'): else: raise response.error(400) -if request.headers.get(b'X-Requested-With') != b'XMLHttpRequest': - response.redirect(request.body.get('back_to') or '/for/'+community.name, trusted_url=False) +back_to = response.sanitize_untrusted_url(request.body.get('back_to') or '/for/'+community.name) +form_post_success(state, redirect_url=back_to) [---] text/html % extends "templates/layouts/base-thin.html" @@ -27,6 +27,3 @@ if request.headers.get(b'X-Requested-With') != b'XMLHttpRequest': % block thin_content

These aren't the droids you're looking for.

% endblock - -[---] application/json -{} diff --git a/www/sign-out.spt b/www/sign-out.spt index 5d9c1f2ac8..2f98b56d54 100644 --- a/www/sign-out.spt +++ b/www/sign-out.spt @@ -1,4 +1,5 @@ from liberapay.security.authentication import ANON +from liberapay.utils import form_post_success [---] @@ -8,16 +9,7 @@ if user.ANON: if request.method == 'POST': user.sign_out(response.headers.cookie) state['user'] = ANON - - if request.headers.get(b'X-Requested-With') == b'XMLHttpRequest': - raise response.success() - - if 'back_to' in request.body: - back_to = request.body['back_to'] - else: - back_to = request.headers.get(b'Referer', b'/') - - response.redirect(back_to, trusted_url=False) + form_post_success(state) title = _("Sign Out") [---] text/html From 317ce837bbf19d9bab80a3009e243312ecb803c8 Mon Sep 17 00:00:00 2001 From: Changaco Date: Fri, 31 May 2024 11:26:27 +0200 Subject: [PATCH 4/4] unify JavaScript handling of form submissions - drop the `js-submit` class and merge the two separate `submit` event handlers into one - it's now easy to run custom functions both before and after a form submission - however, the after functions may not be called if the server responds to the form submission with an error - the unified function's source code is commented - the unified function sends debugging messages to the console - form inputs are no longer disabled during submission, because it was complicating the JavaScript code - CSS is used instead to gray out the entire form and disable mouse events, but keyboard events aren't be disabled - the submission feedback overlay is back, for forms making slow requests before submitting - replace most uses of `jQuery.ajax()` with `fetch()`, simplifying the `Liberapay.error()` function and removing the need for the `Liberapay.getCookie()` function - all forms are now required to include an anti-CSRF token, whereas previously the forms that required JavaScript didn't need to contain the token as it would be taken from the cookies anyway - since we no longer need the `csrf_token` cookie to be accessible from JavaScript, mark it as `httponly` --- error.spt | 14 +- js/10-base.js | 45 +-- js/charts.js | 16 +- js/forms.js | 306 ++++++++++++------ js/notification.js | 2 +- js/s3.js | 38 +-- js/stripe.js | 84 ++--- js/tail_log.js | 2 +- liberapay/security/csrf.py | 4 +- style/base/forms.scss | 6 + templates/macros/admin.html | 5 +- templates/macros/elsewhere.html | 2 +- templates/macros/repos.html | 1 + tests/py/test_communities.py | 4 +- tests/py/test_cron.py | 8 +- www/%username/edit/communities.spt | 2 +- www/%username/edit/privacy.spt | 2 +- www/%username/edit/repositories.spt | 8 +- www/%username/emails/index.spt | 12 +- www/%username/giving/pay/stripe/%payin_id.spt | 11 +- www/%username/invoices/new.spt | 11 +- www/%username/routes/add.spt | 2 + www/admin/users.spt | 5 +- 23 files changed, 327 insertions(+), 263 deletions(-) diff --git a/error.spt b/error.spt index ff525f35d7..fb893ddaa3 100644 --- a/error.spt +++ b/error.spt @@ -88,11 +88,13 @@ user_agent = request.headers.get(b'User-Agent') % endif % endblock [----------------------------------------] application/json via json_dump -{ "error_code": code -, "error_message_short": msg -, "error_message_long": err -, "error_id": sentry_ident -, "error_location": error_location - } +{ + "error_code": code, + "error_id": sentry_ident, + "error_location": error_location, + "error_message_long": err, + "error_message_short": msg, + "html_template": getattr(response, 'html_template', None), +} [----------------------------------------] text/plain {{err or msg}} diff --git a/js/10-base.js b/js/10-base.js index c760766d7b..41e36af516 100644 --- a/js/10-base.js +++ b/js/10-base.js @@ -1,23 +1,4 @@ -Liberapay.getCookie = function(key) { - var o = new RegExp("(?:^|; ?)" + escape(key) + "=([^;]+)").exec(document.cookie); - if (!o) return null; - var value = o[1]; - if (value.charAt(0) === '"') value = value.slice(1, -1); - return unescape(value); -} - Liberapay.init = function() { - // https://docs.djangoproject.com/en/dev/ref/contrib/csrf/#ajax - jQuery.ajaxSetup({ - beforeSend: function(xhr, settings) { - var safeMethod = (/^(GET|HEAD|OPTIONS|TRACE)$/.test(settings.type)); - if (!safeMethod && !settings.crossDomain) { - // We have to avoid httponly on the csrf_token cookie because of this. - xhr.setRequestHeader("X-CSRF-TOKEN", Liberapay.getCookie('csrf_token')); - } - } - }); - Liberapay.forms.jsSubmit(); var success_re = /([?&])success=[^&]*/; @@ -129,31 +110,27 @@ Liberapay.init = function() { $(function(){ Liberapay.init(); }); -Liberapay.error = function(jqXHR, textStatus, errorThrown) { - var msg = null; - if (jqXHR.responseText > "") { - try { - msg = JSON.parse(jqXHR.responseText).error_message_long; - } catch(exc) {} - } - if (typeof msg != "string" || msg.length == 0) { - msg = "An error occurred (" + (errorThrown || textStatus || jqXHR.status) + ").\n" + +Liberapay.error = function(exc) { + console.error(exc); + var msg = "An error occurred (" + exc + ").\n" + "Please contact support@liberapay.com if the problem persists."; - } Liberapay.notification(msg, 'error', -1); } Liberapay.wrap = function(f) { - return function() { + return async function() { try { - return f.apply(this, arguments); - } catch (e) { - console.log(e); - Liberapay.notification(e, 'error', -1); + return await f.apply(this, arguments); + } catch (exc) { + Liberapay.error(exc); } } }; +Liberapay.get_object_by_name = function(name) { + return name.split('.').reduce(function(o, k) {return o[k]}, window); +} + Liberapay.jsonml = function(jsonml) { var node = document.createElement(jsonml[0]); diff --git a/js/charts.js b/js/charts.js index b92bc6efe8..6fe184c3a3 100644 --- a/js/charts.js +++ b/js/charts.js @@ -16,19 +16,21 @@ Liberapay.charts.init = function() { } Liberapay.charts.load = function(url, $container) { - jQuery.get(url, function(series) { - $(function() { - Liberapay.charts.make(series, $container); - }); - }).fail(Liberapay.error); + fetch(url).then(function(response) { + response.json().then(function(series) { + $(function() { + Liberapay.charts.make(series, $container); + }); + }).catch(Liberapay.error); + }).catch(Liberapay.error); } Liberapay.charts.make = function(series, $container) { if (series.length) { $('.chart-wrapper').show(); } else { - if (!!$container.data('msg-empty')) { - $container.append($('').text(' '+$container.data('msg-empty'))); + if ($container.attr('data-msg-empty')) { + $container.append($('').text(' '+$container.attr('data-msg-empty'))); } return; } diff --git a/js/forms.js b/js/forms.js index ab5cee5df7..10c50fabbb 100644 --- a/js/forms.js +++ b/js/forms.js @@ -11,7 +11,7 @@ Liberapay.forms.focusInvalid = function($form) { Liberapay.forms.setInvalid = function($input, invalid) { $input.toggleClass('invalid', invalid); - if (!!$input.attr('title') && $input.nextAll('.invalid-msg').length == 0) { + if ($input.attr('title') && $input.nextAll('.invalid-msg').length == 0) { $input.after($('').text($input.attr('title'))); } }; @@ -22,109 +22,225 @@ Liberapay.forms.setValidity = function($input, validity) { }; Liberapay.forms.jsSubmit = function() { - // Initialize forms with the `js-submit` class - function submit(e) { - var form = this.form || this; + var $body = $('body'); + var $overlay = $('
').css({ + 'align-items': 'center', + 'background-color': 'rgba(0, 0, 0, 0.33)', + 'bottom': 0, + 'display': 'flex', + 'justify-content': 'center', + 'left': 0, + 'position': 'fixed', + 'right': 0, + 'top': 0, + 'z-index': 1040, + }); + var $overlay_text_container = $('').appendTo($overlay); + + function add_overlay() { + if ($overlay.parent().length > 0) return; + $overlay.appendTo($body); + $('html').css('overflow', 'hidden'); + } + function remove_overlay() { + clearTimeout($overlay.data('timeoutId')); + $('html').css('overflow', 'auto'); + $overlay.detach(); + } + + var $result_container = $(''); + + async function submit(e) { + console.debug('jsSubmit: called with event', e); + var form = this; var $form = $(form); - if ($form.data('bypass-js-submit') === 'on') { - setTimeout(function () { $form.data('bypass-js-submit', 'off') }, 100); + var target = $form.attr('action'); + if (target.startsWith('javascript:')) { + form.attr('action', target.substr(11)); + } + // Don't interfere with stage 2 submission + if ($form.attr('submitting') == '2') { + console.debug('jsSubmit: not interfering with stage 2'); return } - e.preventDefault(); - if (form.reportValidity && form.reportValidity() == false) return; - var target = $form.attr('action'); - var js_only = target.substr(0, 11) == 'javascript:'; - var data = $form.serializeArray(); - if (js_only) { - // workaround for http://stackoverflow.com/q/11424037/2729778 - $form.find('input[type="checkbox"]').each(function () { - var $input = $(this); - if (!$input.prop('checked')) { - data.push({name: $input.attr('name'), value: 'off'}); - } - }); + // Determine the submission mode + var form_on_success = form.getAttribute('data-on-success'); + var button, button_on_success; + if (e.submitter.tagName == 'BUTTON') { + button = e.submitter; + button_on_success = button.getAttribute('data-on-success'); } - var button = this.tagName == 'BUTTON' ? this : null; - if (this.tagName == 'BUTTON') { - data.push({name: this.name, value: this.value}); + var navigate = (button_on_success || form_on_success || '') == ''; + // Ask the browser to tell the user if the form is in an invalid state + if (form.reportValidity && form.reportValidity() == false) { + console.debug('jsSubmit: form.reportValidity() returned false'); + e.preventDefault(); + return } - var $inputs = $form.find(':not(:disabled)'); - $inputs.prop('disabled', true); - jQuery.ajax({ - url: js_only ? target.substr(11) : target, - type: 'POST', - data: data, - dataType: 'json', - success: Liberapay.forms.success($form, $inputs, button), - error: function (jqXHR, textStatus, errorThrown) { - $inputs.prop('disabled', false); - var msg = null; - if (jqXHR.responseText > "") { - try { - msg = JSON.parse(jqXHR.responseText).error_message_long; - } catch(exc) { - if (!js_only) { - $form.data('bypass-js-submit', 'on'); - if (button) { - $(button).click(); - } else { - $form.submit(); - } - $inputs.prop('disabled', true); - return - } + // Prevent parallel submissions + if ($form.attr('submitting')) { + console.debug('jsSubmit: ignoring duplicate event'); + e.preventDefault(); + return + } + $form.attr('submitting', '1'); + $result_container.detach(); + // Execute the custom pre-submission actions, if there are any + var before_submit = [ + button && button.getAttribute('data-before-submit'), + form.getAttribute('data-before-submit') + ]; + var proceed = true; + $overlay_text_container.text($form.attr('data-msg-submitting') || '…'); + $overlay.data('timeoutId', setTimeout(add_overlay, 50)); + for (const action of before_submit) { + if (!action) continue; + if (action.startsWith('call:')) { + // We have to prevent the form submission here because browsers + // don't await event handlers. + e.preventDefault(); + var func = Liberapay.get_object_by_name(action.substr(5)); + try { + console.debug('jsSubmit: calling pre-submit function', func); + proceed = await func(); + if (proceed === false) { + console.debug('jsSubmit: the pre-submit function returned false'); } + } catch(exc) { + Liberapay.error(exc); + proceed = false; } - if (typeof msg != "string" || msg.length == 0) { - msg = "An error occurred (" + (errorThrown || textStatus || jqXHR.status) + ").\n" + - "Please contact support@liberapay.com if the problem persists."; + } else { + Liberapay.error("invalid value in `data-before-submit` attribute"); + proceed = false; + } + } + clearTimeout($overlay.data('timeoutId')); + if (proceed === false) { + form.removeAttribute('submitting'); + remove_overlay(); + return + } + // If we don't want to send a custom request, proceed with a normal submission + if (navigate) { + // Try to unlock the form if the user navigates back to the page + $(window).on('pageshow', function () { + form.removeAttribute('submitting'); + remove_overlay(); + }); + // Trigger another submit event if we've canceled the original one + if (e.defaultPrevented) { + console.debug('jsSubmit: initiating stage 2'); + $form.attr('submitting', '2'); + if (button) { + $(button).click(); + } else { + $form.submit(); } - Liberapay.notification(msg, 'error', -1); - }, - }); - } - $('.js-submit').submit(submit); - $('.js-submit button').filter(':not([type]), [type="submit"]').click(submit); - // Prevent accidental double-submits of non-JS forms - $('form:not(.js-submit):not([action^="javascript:"])').on('submit', function (e) { - // Check that the form hasn't already been submitted recently - var $form = $(this); - if ($form.data('js-submit-disable')) { - e.preventDefault(); - return false; + } + return; } - // Prevent submitting again - $form.data('js-submit-disable', true); - var $inputs = $form.find(':not(:disabled)'); - setTimeout(function () { $inputs.prop('disabled', true); }, 100); - // Unlock if the user comes back to the page - $(window).on('focus pageshow', function () { - $form.data('js-submit-disable', false); - $inputs.prop('disabled', false); - }); - }); -}; - -Liberapay.forms.success = function($form, $inputs, button) { return function(data) { - $inputs.prop('disabled', false); - if (data.confirm) { - if (window.confirm(data.confirm)) { - $form.append(''); - $form.submit(); + e.preventDefault(); + // Send the form data, asking for JSON in response + try { + console.debug('jsSubmit: submitting form with `fetch()`'); + var response = await fetch(target, { + body: new FormData(form, e.submitter), + headers: {'Accept': 'application/json'}, + method: 'POST' + }); + var data = (await response.json()) || {}; + var navigating = false; + $result_container.text('').hide().insertAfter($form); + if (data.confirm) { + console.debug("jsSubmit: asking the user to confirm"); + if (window.confirm(data.confirm)) { + form.removeAttribute('submitting'); + $form.append(''); + if (button) { + $(button).click(); + } else { + $form.submit(); + } + return + } + } else if (data.html_template) { + console.debug("jsSubmit: received a complex response; trying a native submission"); + form.setAttribute('submitting', '2'); + if (button) { + $(button).click(); + } else { + $form.submit(); + } + return + } else if (data.error_message_long) { + console.debug("jsSubmit: showing error message received from server"); + $result_container.addClass('alert-danger').removeClass('alert-success'); + $result_container.text(data.error_message_long); + } else { + for (const action of [button_on_success, form_on_success]) { + if (!action) continue; + if (action.startsWith("call:")) { + console.debug('jsSubmit: calling post-submit function', func); + var func = Liberapay.get_object_by_name(action.substr(5)); + try { + await func(data); + } catch(exc) { + Liberapay.error(exc); + } + } else if (action.startsWith("fadeOut:")) { + var $e = $(button).parents(action.substr(8)).eq(0); + if ($e.length > 0) { + console.debug('jsSubmit: calling fadeOut on', $e[0]); + $e.fadeOut(400, function() { $e.remove() }); + } else { + console.error("jsSubmit: fadeOut element not found; reloading page"); + window.location.href = window.location.href; + navigating = true; + } + } else if (action == "notify") { + var msg = data && data.msg; + if (msg) { + console.debug("jsSubmit: showing success message"); + $result_container.addClass('alert-success').removeClass('alert-danger'); + $result_container.text(msg); + } else { + console.error("jsSubmit: empty or missing `msg` key in server response:", data); + window.location.href = window.location.href; + navigating = true; + } + } else { + Liberapay.error("invalid value in `data-on-success` attribute"); + } + } + } + if ($result_container.text() > '') { + $result_container.css('visibility', 'visible').fadeIn()[0].scrollIntoView(); + } + if (navigating) { + // Try to unlock the form if the user navigates back to the page + $(window).on('pageshow', function () { + form.removeAttribute('submitting'); + remove_overlay(); + }); + } else { + remove_overlay(); + // Allow submitting again after 0.2s + setTimeout(function () { form.removeAttribute('submitting'); }, 200); + } + $form.find('[type="password"]').val(''); + } catch (exc) { + console.error(exc); + console.debug('jsSubmit: trying a native submission'); + form.setAttribute('submitting', '2'); + if (button) { + $(button).click(); + } else { + $form.submit(); + } } - return; } - $inputs.filter('[type=password]').val(''); - var on_success = $form.data('on-success'); - if (on_success && on_success.substr(0, 8) == 'fadeOut:') { - var $e = $(button).parents(on_success.substr(8)).eq(0); - return $e.fadeOut(null, $e.remove); + for (const form of document.getElementsByTagName('form')) { + form.addEventListener('submit', Liberapay.wrap(submit)); } - var msg = data && data.msg || $form.data('success'); - var on_success = $form.data('on-success'); - if (msg && on_success != 'reload') { - Liberapay.notification(msg, 'success'); - } else { - window.location.href = window.location.href; - } -}}; +}; diff --git a/js/notification.js b/js/notification.js index b7f6808b91..ec8ba3e3ec 100644 --- a/js/notification.js +++ b/js/notification.js @@ -8,7 +8,7 @@ Liberapay.notification = function(text, type, timeout) { var type = type || 'notice'; var timeout = timeout || (type == 'error' ? 10000 : 5000); - var dialog = ['div', { 'class': 'notification notification-' + type }, text]; + var dialog = ['output', { 'class': 'notification notification-' + type }, text]; var $dialog = $(Liberapay.jsonml(dialog)); if (!$('#notification-area-bottom').length) diff --git a/js/s3.js b/js/s3.js index 9c1dd14048..277ab27536 100644 --- a/js/s3.js +++ b/js/s3.js @@ -48,7 +48,7 @@ Liberapay.s3_uploader_init = function () { } }, onSubmitted: function () { - $('#invoice-form button').filter(':not([type]), [type="submit"]').prop('disabled', false); + $form.find('button').filter(':not([type]), [type="submit"]').prop('disabled', false); }, }, }); @@ -59,44 +59,18 @@ Liberapay.s3_uploader_init = function () { } function custom_headers() { return { - 'X-CSRF-TOKEN': Liberapay.getCookie('csrf_token'), + 'X-CSRF-TOKEN': $form.find('input[name="csrf_token"]').val(), 'X-Invoice-Id': uploader._invoice_id, }} - function submit(e) { - e.preventDefault(); - var form = $form.get(0); - if (form.reportValidity && form.reportValidity() == false) return; - var $inputs = $form.find(':not(:disabled)').filter(function () { - return $(this).parents('#fine-uploader').length == 0 - }); - var data = $form.serializeArray(); - $inputs.prop('disabled', true); - jQuery.ajax({ - url: '', - type: 'POST', - data: data, - dataType: 'json', - success: function(data) { - uploader._invoice_id = data.invoice_id; - history.pushState(null, null, location.pathname + '?id=' + data.invoice_id); - return upload_docs() - }, - error: [ - function () { $inputs.prop('disabled', false); }, - Liberapay.error, - ], - }); - } - $('#invoice-form').submit(submit); - $('#invoice-form button').filter(':not([type]), [type="submit"]').click(submit); - - function upload_docs() { + Liberapay.upload_to_s3 = function(data) { + uploader._invoice_id = data.invoice_id; + history.pushState(null, null, location.pathname + '?id=' + data.invoice_id); if (uploader._storedIds.length !== 0) { uploader.uploadStoredFiles(); } else { window.location.href = base_path + uploader._invoice_id; } - } + }; }; diff --git a/js/stripe.js b/js/stripe.js index f024f583cd..25a5955b0a 100644 --- a/js/stripe.js +++ b/js/stripe.js @@ -61,43 +61,24 @@ Liberapay.stripe_form_init = function($form) { } }); - var submitting = false; - $form.submit(Liberapay.wrap(function(e) { - if ($form.data('js-submit-disable')) { - e.preventDefault(); - return false; - } - if (submitting) { - submitting = false; - // Prevent submitting again - $form.data('js-submit-disable', true); - var $inputs = $form.find(':not(:disabled)'); - setTimeout(function () { $inputs.prop('disabled', true); }, 100); - // Unlock if the user comes back to the page - $(window).on('focus pageshow', function () { - $form.data('js-submit-disable', false); - $inputs.prop('disabled', false); - }); - return; - } - e.preventDefault(); + Liberapay.stripe_before_submit = async function() { + // If the Payment Element is hidden, simply let the browser submit the form if ($container.parents('.hidden').length > 0) { - submitting = true; - $form.submit(); - return; + return true; } + // Create the PaymentMethod var pmType = element_type; if (element_type == 'iban') { pmType = 'sepa_debit'; if (is_postal_address_required() && !is_postal_address_filled()) { $postal_address_alert.removeClass('hidden').hide().fadeIn()[0].scrollIntoView(); - return; + return false; } } var local_address = $postal_address_local.val(); - local_address = !!local_address ? local_address.split(/(?:\r\n?|\n)/g) : [null]; + local_address = local_address ? local_address.split(/(?:\r\n?|\n)/g) : [undefined]; if (local_address.length === 1) { - local_address.push(null); + local_address.push(undefined); } if (element_type == 'iban') { var tokenData = { @@ -110,19 +91,16 @@ Liberapay.stripe_form_init = function($form) { address_line1: local_address[0], address_line2: local_address[1], }; - stripe.createToken(element, tokenData).then(Liberapay.wrap(function(result) { - if (result.error) { - $errorElement.text(result.error.message); - } else { - submitting = true; - $form.find('input[name="route"]').remove(); - $form.find('input[name="token"]').remove(); - var $hidden_input = $(''); - $hidden_input.val(result.token.id); - $form.append($hidden_input); - $form.submit(); - } - })); + var result = await stripe.createToken(element, tokenData); + if (result.error) { + $errorElement.text(result.error.message)[0].scrollIntoView(); + return false; + } + $form.find('input[name="route"]').remove(); + $form.find('input[name="token"]').remove(); + var $token_input = $(''); + $token_input.val(result.token.id); + $form.append($token_input); } else { var pmData = { billing_details: { @@ -138,21 +116,21 @@ Liberapay.stripe_form_init = function($form) { name: $form.find('input[name="owner.name"]').val(), } }; - stripe.createPaymentMethod(pmType, element, pmData).then(Liberapay.wrap(function(result) { - if (result.error) { - $errorElement.text(result.error.message); - } else { - submitting = true; - $form.find('input[name="route"]').remove(); - $form.find('input[name="stripe_pm_id"]').remove(); - var $hidden_input = $(''); - $hidden_input.val(result.paymentMethod.id); - $form.append($hidden_input); - $form.submit(); - } - })); + var result = await stripe.createPaymentMethod(pmType, element, pmData); + // If the PaymentMethod has been created, submit the form. Otherwise, + // display an error. + if (result.error) { + $errorElement.text(result.error.message)[0].scrollIntoView(); + return false; + } + $form.find('input[name="route"]').remove(); + $form.find('input[name="stripe_pm_id"]').remove(); + var $pm_id_input = $(''); + $pm_id_input.val(result.paymentMethod.id); + $pm_id_input.appendTo($form); } - })); + return true; + }; $form.attr('action', ''); }; diff --git a/js/tail_log.js b/js/tail_log.js index 52363aa5b3..8632cbbeb4 100644 --- a/js/tail_log.js +++ b/js/tail_log.js @@ -57,7 +57,7 @@ Liberapay.tail_log = function($pre) { Liberapay.stream_lines($pre.data('log-url'), function(data, final, file_is_partial){ $pre.append(document.createTextNode(data)); if (final && file_was_partial) { - Liberapay.notification($pre.data('msg-success'), 'success', -1); + Liberapay.notification($pre.attr('data-msg-success'), 'success', -1); } if (file_is_partial || file_was_partial) { $('html').scrollTop($pre.offset().top + $pre.outerHeight(true) - $('html').outerHeight() + 50); diff --git a/liberapay/security/csrf.py b/liberapay/security/csrf.py index d349af57fb..4667b94f9f 100644 --- a/liberapay/security/csrf.py +++ b/liberapay/security/csrf.py @@ -112,9 +112,7 @@ def add_token_to_response(response, csrf_token=None): """Store the latest CSRF token as a cookie. """ if csrf_token: - # Don't set httponly so that we can POST using XHR. - # https://github.com/gratipay/gratipay.com/issues/3030 - response.set_cookie(CSRF_TOKEN, str(csrf_token), expires=CSRF_TIMEOUT, httponly=False) + response.set_cookie(CSRF_TOKEN, str(csrf_token), expires=CSRF_TIMEOUT) def require_cookie(state): diff --git a/style/base/forms.scss b/style/base/forms.scss index 591fffa04e..975470158f 100644 --- a/style/base/forms.scss +++ b/style/base/forms.scss @@ -1,3 +1,9 @@ +form[submitting] { + cursor: wait; + opacity: 0.6; + pointer-events: none; +} + // Date inputs input.form-control[type="date"] { diff --git a/templates/macros/admin.html b/templates/macros/admin.html index cb99a33ac1..2b8207f66f 100644 --- a/templates/macros/admin.html +++ b/templates/macros/admin.html @@ -1,8 +1,9 @@ % from "templates/macros/icons.html" import icon with context % macro admin_form(p, reload=False, style='columns-sm-2 block-labels') -
+ +

This account is…

diff --git a/templates/macros/elsewhere.html b/templates/macros/elsewhere.html index 8bf9a489b7..4c2f78ad95 100644 --- a/templates/macros/elsewhere.html +++ b/templates/macros/elsewhere.html @@ -10,7 +10,7 @@ % if edit + method="POST" class="inline-block" data-on-success="fadeOut:.account"> diff --git a/templates/macros/repos.html b/templates/macros/repos.html index b129abf77d..8c49b6f74d 100644 --- a/templates/macros/repos.html +++ b/templates/macros/repos.html @@ -25,6 +25,7 @@

{{ repo.description or '' }}

% if edit

    % for community in communities diff --git a/www/%username/edit/privacy.spt b/www/%username/edit/privacy.spt index 5cd56fa8de..0a4940f0b3 100644 --- a/www/%username/edit/privacy.spt +++ b/www/%username/edit/privacy.spt @@ -30,7 +30,7 @@ subhead = _("Privacy") % block form - + diff --git a/www/%username/edit/repositories.spt b/www/%username/edit/repositories.spt index 41802c260d..7e30c3dcd9 100644 --- a/www/%username/edit/repositories.spt +++ b/www/%username/edit/repositories.spt @@ -116,8 +116,7 @@ subhead = _("Repositories") n=total_count, platform=platform.display_name, timespan_ago=to_age(last_fetch.ts) ) }}

    - - + % if repos % for repo in repos @@ -131,7 +130,7 @@ subhead = _("Repositories") _("Next Page →") }} % endif - + % else

    {{ _("No repositories found.") }}

    % endif @@ -146,8 +145,7 @@ subhead = _("Repositories") % set repos = participant.get_repos_for_profile() % if repos

    {{ _("The following repositories are currently displayed on your profile:") }}

    - + % for repo in repos {{ show_repo(repo, unlist=True) }} diff --git a/www/%username/emails/index.spt b/www/%username/emails/index.spt index e961ab2882..cfd291cc8c 100644 --- a/www/%username/emails/index.spt +++ b/www/%username/emails/index.spt @@ -75,7 +75,7 @@ email_locale = website.locales.get(participant.email_lang) or website.locales['e

    {{ ngettext("Email Address (Private)", "Email Addresses (Private)", len(emails)) }}

    - +
      % for email in emails @@ -84,19 +84,19 @@ email_locale = website.locales.get(participant.email_lang) or website.locales['e % if email.blacklisted {{ _("Blacklisted") }} % if participant.email and email.address != participant.email - % endif % elif email.address == participant.email {{ _("Primary") }} % else - % if email.disavowed {{ _("Disavowed") }} % elif not email.verified {{ _("Unverified") }} - % else {{ _("Verified") }} @@ -122,7 +122,7 @@ email_locale = website.locales.get(participant.email_lang) or website.locales['e

      {{ _("Notifications") }}

      - + {{ _("Send me notifications via email:") }}
      @@ -147,7 +147,7 @@ email_locale = website.locales.get(participant.email_lang) or website.locales['e

      {{ _("Language") }}

      - + % if len(payment.recipient_names) == 1

      {{ _( "Your donation of {amount} to {recipient} is awaiting payment.", @@ -390,7 +396,6 @@ title = _("Funding your donations") % endif

      {{ _("Please select or input a payment amount:") }}

      -
        diff --git a/www/%username/invoices/new.spt b/www/%username/invoices/new.spt index 9fe0cd2390..0269aa5590 100644 --- a/www/%username/invoices/new.spt +++ b/www/%username/invoices/new.spt @@ -75,7 +75,10 @@ if request.method == 'POST': VALUES (%s, %s, %s, %s, %s, %s, '{}'::jsonb, 'pre') RETURNING id """, (user.id, addressee.id, invoice_nature, amount, description, details)) - raise response.json({'invoice_id': invoice_id}) + if request.headers.get(b'Accept', b'').startswith(b'application/json'): + raise response.json({'invoice_id': invoice_id}) + else: + raise response.redirect(f'?id={invoice_id}') title = _("Invoice {someone}", someone=addressee.username) @@ -90,6 +93,7 @@ title = _("Invoice {someone}", someone=addressee.username) {{ _("What is Markdown?") }}

        +

        {{ _("Documents (private)") }}

        {{ _("A reimbursement request is more likely to be accepted if you provide proof that the expense actually happened.") }}

        @@ -145,9 +150,7 @@ title = _("Invoice {someone}", someone=addressee.username)

        {{ _("We will not store these documents forever, archiving them for the long term is your responsibility.") }}


        - - - + % endblock diff --git a/www/%username/routes/add.spt b/www/%username/routes/add.spt index c171a35d4c..fea231f5e5 100644 --- a/www/%username/routes/add.spt +++ b/www/%username/routes/add.spt @@ -61,6 +61,8 @@ title = _("Add a payment instrument")
        diff --git a/www/admin/users.spt b/www/admin/users.spt index 5f023d128c..1159b22313 100644 --- a/www/admin/users.spt +++ b/www/admin/users.spt @@ -2,6 +2,7 @@ from dateutil.parser import parse as parse_date from liberapay.i18n.base import LOCALE_EN as locale from liberapay.models.participant import Participant +from liberapay.utils import form_post_success PT_STATUS_MAP = { 'failed': 'danger', @@ -44,10 +45,10 @@ if request.method == 'POST': event_data['reason'] = request.body['reason'] p.add_event(cursor, 'flags_changed', event_data, user.id) - raise response.json({'msg': ( + form_post_success(state, msg=( f"Done, {updated} attribute has been updated." if updated == 1 else f"Done, {updated} attributes have been updated." - )}) + )) last_showed = request.qs.get_int('last_showed', default=None) mode = request.qs.get('mode', 'all')