Add pages to invite, edit, and delete users

This takes the original prototype version of this page, and, using the same
fake data (ie nothing is wired up):
- adds an invite users page
- adds an edit (and delete) user page

Both these pages allow the user to set another user’s permissions.

This commit adds images for the ticks and crosses, so we have control over their
appearance.
This commit is contained in:
Chris Hill-Scott
2016-02-19 15:02:13 +00:00
parent a86be302ce
commit 17b99c9bf2
23 changed files with 443 additions and 132 deletions

BIN
app/assets/images/cross.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 971 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 622 B

BIN
app/assets/images/tick.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 834 B

BIN
app/assets/images/tick.psd Normal file

Binary file not shown.

View File

@@ -66,3 +66,16 @@ a {
font-family: monospace;
overflow-x: scroll;
}
.inline {
.block-label {
@include media(tablet) {
float: none;
display: inline-block;
}
}
}

View File

@@ -16,19 +16,13 @@
.banner-with-tick,
.banner-default-with-tick {
@extend %banner;
padding: $gutter-half ($gutter + $gutter-half);
&:before {
@include core-24;
content: '';
position: absolute;
top: $gutter-half;
left: $gutter-half;
margin-top: -2px;
}
background-image: file-url('tick-white.png');
background-size: 19px;
background-repeat: no-repeat;
background-position: $gutter-half $gutter-half;
font-weight: bold;
}
.banner-dangerous {

View File

@@ -7,6 +7,12 @@
margin: 40px 0 5px 0;
}
.table-field-headings {
th {
padding: 0 0 5px 0;
}
}
%table-field,
.table-field {
@@ -36,6 +42,23 @@
}
&-yes,
&-no {
display: block;
text-indent: -999em;
background-size: 19px 19px;
background-repeat: no-repeat;
background-position: 50% 50%;
}
&-yes {
background-image: file-url('tick.png');
}
&-no {
background-image: file-url('cross.png');
}
&-missing {
color: $error-colour;
font-weight: bold;
@@ -77,4 +100,5 @@
margin-top: -20px;
border-bottom: 1px solid $border-colour;
padding-bottom: 10px;
text-align: center;
}

View File

@@ -0,0 +1,36 @@
.yes-no-wrapper {
border-bottom: 1px solid $border-colour;
margin: 0 0 $gutter 0;
}
.yes-no {
border-top: 1px solid $border-colour;
padding: 10px 0;
&-label {
padding-top: 19px;
float: left;
}
&-fields {
text-align: right;
.block-label {
@include media(tablet) {
margin-bottom: 0;
&:last-child {
margin-right: 0;
}
}
}
}
}

View File

@@ -47,6 +47,7 @@ $path: '/static/images/';
@import 'components/browse-list';
@import 'components/email-message';
@import 'components/api-key';
@import 'components/yes-no';
@import 'views/job';
@import 'views/edit-template';

View File

@@ -5,5 +5,5 @@ main = Blueprint('main', __name__)
from app.main.views import (
index, sign_in, sign_out, register, two_factor, verify, sms, add_service,
code_not_received, jobs, dashboard, templates, service_settings, forgot_password,
new_password, styleguide, user_profile, choose_service, api_keys
new_password, styleguide, user_profile, choose_service, api_keys, manage_users
)

View File

@@ -1,7 +1,7 @@
from flask import url_for
from flask import url_for, abort
from app import notifications_api_client
from notifications_python_client.errors import HTTPError
from app.utils import BrowsableItem
from notifications_python_client.errors import HTTPError
def insert_new_service(service_name, user_id):
@@ -29,7 +29,9 @@ def get_service_by_id(id_):
def get_service_by_id_or_404(id_):
try:
return get_service_by_id(id_)
return notifications_api_client.get_service(id_)['data']
except KeyError:
abort(404)
except HTTPError as e:
if e.status_code == 404:
abort(404)

View File

