diff --git a/CHANGELOG.md b/CHANGELOG.md index 471bcf6ce..c8cbb8217 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,8 @@ [Full changelog][unreleased] +- Add functionality to anonymise a user + ## Release 161 - 2025-01-14 [Full changelog][161] diff --git a/app/controllers/users_controller.rb b/app/controllers/users_controller.rb index d76089f6d..e76ccc956 100644 --- a/app/controllers/users_controller.rb +++ b/app/controllers/users_controller.rb @@ -97,6 +97,28 @@ def reactivate add_breadcrumb t("breadcrumb.users.reactivate"), reactivate_user_path(@user) end + def anonymise + @user = User.find(id) + authorize @user + + add_breadcrumb t("breadcrumb.users.edit"), edit_user_path(@user) + add_breadcrumb t("breadcrumb.users.anonymise"), anonymise_user_path(@user) + end + + def anonymise_update + @user = User.find(id) + authorize @user + + result = AnonymiseUser.new(user: @user).call + + if result.success? + flash[:notice] = t("action.user.update.success_anonymised") + redirect_to user_path(@user) + else + render :anonymise + end + end + def user_params params.require(:user).permit(:name, :email, :organisation_id, :active, :reset_mfa, additional_organisations: []) end diff --git a/app/models/user.rb b/app/models/user.rb index c13cbc006..203ba2c8e 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -15,8 +15,8 @@ class User < ApplicationRecord organisation_id: :organisation }.freeze - scope :active, -> { where(deactivated_at: nil) } - scope :deactivated, -> { where.not(deactivated_at: nil) } + scope :active, -> { where(deactivated_at: nil, anonymised_at: nil) } + scope :deactivated, -> { where(anonymised_at: nil).where.not(deactivated_at: nil) } scope :all_active, -> { active.includes(:organisation).joins(:organisation).order("organisations.name ASC, users.name ASC") @@ -70,6 +70,8 @@ def ensure_otp_secret! end def email_cannot_be_changed_after_create + return true if !anonymised_at.nil? + if email.to_s.squish.downcase != email_was.to_s.squish.downcase errors.add(:email, :cannot_be_changed) end diff --git a/app/policies/user_policy.rb b/app/policies/user_policy.rb index ca740da40..1fcf3573f 100644 --- a/app/policies/user_policy.rb +++ b/app/policies/user_policy.rb @@ -26,4 +26,12 @@ def deactivate? def reactivate? beis_user? end + + def anonymise? + beis_user? + end + + def anonymise_update? + beis_user? + end end diff --git a/app/services/anonymise_user.rb b/app/services/anonymise_user.rb new file mode 100644 index 000000000..b951193fd --- /dev/null +++ b/app/services/anonymise_user.rb @@ -0,0 +1,22 @@ +class AnonymiseUser + attr_accessor :user + + def initialize(user:) + self.user = user + end + + def call + result = Result.new(true) + + User.transaction do + user.anonymised_at = DateTime.now + new_email = "deleted.user.#{user.id}@#{user.email.split("@").last}" + user.email = new_email + user.name = "Deleted User #{user.id}" + user.mobile_number = "" + result.success = user.save! + end + + result + end +end diff --git a/app/views/users/_form.html.haml b/app/views/users/_form.html.haml index 4d4220c51..4537aea67 100644 --- a/app/views/users/_form.html.haml +++ b/app/views/users/_form.html.haml @@ -33,3 +33,4 @@ = link_to t("form.button.user.deactivate"), deactivate_user_path, class: "govuk-button govuk-button--secondary" - else = link_to t("form.button.user.reactivate"), reactivate_user_path, class: "govuk-button govuk-button--secondary" + = link_to t("form.button.user.anonymise"), anonymise_user_path, class: "govuk-button govuk-button--warning" diff --git a/app/views/users/anonymise.html.haml b/app/views/users/anonymise.html.haml new file mode 100644 index 000000000..908be2c2c --- /dev/null +++ b/app/views/users/anonymise.html.haml @@ -0,0 +1,14 @@ +=content_for :page_title_prefix, t("page_title.users.anonymise") + +%main.govuk-main-wrapper#main-content{ role: "main" } + .govuk-grid-row + .govuk-grid-column-two-thirds + %h1.govuk-heading-xl + = t("page_content.users.anonymise.title") + + = t("page_content.users.anonymise.content_html", email: @user.email) + + = form_with model: @user, url: anonymise_update_user_path do |f| + .govuk-button-group + = f.govuk_submit t("page_content.users.button.continue"), class: "govuk-button govuk-button--warning" + = link_to t("page_content.users.button.cancel"), edit_user_path, class: "govuk-button govuk-button--secondary" diff --git a/config/locales/models/user.en.yml b/config/locales/models/user.en.yml index af20853f5..7ac2932ff 100644 --- a/config/locales/models/user.en.yml +++ b/config/locales/models/user.en.yml @@ -10,12 +10,14 @@ en: success: User successfully updated success_deactivated: User successfully deactivated success_reactivated: User successfully reactivated + success_anonymised: User successfully anonymised form: button: user: submit: Submit deactivate: Deactivate user reactivate: Reactivate user + anonymise: Anonymise user label: user: email: Email address @@ -95,6 +97,28 @@ en:

