diff options
44 files changed, 1221 insertions, 102 deletions
diff --git a/CHANGELOG.md b/CHANGELOG.md index 38aeb4ad9..48d866c13 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,8 @@ ## Releases * Unreleased + - Admin improvements: + - Add new roles system, to group permissions and apply to users. - Bugfixes: - Prevent creation of two templates with same title. - Fix bug going between report/new pages client side diff --git a/bin/update-schema b/bin/update-schema index 9aff9ec5b..900e628e6 100755 --- a/bin/update-schema +++ b/bin/update-schema @@ -212,6 +212,7 @@ else { # (assuming schema change files are never half-applied, which should be the case) sub get_db_version { return 'EMPTY' if ! table_exists('problem'); + return '0067' if table_exists('roles'); return '0066' if column_exists('users', 'area_ids'); return '0065' if constraint_contains('admin_log_object_type_check', 'moderation'); return '0064' if index_exists('moderation_original_data_problem_id_comment_id_idx'); @@ -84,6 +84,7 @@ requires 'Geography::NationalGrid', mirror => 'https://cpan.metacpan.org/'; requires 'Getopt::Long::Descriptive'; requires 'HTML::Entities'; +requires 'HTML::FormHandler::Model::DBIC'; requires 'HTTP::Request::Common'; requires 'Image::Size', '3.300'; requires 'IO::Socket::SSL', '2.066'; diff --git a/cpanfile.snapshot b/cpanfile.snapshot index e2398c297..62f454435 100644 --- a/cpanfile.snapshot +++ b/cpanfile.snapshot @@ -1029,6 +1029,19 @@ DISTRIBUTIONS ExtUtils::MakeMaker 0 Pod::Text 2.08 Pod::Usage 1.33 + Crypt-Blowfish-2.14 + pathname: D/DP/DPARIS/Crypt-Blowfish-2.14.tar.gz + provides: + Crypt::Blowfish 2.14 + requirements: + ExtUtils::MakeMaker 0 + Crypt-CBC-2.33 + pathname: L/LD/LDS/Crypt-CBC-2.33.tar.gz + provides: + Crypt::CBC 2.33 + requirements: + Digest::MD5 2.00 + ExtUtils::MakeMaker 0 Crypt-Eksblowfish-0.009 pathname: Z/ZE/ZEFRAM/Crypt-Eksblowfish-0.009.tar.gz provides: @@ -1466,6 +1479,21 @@ DISTRIBUTIONS Moo 2 Time::HiRes 0 Types::Standard 0 + DBIx-Class-ResultSet-RecursiveUpdate-0.34 + pathname: G/GS/GSHANK/DBIx-Class-ResultSet-RecursiveUpdate-0.34.tar.gz + provides: + DBIx::Class::ResultSet::RecursiveUpdate 0.34 + DBIx::Class::ResultSet::RecursiveUpdate::Functions 0.34 + requirements: + Carp::Clan 6.04 + DBD::SQLite 1.21 + DBIx::Class 0.08103 + DBIx::Class::IntrospectableM2M 0 + DateTime 0 + ExtUtils::MakeMaker 6.30 + List::MoreUtils 0.22 + Readonly 1.03 + SQL::Translator 0.11016 DBIx-Class-Schema-Loader-0.07035 pathname: G/GE/GENEHACK/DBIx-Class-Schema-Loader-0.07035.tar.gz provides: @@ -1579,6 +1607,19 @@ DISTRIBUTIONS Test::More 0.88 Test::Warn 0.08 perl 5.008_004 + Data-Clone-0.004 + pathname: G/GF/GFUJI/Data-Clone-0.004.tar.gz + provides: + Data::Clone 0.004 + requirements: + Devel::PPPort 3.19 + ExtUtils::MakeMaker 6.59 + ExtUtils::ParseXS 3.18 + Test::More 0.88 + Test::Requires 0.03 + XSLoader 0.02 + parent 0 + perl 5.008001 Data-Compare-1.22 pathname: D/DC/DCANTRELL/Data-Compare-1.22.tar.gz provides: @@ -3066,6 +3107,189 @@ DISTRIBUTIONS HTTP::Request::Common 6.03 URI 1.10 perl 5.008001 + HTML-FormHandler-0.40068 + pathname: G/GS/GSHANK/HTML-FormHandler-0.40068.tar.gz + provides: + HTML::FormHandler 0.40068 + HTML::FormHandler::Base 0.40068 + HTML::FormHandler::Blocks 0.40068 + HTML::FormHandler::BuildFields 0.40068 + HTML::FormHandler::BuildPages 0.40068 + HTML::FormHandler::Field 0.40068 + HTML::FormHandler::Field::AddElement 0.40068 + HTML::FormHandler::Field::BoolSelect 0.40068 + HTML::FormHandler::Field::Boolean 0.40068 + HTML::FormHandler::Field::Button 0.40068 + HTML::FormHandler::Field::Captcha 0.40068 + HTML::FormHandler::Field::Checkbox 0.40068 + HTML::FormHandler::Field::Compound 0.40068 + HTML::FormHandler::Field::Date 0.40068 + HTML::FormHandler::Field::DateMDY 0.40068 + HTML::FormHandler::Field::DateTime 0.40068 + HTML::FormHandler::Field::Display 0.40068 + HTML::FormHandler::Field::Duration 0.40068 + HTML::FormHandler::Field::Email 0.40068 + HTML::FormHandler::Field::File 0.40068 + HTML::FormHandler::Field::Float 0.40068 + HTML::FormHandler::Field::Hidden 0.40068 + HTML::FormHandler::Field::Hour 0.40068 + HTML::FormHandler::Field::IntRange 0.40068 + HTML::FormHandler::Field::Integer 0.40068 + HTML::FormHandler::Field::Minute 0.40068 + HTML::FormHandler::Field::Money 0.40068 + HTML::FormHandler::Field::Month 0.40068 + HTML::FormHandler::Field::MonthDay 0.40068 + HTML::FormHandler::Field::MonthName 0.40068 + HTML::FormHandler::Field::Multiple 0.40068 + HTML::FormHandler::Field::Nested 0.40068 + HTML::FormHandler::Field::NoValue 0.40068 + HTML::FormHandler::Field::NonEditable 0.40068 + HTML::FormHandler::Field::Password 0.40068 + HTML::FormHandler::Field::PasswordConf 0.40068 + HTML::FormHandler::Field::PosInteger 0.40068 + HTML::FormHandler::Field::PrimaryKey 0.40068 + HTML::FormHandler::Field::Repeatable 0.40068 + HTML::FormHandler::Field::RequestToken 0.40068 + HTML::FormHandler::Field::Reset 0.40068 + HTML::FormHandler::Field::Result 0.40068 + HTML::FormHandler::Field::RmElement 0.40068 + HTML::FormHandler::Field::Role::RequestToken 0.40068 + HTML::FormHandler::Field::Second 0.40068 + HTML::FormHandler::Field::Select 0.40068 + HTML::FormHandler::Field::SelectCSV 0.40068 + HTML::FormHandler::Field::Submit 0.40068 + HTML::FormHandler::Field::Text 0.40068 + HTML::FormHandler::Field::TextArea 0.40068 + HTML::FormHandler::Field::TextCSV 0.40068 + HTML::FormHandler::Field::Upload 0.40068 + HTML::FormHandler::Field::Weekday 0.40068 + HTML::FormHandler::Field::Year 0.40068 + HTML::FormHandler::Fields 0.40068 + HTML::FormHandler::Foo 0.40068 + HTML::FormHandler::I18N 0.40068 + HTML::FormHandler::I18N::ar_kw 0.40068 + HTML::FormHandler::I18N::bg_bg 0.40068 + HTML::FormHandler::I18N::ca_es 0.40068 + HTML::FormHandler::I18N::cs_cz 0.40068 + HTML::FormHandler::I18N::de_de 0.40068 + HTML::FormHandler::I18N::en_us 0.40068 + HTML::FormHandler::I18N::es_es 0.40068 + HTML::FormHandler::I18N::hu_hu 0.40068 + HTML::FormHandler::I18N::it_it 0.40068 + HTML::FormHandler::I18N::ja_jp 0.40068 + HTML::FormHandler::I18N::pt_br 0.40068 + HTML::FormHandler::I18N::ru_ru 0.40068 + HTML::FormHandler::I18N::sv_se 0.40068 + HTML::FormHandler::I18N::tr_tr 0.40068 + HTML::FormHandler::I18N::ua_ua 0.40068 + HTML::FormHandler::InitResult 0.40068 + HTML::FormHandler::Merge 0.40068 + HTML::FormHandler::Model 0.40068 + HTML::FormHandler::Model::Object 0.40068 + HTML::FormHandler::Moose 0.40068 + HTML::FormHandler::Moose::Role 0.40068 + HTML::FormHandler::Page 0.40068 + HTML::FormHandler::Page::Simple 0.40068 + HTML::FormHandler::Pages 0.40068 + HTML::FormHandler::Render::RepeatableJs 0.40068 + HTML::FormHandler::Render::Simple 0.40068 + HTML::FormHandler::Render::Table 0.40068 + HTML::FormHandler::Render::Util 0.40068 + HTML::FormHandler::Render::WithTT 0.40068 + HTML::FormHandler::Result 0.40068 + HTML::FormHandler::Result::Role 0.40068 + HTML::FormHandler::Test 0.40068 + HTML::FormHandler::TraitFor::Captcha 0.40068 + HTML::FormHandler::TraitFor::I18N 0.40068 + HTML::FormHandler::TraitFor::Types 0.40068 + HTML::FormHandler::Traits 0.40068 + HTML::FormHandler::Types 0.40068 + HTML::FormHandler::Validate 0.40068 + HTML::FormHandler::Widget::ApplyRole 0.40068 + HTML::FormHandler::Widget::Block 0.40068 + HTML::FormHandler::Widget::Block::Bootstrap 0.40068 + HTML::FormHandler::Widget::Field::Button 0.40068 + HTML::FormHandler::Widget::Field::ButtonTag 0.40068 + HTML::FormHandler::Widget::Field::Captcha 0.40068 + HTML::FormHandler::Widget::Field::Checkbox 0.40068 + HTML::FormHandler::Widget::Field::CheckboxGroup 0.40068 + HTML::FormHandler::Widget::Field::Compound 0.40068 + HTML::FormHandler::Widget::Field::Hidden 0.40068 + HTML::FormHandler::Widget::Field::HorizCheckboxGroup 0.40068 + HTML::FormHandler::Widget::Field::NoRender 0.40068 + HTML::FormHandler::Widget::Field::Password 0.40068 + HTML::FormHandler::Widget::Field::RadioGroup 0.40068 + HTML::FormHandler::Widget::Field::Repeatable 0.40068 + HTML::FormHandler::Widget::Field::Reset 0.40068 + HTML::FormHandler::Widget::Field::Role::HTMLAttributes 0.40068 + HTML::FormHandler::Widget::Field::Role::SelectedOption 0.40068 + HTML::FormHandler::Widget::Field::Select 0.40068 + HTML::FormHandler::Widget::Field::Span 0.40068 + HTML::FormHandler::Widget::Field::Submit 0.40068 + HTML::FormHandler::Widget::Field::Text 0.40068 + HTML::FormHandler::Widget::Field::Textarea 0.40068 + HTML::FormHandler::Widget::Field::Upload 0.40068 + HTML::FormHandler::Widget::Form::Role::HTMLAttributes 0.40068 + HTML::FormHandler::Widget::Form::Simple 0.40068 + HTML::FormHandler::Widget::Form::Table 0.40068 + HTML::FormHandler::Widget::Theme::Bootstrap 0.40068 + HTML::FormHandler::Widget::Theme::Bootstrap3 0.40068 + HTML::FormHandler::Widget::Theme::BootstrapFormMessages 0.40068 + HTML::FormHandler::Widget::Wrapper::Base 0.40068 + HTML::FormHandler::Widget::Wrapper::Bootstrap 0.40068 + HTML::FormHandler::Widget::Wrapper::Bootstrap3 0.40068 + HTML::FormHandler::Widget::Wrapper::Fieldset 0.40068 + HTML::FormHandler::Widget::Wrapper::None 0.40068 + HTML::FormHandler::Widget::Wrapper::Simple 0.40068 + HTML::FormHandler::Widget::Wrapper::SimpleInline 0.40068 + HTML::FormHandler::Widget::Wrapper::Table 0.40068 + HTML::FormHandler::Widget::Wrapper::TableInline 0.40068 + HTML::FormHandler::Wizard 0.40068 + requirements: + Carp 0 + Class::Load 0.06 + Crypt::Blowfish 0 + Crypt::CBC 0 + Data::Clone 0 + DateTime 0 + DateTime::Format::Strptime 0 + Email::Valid 0 + ExtUtils::MakeMaker 0 + File::ShareDir 0 + File::ShareDir::Install 0.06 + File::Spec 0 + HTML::Entities 0 + HTML::TreeBuilder 3.23 + JSON::MaybeXS 1.003003 + List::Util 1.33 + Locale::Maketext 1.09 + MIME::Base64 0 + Moose 2.1403 + MooseX::Getopt 0.16 + MooseX::Types 0.20 + MooseX::Types::Common 0 + MooseX::Types::LoadableClass 0.006 + Sub::Exporter 0 + Sub::Name 0 + Try::Tiny 0 + aliased 0 + namespace::autoclean 0.09 + HTML-FormHandler-Model-DBIC-0.29 + pathname: G/GS/GSHANK/HTML-FormHandler-Model-DBIC-0.29.tar.gz + provides: + HTML::FormHandler::Generator::DBIC 0.04 + HTML::FormHandler::Generator::DBIC::Cmd 0.04 + HTML::FormHandler::Model::DBIC 0.29 + HTML::FormHandler::Model::DBIC::TypeMap undef + HTML::FormHandler::TraitFor::DBICFields undef + HTML::FormHandler::TraitFor::Model::DBIC 0.26 + requirements: + DBIx::Class 0.08250 + DBIx::Class::ResultSet::RecursiveUpdate 0.25 + ExtUtils::MakeMaker 6.30 + HTML::FormHandler 0.40016 + Moose 2.0007 + namespace::autoclean 0.09 HTML-Parser-3.69 pathname: G/GA/GAAS/HTML-Parser-3.69.tar.gz provides: @@ -4662,6 +4886,21 @@ DISTRIBUTIONS Test::More 0.88 Test::Requires 0 namespace::clean 0.19 + MooseX-Types-Common-0.001014 + pathname: E/ET/ETHER/MooseX-Types-Common-0.001014.tar.gz + provides: + MooseX::Types::Common 0.001014 + MooseX::Types::Common::Numeric 0.001014 + MooseX::Types::Common::String 0.001014 + requirements: + Carp 0 + Module::Build::Tiny 0.034 + MooseX::Types 0 + MooseX::Types::Moose 0 + if 0 + perl 5.008 + strict 0 + warnings 0 MooseX-Types-DateTime-0.13 pathname: E/ET/ETHER/MooseX-Types-DateTime-0.13.tar.gz provides: diff --git a/db/downgrade_0067---0066.sql b/db/downgrade_0067---0066.sql new file mode 100644 index 000000000..cb74315bd --- /dev/null +++ b/db/downgrade_0067---0066.sql @@ -0,0 +1,6 @@ +BEGIN; + +DROP TABLE user_roles; +DROP TABLE roles; + +COMMIT; diff --git a/db/rerun_dbic_loader.pl b/db/rerun_dbic_loader.pl index 1eff12d9e..9ee029668 100755 --- a/db/rerun_dbic_loader.pl +++ b/db/rerun_dbic_loader.pl @@ -27,7 +27,10 @@ my @tables_to_ignore = ( my $exclude = '^(?:' . join( '|', @tables_to_ignore ) . ')$'; make_schema_at( - 'FixMyStreet::DB::Schema', + # Something funny here if you use FixMyStreet::DB::Schema, where it should be, + # as it tries to dump it twice and dies on reload; with this, it works, but + # then the changes to DB.pm need removing + 'FixMyStreet::DB', { debug => 0, # switch on to be chatty dump_directory => './perllib', # edit files in place diff --git a/db/schema.sql b/db/schema.sql index 98005028c..93d73ab00 100644 --- a/db/schema.sql +++ b/db/schema.sql @@ -73,6 +73,22 @@ create unique index body_areas_body_id_area_id_idx on body_areas(body_id, area_i ALTER TABLE users ADD CONSTRAINT users_from_body_fkey FOREIGN KEY (from_body) REFERENCES body(id); +-- roles table +create table roles ( + id serial not null primary key, + body_id integer not null references body(id) ON DELETE CASCADE, + name text, + permissions text ARRAY, + unique(body_id, name) +); + +-- Record which role(s) each user holds +create table user_roles ( + id serial not null primary key, + role_id integer not null references roles(id) ON DELETE CASCADE, + user_id integer not null references users(id) ON DELETE CASCADE +); + -- The contact for a category within a particular body create table contacts ( id serial primary key, diff --git a/db/schema_0067-user-roles.sql b/db/schema_0067-user-roles.sql new file mode 100644 index 000000000..d3d773105 --- /dev/null +++ b/db/schema_0067-user-roles.sql @@ -0,0 +1,19 @@ +BEGIN; + +-- roles table +create table roles ( + id serial not null primary key, + body_id integer not null references body(id) ON DELETE CASCADE, + name text, + permissions text ARRAY, + unique(body_id, name) +); + +-- Record which role(s) each user holds +create table user_roles ( + id serial not null primary key, + role_id integer not null references roles(id) ON DELETE CASCADE, + user_id integer not null references users(id) ON DELETE CASCADE +); + +COMMIT; diff --git a/docs/_includes/admin-tasks-content.md b/docs/_includes/admin-tasks-content.md index b62e9f531..3f18a00dc 100644 --- a/docs/_includes/admin-tasks-content.md +++ b/docs/_includes/admin-tasks-content.md @@ -468,7 +468,7 @@ To synchronise your records once you're back online, just visit any page on FixM #### Creating a staff account -<span class="admin-task__permissions">Permissions required: User must be marked as staff; ‘Edit other users’ permissions’ must be ticked.</span> +<span class="admin-task__permissions">Permissions required: User must be marked as staff; ‘Edit users’ details’ must be ticked.</span> <img alt="From the user admin page you can administer staff accounts" src="/assets/img/pro-user-guide/user-admin-page.png" class="admin-screenshot" /> @@ -478,29 +478,43 @@ You’ll see a table of existing users. Below this is the ‘add user’ interfa First create the basic user account by inputting a name and email address, plus any other contact details desired. Once this is done and you have saved the input, you can edit the account to assign -any permissions required. +any roles or permissions required. You can use the same form to create a user account for a resident or a member of staff, so tick ‘staff user’ if you wish to create an admin account. The new staff user can then log in via the normal method. -#### Assigning permissions +#### Creating roles + +<span class="admin-task__permissions">Permissions required: User must be marked as staff; ‘Edit other users' permissions must be ticked.</span> + +Go to the ‘Roles’ tab in the admin menu. + +Here you can create a group of permissions, called a ‘role’. See [Staff user +accounts](../staff-user-accounts/) for some possible examples, but you can use +whatever makes sense to your own organisation. + +Once you have created a role, you can then assign a user to one or more roles +to give that user access to all the permissions of those roles, without having +to assign permissions individually for each user. + +#### Assigning roles and permissions <span class="admin-task__permissions">Permissions required: User must be marked as staff; ‘Edit other users' permissions must be ticked.</span> <img alt="You can assign permissions to users on this screen" src="/assets/img/pro-user-guide/user-admin-permissions.png" class="admin-screenshot" /> Once you have created the basic staff user account, you’ll be taken to a page where you can assign -permissions. +roles and permissions. You can also always edit any user from the table at the top of the Users page by clicking ‘edit’. -Check the boxes relating to the permissions you wish that user to have, and click ‘submit changes’. +Select the roles or permissions you wish that user to have, and click ‘submit changes’. #### Setting categories or areas -<span class="admin-task__permissions">Permissions required: User must be marked as staff; ‘Edit other users' permissions’ must be ticked.</span> +<span class="admin-task__permissions">Permissions required: User must be marked as staff; ‘Assign users to areas’ must be ticked (for areas).</span> <img alt="You can allocate categories to individual users by checking the relevant category boxes" src="/assets/img/pro-user-guide/user-admin-categories.png" class="admin-screenshot" /> diff --git a/docs/pro-manual/admin-tasks.md b/docs/pro-manual/admin-tasks.md index 3dd6e30e1..933592d79 100644 --- a/docs/pro-manual/admin-tasks.md +++ b/docs/pro-manual/admin-tasks.md @@ -7,9 +7,14 @@ user-guide: true # Administrator tasks -If you find that you cannot perform any of the tasks detailed below when logged into your FixMyStreet account, it is likely that your account has not been given the relevant permissions. +If you find that you cannot perform any of the tasks detailed below when logged +into your FixMyStreet account, it is likely that your account has not been +given the relevant permissions or role. -Contact your account administrator to request these permissions. Accompanying each task covered in this document, you will see ‘permissions required’, listing the checkboxes an Administrator must tick on your account for you to be able to perform this task. +Contact your account administrator to request these permissions. Accompanying +each task covered in this document, you will see ‘permissions required’, +listing the permissions an Administrator must give you (either individually or +via a role) for you to be able to perform this task. {% include admin-tasks-content.md %} diff --git a/docs/pro-manual/index.md b/docs/pro-manual/index.md index 85ed8377f..ee5af3990 100644 --- a/docs/pro-manual/index.md +++ b/docs/pro-manual/index.md @@ -48,7 +48,7 @@ been developed in consultation with UK councils. FixMyStreet Pro offers enhanced features to client councils, including: - A FixMyStreet instance for the council website, providing the council's fault-reporting interface, and branded to look like the rest of the site -- A variety of permissions which can be allocated to staff at a granular level +- A variety of permissions which can be allocated to staff at a granular level, and grouped into custom roles - Simple channels of communication with report makers, through status updates and template responses - The ability to moderate, edit or remove reports @@ -58,4 +58,4 @@ Depending on which type of installation the council has opted for, there may als - The ability to view and update reports even when offline - Workflow prioritisation and shortlist generation - The integration of council-owned assets, such as bins or streetlights, into the map -interface
\ No newline at end of file +interface diff --git a/docs/pro-manual/staff-user-accounts.md b/docs/pro-manual/staff-user-accounts.md index 3712dfcf5..1ea927fe9 100644 --- a/docs/pro-manual/staff-user-accounts.md +++ b/docs/pro-manual/staff-user-accounts.md @@ -46,13 +46,14 @@ the end of your session. You can change your password at any time by clicking on ‘Your account’ in the top menu bar. -## Permissions +## Permissions and Roles -Staff accounts can be assigned a variety of permissions, depending on each team member’s needs -or responsibilities. Permissions can be combined in any way. +Staff accounts can be assigned a variety of permissions or roles, depending on +each team member’s needs or responsibilities. Permissions can be combined in +any way, grouped into custom roles, or applied individually. Some common staff roles and the appropriate permissions are shown in the table below, but you -can tailor your permissions to best reflect your team’s operations. +can tailor your permissions and roles to best reflect your team’s operations. <table class="table table--responsive"> diff --git a/docs/running/staff.md b/docs/running/staff.md index 77ca05c8e..4330190e2 100644 --- a/docs/running/staff.md +++ b/docs/running/staff.md @@ -11,8 +11,8 @@ author: matthew Staff users are a middle rung of account, inbetween normal users of the site and superusers with full access to everything. They are associated with a particular body, and can have access to different features, depending upon the -permissions granted to them. All their abilities only apply to reports made -to the body with which they are associated; all staff users have access to all -report states, not just open/fixed. +permissions or roles granted to them. All their abilities only apply to reports +made to the body with which they are associated; all staff users have access to +all report states, not just open/fixed. {% include admin-tasks-content.md %} diff --git a/docs/running/users.md b/docs/running/users.md index 87b38f0d2..48cb2a791 100644 --- a/docs/running/users.md +++ b/docs/running/users.md @@ -117,12 +117,13 @@ FixMyStreet website). You can mark any FixMyStreet user as belonging to a body. This marks them as a "staff user" for that body. Staff users have extra privileges *which only apply to problem reports under the jurisdiction of the body to which the use -belongs*. These permissions can be set on a per-user basis. +belongs*. Permissions can be grouped into custom roles, and these roles and +individual permissions can be set on a per-user basis. To set (or revoke) staff user status, choose **Users** in the admin, and enter the email or name. (It's also possible to access a user via the reports they have made). Choose the appropriate body from the **Body** dropdown. Normal -(not staff) users have no body associated. Then you can assign permissions +(not staff) users have no body associated. Then you can assign roles or permissions to that user, depending upon what they require access to. For full details of what staff accounts can do, please see the diff --git a/perllib/FixMyStreet/App/Controller/Admin.pm b/perllib/FixMyStreet/App/Controller/Admin.pm index 6167a16f5..5f18f8557 100644 --- a/perllib/FixMyStreet/App/Controller/Admin.pm +++ b/perllib/FixMyStreet/App/Controller/Admin.pm @@ -484,15 +484,14 @@ sub report_edit : Path('report_edit') : Args(1) { $problem->resend; $problem->update(); - $c->stash->{status_message} = - '<p><em>' . _('That problem will now be resent.') . '</em></p>'; + $c->stash->{status_message} = _('That problem will now be resent.'); $c->forward( 'log_edit', [ $id, 'problem', 'resend' ] ); } elsif ( $c->get_param('mark_sent') ) { $c->forward('/auth/check_csrf_token'); $problem->update({ whensent => \'current_timestamp' })->discard_changes; - $c->stash->{status_message} = '<p><em>' . _('That problem has been marked as sent.') . '</em></p>'; + $c->stash->{status_message} = _('That problem has been marked as sent.'); $c->forward( 'log_edit', [ $id, 'problem', 'marked sent' ] ); } elsif ( $c->get_param('flaguser') ) { @@ -571,8 +570,7 @@ sub report_edit : Path('report_edit') : Args(1) { } $c->forward( 'log_edit', [ $id, 'problem', 'edit' ] ); - $c->stash->{status_message} = - '<p><em>' . _('Updated!') . '</em></p>'; + $c->stash->{status_message} = _('Updated!'); # do this here otherwise lastupdate and confirmed times # do not display correctly @@ -833,7 +831,7 @@ sub template_edit : Path('templates') : Args(2) { $template->update_or_insert; $template->contact_response_templates->search({ - contact_id => { '!=' => \@new_contact_ids }, + contact_id => { -not_in => \@new_contact_ids }, })->delete; foreach my $contact_id (@new_contact_ids) { $template->contact_response_templates->find_or_create({ @@ -921,13 +919,12 @@ sub update_edit : Path('update_edit') : Args(1) { $self->remove_photo($c, $update, $remove_photo_param); } - $c->stash->{status_message} = '<p><em>' . _('Updated!') . '</em></p>'; + $c->stash->{status_message} = _('Updated!'); # Must call update->hide while it's not hidden (so is_latest works) if ($new_state eq 'hidden') { my $outcome = $update->hide; - $c->stash->{status_message} .= - '<p><em>' . _('Problem marked as open.') . '</em></p>' + $c->stash->{status_message} .= _('Problem marked as open.') if $outcome->{reopened}; } diff --git a/perllib/FixMyStreet/App/Controller/Admin/DefectTypes.pm b/perllib/FixMyStreet/App/Controller/Admin/DefectTypes.pm index ed9b40fd0..6c1a25e5a 100644 --- a/perllib/FixMyStreet/App/Controller/Admin/DefectTypes.pm +++ b/perllib/FixMyStreet/App/Controller/Admin/DefectTypes.pm @@ -76,7 +76,7 @@ sub edit : Path : Args(2) { my @new_contact_ids = $c->get_param_list('categories'); @new_contact_ids = @{ mySociety::ArrayUtils::intersection(\@live_contact_ids, \@new_contact_ids) }; $defect_type->contact_defect_types->search({ - contact_id => { '!=' => \@new_contact_ids }, + contact_id => { -not_in => \@new_contact_ids }, })->delete; foreach my $contact_id (@new_contact_ids) { $defect_type->contact_defect_types->find_or_create({ diff --git a/perllib/FixMyStreet/App/Controller/Admin/ResponsePriorities.pm b/perllib/FixMyStreet/App/Controller/Admin/ResponsePriorities.pm index 2613f6ae0..5077fe78f 100644 --- a/perllib/FixMyStreet/App/Controller/Admin/ResponsePriorities.pm +++ b/perllib/FixMyStreet/App/Controller/Admin/ResponsePriorities.pm @@ -71,7 +71,7 @@ sub edit : Path : Args(2) { my @live_contact_ids = map { $_->id } @live_contacts; my @new_contact_ids = grep { $c->get_param("contacts[$_]") } @live_contact_ids; $priority->contact_response_priorities->search({ - contact_id => { '!=' => \@new_contact_ids }, + contact_id => { -not_in => \@new_contact_ids }, })->delete; foreach my $contact_id (@new_contact_ids) { $priority->contact_response_priorities->find_or_create({ 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/Controller/Admin/Users.pm b/perllib/FixMyStreet/App/Controller/Admin/Users.pm index 6af4ae831..e55a3d111 100644 --- a/perllib/FixMyStreet/App/Controller/Admin/Users.pm +++ b/perllib/FixMyStreet/App/Controller/Admin/Users.pm @@ -27,37 +27,69 @@ Admin pages for editing users sub index :Path : Args(0) { my ( $self, $c ) = @_; - $c->detach('add') if $c->req->method eq 'POST'; # Add a user - - if (my $search = $c->get_param('search')) { - $search = $self->trim($search); - $search =~ s/^<(.*)>$/$1/; # In case email wrapped in <...> - $c->stash->{searched} = $search; - - my $isearch = '%' . $search . '%'; - my $search_n = 0; - $search_n = int($search) if $search =~ /^\d+$/; - - my $users = $c->cobrand->users->search( - { - -or => [ - email => { ilike => $isearch }, - phone => { ilike => $isearch }, - name => { ilike => $isearch }, - from_body => $search_n, - ] + if ($c->req->method eq 'POST') { + my @uids = $c->get_param_list('uid'); + my @role_ids = $c->get_param_list('roles'); + my $user_rs = FixMyStreet::DB->resultset("User")->search({ id => \@uids }); + foreach my $user ($user_rs->all) { + $user->admin_user_body_permissions->delete; + $user->user_roles->search({ + role_id => { -not_in => \@role_ids }, + })->delete; + foreach my $role (@role_ids) { + $user->user_roles->find_or_create({ + role_id => $role, + }); } - ); + } + $c->stash->{status_message} = _('Updated!'); + } + + my $search = $c->get_param('search'); + my $role = $c->get_param('role'); + if ($search || $role) { + my $users = $c->cobrand->users; + my $isearch; + if ($search) { + $search = $self->trim($search); + $search =~ s/^<(.*)>$/$1/; # In case email wrapped in <...> + $c->stash->{searched} = $search; + + $isearch = '%' . $search . '%'; + my $search_n = 0; + $search_n = int($search) if $search =~ /^\d+$/; + + $users = $users->search( + { + -or => [ + email => { ilike => $isearch }, + phone => { ilike => $isearch }, + name => { ilike => $isearch }, + from_body => $search_n, + ] + } + ); + } + if ($role) { + $c->stash->{role_selected} = $role; + $users = $users->search({ + role_id => $role, + }, { + join => 'user_roles', + }); + } + my @users = $users->all; $c->stash->{users} = [ @users ]; - $c->forward('/admin/add_flags', [ { email => { ilike => $isearch } } ]); + if ($search) { + $c->forward('/admin/add_flags', [ { email => { ilike => $isearch } } ]); + } } else { $c->forward('/auth/get_csrf_token'); $c->forward('/admin/fetch_all_bodies'); $c->cobrand->call_hook('admin_user_edit_extra_data'); - # Admin users by default my $users = $c->cobrand->users->search( { from_body => { '!=', undef } }, @@ -67,6 +99,14 @@ sub index :Path : Args(0) { $c->stash->{users} = \@users; } + my $rs; + if ($c->user->is_superuser) { + $rs = $c->model('DB::Role')->search_rs({}, { join => 'body', order_by => ['body.name', 'me.name'] }); + } elsif ($c->user->from_body) { + $rs = $c->user->from_body->roles->search_rs({}, { order_by => 'name' }); + } + $c->stash->{roles} = [ $rs->all ]; + return 1; } @@ -113,9 +153,7 @@ sub add : Local : Args(0) { $c->stash->{field_errors}->{username} = _('User already exists'); } - return if %{$c->stash->{field_errors}}; - - my $user = $c->model('DB::User')->create( { + my $user = $c->model('DB::User')->new( { name => $c->get_param('name'), email => $email ? $email : undef, email_verified => $email && $email_v ? 1 : 0, @@ -127,8 +165,11 @@ sub add : Local : Args(0) { is_superuser => ( $c->user->is_superuser && $c->get_param('is_superuser') ) || 0, } ); $c->stash->{user} = $user; + + return if %{$c->stash->{field_errors}}; + $c->forward('user_cobrand_extra_fields'); - $user->update; + $user->insert; $c->forward( '/admin/log_edit', [ $user->id, 'user', 'edit' ] ); @@ -136,6 +177,18 @@ sub add : Local : Args(0) { $c->res->redirect( $c->uri_for_action( 'admin/users/edit', $user->id ) ); } +sub fetch_body_roles : Private { + my ($self, $c, $body ) = @_; + + my $roles = $body->roles->search(undef, { order_by => 'name' }); + unless ($roles) { + delete $c->stash->{roles}; # Body doesn't have any roles + return; + } + + $c->stash->{roles} = [ $roles->all ]; +} + sub edit : Path : Args(1) { my ( $self, $c, $id ) = @_; @@ -157,11 +210,11 @@ sub edit : Path : Args(1) { $c->forward('/admin/fetch_all_bodies'); $c->forward('/admin/fetch_body_areas', [ $user->from_body ]) if $user->from_body; + $c->forward('fetch_body_roles', [ $user->from_body ]) if $user->from_body; $c->cobrand->call_hook('admin_user_edit_extra_data'); if ( defined $c->flash->{status_message} ) { - $c->stash->{status_message} = - '<p><em>' . $c->flash->{status_message} . '</em></p>'; + $c->stash->{status_message} = $c->flash->{status_message}; } $c->forward('/auth/check_csrf_token') if $c->get_param('submit'); @@ -270,26 +323,45 @@ sub edit : Path : Args(1) { # If so, we need to re-fetch areas so the UI is up to date. if ( $user->from_body && $user->from_body->id ne $c->stash->{fetched_areas_body_id} ) { $c->forward('/admin/fetch_body_areas', [ $user->from_body ]); + $c->forward('fetch_body_roles', [ $user->from_body ]); } if (!$user->from_body) { # Non-staff users aren't allowed any permissions or to be in an area $user->admin_user_body_permissions->delete; + $user->user_roles->delete; $user->area_ids(undef); delete $c->stash->{areas}; + delete $c->stash->{roles}; delete $c->stash->{fetched_areas_body_id}; } elsif ($c->stash->{available_permissions}) { - my @all_permissions = map { keys %$_ } values %{ $c->stash->{available_permissions} }; - my @user_permissions = grep { $c->get_param("permissions[$_]") ? 1 : undef } @all_permissions; - $user->admin_user_body_permissions->search({ - body_id => $user->from_body->id, - permission_type => { '!=' => \@user_permissions }, - })->delete; - foreach my $permission_type (@user_permissions) { - $user->user_body_permissions->find_or_create({ + my %valid_roles = map { $_->id => 1 } @{$c->stash->{roles}}; + my @role_ids = grep { $valid_roles{$_} } $c->get_param_list('roles'); + if (@role_ids) { + # Roles take precedence over permissions + $user->admin_user_body_permissions->delete; + $user->user_roles->search({ + role_id => { -not_in => \@role_ids }, + })->delete; + foreach my $role (@role_ids) { + $user->user_roles->find_or_create({ + role_id => $role, + }); + } + } else { + $user->user_roles->delete; + my @all_permissions = map { keys %$_ } values %{ $c->stash->{available_permissions} }; + my @user_permissions = grep { $c->get_param("permissions[$_]") ? 1 : undef } @all_permissions; + $user->admin_user_body_permissions->search({ body_id => $user->from_body->id, - permission_type => $permission_type, - }); + permission_type => { -not_in => \@user_permissions }, + })->delete; + foreach my $permission_type (@user_permissions) { + $user->user_body_permissions->find_or_create({ + body_id => $user->from_body->id, + permission_type => $permission_type, + }); + } } } @@ -303,7 +375,7 @@ sub edit : Path : Args(1) { my @trusted_bodies = $c->get_param_list('trusted_bodies'); if ( $c->user->is_superuser ) { $user->user_body_permissions->search({ - body_id => { '!=' => \@trusted_bodies }, + body_id => { -not_in => \@trusted_bodies }, permission_type => 'trusted', })->delete; foreach my $body_id (@trusted_bodies) { @@ -389,9 +461,8 @@ sub import :Local { my $fh = $c->req->upload('csvfile')->fh; $csv->getline($fh); # discard the header while (my $row = $csv->getline($fh)) { - my ($name, $email, $from_body, $permissions) = @$row; + my ($name, $email, $from_body, $permissions, $roles) = @$row; $email = lc Utils::trim_text($email); - my @permissions = split(/:/, $permissions); my $user = FixMyStreet::DB->resultset("User")->find_or_new({ email => $email, email_verified => 1 }); if ($user->in_storage) { @@ -403,12 +474,24 @@ sub import :Local { $user->from_body($from_body || undef); $user->update_or_insert; - my @user_permissions = grep { $available_permissions{$_} } @permissions; - foreach my $permission_type (@user_permissions) { - $user->user_body_permissions->find_or_create({ - body_id => $user->from_body->id, - permission_type => $permission_type, - }); + if ($roles) { + my @roles = split(/:/, $roles); + foreach my $role (@roles) { + $role = FixMyStreet::DB->resultset("Role")->find({ + body_id => $user->from_body->id, + name => $role, + }) or next; + $user->add_to_roles($role); + } + } else { + my @permissions = split(/:/, $permissions); + my @user_permissions = grep { $available_permissions{$_} } @permissions; + foreach my $permission_type (@user_permissions) { + $user->user_body_permissions->find_or_create({ + body_id => $user->from_body->id, + permission_type => $permission_type, + }); + } } push @{$c->stash->{new_users}}, $user; 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/Body.pm b/perllib/FixMyStreet/DB/Result/Body.pm index 663181746..9424eaf03 100644 --- a/perllib/FixMyStreet/DB/Result/Body.pm +++ b/perllib/FixMyStreet/DB/Result/Body.pm @@ -117,6 +117,12 @@ __PACKAGE__->has_many( { cascade_copy => 0, cascade_delete => 0 }, ); __PACKAGE__->has_many( + "roles", + "FixMyStreet::DB::Result::Role", + { "foreign.body_id" => "self.id" }, + { cascade_copy => 0, cascade_delete => 0 }, +); +__PACKAGE__->has_many( "user_body_permissions", "FixMyStreet::DB::Result::UserBodyPermission", { "foreign.body_id" => "self.id" }, @@ -130,8 +136,8 @@ __PACKAGE__->has_many( ); -# Created by DBIx::Class::Schema::Loader v0.07035 @ 2019-04-25 12:06:39 -# DO NOT MODIFY THIS OR ANYTHING ABOVE! md5sum:8CuxbffDaYS7TFlgff1nEg +# Created by DBIx::Class::Schema::Loader v0.07035 @ 2019-05-23 18:03:28 +# DO NOT MODIFY THIS OR ANYTHING ABOVE! md5sum:9sFgYQ9qhnZNcz3kUFYuvg __PACKAGE__->load_components("+FixMyStreet::DB::RABXColumn"); __PACKAGE__->rabx_column('extra'); diff --git a/perllib/FixMyStreet/DB/Result/Role.pm b/perllib/FixMyStreet/DB/Result/Role.pm new file mode 100644 index 000000000..e35b0b195 --- /dev/null +++ b/perllib/FixMyStreet/DB/Result/Role.pm @@ -0,0 +1,53 @@ +use utf8; +package FixMyStreet::DB::Result::Role; + +# Created by DBIx::Class::Schema::Loader +# DO NOT MODIFY THE FIRST PART OF THIS FILE + +use strict; +use warnings; + +use base 'DBIx::Class::Core'; +__PACKAGE__->load_components( + "FilterColumn", + "FixMyStreet::InflateColumn::DateTime", + "FixMyStreet::EncodedColumn", +); +__PACKAGE__->table("roles"); +__PACKAGE__->add_columns( + "id", + { + data_type => "integer", + is_auto_increment => 1, + is_nullable => 0, + sequence => "roles_id_seq", + }, + "body_id", + { data_type => "integer", is_foreign_key => 1, is_nullable => 0 }, + "name", + { data_type => "text", is_nullable => 1 }, + "permissions", + { data_type => "text[]", is_nullable => 1 }, +); +__PACKAGE__->set_primary_key("id"); +__PACKAGE__->add_unique_constraint("roles_body_id_name_key", ["body_id", "name"]); +__PACKAGE__->belongs_to( + "body", + "FixMyStreet::DB::Result::Body", + { id => "body_id" }, + { is_deferrable => 0, on_delete => "CASCADE,", on_update => "NO ACTION" }, +); +__PACKAGE__->has_many( + "user_roles", + "FixMyStreet::DB::Result::UserRole", + { "foreign.role_id" => "self.id" }, + { cascade_copy => 0, cascade_delete => 0 }, +); + + +# 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' ); + +1; diff --git a/perllib/FixMyStreet/DB/Result/User.pm b/perllib/FixMyStreet/DB/Result/User.pm index d01ba92d0..fc651b4d1 100644 --- a/perllib/FixMyStreet/DB/Result/User.pm +++ b/perllib/FixMyStreet/DB/Result/User.pm @@ -121,10 +121,16 @@ __PACKAGE__->has_many( { "foreign.user_id" => "self.id" }, { cascade_copy => 0, cascade_delete => 0 }, ); +__PACKAGE__->has_many( + "user_roles", + "FixMyStreet::DB::Result::UserRole", + { "foreign.user_id" => "self.id" }, + { cascade_copy => 0, cascade_delete => 0 }, +); -# Created by DBIx::Class::Schema::Loader v0.07035 @ 2019-04-25 12:06:39 -# DO NOT MODIFY THIS OR ANYTHING ABOVE! md5sum:BCCqv3JCec8psuRk/SdCJQ +# Created by DBIx::Class::Schema::Loader v0.07035 @ 2019-05-23 18:03:28 +# DO NOT MODIFY THIS OR ANYTHING ABOVE! md5sum:qtmzA7ywVkyQpjLh1ienNg # These are not fully unique constraints (they only are when the *_verified # is true), but this is managed in ResultSet::User's find() wrapper. @@ -143,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; @@ -375,7 +382,18 @@ has body_permissions => ( lazy => 1, default => sub { my $self = shift; - return [ $self->user_body_permissions->all ]; + my $perms = []; + foreach my $role ($self->roles->all) { + push @$perms, map { { + body_id => $role->body_id, + permission => $_, + } } @{$role->permissions}; + } + push @$perms, map { { + body_id => $_->body_id, + permission => $_->permission_type, + } } $self->user_body_permissions->all; + return $perms; }, ); @@ -392,8 +410,8 @@ sub permissions { return unless $self->belongs_to_body($body_id); - my @permissions = grep { $_->body_id == $self->from_body->id } @{$self->body_permissions}; - return { map { $_->permission_type => 1 } @permissions }; + my @permissions = grep { $_->{body_id} == $self->from_body->id } @{$self->body_permissions}; + return { map { $_->{permission} => 1 } @permissions }; } sub has_permission_to { @@ -415,7 +433,7 @@ sub has_permission_to { my %body_ids = map { $_ => 1 } @$body_ids; foreach (@{$self->body_permissions}) { - return 1 if $_->permission_type eq $permission_type && $body_ids{$_->body_id}; + return 1 if $_->{permission} eq $permission_type && $body_ids{$_->{body_id}}; } return 0; } @@ -621,4 +639,19 @@ sub in_area { return $self->areas_hash->{$area}; } +has roles_hash => ( + is => 'ro', + lazy => 1, + default => sub { + my $self = shift; + my %ids = map { $_->role_id => 1 } $self->user_roles->all; + return \%ids; + }, +); + +sub in_role { + my ($self, $role) = @_; + return $self->roles_hash->{$role}; +} + 1; diff --git a/perllib/FixMyStreet/DB/Result/UserRole.pm b/perllib/FixMyStreet/DB/Result/UserRole.pm new file mode 100644 index 000000000..9186e2aa1 --- /dev/null +++ b/perllib/FixMyStreet/DB/Result/UserRole.pm @@ -0,0 +1,50 @@ +use utf8; +package FixMyStreet::DB::Result::UserRole; + +# Created by DBIx::Class::Schema::Loader +# DO NOT MODIFY THE FIRST PART OF THIS FILE + +use strict; +use warnings; + +use base 'DBIx::Class::Core'; +__PACKAGE__->load_components( + "FilterColumn", + "FixMyStreet::InflateColumn::DateTime", + "FixMyStreet::EncodedColumn", +); +__PACKAGE__->table("user_roles"); +__PACKAGE__->add_columns( + "id", + { + data_type => "integer", + is_auto_increment => 1, + is_nullable => 0, + sequence => "user_roles_id_seq", + }, + "role_id", + { data_type => "integer", is_foreign_key => 1, is_nullable => 0 }, + "user_id", + { data_type => "integer", is_foreign_key => 1, is_nullable => 0 }, +); +__PACKAGE__->set_primary_key("id"); +__PACKAGE__->belongs_to( + "role", + "FixMyStreet::DB::Result::Role", + { id => "role_id" }, + { is_deferrable => 0, on_delete => "CASCADE,", on_update => "NO ACTION" }, +); +__PACKAGE__->belongs_to( + "user", + "FixMyStreet::DB::Result::User", + { id => "user_id" }, + { is_deferrable => 0, on_delete => "CASCADE,", on_update => "NO ACTION" }, +); + + +# Created by DBIx::Class::Schema::Loader v0.07035 @ 2019-05-23 16:52:59 +# DO NOT MODIFY THIS OR ANYTHING ABOVE! md5sum:1BSR4j0o5PApKEZmzVAnLg + + +# You can replace this text with custom code or comments, and it will be preserved on regeneration +1; diff --git a/t/app/controller/admin/roles.t b/t/app/controller/admin/roles.t new file mode 100644 index 000000000..6dd40cbb6 --- /dev/null +++ b/t/app/controller/admin/roles.t @@ -0,0 +1,132 @@ +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); +my $user = $mech->create_user_ok('staffuser@example.com', name => 'Other Council User', from_body => $body); + +$editor->user_body_permissions->create({ + body => $body, + permission_type => 'user_edit', +}); +$editor->user_body_permissions->create({ + body => $body, + permission_type => 'user_manage_permissions', +}); +$user->user_body_permissions->create({ + body => $body, + permission_type => 'report_edit_priority', +}); + +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', + MAPIT_URL => 'http://mapit.uk', +}, 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 'assign a user to a role' => sub { + $mech->get_ok('/admin/users/' . $user->id); + $mech->content_contains('Role B'); + $mech->content_lacks('Role Z'); + $mech->submit_form_ok({ with_fields => { + roles => 'Role B', + }}); + $mech->content_like(qr/<option[^>]*selected>Role B/); + $mech->content_like(qr/<input[^>]*checkbox[^>]*template_edit[^>]*checked/); + is $user->roles->count, 1, 'in one role'; + is $user->user_body_permissions->count, 0, 'permissions removed'; + }; + + subtest 'check user has the permissions of the role' => sub { + $mech->log_in_ok($user->email); + $mech->get_ok('/admin/templates'); + }; + + subtest 'remove user from role' => sub { + $mech->log_in_ok( $editor->email ); + $mech->get_ok('/admin/users/' . $user->id); + $mech->submit_form_ok({ with_fields => { + roles => undef, + }}, 'remove role'); + }; +}; + +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/t/app/controller/admin/users.t b/t/app/controller/admin/users.t index e2c922a23..ce29a5f7c 100644 --- a/t/app/controller/admin/users.t +++ b/t/app/controller/admin/users.t @@ -60,6 +60,7 @@ subtest 'show flagged entries' => sub { $user->update; }; +my $role; subtest 'user search' => sub { $mech->get_ok('/admin/users'); $mech->get_ok('/admin/users?search=' . $user->name); @@ -74,8 +75,25 @@ subtest 'user search' => sub { $user->from_body($haringey->id); $user->update; + $role = $user->roles->create({ + body => $haringey, + name => 'Role A', + permissions => ['moderate', 'user_edit'], + }); + $user->add_to_roles($role); $mech->get_ok('/admin/users?search=' . $haringey->id ); - $mech->content_contains('Haringey'); + $mech->content_contains('test@example.com'); + $mech->get_ok('/admin/users?role=' . $role->id); + $mech->content_contains('selected>Role A'); + $mech->content_contains('test@example.com'); +}; + +subtest 'user assign role' => sub { + $user->remove_from_roles($role); + is $user->roles->count, 0; + $mech->get_ok('/admin/users'); + $mech->submit_form_ok({ with_fields => { uid => $user->id, roles => $role->id } }); + is $user->roles->count, 1; }; subtest 'search does not show user from another council' => sub { @@ -157,6 +175,7 @@ for my $test ( subtest $test->{desc} => sub { $mech->get_ok('/admin/users'); $mech->submit_form_ok( { with_fields => $test->{fields} } ); + $mech->content_contains('Norman') if $test->{fields}{name}; if ($test->{error}) { $mech->content_contains($_) for @{$test->{error}}; } else { @@ -166,7 +185,7 @@ for my $test ( } my %default_perms = ( - "permissions[moderate]" => undef, + "permissions[moderate]" => 'on', "permissions[planned_reports]" => undef, "permissions[report_mark_private]" => undef, "permissions[report_edit]" => undef, @@ -180,7 +199,7 @@ my %default_perms = ( "permissions[contribute_as_body]" => undef, "permissions[default_to_body]" => undef, "permissions[view_body_contribute_details]" => undef, - "permissions[user_edit]" => undef, + "permissions[user_edit]" => 'on', "permissions[user_manage_permissions]" => undef, "permissions[user_assign_body]" => undef, "permissions[user_assign_areas]" => undef, @@ -211,6 +230,7 @@ FixMyStreet::override_config { is_superuser => undef, area_ids => undef, %default_perms, + roles => $role->id, }, changes => { name => 'Changed User', @@ -231,6 +251,7 @@ FixMyStreet::override_config { is_superuser => undef, area_ids => undef, %default_perms, + roles => $role->id, }, changes => { email => 'changed@example.com', @@ -251,10 +272,14 @@ FixMyStreet::override_config { is_superuser => undef, area_ids => undef, %default_perms, + roles => $role->id, }, changes => { body => $southend->id, }, + removed => [ + 'roles', + ], log_count => 3, log_entries => [qw/edit edit edit/], }, @@ -339,6 +364,8 @@ FixMyStreet::override_config { }, added => { %default_perms, + 'permissions[moderate]' => undef, + 'permissions[user_edit]' => undef, }, log_count => 5, log_entries => [qw/edit edit edit edit edit/], diff --git a/t/app/controller/admin/users_import.t b/t/app/controller/admin/users_import.t new file mode 100644 index 000000000..df8884797 --- /dev/null +++ b/t/app/controller/admin/users_import.t @@ -0,0 +1,34 @@ +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(2509, 'Haringey Borough Council'); + +$mech->log_in_ok( $superuser->email ); + +my $body_id = $body->id; +my $csv = <<EOF; +name,email,from_body,permissions,roles +Adrian,adrian\@example.org,$body_id,moderate:user_edit, +Belinda,belinda\@example.org,$body_id,,Customer Service +EOF + +FixMyStreet::DB->resultset("Role")->create({ + body => $body, + name => 'Customer Service', +}); + +subtest 'import CSV file' => sub { + $mech->get_ok('/admin/users/import'); + $mech->submit_form_ok({ with_fields => { + csvfile => [ [ undef, 'foo.csv', Content => $csv ], 1], + }}); + $mech->content_contains('Created 2 new users'); + my $a = FixMyStreet::DB->resultset("User")->find({ email => 'adrian@example.org' }); + is $a->user_body_permissions->count, 2; + my $b = FixMyStreet::DB->resultset("User")->find({ email => 'belinda@example.org' }); + is $b->roles->count, 1; +}; + +done_testing(); diff --git a/templates/web/base/admin/report_blocks.html b/templates/web/base/admin/report_blocks.html index 4c52b14bb..4c679e3b8 100644 --- a/templates/web/base/admin/report_blocks.html +++ b/templates/web/base/admin/report_blocks.html @@ -47,3 +47,7 @@ SET state_groups = c.cobrand.state_groups_admin; </span> [% END %] [% END %] + +[% BLOCK status_message %] + <p><em>[% status_message %]</em></p> +[% END %] diff --git a/templates/web/base/admin/report_edit.html b/templates/web/base/admin/report_edit.html index a26a35c50..e7d126558 100644 --- a/templates/web/base/admin/report_edit.html +++ b/templates/web/base/admin/report_edit.html @@ -116,7 +116,7 @@ class="admin-offsite-link">[% problem.latitude %], [% problem.longitude %]</a> </ul> </div> -[% status_message %] +[% INCLUDE status_message %] <ul class="plain-list"> <li><label for='title'>[% loc('Subject:') %]</label> 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..ba3fd434b --- /dev/null +++ b/templates/web/base/admin/roles/index.html @@ -0,0 +1,35 @@ +[% 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]) %]">[% loc('Edit') %]</a> + · <a href="[% c.uri_for_action('admin/users/index', role = role.id) %]">[% loc('Users') %]</a> + </td> + </tr> +[% END -%] +</table> + +<p> + <a href="[% c.uri_for(c.controller.action_for('create')) %]">Create</a> +</p> + +[% INCLUDE 'admin/footer.html' %] diff --git a/templates/web/base/admin/update_edit.html b/templates/web/base/admin/update_edit.html index fca9b1904..527b3f9f8 100644 --- a/templates/web/base/admin/update_edit.html +++ b/templates/web/base/admin/update_edit.html @@ -1,7 +1,7 @@ [% INCLUDE 'admin/header.html' title=tprintf(loc('Editing update %d'), update.id ) -%] [% PROCESS 'admin/report_blocks.html' %] -[% status_message %] +[% INCLUDE status_message %] <form method="post" action="[% c.uri_for( 'update_edit', update.id ) %]" enctype="application/x-www-form-urlencoded" accept-charset="utf-8"> <input type="hidden" name="token" value="[% csrf_token %]" > diff --git a/templates/web/base/admin/users/edit.html b/templates/web/base/admin/users/edit.html index 8dd2b926f..204e58607 100644 --- a/templates/web/base/admin/users/edit.html +++ b/templates/web/base/admin/users/edit.html @@ -6,7 +6,7 @@ [% INCLUDE 'admin/header.html' title=title -%] [% PROCESS 'admin/report_blocks.html' %] -[% status_message %] +[% INCLUDE status_message %] [% INCLUDE 'admin/users/form.html' %] diff --git a/templates/web/base/admin/users/form.html b/templates/web/base/admin/users/form.html index 8f0f9ad6e..c9cc6463b 100644 --- a/templates/web/base/admin/users/form.html +++ b/templates/web/base/admin/users/form.html @@ -1,4 +1,4 @@ -<form method="post" id="user_edit" action="[% c.uri_for_action( 'admin/users/edit', user.id ) %]" enctype="application/x-www-form-urlencoded" accept-charset="utf-8"> +<form method="post" id="user_edit" action="[% c.uri_for_action( 'admin/users/edit', user.id || 'add' ) %]" enctype="application/x-www-form-urlencoded" accept-charset="utf-8"> <input type="hidden" name="token" value="[% csrf_token %]" > <input type="hidden" name="submit" value="1" > @@ -155,6 +155,20 @@ [% IF available_permissions AND NOT user.is_superuser %] <li> + <div class="admin-hint"> + <p> + [% loc("Users can be assigned one or more roles to give them all the permissions of those roles. Selecting a role or roles will disable manual permission selection.") %] + </p> + </div> + <label for="roles">[% loc('Role:') %]</label> + <select class="form-control js-multiple" id="roles" name="roles" multiple> + [% FOREACH role IN roles %] + <option data-permissions='["[% role.permissions.join('","') | html %]"]' value="[% role.id %]"[% ' selected' IF user.in_role(role.id) %]>[% role.name | html %]</option> + [% END %] + </select> + </li> + + <li> <fieldset> <legend> <div class="admin-hint"> diff --git a/templates/web/base/admin/users/import.html b/templates/web/base/admin/users/import.html index 0c0b903d7..4a93e0255 100644 --- a/templates/web/base/admin/users/import.html +++ b/templates/web/base/admin/users/import.html @@ -1,7 +1,7 @@ [% INCLUDE 'admin/header.html' title=loc("User Import") -%] [% PROCESS 'admin/report_blocks.html' %] -[% status_message %] +[% INCLUDE status_message %] <form method="post" id="user_edit" enctype="multipart/form-data" accept-charset="utf-8"> <input type="hidden" name="token" value="[% csrf_token %]" > @@ -66,10 +66,11 @@ <p>[% loc("Existing users won't be modified.") %]</p> <p> [% loc("The uploaded CSV file must contain a header row, and records must have the following fields (in this order):") %] - <pre>name,email,from_body,permissions</pre> + <pre>name,email,from_body,permissions,roles</pre> <ul> <li><code>from_body</code>: [% loc("the database id of the body to associate that user with, e.g. <code>2217</code> for Buckinghamshire.") %]</li> <li><code>permissions</code>: [% loc("a colon-separated list of permissions to grant that user, e.g. <code>contribute_as_body:moderate:user_edit</code>.") %]</li> + <li><code>roles</code>: [% loc("a colon-separated list of roles to assign to that user.") %]</li> </ul> </p> diff --git a/templates/web/base/admin/users/index.html b/templates/web/base/admin/users/index.html index e939f008b..880b4a417 100644 --- a/templates/web/base/admin/users/index.html +++ b/templates/web/base/admin/users/index.html @@ -1,18 +1,45 @@ [% INCLUDE 'admin/header.html' title=loc('Search Users') %] [% PROCESS 'admin/report_blocks.html' %] +[% BLOCK role_select %] +<select name="[% label %]" id="[% label %]" class="form-control"> + <option value="">---</option> + [% FOR role IN roles %] + [% IF c.user.is_superuser AND last_name != role.body.name %] + <optgroup label="[% role.body.name %]"> + [% SET last_name = role.body.name %] + [% END %] + <option value="[% role.id %]"[% ' selected' IF role.id == role_selected %]>[% role.name | html %]</option> + [% END %] +</select> +[% END %] + +[% INCLUDE status_message %] + <div class="fms-admin-info"> [% loc("User search finds matches in users' names and email addresses.") %] </div> + <form method="get" action="[% c.uri_for_action('admin/users/index') %]" enctype="application/x-www-form-urlencoded" accept-charset="utf-8"> +<div class="filters"> + <p><label for="search">[% loc('Search:') %]</label> <input class="form-control" type="text" name="search" size="30" id="search" value="[% searched | html %]"> + [% IF roles %] + <p><label for="role">[% loc('Role:') %]</label> + [% INCLUDE role_select label='role' %] + [% END %] + <p class="no-label"><input type="submit" value="[% loc('Go') %]" class="btn"> + +</div> </form> [% IF users.size %] +<form method="post"> <table cellspacing="0" cellpadding="2" border="1"> <tr> + <th>*</th> <th>[% loc('Name') %]</th> <th>[% loc('Email') %]</th> <th>[% loc('Body') %]</th> @@ -23,7 +50,13 @@ </tr> [%- FOREACH user IN users %] <tr> - <td>[% PROCESS value_or_nbsp value=user.name %]</td> + <td align="center">[% IF user.from_body %] + <input type="checkbox" name="uid" value="[% user.id %]" id="uid[% user.id %]"> + [% END %]</td> + <td>[% IF user.from_body %]<label class="inline" for="uid[% user.id %]">[% END %] + [% PROCESS value_or_nbsp value=user.name %] + [% IF user.from_body %]</label>[% END %] + </td> <td><a href="[% c.uri_for_action( 'admin/reports', search => user.email ) %]">[% PROCESS value_or_nbsp value=user.email %]</a></td> <td>[% PROCESS value_or_nbsp value=user.from_body.name %] [% IF user.is_superuser %] * [% END %] @@ -36,7 +69,20 @@ [%- END -%] </table> -[% ELSIF searched %] +<p><label for="roles">[% loc('Assign selected to role:') %]</label> +[% IF c.user.is_superuser %] +[% INCLUDE role_select label='roles' %] +[% ELSE %] +<select class="form-control js-multiple" id="roles" name="roles" multiple> + [% FOREACH role IN roles %] + <option value="[% role.id %]">[% role.name | html %]</option> + [% END %] +</select> +[% END %] +<p><input class="btn" type="submit" value="[% loc('Save changes') %]"> +</form> + +[% ELSIF searched || role_selected %] <div class="fms-admin-warning"> [% loc("Searching found no users.") %] diff --git a/templates/web/zurich/admin/index-dm.html b/templates/web/zurich/admin/index-dm.html index 4d77cf264..c93adbfb3 100644 --- a/templates/web/zurich/admin/index-dm.html +++ b/templates/web/zurich/admin/index-dm.html @@ -1,7 +1,7 @@ [% PROCESS 'admin/header.html' title=loc('Summary') -%] [% PROCESS 'admin/report_blocks.html' %] -[% status_message %] +[% INCLUDE status_message %] <h2 id="submitted">[% loc('Submitted') %]</h2> [% INCLUDE list, problems = submitted.all, hash = 'submitted' %] diff --git a/templates/web/zurich/admin/report_edit-sdm.html b/templates/web/zurich/admin/report_edit-sdm.html index e6b17b8f0..b9fb6ff4d 100644 --- a/templates/web/zurich/admin/report_edit-sdm.html +++ b/templates/web/zurich/admin/report_edit-sdm.html @@ -79,7 +79,7 @@ <dd>[% problem.get_time_spent %]</dd> <dd> - [% status_message %] + [% INCLUDE status_message %] </dd> [% IF problem.photo %] diff --git a/templates/web/zurich/admin/report_edit.html b/templates/web/zurich/admin/report_edit.html index d6f9c4c39..0186a6286 100644 --- a/templates/web/zurich/admin/report_edit.html +++ b/templates/web/zurich/admin/report_edit.html @@ -129,7 +129,7 @@ <div class="admin-report-edit admin-report-edit--interact"> -[% status_message %] +[% INCLUDE status_message %] <dl [% IF status_message %]class="with-message"[% END %]> diff --git a/templates/web/zurich/admin/update_edit.html b/templates/web/zurich/admin/update_edit.html index bcf849732..314ae3e2b 100644 --- a/templates/web/zurich/admin/update_edit.html +++ b/templates/web/zurich/admin/update_edit.html @@ -1,7 +1,7 @@ [% INCLUDE 'admin/header.html' title=tprintf(loc('Editing update %d'), update.id ) -%] [% PROCESS 'admin/report_blocks.html' %] -[% status_message %] +[% INCLUDE status_message %] <form method="post" action="[% c.uri_for( 'update_edit', update.id ) %]" enctype="application/x-www-form-urlencoded" accept-charset="utf-8"> <input type="hidden" name="token" value="[% csrf_token %]" > diff --git a/web/cobrands/fixmystreet/admin.js b/web/cobrands/fixmystreet/admin.js index b947b3e49..8bc956c57 100644 --- a/web/cobrands/fixmystreet/admin.js +++ b/web/cobrands/fixmystreet/admin.js @@ -80,6 +80,32 @@ $(function(){ $("form#user_edit .js-user-categories").toggle(show_area); }); + $('form#user_edit select#roles').change(function() { + var $perms = $('.permissions-checkboxes'); + if ($(this).val()) { + var selected_perms = {}; + $(this).find(':selected').each(function() { + $.each($(this).data('permissions'), function(i, p) { + selected_perms['permissions[' + p + ']'] = 1; + }); + }); + console.log(selected_perms); + $perms.css('color', '#666'); + $perms.find('a').css('color', '#666'); + $perms.find('input').each(function() { + this.checked = selected_perms[this.name] || false; + }); + $perms.find('input').prop('disabled', true); + } else { + $perms.css('color', ''); + $perms.find('a').css('color', ''); + $perms.find('input').each(function() { + this.checked = this.hasAttribute('checked'); + }); + $perms.find('input').prop('disabled', false); + } + }).change(); + // On category edit page, hide the reputation input if inspection isn't required $("form#category_edit #inspection_required").change(function() { var $p = $("form#category_edit #reputation_threshold").closest("p"); |