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