From 9784a9936cedbf12076d82384c2411895fbe2aab Mon Sep 17 00:00:00 2001 From: Chris Hill-Scott Date: Tue, 19 Jan 2016 09:55:13 +0000 Subject: [PATCH] Add pages for create/view/revoke API keys MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Copying what they’ve done on GOV.UK Pay, we should let users: - generate as many keys as they want - only see the key at time of creation - give keys a name - revoke any key at any time (this should be a one way operation) And based on discussions with @minglis and @servingUpAces, the keys should be used in conjunction with some kind of service ID, which gets encrypted with the key. In other words the secret itself never gets sent over the wire. This commit adds the UI (but not the underlying API integration) for doing the above. --- app/assets/javascripts/apiKey.js | 23 ++--- .../stylesheets/components/api-key.scss | 5 ++ app/assets/stylesheets/components/table.scss | 11 +++ app/main/forms.py | 4 + app/main/views/api_keys.py | 52 ++++++++++- app/templates/components/api-key.html | 5 +- app/templates/components/table.html | 4 + app/templates/main_nav.html | 3 +- app/templates/views/api-keys.html | 87 ++++++++++--------- app/templates/views/api-keys/create.html | 24 +++++ app/templates/views/api-keys/revoke.html | 37 ++++++++ app/templates/views/api-keys/show.html | 33 +++++++ app/templates/views/documentation.html | 49 +++++++++++ tests/app/main/views/test_api_keys.py | 82 ++++++++++++++++- 14 files changed, 356 insertions(+), 63 deletions(-) create mode 100644 app/templates/views/api-keys/create.html create mode 100644 app/templates/views/api-keys/revoke.html create mode 100644 app/templates/views/api-keys/show.html create mode 100644 app/templates/views/documentation.html diff --git a/app/assets/javascripts/apiKey.js b/app/assets/javascripts/apiKey.js index 30d87278d..215be5672 100644 --- a/app/assets/javascripts/apiKey.js +++ b/app/assets/javascripts/apiKey.js @@ -1,15 +1,11 @@ (function(Modules) { "use strict"; + if (!document.queryCommandSupported('copy')) return; + Modules.ApiKey = function() { const states = { - 'initial': ` - - `, - 'keyVisibleBasic': key => ` - ${key} - `, 'keyVisible': key => ` ${key} @@ -33,23 +29,22 @@ this.start = function(component) { - const $component = $(component).html(states.initial).attr('aria-live', 'polite'), + const $component = $(component), key = $component.data('key'); $component - .on( - 'click', '.api-key-button-show', () => - $component.html( - document.queryCommandSupported('copy') ? - states.keyVisible(key) : states.keyVisibleBasic(key) - ) - ) + .html(states.keyVisible(key)) + .attr('aria-live', 'polite') .on( 'click', '.api-key-button-copy', () => this.copyKey( $('.api-key-key', component)[0], () => $component.html(states.keyCopied) ) + ) + .on( + 'click', '.api-key-button-show', () => + $component.html(states.keyVisible(key)) ); }; diff --git a/app/assets/stylesheets/components/api-key.scss b/app/assets/stylesheets/components/api-key.scss index 22c787355..e17b45ef4 100644 --- a/app/assets/stylesheets/components/api-key.scss +++ b/app/assets/stylesheets/components/api-key.scss @@ -1,5 +1,10 @@ .api-key { + &-name { + @include bold-19; + margin-bottom: 5px; + } + &-key { font-family: monospace; display: block; diff --git a/app/assets/stylesheets/components/table.scss b/app/assets/stylesheets/components/table.scss index ed749b6e3..ce6930370 100644 --- a/app/assets/stylesheets/components/table.scss +++ b/app/assets/stylesheets/components/table.scss @@ -1,3 +1,7 @@ +.table { + margin-bottom: $gutter; +} + .table-heading { text-align: left; } @@ -16,8 +20,15 @@ } &-error { + color: $error-colour; font-weight: bold; + + a:link, + a:visited { + color: $error-colour; + } + } } diff --git a/app/main/forms.py b/app/main/forms.py index 70af82666..6b8a224a2 100644 --- a/app/main/forms.py +++ b/app/main/forms.py @@ -235,3 +235,7 @@ class ChangeMobileNumberForm(Form): class ConfirmMobileNumberForm(Form): sms_code = sms_code() + + +class CreateKeyForm(Form): + key_name = StringField(u'Description of key') diff --git a/app/main/views/api_keys.py b/app/main/views/api_keys.py index 0241346a8..74a5abab4 100644 --- a/app/main/views/api_keys.py +++ b/app/main/views/api_keys.py @@ -1,9 +1,57 @@ -from flask import render_template +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 + + +@main.route("/services//documentation") +@login_required +def documentation(service_id): + return render_template('views/documentation.html', service_id=service_id) @main.route("/services//api-keys") @login_required def api_keys(service_id): - return render_template('views/api-keys.html', service_id=service_id) + return render_template( + 'views/api-keys.html', + service_id=service_id, + keys=[ + {'name': 'Test key 1', 'last_used': '12 January 2016, 10:01AM', 'id': 1}, + {'name': 'Test key 2', 'last_used': '12 January 2016, 9:50AM', 'id': 1}, + {'name': 'Test key 3', 'last_used': '12 January 2016, 9:49AM', 'id': 1}, + { + 'name': 'My first key', 'last_used': '25 December 2015, 09:49AM', 'id': 1, + 'revoked': '4 January 2016, 6:00PM' + } + ] + ) + + +@main.route("/services//api-keys/create", methods=['GET', 'POST']) +@login_required +def create_api_key(service_id): + form = CreateKeyForm() + if form.validate_on_submit(): + return redirect(url_for('.show_api_key', service_id=service_id)) + return render_template( + 'views/api-keys/create.html', + service_id=service_id, + key_name=form.key_name + ) + + +@main.route("/services//api-keys/show") +@login_required +def show_api_key(service_id): + return render_template('views/api-keys/show.html', service_id=service_id) + + +@main.route("/services//api-keys/revoke/", methods=['GET', 'POST']) +@login_required +def revoke_api_key(service_id, key_id): + if request.method == 'GET': + return render_template('views/api-keys/revoke.html', service_id=service_id) + elif request.method == 'POST': + flash('‘Test key 1’ was revoked') + return redirect(url_for('.api_keys', service_id=service_id)) diff --git a/app/templates/components/api-key.html b/app/templates/components/api-key.html index 014136bc4..2a2eb2525 100644 --- a/app/templates/components/api-key.html +++ b/app/templates/components/api-key.html @@ -1,4 +1,7 @@ -{% macro api_key(key) %} +{% macro api_key(key, name) %} +

+ {{ name }} +

{{ key }}
diff --git a/app/templates/components/table.html b/app/templates/components/table.html index fb41a6aa7..b5fe9e9d2 100644 --- a/app/templates/components/table.html +++ b/app/templates/components/table.html @@ -55,3 +55,7 @@ {% macro right_aligned_field_heading(text) %} {{ text }} {%- endmacro %} + +{% macro hidden_field_heading(text) %} + {{ text }} +{%- endmacro %} diff --git a/app/templates/main_nav.html b/app/templates/main_nav.html index 65cc28c57..d78cdd3ca 100644 --- a/app/templates/main_nav.html +++ b/app/templates/main_nav.html @@ -9,7 +9,8 @@
  • Templates
    • Manage users
    • diff --git a/app/templates/views/api-keys.html b/app/templates/views/api-keys.html index 7cc837971..601deead0 100644 --- a/app/templates/views/api-keys.html +++ b/app/templates/views/api-keys.html @@ -1,6 +1,5 @@ {% extends "withnav_template.html" %} -{% from "components/page-footer.html" import page_footer %} -{% from "components/api-key.html" import api_key %} +{% from "components/table.html" import list_table, field, hidden_field_heading %} {% block page_title %} GOV.UK Notify | API keys and documentation @@ -8,49 +7,55 @@ {% block maincolumn_content %} -
      -
      +

      + API keys +

      -

      - API keys and documentation -

      +

      + To connect to the API you will need to send your service ID, encrypted with + an API key. The API key stays secret. +

      -

      - How to integrate GOV.UK Notify into your service -

      +

      + There are client libraries for several languages which manage this for + you. See + the + developer documentation for more information. +

      -

      - blah blah blah this is where we tell you how the API works -

      +

      + Service ID +

      +

      + {{ service_id }} +

      -

      Repositories

      + {% call(item) list_table( + keys, + empty_message="You haven’t created any API keys yet", + caption="API keys", + caption_visible=False, + field_headings=['Key name', 'Created at', hidden_field_heading('Action')] + ) %} + {% call field() %} + {{ item.name }} + {% endcall %} + {% call field() %} + {{ item.last_used }} + {% endcall %} + {% if item.revoked %} + {% call field(align='right', status='default') %} + Revoked {{ item.revoked }} + {% endcall %} + {% else %} + {% call field(align='right', status='error') %} + Revoke + {% endcall %} + {% endif %} + {% endcall %} - - -

      - GOV.UK Notify API -

      - -

      - GOV.UK Notify Python client -

      - -

      API key for [service name]

      - - {{ api_key('d30512af92e1386d63b90e5973b49a10') }} - -

      API endpoint

      - -

      - https://www.notify.works/api/endpoint -

      - -
      -
      - - {{ page_footer( - back_link=url_for('.service_dashboard', service_id=service_id), - back_link_text='Back to dashboard' - ) }} +

      + Create a new API key +

      {% endblock %} diff --git a/app/templates/views/api-keys/create.html b/app/templates/views/api-keys/create.html new file mode 100644 index 000000000..570a54194 --- /dev/null +++ b/app/templates/views/api-keys/create.html @@ -0,0 +1,24 @@ +{% extends "withnav_template.html" %} +{% from "components/page-footer.html" import page_footer %} +{% from "components/textbox.html" import textbox %} + +{% block page_title %} + GOV.UK Notify | API keys and documentation +{% endblock %} + +{% block maincolumn_content %} + +

      + Add a new API key +

      + +
      + {{ textbox(key_name, hint='eg CRM application') }} + {{ page_footer( + 'Continue', + back_link=url_for('.api_keys', service_id=service_id), + back_link_text='Back to API keys' + ) }} +
      + +{% endblock %} diff --git a/app/templates/views/api-keys/revoke.html b/app/templates/views/api-keys/revoke.html new file mode 100644 index 000000000..3597a6270 --- /dev/null +++ b/app/templates/views/api-keys/revoke.html @@ -0,0 +1,37 @@ +{% extends "withnav_template.html" %} +{% from "components/page-footer.html" import page_footer %} +{% from "components/api-key.html" import api_key %} + +{% block page_title %} + GOV.UK Notify | API keys and documentation +{% endblock %} + +{% block maincolumn_content %} + +
      +
      + +

      + Revoke API key +

      + +

      + ‘Test key 1’ will no longer let you connect to GOV.UK Notify. +

      +

      + You can’t undo this. +

      + +
      +
      + +
      + {{ page_footer( + 'Revoke this API key', + back_link=url_for('.api_keys', service_id=service_id), + back_link_text='Back to API keys', + destructive=True + ) }} +
      + +{% endblock %} diff --git a/app/templates/views/api-keys/show.html b/app/templates/views/api-keys/show.html new file mode 100644 index 000000000..fca59852d --- /dev/null +++ b/app/templates/views/api-keys/show.html @@ -0,0 +1,33 @@ +{% extends "withnav_template.html" %} +{% from "components/page-footer.html" import page_footer %} +{% from "components/api-key.html" import api_key %} + +{% block page_title %} + GOV.UK Notify | API keys and documentation +{% endblock %} + +{% block maincolumn_content %} + +
      +
      + +

      + New API key +

      + +

      + Copy your key to somewhere safe. You won’t be able to see it again + once you leave this page. +

      + + {{ api_key('d30512af92e1386d63b90e5973b49a10', 'CRM application') }} + +
      +
      + + {{ page_footer( + back_link=url_for('.api_keys', service_id=service_id), + back_link_text='Back to API keys' + ) }} + +{% endblock %} diff --git a/app/templates/views/documentation.html b/app/templates/views/documentation.html new file mode 100644 index 000000000..944c84b5c --- /dev/null +++ b/app/templates/views/documentation.html @@ -0,0 +1,49 @@ +{% extends "withnav_template.html" %} +{% from "components/page-footer.html" import page_footer %} +{% from "components/api-key.html" import api_key %} + +{% block page_title %} + GOV.UK Notify | API keys and documentation +{% endblock %} + +{% block maincolumn_content %} + +
      +
      + +

      + Developer documentation +

      + +

      + How to integrate GOV.UK Notify into your service +

      + +

      + blah blah blah this is where we tell you how the API works +

      + +

      Repositories

      + +

      + GOV.UK Notify API +

      + +

      + GOV.UK Notify Python client +

      + +

      API endpoint

      + +

      + https://www.notify.works/api/endpoint +

      + +

      + API keys for your service +

      + +
      +
      + +{% endblock %} diff --git a/tests/app/main/views/test_api_keys.py b/tests/app/main/views/test_api_keys.py index 4b863f889..1e274486d 100644 --- a/tests/app/main/views/test_api_keys.py +++ b/tests/app/main/views/test_api_keys.py @@ -1,13 +1,87 @@ from flask import url_for -def test_should_show_api_keys_and_documentation_page(app_, - db_, - db_session, - active_user): +def test_should_show_documentation_page(app_, + db_, + db_session, + active_user): + with app_.test_request_context(): + with app_.test_client() as client: + client.login(active_user) + response = client.get(url_for('main.documentation', service_id=123)) + + assert response.status_code == 200 + + +def test_should_show_api_keys_page(app_, + db_, + db_session, + active_user): with app_.test_request_context(): with app_.test_client() as client: client.login(active_user) response = client.get(url_for('main.api_keys', service_id=123)) assert response.status_code == 200 + + +def test_should_show_name_api_key_page(app_, + db_, + db_session, + active_user): + with app_.test_request_context(): + with app_.test_client() as client: + client.login(active_user) + response = client.get(url_for('main.create_api_key', service_id=123)) + + assert response.status_code == 200 + + +def test_should_redirect_to_new_api_key(app_, + db_, + db_session, + active_user): + with app_.test_request_context(): + with app_.test_client() as client: + client.login(active_user) + response = client.post(url_for('main.create_api_key', service_id=123)) + + assert response.status_code == 302 + assert response.location == url_for('main.show_api_key', service_id=123, _external=True) + + +def test_should_show_new_api_key(app_, + db_, + db_session, + active_user): + with app_.test_request_context(): + with app_.test_client() as client: + client.login(active_user) + response = client.get(url_for('main.show_api_key', service_id=123)) + + assert response.status_code == 200 + + +def test_should_show_confirm_revoke_api_key(app_, + db_, + db_session, + active_user): + with app_.test_request_context(): + with app_.test_client() as client: + client.login(active_user) + response = client.get(url_for('main.revoke_api_key', service_id=123, key_id=321)) + + assert response.status_code == 200 + + +def test_should_redirect_after_revoking_api_key(app_, + db_, + db_session, + active_user): + with app_.test_request_context(): + with app_.test_client() as client: + client.login(active_user) + response = client.post(url_for('main.revoke_api_key', service_id=123, key_id=321)) + + assert response.status_code == 302 + assert response.location == url_for('.api_keys', service_id=123, _external=True)