@@ -21,10 +21,10 @@ from app.utils import (
)
def email_address():
def email_address(label='Email address'):
gov_uk_email \
= "(^[^@^\\s]+@[^@^\\.^\\s]+(\\.[^@^\\.^\\s]*)*.gov.uk)"
return EmailField('Email address', validators=[
return EmailField(label, validators=[
Length(min=5, max=255),
DataRequired(message='Email cannot be empty'),
Email(message='Enter a valid email address'),
@@ -96,6 +96,10 @@ class RegisterUserForm(Form):
password = password()
class InviteUserForm(Form):
email_address = email_address('Their email address')
class TwoFactorForm(Form):
def __init__(self, validate_code_func, *args, **kwargs):
'''

View File

@@ -34,63 +34,3 @@ def send_email(service_id):
@login_required
def check_email(service_id):
return render_template('views/check-email.html')
@main.route("/services/<service_id>/manage-users")
@login_required
def manage_users(service_id):
users = [
{
'name': 'Henry Hadlow',
'permission_send_messages': True,
'permission_manage_service': False,
'permission_manage_api_keys': False
},
{
'name': 'Pete Herlihy',
'permission_send_messages': False,
'permission_manage_service': False,
'permission_manage_api_keys': False,
},
{
'name': 'Chris Hill-Scott',
'permission_send_messages': True,
'permission_manage_service': True,
'permission_manage_api_keys': True
},
{
'name': 'Martyn Inglis',
'permission_send_messages': True,
'permission_manage_service': True,
'permission_manage_api_keys': True
}
]
invited_users = [
{
'email_localpart': 'caley.smolska',
'permission_send_messages': True,
'permission_manage_service': False,
'permission_manage_api_keys': False
},
{
'email_localpart': 'ash.stephens',
'permission_send_messages': False,
'permission_manage_service': False,
'permission_manage_api_keys': False
},
{
'email_localpart': 'nicholas.staples',
'permission_send_messages': True,
'permission_manage_service': True,
'permission_manage_api_keys': True
},
{
'email_localpart': 'adam.shimali',
'permission_send_messages': True,
'permission_manage_service': True,
'permission_manage_api_keys': True
}
]
return render_template('views/manage-users.html', service_id=service_id, users=users, invited_users=invited_users)

View File

@@ -0,0 +1,141 @@
from flask import (
request,
render_template,
redirect,
abort,
url_for,
flash
)
from flask_login import login_required, current_user
from app.main import main
from app.main.dao import users_dao
from app.main.forms import InviteUserForm
from app.main.dao.services_dao import get_service_by_id_or_404
from app import user_api_client
fake_users = [
{
'name': 'Henry Hadlow',
'email_localpart': 'henry.hadlow',
'permission_send_messages': True,
'permission_manage_service': False,
'permission_manage_api_keys': False,
'active': True
},
{
'name': 'Pete Herlihy',
'email_localpart': 'pete.herlihy',
'permission_send_messages': False,
'permission_manage_service': False,
'permission_manage_api_keys': False,
'active': True
},
{
'name': 'Chris Hill-Scott',
'email_localpart': 'chris.hill-scott',
'permission_send_messages': True,
'permission_manage_service': True,
'permission_manage_api_keys': True,
'active': True
},
{
'name': 'Martyn Inglis',
'email_localpart': 'martyn.inglis',
'permission_send_messages': True,
'permission_manage_service': True,
'permission_manage_api_keys': True,
'active': True
},
{
'email_localpart': 'caley.smolska',
'permission_send_messages': True,
'permission_manage_service': False,
'permission_manage_api_keys': False,
'active': False
},
{
'email_localpart': 'ash.stephens',
'permission_send_messages': False,
'permission_manage_service': False,
'permission_manage_api_keys': False,
'active': False
}
]
@main.route("/services/<service_id>/users")
@login_required
def manage_users(service_id):
return render_template(
'views/manage-users.html',
service_id=service_id,
users=[
dict(id=user_id, **user) for (user_id, user) in enumerate(fake_users) if user['active']
],
invited_users=[
dict(id=user_id, **user) for (user_id, user) in enumerate(fake_users) if not user['active']
]
)
@main.route("/services/<service_id>/users/invite", methods=['GET', 'POST'])
@login_required
def invite_user(service_id):
form = InviteUserForm()
if form.validate_on_submit():
flash('Invite sent to {}'.format(form.email_address.data), 'default_with_tick')
return redirect(url_for('.manage_users', service_id=service_id))
return render_template(
'views/invite-user.html',
user={},
service=get_service_by_id_or_404(service_id),
service_id=service_id,
form=form
)
@main.route("/services/<service_id>/users/<user_id>", methods=['GET', 'POST'])
@login_required
def edit_user(service_id, user_id):
if request.method == 'POST':
return redirect(url_for('.manage_users', service_id=service_id))
return render_template(
'views/invite-user.html',
user=fake_users[int(user_id)],
user_id=user_id,
service=get_service_by_id_or_404(service_id),
service_id=service_id
)
@main.route("/services/<service_id>/users/<user_id>/delete", methods=['GET', 'POST'])
@login_required
def delete_user(service_id, user_id):
if request.method == 'POST':
return redirect(url_for('.manage_users', service_id=service_id))
user = fake_users[int(user_id)]
flash(
'Are you sure you want to delete {}s account?'.format(user.get('name') or user['email_localpart']),
'delete'
)
return render_template(
'views/invite-user.html',
user=user,
user_id=user_id,
service=get_service_by_id_or_404(service_id),
service_id=service_id
)

View File

@@ -55,6 +55,18 @@
</td>
{%- endmacro %}
{% macro text_field(text) -%}
{% call field() %}
{{ text }}
{% endcall %}
{%- endmacro %}
{% macro boolean_field(yes) -%}
{% call field(status='yes' if yes else 'no') %}
{{ "Yes" if yes else "No" }}
{% endcall %}
{%- endmacro %}
{% macro right_aligned_field_heading(text) %}
<span class="table-field-heading-right-aligned">{{ text }}</span>
{%- endmacro %}

View File

@@ -0,0 +1,17 @@
{% macro yes_no(name, label, current_value=None) %}
<fieldset class='yes-no'>
<legend class='yes-no-label'>
{{ label }}
</legend>
<div class='yes-no-fields inline'>
<label class='block-label'>
<input type='radio' name='{{ name }}' {% if current_value == True %}checked{% endif %} />
Yes
</label>
<label class='block-label'>
<input type='radio' name='{{ name }}' {% if current_value == False %}checked{% endif %} />
No
</label>
</div>
</fieldset>
{% endmacro %}

View File

@@ -5,7 +5,7 @@
{{ banner(
message,
'default' if ((category == 'default') or (category == 'default_with_tick')) else 'dangerous',
delete_button="Yes, delete this template" if 'delete' == category else None,
delete_button="Yes, delete" if 'delete' == category else None,
with_tick=True if category == 'default_with_tick' else False
)}}
{% endfor %}

View File

@@ -8,7 +8,7 @@
</ul>
<ul>
<li><a href="{{ url_for('.manage_service_templates', service_id=service_id) }}">Manage templates</a></li>
<li><a href="{{ url_for('.manage_users', service_id=service_id) }}">Manage users</a></li>
<li><a href="{{ url_for('.manage_users', service_id=service_id) }}">Manage team</a></li>
<li><a href="{{ url_for('.service_settings', service_id=service_id) }}">Manage service settings</a></li>
</ul>
<ul>

View File

@@ -57,7 +57,7 @@
{% endif %}
{% endcall %}
<p>
<p class='table-show-more-link'>
<a href="{{ url_for('.create_api_key', service_id=service_id) }}">Create a new API key</a>
</p>

View File

@@ -0,0 +1,48 @@
{% extends "withnav_template.html" %}
{% from "components/yes-no.html" import yes_no %}
{% from "components/textbox.html" import textbox %}
{% from "components/page-footer.html" import page_footer %}
{% block page_title %}
Manage users GOV.UK Notify
{% endblock %}
{% block maincolumn_content %}
<h1 class="heading-large">
{{ user.name or user.email_localpart or "Add a new team member" }}
</h1>
<div class="grid-row">
<form method="post" class="column-three-quarters">
{% if user %}
<p class='bottom-gutter'>
{{ user.email_localpart }}@digital.cabinet-office.gov.uk
</p>
{% else %}
{{ textbox(form.email_address, hint='Email address must end in .gov.uk', width='1-1') }}
{% endif %}
<fieldset class='yes-no-wrapper'>
<legend class='heading-small'>
Permissions
</legend>
{{ yes_no('send_messages', 'Send messages', user.permission_send_messages) }}
{{ yes_no('manage_service', 'Manage service', user.permission_manage_service) }}
{{ yes_no('manage_api_keys', 'Manage API keys', user.permission_manage_api_keys) }}
</fieldset>
{% if user %}
{{ page_footer(
'Save',
delete_link=url_for('.delete_user', service_id=service_id, user_id=user_id),
delete_link_text='delete this account'
) }}
{% else %}
{{ page_footer('Send invitation email') }}
{% endif %}
</form>
</div>
{% endblock %}

View File

@@ -1,65 +1,56 @@
{% extends "withnav_template.html" %}
{% from "components/table.html" import list_table, row, field %}
{% from "components/table.html" import list_table, row, field, boolean_field, hidden_field_heading %}
{% from "components/page-footer.html" import page_footer %}
{% set table_options = {
'field_headings': [
'Name', 'Send messages', 'Manage service', 'Manage API keys', hidden_field_heading('Link to change')
],
'field_headings_visible': True,
'caption_visible': True
} %}
{% block page_title %}
Manage users GOV.UK Notify
{% endblock %}
{% block maincolumn_content %}
<h1 class="heading-large">Manage users</h1>
<h1 class="heading-large">
Manage team
</h1>
<p>
<a href="#" class="button">Invite users</a>
</p>
{% call(item) list_table(
users, caption='Active', **table_options
) %}
{% call field() %}
{{ item.name }}
{% endcall %}
{{ boolean_field(item.permission_send_messages) }}
{{ boolean_field(item.permission_manage_service) }}
{{ boolean_field(item.permission_manage_api_keys) }}
{% call field(align='right') %}
<a href="{{ url_for('.edit_user', service_id=service_id, user_id=item.id)}}">Change</a>
{% endcall %}
{% endcall %}
<p class='table-show-more-link'>
<a href="{{ url_for('.invite_user', service_id=service_id) }}">Add a new team member</a>
</p>
{% call(item) list_table(
users,
caption='Active users',
field_headings=['Name', 'Send messages', 'Manage Service', 'Manage API keys', 'Link to change'],
field_headings_visible=True,
caption_visible=True
) %}
{% call field() %}
{{ item.name }}
{% endcall %}
{% call field() %}
{{ "✔" if item.permission_send_messages else "❌" }}
{% endcall %}
{% call field() %}
{{ "✔" if item.permission_manage_service else "❌" }}
{% endcall %}
{% call field() %}
{{ "✔" if item.permission_manage_api_keys else "❌" }}
{% endcall %}
{% call field(align='right') %}
<a href="#">Change</a>
{% endcall %}
{% endcall %}
{% call(item) list_table(
invited_users,
caption='Invited users',
field_headings=['Name', 'Send messages', 'Manage Service', 'Manage API keys', 'Link to change'],
field_headings_visible=True,
caption_visible=True
) %}
{% call field() %}
{{ item.email_localpart }}
{% endcall %}
{% call field() %}
{{ "✔" if item.permission_send_messages else "❌" }}
{% endcall %}
{% call field() %}
{{ "✔" if item.permission_manage_service else "❌" }}
{% endcall %}
{% call field() %}
{{ "✔" if item.permission_manage_api_keys else "❌" }}
{% endcall %}
{% call field(align='right') %}
<a href="#">Change</a>
{% endcall %}
{% endcall %}
{% if invited_users %}
{% call(item) list_table(
invited_users, caption='Invited', **table_options
) %}
{% call field() %}
{{ item.email_localpart }}
{% endcall %}
{{ boolean_field(item.permission_send_messages) }}
{{ boolean_field(item.permission_manage_service) }}
{{ boolean_field(item.permission_manage_api_keys) }}
{% call field(align='right') %}
<a href="{{ url_for('.edit_user', service_id=service_id, user_id=item.id)}}">Change</a>
{% endcall %}
{% endcall %}
{% endif %}
{% endblock %}

View File

@@ -82,6 +82,7 @@ gulp.task('watchForChanges', function() {
gulp.watch(paths.src + 'javascripts/**/*', ['javascripts']);
gulp.watch(paths.src + 'stylesheets/**/*', ['sass']);
gulp.watch(paths.src + 'images/**/*', ['images']);
gulp.watch('gulpfile.babel.js', ['default']);
});
gulp.task('lint:sass', () => gulp

View File

@@ -0,0 +1,87 @@
import json
from flask import url_for
def test_should_show_overview_page(
app_,
api_user_active,
mock_login,
mock_get_service
):
with app_.test_request_context():
with app_.test_client() as client:
client.login(api_user_active)
response = client.get(url_for('main.manage_users', service_id=55555))
assert 'Manage team' in response.get_data(as_text=True)
assert 'Henry Hadlow' in response.get_data(as_text=True)
assert 'caley.smolska' in response.get_data(as_text=True)
assert response.status_code == 200
def test_should_show_page_for_one_user(
app_,
api_user_active,
mock_login,
mock_get_service
):
with app_.test_request_context():
with app_.test_client() as client:
client.login(api_user_active)
response = client.get(url_for('main.edit_user', service_id=55555, user_id=0))
assert 'Henry Hadlow' in response.get_data(as_text=True)
assert response.status_code == 200
def test_redirect_after_saving_user(
app_,
api_user_active,
mock_login,
mock_get_service
):
with app_.test_request_context():
with app_.test_client() as client:
client.login(api_user_active)
response = client.post(url_for(
'main.edit_user', service_id=55555, user_id=0
))
assert response.status_code == 302
assert response.location == url_for(
'main.manage_users', service_id=55555, _external=True
)
def test_should_show_page_for_inviting_user(
app_,
api_user_active,
mock_login,
mock_get_service
):
with app_.test_request_context():
with app_.test_client() as client:
client.login(api_user_active)
response = client.get(url_for('main.invite_user', service_id=55555))
assert 'Add a new team member' in response.get_data(as_text=True)
assert response.status_code == 200
def test_invite_user(
app_,
api_user_active,
mock_login,
mock_get_service
):
with app_.test_request_context():
with app_.test_client() as client:
client.login(api_user_active)
response = client.post(
url_for('main.invite_user', service_id=55555),
data={'email_address': 'test@example.gov.uk'},
follow_redirects=True
)
assert response.status_code == 200
assert 'Invite sent to test@example.gov.uk' in response.get_data(as_text=True)