diff options
-rw-r--r-- | perllib/FixMyStreet/App/Controller/Admin/Roles.pm | 96 | ||||
-rw-r--r-- | perllib/FixMyStreet/App/Form/I18N.pm | 13 | ||||
-rw-r--r-- | perllib/FixMyStreet/App/Form/Role.pm | 66 | ||||
-rw-r--r-- | perllib/FixMyStreet/App/Form/Widget/Field/CheckboxGroup.pm | 62 | ||||
-rw-r--r-- | perllib/FixMyStreet/Cobrand/Default.pm | 3 | ||||
-rw-r--r-- | perllib/FixMyStreet/DB/Result/Role.pm | 2 | ||||
-rw-r--r-- | perllib/FixMyStreet/DB/Result/User.pm | 1 | ||||
-rw-r--r-- | t/app/controller/admin/roles.t | 96 | ||||
-rw-r--r-- | templates/web/base/admin/roles/form.html | 32 | ||||
-rw-r--r-- | templates/web/base/admin/roles/index.html | 34 |
10 files changed, 403 insertions, 2 deletions
diff --git a/perllib/FixMyStreet/App/Controller/Admin/Roles.pm b/perllib/FixMyStreet/App/Controller/Admin/Roles.pm new file mode 100644 index 000000000..15c96a4ed --- /dev/null +++ b/perllib/FixMyStreet/App/Controller/Admin/Roles.pm @@ -0,0 +1,96 @@ +package FixMyStreet::App::Controller::Admin::Roles; +use Moose; +use namespace::autoclean; + +BEGIN { extends 'Catalyst::Controller'; } + +use FixMyStreet::App::Form::Role; + +sub auto :Private { + my ($self, $c) = @_; + + my $user = $c->user; + if ($user->is_superuser) { + $c->stash(rs => $c->model('DB::Role')->search_rs({}, { join => 'body', order_by => ['body.name', 'me.name'] })); + } elsif ($user->from_body) { + $c->stash(rs => $user->from_body->roles->search_rs({}, { order_by => 'name' })); + } +} + +sub index :Path :Args(0) { + my ($self, $c) = @_; + + my $p = $c->cobrand->available_permissions; + my %labels; + foreach my $group (sort keys %$p) { + my $group_vals = $p->{$group}; + foreach (sort keys %$group_vals) { + $labels{$_} = $group_vals->{$_}; + } + } + + $c->stash( + roles => [ $c->stash->{rs}->all ], + labels => \%labels, + ); +} + +sub create :Local :Args(0) { + my ($self, $c, $id) = @_; + + my $role = $c->stash->{rs}->new_result({}); + return $self->form($c, $role); +} + +sub item :PathPart('admin/roles') :Chained :CaptureArgs(1) { + my ($self, $c, $id) = @_; + + my $obj = $c->stash->{rs}->find($id) + or $c->detach('/page_error_404_not_found', []); + $c->stash(obj => $obj); +} + +sub edit :PathPart('') :Chained('item') :Args(0) { + my ($self, $c) = @_; + return $self->form($c, $c->stash->{obj}); +} + +sub form { + my ($self, $c, $role) = @_; + + if ($c->get_param('delete_role')) { + $role->delete; + $c->response->redirect($c->uri_for($self->action_for('list'))); + $c->detach; + } + + my $perms = []; + my $p = $c->cobrand->available_permissions; + foreach my $group (sort keys %$p) { + my $group_vals = $p->{$group}; + my @foo; + foreach (sort keys %$group_vals) { + push @foo, { value => $_, label => $group_vals->{$_} }; + } + push @$perms, { group => $group, options => \@foo }; + } + my $opts = { + field_list => [ + '+permissions' => { options => $perms }, + ], + }; + + if (!$c->user->is_superuser && $c->user->from_body) { + push @{$opts->{field_list}}, '+body', { inactive => 1 }; + $opts->{body_id} = $c->user->from_body->id; + } + + my $form = FixMyStreet::App::Form::Role->new(%$opts); + $c->stash(template => 'admin/roles/form.html', form => $form); + $form->process(item => $role, params => $c->req->params); + return unless $form->validated; + + $c->response->redirect($c->uri_for($self->action_for('list'))); +} + +1; diff --git a/perllib/FixMyStreet/App/Form/I18N.pm b/perllib/FixMyStreet/App/Form/I18N.pm new file mode 100644 index 000000000..b37f7ac53 --- /dev/null +++ b/perllib/FixMyStreet/App/Form/I18N.pm @@ -0,0 +1,13 @@ +package FixMyStreet::App::Form::I18N; + +use Moo; + +sub maketext { + my ($self, $msg, @args) = @_; + + no if ($] >= 5.022), warnings => 'redundant'; + return sprintf(_($msg), @args); +} + +1; + diff --git a/perllib/FixMyStreet/App/Form/Role.pm b/perllib/FixMyStreet/App/Form/Role.pm new file mode 100644 index 000000000..f0711af15 --- /dev/null +++ b/perllib/FixMyStreet/App/Form/Role.pm @@ -0,0 +1,66 @@ +package FixMyStreet::App::Form::Role; + +use HTML::FormHandler::Moose; +use FixMyStreet::App::Form::I18N; +extends 'HTML::FormHandler::Model::DBIC'; +use namespace::autoclean; + +has 'body_id' => ( isa => 'Int', is => 'ro' ); + +has '+widget_name_space' => ( default => sub { ['FixMyStreet::App::Form::Widget'] } ); +has '+widget_tags' => ( default => sub { { wrapper_tag => 'p' } } ); +has '+item_class' => ( default => 'Role' ); +has_field 'name' => ( required => 1 ); +has_field 'body' => ( type => 'Select', empty_select => 'Select a body', required => 1 ); +has_field 'permissions' => ( + type => 'Multiple', + widget => 'CheckboxGroup', + tags => { inline => 1, wrapper_tag => 'fieldset', }, +); + +before 'update_model' => sub { + my $self = shift; + $self->item->body_id($self->body_id) if $self->body_id; +}; + +sub _build_language_handle { FixMyStreet::App::Form::I18N->new } + +has '+unique_messages' => ( + default => sub { + { roles_body_id_name_key => "Role names must be unique" }; + } +); + +sub validate { + my $self = shift; + + my $rs = $self->resultset; + my $value = $self->value; + + return 0 if $self->body_id; # The core validation catches this, because body_id is set on $self->item + return 0 if $self->item_id && $self->item->body_id == $value->{body}; # Correctly caught by core validation + + # Okay, due to a bug we need to check this ourselves + # https://github.com/gshank/html-formhandler-model-dbic/issues/20 + my @id_clause = (); + @id_clause = HTML::FormHandler::Model::DBIC::_id_clause( $rs, $self->item_id ) if defined $self->item; + + my %form_columns = (body => 'body_id', name => 'name'); + my %where = map { $form_columns{$_} => + exists( $value->{$_} ) ? $value->{$_} : undef || + ( $self->item ? $self->item->get_column($form_columns{$_}) : undef ) + } keys %form_columns; + + my $count = $rs->search( \%where )->search( {@id_clause} )->count; + return 0 if $count < 1; + + my $field = $self->field('name'); + my $constraint = 'roles_body_id_name_key'; + my $field_error = $self->unique_message_for_constraint($constraint); + $field->add_error( $field_error, $constraint ); + return 1; +} + +__PACKAGE__->meta->make_immutable; + +1; diff --git a/perllib/FixMyStreet/App/Form/Widget/Field/CheckboxGroup.pm b/perllib/FixMyStreet/App/Form/Widget/Field/CheckboxGroup.pm new file mode 100644 index 000000000..1dc55e49b --- /dev/null +++ b/perllib/FixMyStreet/App/Form/Widget/Field/CheckboxGroup.pm @@ -0,0 +1,62 @@ +package FixMyStreet::App::Form::Widget::Field::CheckboxGroup; + +use Moose::Role; +with 'HTML::FormHandler::Widget::Field::CheckboxGroup'; +use namespace::autoclean; + +sub render_element { + my ( $self, $result ) = @_; + $result ||= $self->result; + + my $output = '<ul class="permissions-checkboxes">'; + foreach my $option ( @{ $self->{options} } ) { + if ( my $label = $option->{group} ) { + $label = $self->_localize( $label ) if $self->localize_labels; + $output .= qq{\n<li>$label\n<ul class="no-margin no-bullets">}; + $output .= qq{\n<li>(<a href="#" data-select-all>} . _('all') . '</a> / '; + $output .= '<a href="#" data-select-none>' . _('none') . '</a>)</li>'; + foreach my $group_opt ( @{ $option->{options} } ) { + $output .= '<li>'; + $output .= $self->render_option( $group_opt, $result ); + $output .= "</li>\n"; + } + $output .= qq{</ul>\n</li>}; + } + else { + $output .= $self->render_option( $option, $result ); + } + } + $output .= '</ul>'; + $self->reset_options_index; + return $output; +} + +1; + +__END__ + +=pod + +=encoding UTF-8 + +=head1 NAME + +FixMyStreet::App::Form::Widget::Field::CheckboxGroup - checkbox group field role + +=head1 SYNOPSIS + +Subclass of HTML::FormHandler::Widget::Field::CheckboxGroup, but printed +as a nested <ul>. + +=head1 AUTHOR + +FormHandler Contributors - see HTML::FormHandler + +=head1 COPYRIGHT AND LICENSE + +This software is copyright (c) 2017 by Gerda Shank. + +This is free software; you can redistribute it and/or modify it under +the same terms as the Perl 5 programming language system itself. + +=cut diff --git a/perllib/FixMyStreet/Cobrand/Default.pm b/perllib/FixMyStreet/Cobrand/Default.pm index a08a8dff7..cc7f03adb 100644 --- a/perllib/FixMyStreet/Cobrand/Default.pm +++ b/perllib/FixMyStreet/Cobrand/Default.pm @@ -647,7 +647,7 @@ sub admin_pages { my $pages = { 'summary' => [_('Summary'), 0], 'timeline' => [_('Timeline'), 5], - 'stats' => [_('Stats'), 8], + 'stats' => [_('Stats'), 8.5], }; # There are some pages that only super users can see @@ -680,6 +680,7 @@ sub admin_pages { if ( $user->has_body_permission_to('user_edit') ) { $pages->{reports} = [ _('Reports'), 2 ]; $pages->{users} = [ _('Users'), 6 ]; + $pages->{roles} = [ _('Roles'), 7 ]; $pages->{user_edit} = [ undef, undef ]; } if ( $self->allow_report_extra_fields && $user->has_body_permission_to('category_edit') ) { diff --git a/perllib/FixMyStreet/DB/Result/Role.pm b/perllib/FixMyStreet/DB/Result/Role.pm index 17a752f02..e35b0b195 100644 --- a/perllib/FixMyStreet/DB/Result/Role.pm +++ b/perllib/FixMyStreet/DB/Result/Role.pm @@ -48,6 +48,6 @@ __PACKAGE__->has_many( # Created by DBIx::Class::Schema::Loader v0.07035 @ 2019-05-23 18:03:28 # DO NOT MODIFY THIS OR ANYTHING ABOVE! md5sum:KkzVQZuzExH8PhZLJsnZgg +__PACKAGE__->many_to_many( users => 'user_roles', 'user' ); -# You can replace this text with custom code or comments, and it will be preserved on regeneration 1; diff --git a/perllib/FixMyStreet/DB/Result/User.pm b/perllib/FixMyStreet/DB/Result/User.pm index 5719b3150..edcd20fdf 100644 --- a/perllib/FixMyStreet/DB/Result/User.pm +++ b/perllib/FixMyStreet/DB/Result/User.pm @@ -149,6 +149,7 @@ use namespace::clean -except => [ 'meta' ]; with 'FixMyStreet::Roles::Extra'; __PACKAGE__->many_to_many( planned_reports => 'user_planned_reports', 'report' ); +__PACKAGE__->many_to_many( roles => 'user_roles', 'role' ); sub cost { FixMyStreet->test_mode ? 1 : 12; diff --git a/t/app/controller/admin/roles.t b/t/app/controller/admin/roles.t new file mode 100644 index 000000000..a7740a572 --- /dev/null +++ b/t/app/controller/admin/roles.t @@ -0,0 +1,96 @@ +use FixMyStreet::TestMech; + +my $mech = FixMyStreet::TestMech->new; + +my $superuser = $mech->create_user_ok('superuser@example.com', name => 'Super User', is_superuser => 1); + +my $body = $mech->create_body_ok(2237, 'Oxfordshire County Council'); +my $body2 = $mech->create_body_ok(2482, 'Bromley Council'); +my $editor = $mech->create_user_ok('counciluser@example.com', name => 'Council User', from_body => $body); +$editor->user_body_permissions->create({ + body => $body, + permission_type => 'user_edit', +}); + +FixMyStreet::DB->resultset("Role")->create({ + body => $body, + name => 'Role A', + permissions => ['moderate', 'user_edit'], +}); +FixMyStreet::DB->resultset("Role")->create({ + body => $body2, + name => 'Role Z', + permissions => ['report_inspect', 'planned_reports'], +}); + +FixMyStreet::override_config { + ALLOWED_COBRANDS => 'oxfordshire', +}, sub { + + $mech->log_in_ok( $editor->email ); + + subtest 'role index page' => sub { + $mech->get_ok("/admin/roles"); + $mech->content_contains('Role A'); + $mech->content_contains('Moderate report details'); + $mech->content_lacks('Role Z'); + $mech->content_lacks('Manage shortlist'); + $mech->content_lacks('Add/edit response templates'); # About to be added + }; + + subtest 'create a role' => sub { + $mech->follow_link_ok({ text => 'Create' }); + $mech->content_lacks('Body'); + $mech->submit_form_ok({ with_fields => { name => 'Role A' }}); + $mech->content_contains('Role names must be unique'); + $mech->submit_form_ok({ with_fields => { + name => 'Role B', + permissions => [ ['template_edit', 'user_manage_permissions'] ], + }}); + + $mech->content_contains('Role B'); + $mech->content_contains('Add/edit response templates'); + }; + + subtest 'editing a role preselects correct options' => sub { + $mech->follow_link_ok({ text => 'Edit' }); + $mech->content_like(qr/value="moderate"[^>]*checked/); + $mech->content_like(qr/value="user_edit"[^>]*checked/); + }; + + subtest 'editing a role to same name as another fails' => sub { + $mech->submit_form_ok({ with_fields => { name => 'Role B' }}); + $mech->content_contains('Role names must be unique'); + }; + + subtest 'delete a role' => sub { + $mech->submit_form_ok({ button => 'delete_role' }); + $mech->content_lacks('Role A'); + }; + +}; + +subtest 'superuser can see all bodies' => sub { + $mech->log_in_ok( $superuser->email ); + + $mech->get_ok("/admin/roles"); + $mech->content_contains('Oxfordshire'); + $mech->content_contains('Bromley'); + $mech->content_contains('Role B'); + $mech->content_contains('Role Z'); + $mech->follow_link_ok({ text => 'Create' }); + $mech->content_contains('Body'); + $mech->content_contains('Bromley'); + + $mech->submit_form_ok({ with_fields => { body => $body->id, name => 'Role B' }}); + $mech->content_contains('Role names must be unique'); + + $mech->submit_form_ok({ with_fields => { + name => 'Role C', + body => $body2->id, + permissions => 'contribute_as_body', + }}); + $mech->content_contains('Role C'); +}; + +done_testing(); diff --git a/templates/web/base/admin/roles/form.html b/templates/web/base/admin/roles/form.html new file mode 100644 index 000000000..cb50689a6 --- /dev/null +++ b/templates/web/base/admin/roles/form.html @@ -0,0 +1,32 @@ +[% INCLUDE 'admin/header.html' title=loc('Roles') -%] + +<form method="post"> + <div class="admin-hint"> + <p>[% loc("The role's <strong>name</strong> is used to refer to this group of permissions elsewhere in the admin.") %]</p> + </div> + [% form.field('name').render %] + + [% IF form.field('body').is_active %] + [% form.field('body').render %] + [% END %] + + <div class="admin-hint"> + <p>[% loc("Users with this role can perform the following actions within their assigned body or area.") %]</p> + </div> + [% form.field('permissions').render %] + + [% form.field('submit').render %] + + <p> + <input class="btn" type="submit" name="submit" value="[% loc('Save changes') %]"> + </p> + [% IF form.item.id %] + <p> + <input class="btn-danger" type="submit" name="delete_role" value="[% loc('Delete') %]" data-confirm="[% loc('Are you sure?') %]"> + </p> + [% END %] +</form> + +<p><a href="[% c.uri_for(c.controller.action_for('list')) %]">Return to roles list</a></p> + +[% INCLUDE 'admin/footer.html' %] diff --git a/templates/web/base/admin/roles/index.html b/templates/web/base/admin/roles/index.html new file mode 100644 index 000000000..54a4b6ace --- /dev/null +++ b/templates/web/base/admin/roles/index.html @@ -0,0 +1,34 @@ +[% INCLUDE 'admin/header.html' title=loc('Roles') -%] + +<table> + <tr> + <th>[% loc('Role') %]</th> + <th>[% loc('Permissions') %]</th> + <th> </th> + </tr> +[% FOREACH role IN roles -%] + [% IF c.user.is_superuser AND last_name != role.body.name %] + <tr> + <td colspan="3"><strong>[% role.body.name %]</strong></td> + </tr> + [% SET last_name = role.body.name %] + [% END %] + <tr> + <td>[% role.name %]</td> + <td><ul class="no-margin no-bullets"> + [% FOR perm IN role.permissions.sort %] + <li>[% labels.$perm %] + [% END %] + </ul></td> + <td> + <a href="[% c.uri_for(c.controller.action_for('edit'), [role.id]) %]">Edit</a> + </td> + </tr> +[% END -%] +</table> + +<p> + <a href="[% c.uri_for(c.controller.action_for('create')) %]">Create</a> +</p> + +[% INCLUDE 'admin/footer.html' %] |