diff --git a/.rubocop.yml b/.rubocop.yml index 2b259040..22fc107a 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -86,6 +86,7 @@ Layout/LineLength: Exclude: - 'lib/tasks/yarn.rake' - 'spec/support/api_helpers.rb' + - 'app/controllers/user_details_controller.rb' Metrics/BlockLength: Max: 40 diff --git a/app/assets/stylesheets/components/button/_button.scss b/app/assets/stylesheets/components/button/_button.scss index 1c08b9b4..0538f9a0 100644 --- a/app/assets/stylesheets/components/button/_button.scss +++ b/app/assets/stylesheets/components/button/_button.scss @@ -37,4 +37,8 @@ $button-shadow-size: $govuk-border-width-form-element; .govuk-back-link { background-color: transparent; border: none; +} + +.button-group { + align-items: normal; } \ No newline at end of file diff --git a/app/controllers/user_details_controller.rb b/app/controllers/user_details_controller.rb index dc7ee1fe..d707b9c6 100644 --- a/app/controllers/user_details_controller.rb +++ b/app/controllers/user_details_controller.rb @@ -49,6 +49,32 @@ def update_email render :edit_email, status: :service_unavailable end + def deactivate_confirmation + return if current_user.can_deactivate? + + redirect_to user_detail_path, + alert: 'You cannot deactivate your account because you are the only active user for a linked supplier.' + end + + def deactivate + unless current_user.can_deactivate? + return redirect_to user_detail_path, + alert: 'You cannot deactivate your account because you are the only active user for a linked supplier.' + end + + API::User.deactivate + + reset_session + + redirect_to root_path, notice: 'Your account has been deactivated.' + rescue JSONAPI::Consumer::Errors::UnprocessableEntity + redirect_to user_detail_path, + alert: 'You can no longer deactivate your account because you are the only active user for a linked supplier.' + rescue JSONAPI::Consumer::Errors::ConnectionError + redirect_to deactivate_confirmation_user_detail_path, + alert: 'There was a problem connecting to the user service. Please try again later.' + end + private def email_update_validation(email, confirmation) diff --git a/app/models/api/user.rb b/app/models/api/user.rb index 984def89..6cb86013 100644 --- a/app/models/api/user.rb +++ b/app/models/api/user.rb @@ -2,6 +2,7 @@ module API class User < Base custom_endpoint :update_name, on: :collection, request_method: :patch custom_endpoint :update_email, on: :collection, request_method: :patch + custom_endpoint :deactivate, on: :collection, request_method: :patch custom_endpoint :user_auth_logs, on: :collection, request_method: :get end end diff --git a/app/views/user_details/deactivate_confirmation.html.haml b/app/views/user_details/deactivate_confirmation.html.haml new file mode 100644 index 00000000..25073c95 --- /dev/null +++ b/app/views/user_details/deactivate_confirmation.html.haml @@ -0,0 +1,27 @@ +- page_title 'Deactivate my account' + += link_to 'Back', user_detail_path, { class: 'govuk-back-link', title: 'Back to user profile page' } + +.govuk-grid-row + .govuk-grid-column-three-quarters + %h1.govuk-heading-xl + Deactivate my account + + %p + By choosing to proceed you will be logged out of the Report-MI service and will no longer be able + to log in using this account. Management information tasks you have previously completed on behalf + of your organisation(s) are preserved and your account access can be restored in the future by + contacting support: + = mail_to(support_email_address) + + .govuk-button-group{ class: 'button-group govuk-!-margin-top-7' } + = button_to 'Continue to deactivate my account', + deactivate_user_detail_path, + method: :patch, + class: 'govuk-button', + data: { module: 'govuk-button' } + + = link_to 'Cancel', + user_detail_path, + class: 'govuk-button' + \ No newline at end of file diff --git a/app/views/user_details/show.html.haml b/app/views/user_details/show.html.haml index 081cd35e..4230b760 100644 --- a/app/views/user_details/show.html.haml +++ b/app/views/user_details/show.html.haml @@ -38,6 +38,8 @@ %td.govuk-table__cell User since %td.govuk-table__cell= user_created_date(current_user).to_fs(:default) %td.govuk-table__cell + - if current_user.can_deactivate? + = link_to 'Deactivate account', deactivate_confirmation_user_detail_path, class: 'govuk-link' - if @user_logs.present? %tr.govuk-table__row %td.govuk-table__cell Previous logins diff --git a/config/routes.rb b/config/routes.rb index cbedcae9..df3d51b1 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -44,6 +44,8 @@ resource :user_detail, only: %i[show edit update] do get :edit_email patch :update_email + get :deactivate_confirmation, path: 'deactivate' + patch :deactivate end match '/auth/:provider/callback', to: 'sessions#create', via: %i[get post] diff --git a/spec/features/user_can_deactivate_account_spec.rb b/spec/features/user_can_deactivate_account_spec.rb new file mode 100644 index 00000000..1e3d6190 --- /dev/null +++ b/spec/features/user_can_deactivate_account_spec.rb @@ -0,0 +1,30 @@ +require 'rails_helper' + +RSpec.describe 'deactivating an account' do + before do + mock_sso_with(email: 'email@example.com') + mock_notifications_endpoint! + mock_user_who_can_deactivate_endpoint! + mock_suppliers_endpoint! + mock_email_verification_pending_endpoint! + mock_user_auth_logs_endpoint! + mock_deactivate_user_endpoint! + end + + scenario 'an eligible user deactivates their account' do + visit '/' + click_button 'sign-in' + visit user_detail_path + + click_link 'Deactivate account' + + expect(page).to have_content 'Deactivate my account' + expect(page).to have_link('Back', href: user_detail_path) + expect(page).to have_link('Cancel', href: user_detail_path) + + click_button 'Continue to deactivate my account' + + expect(page).to have_current_path(root_path) + expect(page).to have_content 'Your account has been deactivated.' + end +end diff --git a/spec/fixtures/mocks/user_deactivated.json b/spec/fixtures/mocks/user_deactivated.json new file mode 100644 index 00000000..3fa20277 --- /dev/null +++ b/spec/fixtures/mocks/user_deactivated.json @@ -0,0 +1,16 @@ +{ + "data": { + "id": "111c2bdd-4090-466b-8dec-66cdf0fc4871", + "type": "users", + "attributes": { + "multiple_suppliers?": false, + "can_deactivate?": true, + "name": "User Name", + "email": "user@example.com", + "created_at": "2025-12-09T15:25:43.025Z" + } + }, + "jsonapi": { + "version": "1.0" + } +} \ No newline at end of file diff --git a/spec/fixtures/mocks/user_who_can_deactivate.json b/spec/fixtures/mocks/user_who_can_deactivate.json new file mode 100644 index 00000000..29f9949f --- /dev/null +++ b/spec/fixtures/mocks/user_who_can_deactivate.json @@ -0,0 +1,18 @@ +{ + "data": [ + { + "id": "efa8ebb8-de51-4718-b085-a583f8d41e3f", + "type": "users", + "attributes": { + "multiple_suppliers?": false, + "can_deactivate?": true, + "name": "User Name", + "email": "user@example.com", + "created_at": "2023-10-01T12:00:00Z" + } + } + ], + "jsonapi": { + "version": "1.0" + } +} diff --git a/spec/fixtures/mocks/user_who_cannot_deactivate.json b/spec/fixtures/mocks/user_who_cannot_deactivate.json new file mode 100644 index 00000000..8b28bb98 --- /dev/null +++ b/spec/fixtures/mocks/user_who_cannot_deactivate.json @@ -0,0 +1,18 @@ +{ + "data": [ + { + "id": "efa8ebb8-de51-4718-b085-a583f8d41e3f", + "type": "users", + "attributes": { + "multiple_suppliers?": false, + "can_deactivate?": false, + "name": "User Name", + "email": "user@example.com", + "created_at": "2023-10-01T12:00:00Z" + } + } + ], + "jsonapi": { + "version": "1.0" + } +} diff --git a/spec/fixtures/mocks/user_with_multiple_suppliers.json b/spec/fixtures/mocks/user_with_multiple_suppliers.json index c85ac44d..88afca7a 100644 --- a/spec/fixtures/mocks/user_with_multiple_suppliers.json +++ b/spec/fixtures/mocks/user_with_multiple_suppliers.json @@ -5,6 +5,7 @@ "type": "users", "attributes": { "multiple_suppliers?": true, + "can_deactivate?": true, "name": "User Name", "email": "user@example.com", "created_at": "2023-10-01T12:00:00Z" diff --git a/spec/requests/user_details_spec.rb b/spec/requests/user_details_spec.rb index d2997b4a..2811cca0 100644 --- a/spec/requests/user_details_spec.rb +++ b/spec/requests/user_details_spec.rb @@ -3,7 +3,6 @@ RSpec.describe 'the user details page' do before do mock_notifications_endpoint! - mock_user_with_multiple_suppliers_endpoint! mock_suppliers_endpoint! mock_email_verification_pending_endpoint! mock_user_auth_logs_endpoint! @@ -12,6 +11,7 @@ describe 'visiting the user profile page' do it 'shows the user details' do stub_signed_in_user + mock_user_with_multiple_suppliers_endpoint! get user_detail_path @@ -23,11 +23,77 @@ expect(response.body).to include 'Bandersnatch' expect(response.body).to include 'San Junipero' end + + context 'when the user can deactivate their account' do + it 'shows the deactivate account option' do + stub_signed_in_user + mock_user_with_multiple_suppliers_endpoint! + + get user_detail_path + + expect(response).to be_successful + expect(response.body).to include 'Deactivate account' + end + end + + context 'when the user cannot deactivate their account' do + it 'does not show the deactivate account option' do + stub_signed_in_user + mock_user_who_cannot_deactivate_endpoint! + + get user_detail_path + + expect(response).to be_successful + expect(response.body).not_to include 'Deactivate my account' + end + end + end + + describe 'GET /user_detail/deactivate' do + context 'when the user can deactivate their account' do + it 'renders the deactivate confirmation page' do + stub_signed_in_user + mock_user_with_multiple_suppliers_endpoint! + + get deactivate_confirmation_user_detail_path + + expect(response).to be_successful + expect(response.body).to include 'Deactivate my account' + end + end + + context 'when deactivating the account is successful' do + it 'redirects to the sign out page with a notice' do + stub_signed_in_user + mock_user_with_multiple_suppliers_endpoint! + mock_deactivate_user_endpoint! + + patch deactivate_user_detail_path + + expect(response).to redirect_to(root_path) + expect(flash[:notice]).to eq 'Your account has been deactivated.' + end + end + + context 'when the user cannot deactivate their account' do + it 'redirects to the user profile page with an alert' do + stub_signed_in_user + mock_user_who_cannot_deactivate_endpoint! + + get deactivate_confirmation_user_detail_path + + expect(response).to redirect_to(user_detail_path) + follow_redirect! + + expect(response.body).to include 'You cannot deactivate your account because you are the only active user for' + end + end end describe 'GET /user_detail/edit' do it 'renders the edit form' do stub_signed_in_user + mock_user_with_multiple_suppliers_endpoint! get edit_user_detail_path @@ -41,6 +107,7 @@ describe 'PATCH /user_detail' do it 'updates the user name and redirects to the profile page on success' do stub_signed_in_user + mock_user_with_multiple_suppliers_endpoint! mock_update_user_name_endpoint! patch user_detail_path, params: { name: 'New Name' } @@ -53,6 +120,7 @@ it 're-renders the edit form with an error message on failure' do stub_signed_in_user + mock_user_with_multiple_suppliers_endpoint! mock_update_user_name_endpoint_failure! patch user_detail_path, params: { name: 'New Name' } @@ -63,6 +131,7 @@ it 'handles connection errors gracefully' do stub_signed_in_user + mock_user_with_multiple_suppliers_endpoint! mock_update_user_name_endpoint_connection_error! patch user_detail_path, params: { name: 'New Name' } diff --git a/spec/support/api_helpers.rb b/spec/support/api_helpers.rb index 3213d532..09a5b438 100644 --- a/spec/support/api_helpers.rb +++ b/spec/support/api_helpers.rb @@ -387,6 +387,30 @@ def mock_user_with_multiple_suppliers_endpoint! ) end + def mock_user_who_cannot_deactivate_endpoint! + stub_request(:get, api_url("users?filter[auth_id]=#{JWT.encode(mock_auth_id, 'test')}")) + .to_return( + headers: json_headers, + body: json_fixture_file('user_who_cannot_deactivate.json') + ) + end + + def mock_user_who_can_deactivate_endpoint! + stub_request(:get, api_url("users?filter[auth_id]=#{JWT.encode(mock_auth_id, 'test')}")) + .to_return( + headers: json_headers, + body: json_fixture_file('user_who_can_deactivate.json') + ) + end + + def mock_deactivate_user_endpoint! + stub_request(:patch, api_url('users/deactivate')) + .to_return( + headers: json_headers, + body: json_fixture_file('user_deactivated.json') + ) + end + def mock_customer_effort_score_endpoint! feedback_params = { data: {