diff --git a/app/controllers/users_controller.rb b/app/controllers/users_controller.rb index 7727e031b..c4062f6d5 100644 --- a/app/controllers/users_controller.rb +++ b/app/controllers/users_controller.rb @@ -7,8 +7,10 @@ class UsersController < ApplicationController def update if @user.update(user_params) - bypass_sign_in @user - flash[:notice] = I18n.t("devise.passwords.updated") if user_params.key?("password") + if @user == current_user + bypass_sign_in @user + flash[:notice] = I18n.t("devise.passwords.updated") if user_params.key?("password") + end redirect_to user_path(@user) elsif user_params.key?("password") format_error_messages @@ -73,7 +75,11 @@ private end def user_params - params.require(:user).permit(:email, :name, :password, :password_confirmation, :role) + if @user == current_user + params.require(:user).permit(:email, :name, :password, :password_confirmation, :role, :is_dpo) + else + params.require(:user).permit(:email, :name, :role, :is_dpo) + end end def find_resource @@ -81,6 +87,8 @@ private end def authenticate_scope! - render_not_found if current_user != @user + render_not_found and return unless current_user.organisation == @user.organisation + render_not_found and return if action_name == "edit_password" && current_user != @user + render_not_found and return unless current_user.role == "data_coordinator" || current_user == @user end end diff --git a/app/models/user.rb b/app/models/user.rb index a0f5d7872..2fd63c71d 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -14,7 +14,6 @@ class User < ApplicationRecord data_accessor: 0, data_provider: 1, data_coordinator: 2, - data_protection_officer: 3, }.freeze enum role: ROLES @@ -37,4 +36,12 @@ class User < ApplicationRecord def reset_password_notify_template last_sign_in_at ? RESET_PASSWORD_TEMPLATE_ID : SET_PASSWORD_TEMPLATE_ID end + + def is_data_protection_officer? + is_dpo + end + + def is_data_protection_officer! + update!(is_dpo: true) + end end diff --git a/app/services/imports/data_protection_confirmation_import_service.rb b/app/services/imports/data_protection_confirmation_import_service.rb index 142fe039d..30d28b647 100644 --- a/app/services/imports/data_protection_confirmation_import_service.rb +++ b/app/services/imports/data_protection_confirmation_import_service.rb @@ -11,14 +11,14 @@ module Imports dp_officer = User.find_by( name: record_field_value(xml_document, "dp-user"), organisation: org, - role: "data_protection_officer", + is_dpo: true, ) if dp_officer.blank? dp_officer = User.new( name: record_field_value(xml_document, "dp-user"), organisation: org, - role: "data_protection_officer", + is_dpo: true, encrypted_password: SecureRandom.hex(10), ) dp_officer.save!(validate: false) diff --git a/app/views/users/edit.html.erb b/app/views/users/edit.html.erb index 9d30a0fe8..649d4f614 100644 --- a/app/views/users/edit.html.erb +++ b/app/views/users/edit.html.erb @@ -1,4 +1,4 @@ -<% content_for :title, "Change your personal details" %> +<% content_for :title, current_user == @user ? "Change your personal details" : "Change #{@user.name}’s personal details" %> <% content_for :before_content do %> <%= govuk_back_link( @@ -7,7 +7,7 @@ ) %> <% end %> -<%= form_for(current_user, as: :user, html: { method: :patch }) do |f| %> +<%= form_for(@user, as: :user, html: { method: :patch }) do |f| %>
<%= f.govuk_error_summary %> @@ -26,6 +26,14 @@ spellcheck: "false" %> + <%= f.govuk_collection_radio_buttons :is_dpo, + [OpenStruct.new(id: false, name: "No"), OpenStruct.new(id: true, name: "Yes")], + :id, + :name, + inline: true, + legend: { text: "Are #{current_user == @user ? "you" : "they"} a data protection officer?", size: "m" } + %> + <%= f.govuk_submit "Save changes" %>
diff --git a/app/views/users/new.html.erb b/app/views/users/new.html.erb index 53e64eafa..8b89d0c47 100644 --- a/app/views/users/new.html.erb +++ b/app/views/users/new.html.erb @@ -31,6 +31,14 @@ f.govuk_collection_radio_buttons :role, roles, :id, :name, legend: { text: "Role", size: "m" } %> + <%= f.govuk_collection_radio_buttons :is_dpo, + [OpenStruct.new(id: false, name: "No"), OpenStruct.new(id: true, name: "Yes")], + :id, + :name, + inline: true, + legend: { text: "Are #{current_user == @user ? "you" : "they"} a data protection officer?", size: "m" } + %> + <%= f.govuk_submit "Continue" %> diff --git a/app/views/users/show.html.erb b/app/views/users/show.html.erb index 7472d46f8..66c0a9145 100644 --- a/app/views/users/show.html.erb +++ b/app/views/users/show.html.erb @@ -1,4 +1,4 @@ -<% content_for :title, "Your account" %> +<% content_for :title, current_user == @user ? "Your account" : "#{@user.name}’s account" %>
@@ -13,33 +13,43 @@ <%= govuk_summary_list do |summary_list| %> <%= summary_list.row do |row| row.key { 'Name' } - row.value { current_user.name } + row.value { @user.name } row.action(visually_hidden_text: 'name', href: edit_user_path, html_attributes: { 'data-qa': 'change-name' }) end %> <%= summary_list.row() do |row| row.key { 'Email address' } - row.value { current_user.email } + row.value { @user.email } row.action(visually_hidden_text: 'email address', href: edit_user_path, html_attributes: { 'data-qa': 'change-email' }) end %> <%= summary_list.row do |row| row.key { 'Password' } row.value { '••••••••' } - row.action(visually_hidden_text: 'password', href: password_edit_user_path, html_attributes: { 'data-qa': 'change-password' }) + if current_user == @user + row.action(visually_hidden_text: 'password', href: password_edit_user_path, html_attributes: { 'data-qa': 'change-password' }) + else + row.action() + end end %> <%= summary_list.row do |row| row.key { 'Organisation' } - row.value { current_user.organisation.name } + row.value { @user.organisation.name } row.action() end %> <%= summary_list.row do |row| row.key { 'Role' } - row.value { current_user.role.humanize } + row.value { @user.role.humanize } row.action() end %> + + <%= summary_list.row do |row| + row.key { 'Data protection officer' } + row.value { @user.is_data_protection_officer? ? "Yes" : "No" } + row.action(visually_hidden_text: "are #{current_user == @user ? "you" : "they"} a data protection officer?", href: edit_user_path, html_attributes: { "data-qa": "change-are-#{current_user == @user ? "you" : "they"}-a-data-protection-officer" }) + end %> <% end %>
diff --git a/db/migrate/20220328105332_change_dpo_to_attribute.rb b/db/migrate/20220328105332_change_dpo_to_attribute.rb new file mode 100644 index 000000000..6461fd87c --- /dev/null +++ b/db/migrate/20220328105332_change_dpo_to_attribute.rb @@ -0,0 +1,7 @@ +class ChangeDpoToAttribute < ActiveRecord::Migration[7.0] + def change + change_table :users, bulk: true do |t| + t.column :is_dpo, :boolean, default: false + end + end +end diff --git a/db/schema.rb b/db/schema.rb index 1468b9dc9..c5592dc25 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -315,6 +315,7 @@ ActiveRecord::Schema[7.0].define(version: 202202071123100) do t.integer "failed_attempts", default: 0 t.string "unlock_token" t.datetime "locked_at", precision: nil + t.boolean "is_dpo", default: false t.string "phone" t.index ["email"], name: "index_users_on_email", unique: true t.index ["organisation_id"], name: "index_users_on_organisation_id" diff --git a/spec/factories/user.rb b/spec/factories/user.rb index b3dc82583..0de22af97 100644 --- a/spec/factories/user.rb +++ b/spec/factories/user.rb @@ -9,7 +9,7 @@ FactoryBot.define do role { "data_coordinator" } end trait :data_protection_officer do - role { "data_protection_officer" } + is_dpo { true } end created_at { Time.zone.now } updated_at { Time.zone.now } diff --git a/spec/features/user_spec.rb b/spec/features/user_spec.rb index 5f68bcc62..b30f8a855 100644 --- a/spec/features/user_spec.rb +++ b/spec/features/user_spec.rb @@ -236,5 +236,48 @@ RSpec.describe "User Features" do expect(page).to have_content(/Enter an email address in the correct format, like name@example.com/) expect(page).to have_title("Error") end + + it "sets name, email, role and is_dpo" do + visit("users/new") + fill_in("user[name]", with: "New User") + fill_in("user[email]", with: "newuser@example.com") + choose("user-role-data-provider-field") + choose("user-is-dpo-true-field") + click_button("Continue") + expect( + User.find_by(name: "New User", email: "newuser@example.com", role: "data_provider", is_dpo: true), + ).to be_a(User) + end + + it "defaults to is_dpo false" do + visit("users/new") + expect(page).to have_field("user[is_dpo]", with: false) + end + end + + context "when editing someone elses account details" do + let!(:user) { FactoryBot.create(:user, :data_coordinator, last_sign_in_at: Time.zone.now) } + let!(:other_user) { FactoryBot.create(:user, name: "Other name", is_dpo: true, organisation: user.organisation) } + + before do + visit("/logs") + fill_in("user[email]", with: user.email) + fill_in("user[password]", with: "pAssword1") + click_button("Sign in") + end + + it "allows updating other users details" do + visit("/organisations/#{user.organisation.id}") + click_link("Users") + click_link(other_user.name) + expect(page).to have_title("Other name’s account") + first(:link, "Change").click + expect(page).to have_field("user[is_dpo]", with: true) + choose("user-is-dpo-field") + fill_in("user[name]", with: "Updated new name") + click_button("Save changes") + expect(page).to have_title("Updated new name’s account") + expect(User.find_by(name: "Updated new name", role: "data_provider", is_dpo: false)).to be_a(User) + end end end diff --git a/spec/models/user_spec.rb b/spec/models/user_spec.rb index 934ab57a1..a59cd8fdd 100644 --- a/spec/models/user_spec.rb +++ b/spec/models/user_spec.rb @@ -46,6 +46,15 @@ RSpec.describe User, type: :model do expect(user.data_provider?).to be true expect(user.data_coordinator?).to be false end + + it "is not a data protection officer by default" do + expect(user.is_data_protection_officer?).to be false + end + + it "can be set to data protection officer" do + expect { user.is_data_protection_officer! } + .to change { user.reload.is_data_protection_officer? }.from(false).to(true) + end end describe "paper trail" do diff --git a/spec/requests/users_controller_spec.rb b/spec/requests/users_controller_spec.rb index fb4623e28..c71eaf0f8 100644 --- a/spec/requests/users_controller_spec.rb +++ b/spec/requests/users_controller_spec.rb @@ -2,11 +2,12 @@ require "rails_helper" RSpec.describe UsersController, type: :request do let(:user) { FactoryBot.create(:user) } - let(:unauthorised_user) { FactoryBot.create(:user) } + let(:other_user) { FactoryBot.create(:user) } let(:headers) { { "Accept" => "text/html" } } let(:page) { Capybara::Node::Simple.new(response.body) } - let(:new_value) { "new test name" } - let(:params) { { id: user.id, user: { name: new_value } } } + let(:new_name) { "new test name" } + let(:new_email) { "new@example.com" } + let(:params) { { id: user.id, user: { name: new_name } } } let(:notify_client) { instance_double(Notifications::Client) } let(:devise_notify_mailer) { DeviseNotifyMailer.new } @@ -56,7 +57,7 @@ RSpec.describe UsersController, type: :request do context "when the reset token is valid" do let(:params) do { - id: user.id, user: { password: new_value, password_confirmation: "something_else" } + id: user.id, user: { password: new_name, password_confirmation: "something_else" } } end @@ -78,8 +79,8 @@ RSpec.describe UsersController, type: :request do { id: user.id, user: { - password: new_value, - password_confirmation: new_value, + password: new_name, + password_confirmation: new_name, reset_password_token: raw, }, } @@ -109,147 +110,404 @@ RSpec.describe UsersController, type: :request do end end - describe "#show" do - context "when the current user matches the user ID" do - before do - sign_in user - get "/users/#{user.id}", headers: headers, params: {} - end + context "when user is signed in as a data provider" do + describe "#show" do + context "when the current user matches the user ID" do + before do + sign_in user + get "/users/#{user.id}", headers: headers, params: {} + end - it "show the user details" do - expect(page).to have_content("Your account") + it "show the user details" do + expect(page).to have_content("Your account") + end end - end - context "when the current user does not matches the user ID" do - before do - sign_in user - get "/users/#{unauthorised_user.id}", headers: headers, params: {} - end + context "when the current user does not matches the user ID" do + before do + sign_in user + get "/users/#{other_user.id}", headers: headers, params: {} + end - it "returns not found 404" do - expect(response).to have_http_status(:not_found) - end + it "returns not found 404" do + expect(response).to have_http_status(:not_found) + end - it "shows the 404 view" do - expect(page).to have_content("Page not found") + it "shows the 404 view" do + expect(page).to have_content("Page not found") + end end end - end - describe "#edit" do - context "when the current user matches the user ID" do - before do - sign_in user - get "/users/#{user.id}/edit", headers: headers, params: {} + describe "#edit" do + context "when the current user matches the user ID" do + before do + sign_in user + get "/users/#{user.id}/edit", headers: headers, params: {} + end + + it "show the edit personal details page" do + expect(page).to have_content("Change your personal details") + end end - it "show the edit personal details page" do - expect(page).to have_content("Change your personal details") + context "when the current user does not matches the user ID" do + before do + sign_in user + get "/users/#{other_user.id}/edit", headers: headers, params: {} + end + + it "returns not found 404" do + expect(response).to have_http_status(:not_found) + end end end - context "when the current user does not matches the user ID" do - before do - sign_in user - get "/users/#{unauthorised_user.id}/edit", headers: headers, params: {} + describe "#edit_password" do + context "when the current user matches the user ID" do + before do + sign_in user + get "/users/#{user.id}/password/edit", headers: headers, params: {} + end + + it "shows the edit password page" do + expect(page).to have_content("Change your password") + end + + it "shows the password requirements hint" do + expect(page).to have_css("#user-password-hint") + end end - it "returns not found 404" do - expect(response).to have_http_status(:not_found) + context "when the current user does not matches the user ID" do + before do + sign_in user + get "/users/#{other_user.id}/edit", headers: headers, params: {} + end + + it "returns not found 404" do + expect(response).to have_http_status(:not_found) + end end end - end - describe "#edit_password" do - context "when the current user matches the user ID" do - before do - sign_in user - get "/users/#{user.id}/password/edit", headers: headers, params: {} - end + describe "#update" do + context "when the current user matches the user ID" do + before do + sign_in user + patch "/users/#{user.id}", headers: headers, params: params + end - it "shows the edit password page" do - expect(page).to have_content("Change your password") + it "updates the user" do + user.reload + expect(user.name).to eq(new_name) + end + + it "tracks who updated the record" do + user.reload + whodunnit_actor = user.versions.last.actor + expect(whodunnit_actor).to be_a(User) + expect(whodunnit_actor.id).to eq(user.id) + end + + context "when user changes email and dpo" do + let(:params) { { id: user.id, user: { name: new_name, email: new_email, is_dpo: "true" } } } + + it "allows changing email and dpo" do + user.reload + expect(user.email).to eq(new_email) + expect(user.is_data_protection_officer?).to be true + end + end end - it "shows the password requirements hint" do - expect(page).to have_css("#user-password-hint") + context "when the update fails to persist" do + before do + sign_in user + allow(User).to receive(:find_by).and_return(user) + allow(user).to receive(:update).and_return(false) + patch "/users/#{user.id}", headers: headers, params: params + end + + it "show an error" do + expect(response).to have_http_status(:unprocessable_entity) + end end - end - context "when the current user does not matches the user ID" do - before do - sign_in user - get "/users/#{unauthorised_user.id}/edit", headers: headers, params: {} + context "when the current user does not matches the user ID" do + let(:params) { { id: other_user.id, user: { name: new_name } } } + + before do + sign_in user + patch "/users/#{other_user.id}", headers: headers, params: params + end + + it "returns not found 404" do + expect(response).to have_http_status(:not_found) + end end - it "returns not found 404" do - expect(response).to have_http_status(:not_found) + context "when we update the user password" do + let(:params) do + { + id: user.id, user: { password: new_name, password_confirmation: "something_else" } + } + end + + before do + sign_in user + patch "/users/#{user.id}", headers: headers, params: params + end + + it "shows an error if passwords don't match" do + expect(response).to have_http_status(:unprocessable_entity) + expect(page).to have_selector("#error-summary-title") + end end end end - describe "#update" do - context "when the current user matches the user ID" do - before do - sign_in user - patch "/users/#{user.id}", headers: headers, params: params - end + context "when user is signed in as a data coordinator" do + let(:user) { FactoryBot.create(:user, :data_coordinator) } + let(:other_user) { FactoryBot.create(:user, organisation: user.organisation) } - it "updates the user" do - user.reload - expect(user.name).to eq(new_value) + describe "#show" do + context "when the current user matches the user ID" do + before do + sign_in user + get "/users/#{user.id}", headers: headers, params: {} + end + + it "show the user details" do + expect(page).to have_content("Your account") + end end - it "tracks who updated the record" do - user.reload - whodunnit_actor = user.versions.last.actor - expect(whodunnit_actor).to be_a(User) - expect(whodunnit_actor.id).to eq(user.id) + context "when the current user does not matches the user ID" do + before do + sign_in user + get "/users/#{other_user.id}", headers: headers, params: {} + end + + context "when the user is part of the same organisation as the current user" do + it "returns 200" do + expect(response).to have_http_status(:ok) + end + + it "shows the user details page" do + expect(page).to have_content("#{other_user.name}’s account") + end + end + + context "when the user is not part of the same organisation as the current user" do + let(:other_user) { FactoryBot.create(:user) } + + it "returns not found 404" do + expect(response).to have_http_status(:not_found) + end + + it "shows the 404 view" do + expect(page).to have_content("Page not found") + end + end end end - context "when the update fails to persist" do - before do - sign_in user - allow(User).to receive(:find_by).and_return(user) - allow(user).to receive(:update).and_return(false) - patch "/users/#{user.id}", headers: headers, params: params + describe "#edit" do + context "when the current user matches the user ID" do + before do + sign_in user + get "/users/#{user.id}/edit", headers: headers, params: {} + end + + it "show the edit personal details page" do + expect(page).to have_content("Change your personal details") + end end - it "show an error" do - expect(response).to have_http_status(:unprocessable_entity) + context "when the current user does not matches the user ID" do + before do + sign_in user + get "/users/#{other_user.id}/edit", headers: headers, params: {} + end + + context "when the user is part of the same organisation as the current user" do + it "returns 200" do + expect(response).to have_http_status(:ok) + end + + it "shows the user details page" do + expect(page).to have_content("Change #{other_user.name}’s personal details") + end + end + + context "when the user is not part of the same organisation as the current user" do + let(:other_user) { FactoryBot.create(:user) } + + it "returns not found 404" do + expect(response).to have_http_status(:not_found) + end + end end end - context "when the current user does not matches the user ID" do - let(:params) { { id: unauthorised_user.id, user: { name: new_value } } } + describe "#edit_password" do + context "when the current user matches the user ID" do + before do + sign_in user + get "/users/#{user.id}/password/edit", headers: headers, params: {} + end - before do - sign_in user - patch "/users/#{unauthorised_user.id}", headers: headers, params: params + it "shows the edit password page" do + expect(page).to have_content("Change your password") + end + + it "shows the password requirements hint" do + expect(page).to have_css("#user-password-hint") + end end - it "returns not found 404" do - expect(response).to have_http_status(:not_found) + context "when the current user does not matches the user ID" do + before do + sign_in user + get "/users/#{other_user.id}/password/edit", headers: headers, params: {} + end + + it "returns not found 404" do + expect(response).to have_http_status(:not_found) + end end end - context "when we update the user password" do - let(:params) do - { - id: user.id, user: { password: new_value, password_confirmation: "something_else" } - } + describe "#update" do + context "when the current user matches the user ID" do + before do + sign_in user + patch "/users/#{user.id}", headers: headers, params: params + end + + it "updates the user" do + user.reload + expect(user.name).to eq(new_name) + end + + it "tracks who updated the record" do + user.reload + whodunnit_actor = user.versions.last.actor + expect(whodunnit_actor).to be_a(User) + expect(whodunnit_actor.id).to eq(user.id) + end + + context "when user changes email and dpo" do + let(:params) { { id: user.id, user: { name: new_name, email: new_email, is_dpo: "true" } } } + + it "allows changing email and dpo" do + user.reload + expect(user.email).to eq(new_email) + expect(user.is_data_protection_officer?).to be true + end + end + + context "when we update the user password" do + let(:params) do + { + id: user.id, user: { password: new_name, password_confirmation: "something_else" } + } + end + + before do + sign_in user + patch "/users/#{user.id}", headers: headers, params: params + end + + it "shows an error if passwords don't match" do + expect(response).to have_http_status(:unprocessable_entity) + expect(page).to have_selector("#error-summary-title") + end + end end - before do - sign_in user - patch "/users/#{user.id}", headers: headers, params: params + context "when the current user does not matches the user ID" do + before do + sign_in user + end + + context "when the user is part of the same organisation as the current user" do + it "updates the user" do + expect { patch "/users/#{other_user.id}", headers: headers, params: params } + .to change { other_user.reload.name }.from(other_user.name).to(new_name) + end + + it "tracks who updated the record" do + expect { patch "/users/#{other_user.id}", headers: headers, params: params } + .to change { other_user.reload.versions.last.actor&.id }.from(nil).to(user.id) + end + + context "when user changes email and dpo" do + let(:params) { { id: other_user.id, user: { name: new_name, email: new_email, is_dpo: "true" } } } + + it "allows changing email and dpo" do + patch "/users/#{other_user.id}", headers: headers, params: params + other_user.reload + expect(other_user.email).to eq(new_email) + expect(other_user.is_data_protection_officer?).to be true + end + end + + it "does not bypass sign in for the coordinator" do + patch "/users/#{other_user.id}", headers: headers, params: params + follow_redirect! + expect(page).to have_content("#{other_user.reload.name}’s account") + expect(page).to have_content(other_user.reload.email.to_s) + end + + context "when we try to update the user password" do + let(:params) do + { + id: user.id, user: { password: new_name, password_confirmation: new_name, name: "new name" } + } + end + + it "does not update the password" do + expect { patch "/users/#{other_user.id}", headers: headers, params: params } + .not_to change(other_user, :encrypted_password) + end + + it "does update other values" do + expect { patch "/users/#{other_user.id}", headers: headers, params: params } + .to change { other_user.reload.name }.from("Danny Rojas").to("new name") + end + end + end + + context "when the current user does not matches the user ID" do + context "when the user is not part of the same organisation as the current user" do + let(:other_user) { FactoryBot.create(:user) } + let(:params) { { id: other_user.id, user: { name: new_name } } } + + before do + sign_in user + patch "/users/#{other_user.id}", headers: headers, params: params + end + + it "returns not found 404" do + expect(response).to have_http_status(:not_found) + end + end + end end - it "shows an error if passwords don't match" do - expect(response).to have_http_status(:unprocessable_entity) - expect(page).to have_selector("#error-summary-title") + context "when the update fails to persist" do + before do + sign_in user + allow(User).to receive(:find_by).and_return(user) + allow(user).to receive(:update).and_return(false) + patch "/users/#{user.id}", headers: headers, params: params + end + + it "show an error" do + expect(response).to have_http_status(:unprocessable_entity) + end end end end diff --git a/spec/services/imports/data_protection_confirmation_import_service_spec.rb b/spec/services/imports/data_protection_confirmation_import_service_spec.rb index fd513138c..eddfca6a2 100644 --- a/spec/services/imports/data_protection_confirmation_import_service_spec.rb +++ b/spec/services/imports/data_protection_confirmation_import_service_spec.rb @@ -34,7 +34,7 @@ RSpec.describe Imports::DataProtectionConfirmationImportService do it "creates a data protection officer without sign in credentials" do expect { import_service.create_data_protection_confirmations("data_protection_directory") } .to change(User, :count).by(1) - data_protection_officer = User.find_by(organisation:, role: "data_protection_officer") + data_protection_officer = User.find_by(organisation:, is_dpo: true) expect(data_protection_officer.email).to eq("") end