From 3e42042156ab46311c3c3cecb953099a373caae8 Mon Sep 17 00:00:00 2001 From: Chris Hill-Scott Date: Tue, 20 Sep 2016 12:30:00 +0100 Subject: [PATCH] =?UTF-8?q?Add=20a=20page=20to=20manage=20a=20service?= =?UTF-8?q?=E2=80=99s=20whitelist?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Services who are in alpha or building prototypes need a way of sending to any email address or phone number without having to sign the MOU. This commit adds a page where they can whitelist up to 5 email addresses and 5 phone numbers. It uses the ‘list entry’ UI pattern from the Digital Marketplace frontend toolkit [1] [2] [3]. I had to do some modification: - of the Javascript, to make it work with the GOV.UK Module pattern - of the template to make it work with WTForms - of the content security policy, because the list entry pattern uses Hogan[1], which needs to use `eval()` (this should be fine if we’re only allowing it for scripts that we serve) - of our SASS lint config, to allow browser-targeting mixins to come after normal rules (so that they can override them) This commit also adds a new form class to validate and populate the two whitelists. The validation is fairly rudimentary at the moment, and doesn’t highlight which item in the list has the error, but it’s probably good enough. The list can only be updated all-at-once, this is how it’s possible to remove items from the list without having to make multiple `POST` requests. 1. https://github.com/alphagov/digitalmarketplace-frontend-toolkit/blob/434ad307913651ecb041ab94bdee748ebe066d1a/toolkit/templates/forms/list-entry.html 2. https://github.com/alphagov/digitalmarketplace-frontend-toolkit/blob/434ad307913651ecb041ab94bdee748ebe066d1a/toolkit/scss/forms/_list-entry.scss 3. https://github.com/alphagov/digitalmarketplace-frontend-toolkit/blob/434ad307913651ecb041ab94bdee748ebe066d1a/toolkit/javascripts/list-entry.js 4. http://twitter.github.io/hogan.js/ --- .sass-lint.yml | 1 + app/__init__.py | 2 +- app/assets/javascripts/listEntry.js | 206 ++++++++++++++++++ .../stylesheets/components/list-entry.scss | 68 ++++++ app/assets/stylesheets/main.scss | 1 + app/main/forms.py | 43 +++- app/main/views/api_keys.py | 23 +- app/notify_client/service_api_client.py | 6 + app/templates/components/list-entry.html | 52 +++++ app/templates/views/api/index.html | 28 ++- app/templates/views/api/whitelist.html | 43 ++++ gulpfile.babel.js | 2 + package.json | 1 + tests/app/main/views/test_api_keys.py | 45 ++++ tests/app/main/views/test_headers.py | 2 +- tests/conftest.py | 20 ++ 16 files changed, 529 insertions(+), 14 deletions(-) create mode 100644 app/assets/javascripts/listEntry.js create mode 100644 app/assets/stylesheets/components/list-entry.scss create mode 100644 app/templates/components/list-entry.html create mode 100644 app/templates/views/api/whitelist.html diff --git a/.sass-lint.yml b/.sass-lint.yml index f36a2a515..7f179c850 100644 --- a/.sass-lint.yml +++ b/.sass-lint.yml @@ -10,6 +10,7 @@ rules: - exclude: - media + - ie-lte no-warn: 1 no-debug: 1 no-ids: 1 diff --git a/app/__init__.py b/app/__init__.py index a73b20125..63153adf6 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -343,7 +343,7 @@ def useful_headers_after_request(response): response.headers.add('X-XSS-Protection', '1; mode=block') response.headers.add('Content-Security-Policy', ( "default-src 'self' 'unsafe-inline';" - "script-src 'self' *.google-analytics.com 'unsafe-inline' data:;" + "script-src 'self' *.google-analytics.com 'unsafe-inline' 'unsafe-eval' data:;" "object-src 'self';" "font-src 'self' data:;" "img-src 'self' *.google-analytics.com *.notifications.service.gov.uk data:;" diff --git a/app/assets/javascripts/listEntry.js b/app/assets/javascripts/listEntry.js new file mode 100644 index 000000000..07a5ffdfe --- /dev/null +++ b/app/assets/javascripts/listEntry.js @@ -0,0 +1,206 @@ +(function (Modules) { + + 'use strict'; + + var root = this, + $ = this.jQuery; + + var lists = [], + listEntry, + ListEntry; + + ListEntry = function (elm) { + var $elm = $(elm), + idPattern = $elm.prop('id'); + + if (!idPattern) { return false; } + this.idPattern = idPattern; + this.elementSelector = '.list-entry, .list-entry-remove, .list-entry-add'; + this.entries = []; + this.$wrapper = $elm; + this.minEntries = 2; + this.listItemName = this.$wrapper.data('listItemName'); + this.getSharedAttributes(); + + this.getValues(); + this.maxEntries = this.entries.length; + this.trimEntries(); + this.render(); + this.bindEvents(); + }; + ListEntry.optionalAttributes = ['aria-describedby']; + ListEntry.prototype.entryTemplate = Hogan.compile( + '
' + + '' + + '' + + '{{#button}}' + + '' + + '{{/button}}' + + '
' + ); + ListEntry.prototype.addButtonTemplate = Hogan.compile( + '' + ); + ListEntry.prototype.getSharedAttributes = function () { + var $inputs = this.$wrapper.find('input'), + attributeTemplate = Hogan.compile(' {{name}}="{{value}}"'), + generatedAttributes = ['id', 'name', 'value'], + attributes = [], + attrIdx, + elmAttributes, + getAttributesHTML; + + getAttributesHTML = function (attrsByElm) { + var attrStr = '', + elmIdx = attrsByElm.length, + elmAttrs, + attrIdx; + + while (elmIdx--) { + elmAttrs = attrsByElm[elmIdx]; + attrIdx = elmAttrs.length; + while (attrIdx--) { + attrStr += attributeTemplate.render({ 'name': elmAttrs[attrIdx].name, 'value': elmAttrs[attrIdx].value }); + } + } + return attrStr; + }; + + $inputs.each(function (idx, elm) { + attrIdx = elm.attributes.length; + elmAttributes = []; + while(attrIdx--) { + if ($.inArray(elm.attributes[attrIdx].name, generatedAttributes) === -1) { + elmAttributes.push({ + 'name': elm.attributes[attrIdx].name, + 'value': elm.attributes[attrIdx].value + }); + } + } + if (elmAttributes.length) { + attributes.push(elmAttributes); + } + }); + + this.sharedAttributes = (attributes.length) ? getAttributesHTML(attributes) : ''; + }; + ListEntry.prototype.getValues = function () { + this.entries = []; + this.$wrapper.find('input').each(function (idx, elm) { + var val = $(elm).val(); + + this.entries.push(val); + }.bind(this)); + }; + ListEntry.prototype.trimEntries = function () { + var entryIdx = this.entries.length, + newEntries = []; + + while (entryIdx--) { + if (this.entries[entryIdx] !== '') { + newEntries.push(this.entries[entryIdx]); + } else { + if (entryIdx < this.minEntries) { + newEntries.push(''); + } + } + } + this.entries = newEntries.reverse(); + }; + ListEntry.prototype.getId = function (num) { + var pattern = this.idPattern.replace("list-entry-", ""); + if ("undefined" === typeof num) { + return pattern; + } else { + return "input-" + pattern + "-" + num; + } + }; + ListEntry.prototype.bindEvents = function () { + this.$wrapper.on('click', '.list-entry-remove', function (e) { + this.removeEntry($(e.target)); + }.bind(this)); + this.$wrapper.on('click', '.list-entry-add', function (e) { + this.addEntry(); + }.bind(this)); + }; + ListEntry.prototype.shiftFocus = function (opts) { + var numberTargeted; + + if (opts.action === 'remove') { + numberTargeted = (opts.entryNumberFocused > 1) ? opts.entryNumberFocused - 1 : 1; + } else { // opts.action === 'add' + numberTargeted = opts.entryNumberFocused + 1; + } + this.$wrapper.find('.list-entry').eq(numberTargeted - 1).find('input').focus(); + }; + ListEntry.prototype.removeEntryFromEntries = function (entryNumber) { + var idx, + len, + newEntries = []; + + for (idx = 0, len = this.entries.length; idx < len; idx++) { + if ((entryNumber - 1) !== idx) { + newEntries.push(this.entries[idx]); + } + } + this.entries = newEntries; + }; + ListEntry.prototype.addEntry = function ($removeButton) { + var currentLastEntryNumber = this.entries.length; + + this.getValues(); + this.entries.push(''); + this.render(); + this.shiftFocus({ 'action' : 'add', 'entryNumberFocused' : currentLastEntryNumber }); + }; + ListEntry.prototype.removeEntry = function ($removeButton) { + var entryNumber = parseInt($removeButton.find('span').text().match(/\d+/)[0], 10); + + this.getValues(); + this.removeEntryFromEntries(entryNumber); + this.render(); + this.shiftFocus({ 'action' : 'remove', 'entryNumberFocused' : entryNumber }); + }; + ListEntry.prototype.render = function () { + this.$wrapper.find(this.elementSelector).remove(); + $.each(this.entries, function (idx, entry) { + var entryNumber = idx + 1, + dataObj = { + 'id' : this.getId(entryNumber), + 'number' : entryNumber, + 'index': idx, + 'name' : this.getId(), + 'value' : entry, + 'listItemName' : this.listItemName, + 'sharedAttributes': this.sharedAttributes + }; + + if (entryNumber > 1) { + dataObj.button = true; + } + this.$wrapper.append(this.entryTemplate.render(dataObj)); + }.bind(this)); + if (this.entries.length < this.maxEntries) { + this.$wrapper.append(this.addButtonTemplate.render({ + 'listItemName' : this.listItemName, + 'entriesLeft' : (this.maxEntries - this.entries.length) + })); + } + }; + + Modules.ListEntry = function () { + + this.start = component => lists.push(new ListEntry($(component))); + + }; + +})(window.GOVUK.Modules); diff --git a/app/assets/stylesheets/components/list-entry.scss b/app/assets/stylesheets/components/list-entry.scss new file mode 100644 index 000000000..7a509089d --- /dev/null +++ b/app/assets/stylesheets/components/list-entry.scss @@ -0,0 +1,68 @@ +.input-list { + + .list-entry { + vertical-align: middle; + margin-bottom: 15px; + position: relative; + + @include ie-lte(7) { + zoom: 1; + } + } + + .list-entry-remove { + @include core-19; + display: block; + margin-bottom: 15px; + margin-top: 5px; + position: static; + + @include media(tablet) { + @include inline-block; + margin: 0 0 0 10px; + position: absolute; + top: 0; + left: 100%; + } + + @include ie-lte(7) { + top: auto; + left: 110%; + } + } + + .form-control { + padding-left: 1.84em; + width: 100%; + margin-bottom: 0; + + @include ie-lte(8) { + width: 95%; + } + + @include media(desktop) { + @include inline-block; + } + } + + .text-box-number-label { + @include core-19; + float: left; + width: 1.6em; + margin: 6px -1.6em 0 0; + position: relative; + left: 10px; + color: $secondary-text-colour; + font-weight: bold; + pointer-events: none; + } + + .list-entry-add { + @include core-19; + margin: 0 0 20px 0; + } +} + +.button-secondary { + @include button($grey-3); +} diff --git a/app/assets/stylesheets/main.scss b/app/assets/stylesheets/main.scss index 98294ae48..0bf2fd0b5 100644 --- a/app/assets/stylesheets/main.scss +++ b/app/assets/stylesheets/main.scss @@ -57,6 +57,7 @@ $path: '/static/images/'; @import 'components/phone'; @import 'components/research-mode'; @import 'components/tick-cross'; +@import 'components/list-entry'; @import 'views/job'; @import 'views/edit-template'; diff --git a/app/main/forms.py b/app/main/forms.py index 496b2fd86..02984da69 100644 --- a/app/main/forms.py +++ b/app/main/forms.py @@ -15,10 +15,11 @@ from wtforms import ( BooleanField, HiddenField, IntegerField, - RadioField + RadioField, + FieldList ) from wtforms.fields.html5 import EmailField, TelField -from wtforms.validators import (DataRequired, Email, Length, Regexp) +from wtforms.validators import (DataRequired, Email, Length, Regexp, Optional) from app.main.validators import (Blacklist, CsvFileValidator, ValidEmailDomainRegex, NoCommasInPlaceHolders) from app.notify_client.api_key_api_client import KEY_TYPE_NORMAL, KEY_TYPE_TEST, KEY_TYPE_TEAM @@ -411,3 +412,41 @@ class ServiceBrandingOrg(Form): DataRequired() ] ) + + +class Whitelist(Form): + + def populate(self, email_addresses, phone_numbers): + for form_field, existing_whitelist in ( + (self.email_addresses, email_addresses), + (self.phone_numbers, phone_numbers) + ): + for index, value in enumerate(existing_whitelist): + form_field[index].data = value + + email_addresses = FieldList( + EmailField( + '', + validators=[ + Optional(), + Email(message='Enter valid email addresses') + ], + default='' + ), + min_entries=5, + max_entries=5, + label="Email addresses" + ) + + phone_numbers = FieldList( + UKMobileNumber( + '', + validators=[ + Optional() + ], + default='' + ), + min_entries=5, + max_entries=5, + label="Mobile numbers" + ) diff --git a/app/main/views/api_keys.py b/app/main/views/api_keys.py index 757801e77..d7d92122b 100644 --- a/app/main/views/api_keys.py +++ b/app/main/views/api_keys.py @@ -1,8 +1,8 @@ from flask import request, render_template, redirect, url_for, flash from flask_login import login_required from app.main import main -from app.main.forms import CreateKeyForm -from app import api_key_api_client, current_service +from app.main.forms import CreateKeyForm, Whitelist +from app import api_key_api_client, service_api_client, current_service from app.utils import user_has_permissions from app.notify_client.api_key_api_client import KEY_TYPE_NORMAL, KEY_TYPE_TEST, KEY_TYPE_TEAM @@ -25,6 +25,25 @@ def api_documentation(service_id): ) +@main.route("/services//api/whitelist", methods=['GET', 'POST']) +@login_required +@user_has_permissions('manage_api_keys') +def whitelist(service_id): + form = Whitelist() + if form.validate_on_submit(): + service_api_client.update_whitelist(service_id, { + 'email_addresses': list(filter(None, form.email_addresses.data)), + 'phone_numbers': list(filter(None, form.phone_numbers.data)) + }) + return redirect(url_for('.api_integration', service_id=service_id)) + if not form.errors: + form.populate(**service_api_client.get_whitelist(service_id)) + return render_template( + 'views/api/whitelist.html', + form=form + ) + + @main.route("/services//api/keys") @login_required @user_has_permissions('manage_api_keys') diff --git a/app/notify_client/service_api_client.py b/app/notify_client/service_api_client.py index 8886904f6..ed14a803c 100644 --- a/app/notify_client/service_api_client.py +++ b/app/notify_client/service_api_client.py @@ -205,6 +205,12 @@ class ServiceAPIClient(NotificationsAPIClient): def get_weekly_notification_stats(self, service_id): return self.get(url='/service/{}/notifications/weekly'.format(service_id)) + def get_whitelist(self, service_id): + return self.get(url='/service/{}/whitelist'.format(service_id)) + + def update_whitelist(self, service_id, data): + return self.put(url='/service/{}/whitelist'.format(service_id), data=data) + class ServicesBrowsableItem(BrowsableItem): @property diff --git a/app/templates/components/list-entry.html b/app/templates/components/list-entry.html new file mode 100644 index 000000000..6c0237d2f --- /dev/null +++ b/app/templates/components/list-entry.html @@ -0,0 +1,52 @@ +{% macro list_entry( + field, + item_name, + hint='' +) %} + + {% if error %} +
+ {% endif %} +
+ + + {{ field.label }} + {% if hint %} + + {{ hint }} + + {% endif %} + {% if field.errors %} + + {{ field.errors[0][0] }} + + {% endif %} + + + {% if hint %} + + {{ hint }} + + {% endif %} +
+ {% for index in range(0, field.entries|length) %} +
+ + +
+ {% endfor %} +
+
+ {% if error %} +
+ {% endif %} + +{% endmacro %} diff --git a/app/templates/views/api/index.html b/app/templates/views/api/index.html index f40f85174..97261e346 100644 --- a/app/templates/views/api/index.html +++ b/app/templates/views/api/index.html @@ -17,18 +17,30 @@ {% call banner_wrapper(type='warning') %}

Your service is in trial mode

- You can only send messages to people in your team. + You can only send messages to people in your team or whitelist.

{% endcall %} {% endif %} -