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 %} -