From 71d06cda4ba4a3e7f0b90b8c8ab183adf86e577f Mon Sep 17 00:00:00 2001 From: Changaco Date: Fri, 31 May 2024 11:26:27 +0200 Subject: [PATCH] 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 | 3 +- 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, 328 insertions(+), 264 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..ff89ea1993 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..bee30928af 100644 --- a/templates/macros/repos.html +++ b/templates/macros/repos.html @@ -18,13 +18,14 @@

% if unlist + value="off" data-on-success="fadeOut:.repo">{{ _("Unlist") }} % endif

{{ 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..38f8c6fa0b 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')