Doing so will allow them to sign in to the application.

+ anonymise: + title: Anonymise user? + content_html: | +

+ You are about to anonymise the user %{email}. +

+ +

+ Doing so will prevent the user from signing in to the application. +

+ +

+ The changes the user has made will be preserved in the history but no personal information will be shown along with the change. +

+ +

+ Anonymous users are essentially deleted and cannot be re-activated. +

+ +

+ Should an anonymised user need to regain access to the application, a new users will have to be created. +

page_title: users: edit: Edit user @@ -103,6 +127,7 @@ en: show: User deactivate: Deactivate user reactivate: Reactivate user + anonymise: Anonymise user breadcrumb: users: edit: Edit user @@ -111,6 +136,7 @@ en: show: User deactivate: Deactivate user reactivate: Reactivate user + anonymise: Anonymise user tabs: users: active: Active diff --git a/config/routes.rb b/config/routes.rb index e75d30004..239f98166 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -33,6 +33,8 @@ member do get :deactivate get :reactivate + get :anonymise + patch :anonymise_update end end resources :activities, only: [:index] diff --git a/spec/features/beis_users_can_edit_a_user_spec.rb b/spec/features/beis_users_can_edit_a_user_spec.rb index 061ec5687..d1717ca83 100644 --- a/spec/features/beis_users_can_edit_a_user_spec.rb +++ b/spec/features/beis_users_can_edit_a_user_spec.rb @@ -101,6 +101,24 @@ expect(user.reload.active).to be(true) end + scenario "an inactive user can be anonymised" do + administrator_user = create(:beis_user) + user = create(:inactive_user, organisation: create(:partner_organisation)) + authenticate!(user: administrator_user) + + # Navigate to the users page + visit users_index_path(user_state: "inactive") + + find("tr", text: user.name).click_link("Edit") + + click_on t("form.button.user.anonymise") + click_on t("page_content.users.button.continue") + + expect(page).to have_content(t("action.user.update.success_anonymised")) + expect(user.reload.active).to be(false) + expect(user.reload.anonymised_at).to_not be(nil) + end + scenario "a user can have additional organisations" do administrator_user = create(:beis_user) authenticate!(user: administrator_user) diff --git a/spec/features/beis_users_can_view_other_users_spec.rb b/spec/features/beis_users_can_view_other_users_spec.rb index a799258f7..ae8d3c667 100644 --- a/spec/features/beis_users_can_view_other_users_spec.rb +++ b/spec/features/beis_users_can_view_other_users_spec.rb @@ -64,5 +64,17 @@ expect(page).to have_content("Active? No") expect(page).to have_content("1 hour") end + + scenario "an anonymised user cannot be viewed" do + anonymised_user = create(:inactive_user, deactivated_at: DateTime.now, anonymised_at: DateTime.now) + + # Navigate from the landing page + visit organisation_path(user.organisation) + click_on(t("page_title.users.index")) + # Navigate to inactive users tab + click_on(t("tabs.users.inactive")) + + expect(page).not_to have_content(anonymised_user.email) + end end end diff --git a/spec/models/user_spec.rb b/spec/models/user_spec.rb index ae586f34c..c1b872c57 100644 --- a/spec/models/user_spec.rb +++ b/spec/models/user_spec.rb @@ -14,6 +14,15 @@ expect(user.errors[:email]).to eq([I18n.t("activerecord.errors.models.user.attributes.email.cannot_be_changed")]) end + it "should allow an email to be changed if the user is anonymised" do + user = create(:administrator, email: "old@example.com") + + user.anonymised_at = DateTime.now + user.email = "new@example.com" + + expect(user).to be_valid + end + it "is not case sensitive" do # When a non-lowercase email address exists (Devise lowercases emails on creation so this is for pre-existing addresses) user = create(:partner_organisation_user) diff --git a/spec/services/anonymise_user_spec.rb b/spec/services/anonymise_user_spec.rb new file mode 100644 index 000000000..78148d9ef --- /dev/null +++ b/spec/services/anonymise_user_spec.rb @@ -0,0 +1,38 @@ +require "rails_helper" + +RSpec.describe AnonymiseUser do + let(:user) { create(:administrator) } + + describe "#call" do + it "returns a successful result" do + result = described_class.new(user: user).call + + expect(result.success?).to be(true) + expect(result.failure?).to be(false) + end + + it "anonymises the email address" do + email = user.email + described_class.new(user:).call + expect(user.email).not_to eql(email) + + # Domain element of email address should be unchanged: + expect(user.email.split("@").last).to eql(email.split("@").last) + end + + it "anonymises the name" do + name = user.name + described_class.new(user:).call + expect(user.name).not_to eql(name) + + # Check IDs match; they're in this format: + # "Deleted User ", "deleted.user.@" + expect(user.name.split(" ").last).to eql(user.email.split("@").first.split(".").last) + end + + it "removes the mobile number" do + described_class.new(user:).call + expect(user.mobile_number).to be_blank + end + end +end