From 9170f04e421562cb16b8fb6499fd8a1df53ea1c4 Mon Sep 17 00:00:00 2001 From: Daniel James Date: Sun, 19 Jan 2025 21:53:16 +0530 Subject: [PATCH] Migrate anonymization script to react --- app/controllers/admin_controller.rb | 36 ---- app/controllers/tickets_controller.rb | 157 ++++++++++++++++++ app/controllers/users_controller.rb | 22 --- app/models/anonymize_person.rb | 120 ------------- app/models/user.rb | 10 +- app/views/admin/anonymize_person.html.erb | 119 ------------- app/webpacker/components/Panel/PanelPages.jsx | 5 - .../pages/AnonymizationScriptPage/index.jsx | 55 ++++-- .../AnonymizationTicketWorkbench/index.jsx | 45 ----- .../AccountAnonymization.jsx | 0 .../AnonymizeAction.jsx | 43 +++++ .../PerformManualChecks.jsx | 33 ++++ .../ReviewSystemGeneratedChecks.jsx | 61 +++++++ .../VerifyAnonymizeDetails.jsx | 28 ++++ .../api/getDetailsBeforeAnonymization.js | 9 + .../index.jsx | 48 ++++++ app/webpacker/lib/requests/routes.js.erb | 7 +- config/locales/en.yml | 1 - config/routes.rb | 10 +- spec/controllers/admin_controller_spec.rb | 18 -- spec/controllers/tickets_controller_spec.rb | 89 ++++++++++ spec/models/anonymize_person_spec.rb | 63 ------- 22 files changed, 523 insertions(+), 456 deletions(-) delete mode 100644 app/models/anonymize_person.rb delete mode 100644 app/views/admin/anonymize_person.html.erb delete mode 100644 app/webpacker/components/Tickets/TicketWorkbenches/AnonymizationTicketWorkbench/index.jsx rename app/webpacker/components/Tickets/TicketWorkbenches/{AnonymizationTicketWorkbench => AnonymizationTicketWorkbenchForWrt}/AccountAnonymization.jsx (100%) create mode 100644 app/webpacker/components/Tickets/TicketWorkbenches/AnonymizationTicketWorkbenchForWrt/AnonymizeAction.jsx create mode 100644 app/webpacker/components/Tickets/TicketWorkbenches/AnonymizationTicketWorkbenchForWrt/PerformManualChecks.jsx create mode 100644 app/webpacker/components/Tickets/TicketWorkbenches/AnonymizationTicketWorkbenchForWrt/ReviewSystemGeneratedChecks.jsx create mode 100644 app/webpacker/components/Tickets/TicketWorkbenches/AnonymizationTicketWorkbenchForWrt/VerifyAnonymizeDetails.jsx create mode 100644 app/webpacker/components/Tickets/TicketWorkbenches/AnonymizationTicketWorkbenchForWrt/api/getDetailsBeforeAnonymization.js create mode 100644 app/webpacker/components/Tickets/TicketWorkbenches/AnonymizationTicketWorkbenchForWrt/index.jsx create mode 100644 spec/controllers/tickets_controller_spec.rb delete mode 100644 spec/models/anonymize_person_spec.rb diff --git a/app/controllers/admin_controller.rb b/app/controllers/admin_controller.rb index db8b9855e5c..75ec84733e0 100644 --- a/app/controllers/admin_controller.rb +++ b/app/controllers/admin_controller.rb @@ -382,42 +382,6 @@ def leader_senior_voters Competition.includes(associations).find_by_id!(params[:competition_id]) end - def anonymize_person - session[:anonymize_params] = {} - session[:anonymize_step] = nil - @anonymize_person = AnonymizePerson.new(session[:anonymize_params]) - @anonymize_person.current_step = session[:anonymize_step] - end - - def do_anonymize_person - session[:anonymize_params].deep_merge!((params[:anonymize_person]).permit(:person_wca_id)) if params[:anonymize_person] - @anonymize_person = AnonymizePerson.new(session[:anonymize_params]) - @anonymize_person.current_step = session[:anonymize_step] - - if @anonymize_person.valid? - - if params[:back_button] - @anonymize_person.previous_step! - elsif @anonymize_person.last_step? - do_anonymize_person_response = @anonymize_person.do_anonymize_person - - if do_anonymize_person_response && !do_anonymize_person_response[:error] && do_anonymize_person_response[:new_wca_id] - flash.now[:success] = "Successfully anonymized #{@anonymize_person.person_wca_id} to #{do_anonymize_person_response[:new_wca_id]}! Don't forget to run Compute Auxiliary Data and Export Public." - @anonymize_person = AnonymizePerson.new - else - flash.now[:danger] = do_anonymize_person_response[:error] || "Error anonymizing" - end - - else - @anonymize_person.next_step! - end - - session[:anonymize_step] = @anonymize_person.current_step - end - - render 'anonymize_person' - end - def finish_unfinished_persons @finish_persons = FinishPersonsForm.new( competition_ids: params[:competition_ids] || nil, diff --git a/app/controllers/tickets_controller.rb b/app/controllers/tickets_controller.rb index b3838937cef..e98da5b7fca 100644 --- a/app/controllers/tickets_controller.rb +++ b/app/controllers/tickets_controller.rb @@ -97,4 +97,161 @@ def edit_person_validators render json: { dob: dob_validation_issues } end + + private def user_details(user) + return nil if user.nil? + { + email: user.email, + dob: user.dob, + is_currently_banned: user.banned?, + banned_in_past: user.banned_in_past?, + } + end + + private def person_details(person) + return nil if person.nil? + { + dob: person.dob, + record_count: person.records, + championship_podiums: person.championship_podiums, + } + end + + private def get_user_and_person + user_id = params[:userId] + wca_id = params[:wcaId] + + if user_id && !wca_id + user = User.find(user_id) + person = user.person + elsif !user_id && wca_id + person = Person.find_by(wca_id: wca_id) + user = person.user + elsif user_id && wca_id + person = Person.find_by(wca_id: wca_id) + user = User.find(user_id) + end + [user, person] + end + + private def check_errors(user, person) + if user.present? && person.present? && person&.user != user + render status: :unprocessable_entity, json: { + error: "Person and user not linked.", + } + return true + end + + if user.nil? && person.nil? + render status: :unprocessable_entity, json: { + error: "User ID and WCA ID is not provided.", + } + return true + end + false + end + + def details_before_anonymization + user, person = get_user_and_person + return if check_errors(user, person) + + render json: { + user_details: user_details(user), + person_details: person_details(person), + } + end + + def anonymize + user, person = get_user_and_person + return if check_errors(user, person) + + if user&.banned? + return render status: :unprocessable_entity, json: { + error: "Error anonymizing: This person is currently banned and cannot be anonymized.", + } + end + + if person.present? + wca_id_year = person.wca_id[0..3] + semi_id, = FinishUnfinishedPersons.compute_semi_id(wca_id_year, User::ANONYMOUS_NAME) + new_wca_id, = FinishUnfinishedPersons.complete_wca_id(semi_id) + + if new_wca_id.nil? + return render status: :internal_server_error, json: { + error: "Error generating new WCA ID", + } + end + end + + if user.present? && person.present? + users_to_anonymize = User.where(id: user.id).or(User.where(unconfirmed_wca_id: person.wca_id)) + elsif user.present? && person.nil? + users_to_anonymize = User.where(id: user.id) + elsif user.nil? && person.present? + users_to_anonymize = User.where(unconfirmed_wca_id: person.wca_id) + end + + ActiveRecord::Base.transaction do + if person.present? + # Anonymize person's data in Results + person.results.update_all(personId: new_wca_id, personName: User::ANONYMOUS_NAME) + + # Anonymize person's data in Persons + if person.sub_ids.length > 1 + # if an updated person is due to a name change, this will delete the previous person. + # if an updated person is due to a country change, this will keep the sub person with an appropriate subId + previous_persons = Person.where(wca_id: wca_id).where.not(subId: 1).order(:subId) + current_sub_id = 1 + current_country_id = person.countryId + + previous_persons.each do |p| + if p.countryId == current_country_id + p.delete + else + current_sub_id += 1 + current_country_id = p.countryId + p.update( + wca_id: new_wca_id, + name: User::ANONYMOUS_NAME, + gender: User::ANONYMOUS_GENDER, + dob: User::ANONYMOUS_DOB, + subId: current_sub_id, + ) + end + end + end + # Anonymize person's data in Persons for subid 1 + person.update( + wca_id: new_wca_id, + name: User::ANONYMOUS_NAME, + gender: User::ANONYMOUS_GENDER, + dob: User::ANONYMOUS_DOB, + ) + end + + users_to_anonymize.each do |user_to_anonymize| + user_to_anonymize.skip_reconfirmation! + user_to_anonymize.update( + email: user_to_anonymize.id.to_s + User::ANONYMOUS_ACCOUNT_EMAIL_ID_SUFFIX, + name: User::ANONYMOUS_NAME, + unconfirmed_wca_id: nil, + delegate_id_to_handle_wca_id_claim: nil, + dob: User::ANONYMOUS_DOB, + gender: User::ANONYMOUS_GENDER, + current_sign_in_ip: nil, + last_sign_in_ip: nil, + # If the account associated with the WCA ID is a special account (delegate, organizer, + # team member) then we want to keep the link between the Person and the account. + wca_id: user&.is_special_account? ? new_wca_id : nil, + current_avatar_id: user&.is_special_account? ? nil : user_to_anonymize.current_avatar_id, + country_iso2: user&.is_special_account? ? user_to_anonymize.country_iso2 : nil, + ) + end + end + + render json: { + success: true, + new_wca_id: new_wca_id, + } + end end diff --git a/app/controllers/users_controller.rb b/app/controllers/users_controller.rb index 92b9d9bb006..4ad709de5dd 100644 --- a/app/controllers/users_controller.rb +++ b/app/controllers/users_controller.rb @@ -4,7 +4,6 @@ class UsersController < ApplicationController before_action :authenticate_user!, except: [:select_nearby_delegate, :acknowledge_cookies] before_action :check_recent_authentication!, only: [:enable_2fa, :disable_2fa, :regenerate_2fa_backup_codes] before_action :set_recent_authentication!, only: [:edit, :update, :enable_2fa, :disable_2fa] - before_action -> { redirect_to_root_unless_user(:can_admin_results?) }, only: [:anonymize] RECENT_AUTHENTICATION_DURATION = 10.minutes.freeze @@ -374,25 +373,4 @@ def acknowledge_cookies end true end - - def anonymize - user_id = params.require(:id) - user = User.find(user_id) - - user.skip_reconfirmation! - user_update_success = user.update( - email: user_id.to_s + User::ANONYMOUS_ACCOUNT_EMAIL_ID_SUFFIX, - name: User::ANONYMOUS_ACCOUNT_NAME, - wca_id: nil, - unconfirmed_wca_id: nil, - delegate_id_to_handle_wca_id_claim: nil, - dob: User::ANONYMOUS_ACCOUNT_DOB, - gender: User::ANONYMOUS_ACCOUNT_GENDER, - country_iso2: User::ANONYMOUS_ACCOUNT_COUNTRY_ISO2, - current_sign_in_ip: nil, - last_sign_in_ip: nil, - ) - - render json: { success: user_update_success } - end end diff --git a/app/models/anonymize_person.rb b/app/models/anonymize_person.rb deleted file mode 100644 index 23d53fb4c7a..00000000000 --- a/app/models/anonymize_person.rb +++ /dev/null @@ -1,120 +0,0 @@ -# frozen_string_literal: true - -class AnonymizePerson - include ActiveModel::Model - - ANONYMIZED_NAME = "Anonymous" - STEP_1 = "enter_wca_id" - STEP_2 = "request_data_removal" - - attr_writer :current_step - attr_reader :person_wca_id, :person, :account - - def person_wca_id=(wca_id) - @person_wca_id = wca_id - @person = Person.find_by_wca_id(person_wca_id) - @account = User.find_by_wca_id(person_wca_id) - end - - validates :person_wca_id, presence: true - - def current_step - @current_step || steps.first - end - - def steps - [STEP_1, STEP_2] - end - - def next_step! - self.current_step = steps[steps.index(current_step)+(1 % steps.length)] - end - - def previous_step! - self.current_step = steps[steps.index(current_step)-(1 % steps.length)] - end - - def first_step? - current_step == steps.first - end - - def last_step? - current_step == steps.last - end - - def do_anonymize_person - unless valid? - return { error: "invalid form" } - end - - if account&.banned? - return { error: "Error anonymizing: This person is currently banned and cannot be anonymized." } - end - - new_wca_id = generate_new_wca_id - unless new_wca_id - wca_id_year = person_wca_id[0..3] - return { error: "Error anonymizing: SubIds " + wca_id_year + "ANON00 to " + wca_id_year + "ANON99 are already taken." } - end - - ActiveRecord::Base.transaction do - # Anonymize data on Account - if account - account_to_update = User.where('id = ? OR unconfirmed_wca_id = ?', account.id, person_wca_id) - - # If the account associated with the WCA ID is a special account (delegate, organizer, team member) then we want to keep the link between the Person and the account - if account.is_special_account? - account_to_update.update_all(wca_id: new_wca_id, avatar: nil) - else - account_to_update.update_all(wca_id: nil, country_iso2: "US") - end - - account_to_update.update_all(email: account.id.to_s + "@worldcubeassociation.org", - name: ANONYMIZED_NAME, - unconfirmed_wca_id: nil, - delegate_id_to_handle_wca_id_claim: nil, - dob: '1954-12-04', - gender: "o", - current_sign_in_ip: nil, - last_sign_in_ip: nil) - end - - # Anonymize person's data in Results - person.results.update_all(personId: new_wca_id, personName: ANONYMIZED_NAME) - - # Anonymize person's data in Persons - if person.sub_ids.length > 1 - # if an updated person is due to a name change, this will delete the previous person. - # if an updated person is due to a country change, this will keep the sub person with an appropriate subId - previous_persons = Person.where(wca_id: person_wca_id).where.not(subId: 1).order(:subId) - current_sub_id = 1 - current_country_id = person.countryId - - previous_persons.each do |p| - if p.countryId == current_country_id - p.delete - else - current_sub_id += 1 - current_country_id = p.countryId - p.update(wca_id: new_wca_id, name: ANONYMIZED_NAME, gender: "o", dob: nil, subId: current_sub_id) - end - end - - end - - # Anonymize person's data in Persons for subid 1 - person.update(wca_id: new_wca_id, name: ANONYMIZED_NAME, gender: "o", dob: nil) - end - - { new_wca_id: new_wca_id } - end - - def generate_new_wca_id - competition_year = person_wca_id[0..3] - - semi_id, = FinishUnfinishedPersons.compute_semi_id(competition_year, ANONYMIZED_NAME) - wca_id, = FinishUnfinishedPersons.complete_wca_id(semi_id) - - wca_id - end -end diff --git a/app/models/user.rb b/app/models/user.rb index 52b1b99c655..76338041930 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -65,10 +65,10 @@ class User < ApplicationRecord } ANONYMOUS_ACCOUNT_EMAIL_ID_SUFFIX = '@worldcubeassociation.org' - ANONYMOUS_ACCOUNT_NAME = 'Anonymous' - ANONYMOUS_ACCOUNT_DOB = '1954-12-04' - ANONYMOUS_ACCOUNT_GENDER = 'o' - ANONYMOUS_ACCOUNT_COUNTRY_ISO2 = 'US' + ANONYMOUS_NAME = 'Anonymous' + ANONYMOUS_DOB = '1954-12-04' + ANONYMOUS_GENDER = 'o' + ANONYMOUS_COUNTRY_ISO2 = 'US' def self.eligible_voters [ @@ -655,7 +655,6 @@ def self.panel_pages :generateDataExports, :fixResults, :mergeProfiles, - :anonymizePerson, :reassignConnectedWcaId, ].index_with { |panel_page| panel_page.to_s.underscore.dasherize } end @@ -715,7 +714,6 @@ def self.panel_list panel_pages[:generateDataExports], panel_pages[:fixResults], panel_pages[:mergeProfiles], - panel_pages[:anonymizePerson], panel_pages[:reassignConnectedWcaId], ], }, diff --git a/app/views/admin/anonymize_person.html.erb b/app/views/admin/anonymize_person.html.erb deleted file mode 100644 index 8179c5e597d..00000000000 --- a/app/views/admin/anonymize_person.html.erb +++ /dev/null @@ -1,119 +0,0 @@ -<% provide(:title, 'Anonymize Person') %> - -
-

Anonymize person

- -

This process will take you through the steps required to anonymize a WCA Profile. This action is IRREVERSIBLE and any future results they earn through official competitions will result in a new WCA ID assignment and will not be linked to any previous official results earned before the removal of their data.

-

Before processing any anonymization requests, WRT must receive verification with a picture/copy of an official ID verification (passport, driver's license, etc.) with a minimum of their name and birthday (any other information may be blurred-out/obfuscated).

-

After going through the initial steps of requesting data removal from data processors, by clicking confirm:

-

- A new WCA ID will be generated (removing any form of the persons name from the WCAID).

-

- Personally identifiable information (PII) in the users, Results, and Persons tables will be anonymized.

-

- This PII includes: name, DOB, email, avatar, gender, and IP addresses).

- - <%= simple_form_for @anonymize_person, url: admin_do_anonymize_person_path do |f| %> - <% if @anonymize_person.current_step == AnonymizePerson::STEP_1 %> - <%= f.input :person_wca_id, as: :user_ids, persons_table: true, only_one: true, label: "Person to Anonymize" %> - <% elsif @anonymize_person.current_step == AnonymizePerson::STEP_2 %> - <% is_currently_banned = @anonymize_person.account&.banned? %> - <% banned_in_the_past = @anonymize_person.account&.banned_in_past? %> - <% records = @anonymize_person.person.records %> - <% championship_podiums = @anonymize_person.person.championship_podiums %> -

Important information to consider before proceeding:

-
    - <% if is_currently_banned %> -
  1. This person is currently banned and cannot be anonymized.
  2. - <% elsif banned_in_the_past %> -
  3. This person has been banned in the past, please email WIC and WRT to discuss whether to proceed with the anonymization
  4. - <% else %> -
  5. This person has never been banned.
  6. - <% end %> - <% if records[:total] > 0 %> -
  7. This person has held <%= records[:world] %> World Records, <%= records[:continental] %> Contential Records, and <%= records[:national] %> National Records
  8. - <% else %> -
  9. This person has never held any records.
  10. - <% end %> - <% if championship_podiums[:world].any? || championship_podiums[:continental].any? || championship_podiums[:national].any? %> -
  11. This person has achieved World Championship podium <%= championship_podiums[:world].size %> times, Continental Championship podium <%= championship_podiums[:continental].size %> times, and National Championship podium <%= championship_podiums[:national].size %> times.
  12. - <% else %> -
  13. This person has never been on the podium at the World, Continental, or National Championships.
  14. - <% end %> -
-

Request Data Removal from data processors

-
    -
  1. For recently competed competitions (Past 3 months), verify with the delegates that there is nothing outstanding regarding the competitor's involvement in these WCA competitions:
  2. - <% recent_competitions_3_months = @anonymize_person.person.competitions.select{ |c| c.start_date > (Date.today - 3.month) } %> - - <% if recent_competitions_3_months.any? %> - - <% recent_competitions_3_months.each do |c| %> -
    Contact <%= link_to c.display_name(short: true), competition_path(c) %> - <%= mail_to c.delegates.map(&:email).join(", "), name = c.delegates.map(&:name).join(", ") %>
    - <% end %> - - <% else %> -
    No recent competitions to check.
    - <% end %> - -
    - - <% if @anonymize_person.account %> -
  3. If you are an administrator of the WCA forum, search active users (https://forum.worldcubeassociation.org/admin/users/list/active) for any users using this email and anonymize their data. If you are not an administrator of the WCA forum, please ask a WRT member with administrator access to perform this step.
  4. -
    -
  5. Request data removal from OAuth Access Grants.
  6. - <% access_grants = @anonymize_person.account.oauth_access_grants.select { |access_grant| !access_grant.revoked_at.nil? } %> - - <% if access_grants.any? %> - - <% access_grants.each do |grant| %> -
    <%= grant.application.name %> - <%= grant.application.redirect_uri %> - <%= mail_to grant.application.owner.email, name = grant.application.owner.name %>
    - <% end %> - - <% else %> -
    No OAuth Applications to check.
    - <% end %> - - <% else %> -
  7. Request data anonymization from WCA Forum. (Skip - the WCA ID does not have an account associated with it)
  8. -
    -
  9. Request data removal from OAuth Access Grants. (Skip - the WCA ID does not have an account associated with it)
  10. - <% end %> - -
    -
  11. Inspect external websites of competitions for data usage. If so, instruct the website to remove the person's data.
  12. - <% competitions_with_external = @anonymize_person.person.competitions.select{ |c| c.external_website } %> - - <% if competitions_with_external.any? %> - - <% competitions_with_external.each do |c| %> -
    <%= link_to c.display_name(short: true), competition_path(c) %> - <%= link_to c.external_website, c.external_website %> - <%= mail_to c.delegates.map(&:email).join(", "), name = c.delegates.map(&:name).join(", "), cc: UserGroup.teams_committees_group_wrt.metadata.email, subject: "Anonymization Request" %>
    - <% end %> - - <% else %> -
    No Competitions with external websites to check.
    - <% end %> - -
    -
  13. For recently competed competitions (Past 1 month), after anonymizing the person's data, synchrozie the results on <%= link_to "WCA Live", "https://live.worldcubeassociation.org" %>:
  14. - <% recent_competitions_1_month = @anonymize_person.person.competitions.select{ |c| c.start_date > (Date.today - 1.month) } %> - - <% if recent_competitions_1_month.any? %> - - <% recent_competitions_1_month.each do |c| %> -
    <%= link_to c.display_name(short: true), competition_path(c) %>
    - <% end %> - - <% else %> -
    No recent competitions to sync.
    - <% end %> -
-
- <% end %> - -
- <%= f.submit "Back", :class => "btn btn-primary", :name => "back_button" unless @anonymize_person.first_step? %> - <%= f.submit "Next Step", :class => "btn btn-primary" unless @anonymize_person.last_step? %> - <% unless is_currently_banned %> - <%= f.submit "Confirm", :class => "btn btn-success", data: { confirm: "Are you sure you want to anonymize this person? All personal data will be removed from WCA once confirmed." } if @anonymize_person.last_step? %> - <% end %> -
- <% end %> -
diff --git a/app/webpacker/components/Panel/PanelPages.jsx b/app/webpacker/components/Panel/PanelPages.jsx index 08b7747ab01..6a0abc776ab 100644 --- a/app/webpacker/components/Panel/PanelPages.jsx +++ b/app/webpacker/components/Panel/PanelPages.jsx @@ -12,7 +12,6 @@ import { generateDataExportsUrl, fixResultsUrl, mergeProfilesUrl, - anonymizePersonUrl, reassignConnectedWcaIdUrl, } from '../../lib/requests/routes.js.erb'; import PostingCompetitionsTable from '../PostingCompetitions'; @@ -185,10 +184,6 @@ export default { name: 'Merge Profiles', link: mergeProfilesUrl, }, - [PANEL_PAGES.anonymizePerson]: { - name: 'Anonymize Person', - link: anonymizePersonUrl, - }, [PANEL_PAGES.reassignConnectedWcaId]: { name: 'Reassign Connected WCA ID', link: reassignConnectedWcaIdUrl, diff --git a/app/webpacker/components/Panel/pages/AnonymizationScriptPage/index.jsx b/app/webpacker/components/Panel/pages/AnonymizationScriptPage/index.jsx index db4976ab7fd..6f716fd9b9b 100644 --- a/app/webpacker/components/Panel/pages/AnonymizationScriptPage/index.jsx +++ b/app/webpacker/components/Panel/pages/AnonymizationScriptPage/index.jsx @@ -1,25 +1,56 @@ import React from 'react'; -import WcaSearch from '../../../SearchWidget/WcaSearch'; +import { FormField, FormGroup, Radio } from 'semantic-ui-react'; +import { IdWcaSearch } from '../../../SearchWidget/WcaSearch'; import SEARCH_MODELS from '../../../SearchWidget/SearchModel'; import useInputState from '../../../../lib/hooks/useInputState'; -import AnonymizationTicketWorkbench from '../../../Tickets/TicketWorkbenches/AnonymizationTicketWorkbench'; +import AnonymizationTicketWorkbenchForWrt from '../../../Tickets/TicketWorkbenches/AnonymizationTicketWorkbenchForWrt'; + +const MODEL_NAME = { + [SEARCH_MODELS.user]: 'User', + [SEARCH_MODELS.person]: 'Person', +}; export default function AnonymizationScriptPage() { - const [user, setUser] = useInputState(); + const [model, setModel] = useInputState(SEARCH_MODELS.user); + const [searchInput, setSearchInput] = useInputState(); + + const modelSelectHandler = (_, { value: selectedModel }) => { + setModel(selectedModel); + setSearchInput(null); + }; return ( <> - +
Select where to search for
+ {[SEARCH_MODELS.user, SEARCH_MODELS.person].map((searchModel, index) => ( + + + + ))} + + - + {model === SEARCH_MODELS.user && ( + + )} + {model === SEARCH_MODELS.person && ( + + )} ); } diff --git a/app/webpacker/components/Tickets/TicketWorkbenches/AnonymizationTicketWorkbench/index.jsx b/app/webpacker/components/Tickets/TicketWorkbenches/AnonymizationTicketWorkbench/index.jsx deleted file mode 100644 index aad09b4e0d3..00000000000 --- a/app/webpacker/components/Tickets/TicketWorkbenches/AnonymizationTicketWorkbench/index.jsx +++ /dev/null @@ -1,45 +0,0 @@ -import React from 'react'; -import { - Container, Header, List, Message, -} from 'semantic-ui-react'; -import { anonymizePersonUrl } from '../../../../lib/requests/routes.js.erb'; -import AccountAnonymization from './AccountAnonymization'; - -export default function AnonymizationTicketWorkbench({ userId, wcaId }) { - const shouldAnonymizeAccount = !!userId; - const shouldAnonymizeProfile = !!wcaId; - - return ( - -
Anonymization Dashboard
- - {!shouldAnonymizeAccount && !shouldAnonymizeProfile && ( - No user/person to anonymize. - )} - - - {shouldAnonymizeAccount && {`User ID to anonymize: ${userId}`}} - {shouldAnonymizeProfile && {`WCA ID to anonymize: ${wcaId}`}} - - - {shouldAnonymizeAccount && ( - - )} - {shouldAnonymizeProfile && ( - <> -
Profile anonymization
- {'Anonymize profile using '} - old anonymization script - - )} - -
- ); -} diff --git a/app/webpacker/components/Tickets/TicketWorkbenches/AnonymizationTicketWorkbench/AccountAnonymization.jsx b/app/webpacker/components/Tickets/TicketWorkbenches/AnonymizationTicketWorkbenchForWrt/AccountAnonymization.jsx similarity index 100% rename from app/webpacker/components/Tickets/TicketWorkbenches/AnonymizationTicketWorkbench/AccountAnonymization.jsx rename to app/webpacker/components/Tickets/TicketWorkbenches/AnonymizationTicketWorkbenchForWrt/AccountAnonymization.jsx diff --git a/app/webpacker/components/Tickets/TicketWorkbenches/AnonymizationTicketWorkbenchForWrt/AnonymizeAction.jsx b/app/webpacker/components/Tickets/TicketWorkbenches/AnonymizationTicketWorkbenchForWrt/AnonymizeAction.jsx new file mode 100644 index 00000000000..d0dd492ba65 --- /dev/null +++ b/app/webpacker/components/Tickets/TicketWorkbenches/AnonymizationTicketWorkbenchForWrt/AnonymizeAction.jsx @@ -0,0 +1,43 @@ +import React, { useState } from 'react'; +import { Button, Confirm, Message } from 'semantic-ui-react'; +import useSaveAction from '../../../../lib/hooks/useSaveAction'; +import Loading from '../../../Requests/Loading'; +import { actionUrls } from '../../../../lib/requests/routes.js.erb'; + +export default function AnonymizeAction({ userId, wcaId }) { + const [confirmOpen, setConfirmOpen] = useState(false); + const [completed, setCompleted] = useState(false); + + const { save, saving } = useSaveAction(); + + const anonymizeAccount = () => { + setConfirmOpen(false); + save( + actionUrls.tickets.anonymize(userId, wcaId), + { userId, wcaId }, + () => setCompleted(true), + { method: 'POST' }, + ); + }; + + if (saving) return ; + + return ( + <> + {completed && ( + Anonymization completed. + )} + + setConfirmOpen(false)} + onConfirm={anonymizeAccount} + content="Are you sure you want to anonymize the account?" + /> + + ); +} diff --git a/app/webpacker/components/Tickets/TicketWorkbenches/AnonymizationTicketWorkbenchForWrt/PerformManualChecks.jsx b/app/webpacker/components/Tickets/TicketWorkbenches/AnonymizationTicketWorkbenchForWrt/PerformManualChecks.jsx new file mode 100644 index 00000000000..0c56de19b8b --- /dev/null +++ b/app/webpacker/components/Tickets/TicketWorkbenches/AnonymizationTicketWorkbenchForWrt/PerformManualChecks.jsx @@ -0,0 +1,33 @@ +import React from 'react'; +import { List, ListItem } from 'semantic-ui-react'; + +export default function PerformManualChecks() { + // TODO: Add necessary links in this page. + return ( + + + For recently competed competitions (Past 3 months), verify with the delegates that there + is nothing outstanding regarding the competitor's involvement in these WCA + competitions. + + + If you are an administrator of the WCA forum, search active users + (https://forum.worldcubeassociation.org/admin/users/list/active) for any users using this email + and anonymize their data. If you are not an administrator of the WCA forum, please ask a WRT + member with administrator access to perform this step. + + + Request data removal from OAuth Access Grants. + + + Inspect external websites of competitions for data usage. If so, instruct the website to + remove the person's data. + + + For recently competed competitions (Past 3 month), after anonymizing the person's data, + synchronize the results on WCA Live (data more than 3 months old are automatically removed + from WCA Live). + + + ); +} diff --git a/app/webpacker/components/Tickets/TicketWorkbenches/AnonymizationTicketWorkbenchForWrt/ReviewSystemGeneratedChecks.jsx b/app/webpacker/components/Tickets/TicketWorkbenches/AnonymizationTicketWorkbenchForWrt/ReviewSystemGeneratedChecks.jsx new file mode 100644 index 00000000000..f12dbc50ee7 --- /dev/null +++ b/app/webpacker/components/Tickets/TicketWorkbenches/AnonymizationTicketWorkbenchForWrt/ReviewSystemGeneratedChecks.jsx @@ -0,0 +1,61 @@ +import React from 'react'; +import { Message } from 'semantic-ui-react'; +import _ from 'lodash'; + +export default function ReviewSystemGeneratedChecks({ data }) { + const isCurrentlyBanned = data.user_details?.is_currently_banned; + const isBannedInPast = data.user_details?.banned_in_past; + const recordCount = data.person_details?.record_count; + const championshipPodiums = data.person_details?.championship_podiums; + const heldRecords = recordCount?.total > 0; + const heldChampionshipPodiums = championshipPodiums && _.some([ + championshipPodiums.world, + championshipPodiums.continental, + championshipPodiums.national, + ], (arr) => arr.length > 0); + + return ( + <> + {data.user_details && ( + <> + User checks + {isCurrentlyBanned ? ( + This person is currently banned and cannot be anonymized. + ) : ( + This person is currently not banned. + )} + {isBannedInPast ? ( + + This person has been banned in the past, please email WIC and WRT to discuss + whether to proceed with the anonymization. + + ) : ( + This person wasn't banned in the past. + )} + + )} + {data.person_details && ( + <> + Person checks + {heldRecords ? ( + + {`This person held ${recordCount.world} World Records, ${recordCount.continental} Contential Records, and ${recordCount.national} National Records.`} + + ) : ( + This person has never held any records. + )} + {heldChampionshipPodiums ? ( + + {`This person has achieved World Championship podium ${championshipPodiums.world.length} times, Continental Championship podium ${championshipPodiums.continental.length} times, and National Championship podium ${championshipPodiums.national.length} times.`} + + ) : ( + + This person has never been on the podium at the World, Continental, + or National Championships. + + )} + + )} + + ); +} diff --git a/app/webpacker/components/Tickets/TicketWorkbenches/AnonymizationTicketWorkbenchForWrt/VerifyAnonymizeDetails.jsx b/app/webpacker/components/Tickets/TicketWorkbenches/AnonymizationTicketWorkbenchForWrt/VerifyAnonymizeDetails.jsx new file mode 100644 index 00000000000..13014ed8e0b --- /dev/null +++ b/app/webpacker/components/Tickets/TicketWorkbenches/AnonymizationTicketWorkbenchForWrt/VerifyAnonymizeDetails.jsx @@ -0,0 +1,28 @@ +import React from 'react'; +import { List, Message } from 'semantic-ui-react'; + +const anonymizationType = (data) => { + if (data.user_details && !data.person_details) { + return 'Account Only'; + } if (data.person_details && !data.user_details) { + return 'Profile Only'; + } if (data.user_details && data.person_details) { + return 'Account & Profile'; + } + return 'Unknown'; +}; + +export default function VerifyAnonymizeDetails({ data }) { + return ( + + {`Anonymization type: ${anonymizationType(data)}`} + {`DOB: ${data.user_details?.dob || data.person_details?.dob}`} + {`Email: ${data.user_details?.email || 'N/A'}`} + + Before processing any anonymization requests, WRT must receive verification with a + picture/copy of an official ID verification (passport, driver's license, etc.) with a + minimum of their name and birthday (any other information may be blurred-out/obfuscated). + + + ); +} diff --git a/app/webpacker/components/Tickets/TicketWorkbenches/AnonymizationTicketWorkbenchForWrt/api/getDetailsBeforeAnonymization.js b/app/webpacker/components/Tickets/TicketWorkbenches/AnonymizationTicketWorkbenchForWrt/api/getDetailsBeforeAnonymization.js new file mode 100644 index 00000000000..15aae5b221b --- /dev/null +++ b/app/webpacker/components/Tickets/TicketWorkbenches/AnonymizationTicketWorkbenchForWrt/api/getDetailsBeforeAnonymization.js @@ -0,0 +1,9 @@ +import { fetchJsonOrError } from '../../../../../lib/requests/fetchWithAuthenticityToken'; +import { actionUrls } from '../../../../../lib/requests/routes.js.erb'; + +export default async function getDetailsBeforeAnonymization(userId, wcaId) { + const { data } = await fetchJsonOrError( + actionUrls.tickets.getDetailsBeforeAnonymization(userId, wcaId), + ); + return data; +} diff --git a/app/webpacker/components/Tickets/TicketWorkbenches/AnonymizationTicketWorkbenchForWrt/index.jsx b/app/webpacker/components/Tickets/TicketWorkbenches/AnonymizationTicketWorkbenchForWrt/index.jsx new file mode 100644 index 00000000000..74b4fd032ce --- /dev/null +++ b/app/webpacker/components/Tickets/TicketWorkbenches/AnonymizationTicketWorkbenchForWrt/index.jsx @@ -0,0 +1,48 @@ +import React from 'react'; +import { Container, Header, Message } from 'semantic-ui-react'; +import { QueryClient, useQuery } from '@tanstack/react-query'; +import getDetailsBeforeAnonymization from './api/getDetailsBeforeAnonymization'; +import Loading from '../../../Requests/Loading'; +import Errored from '../../../Requests/Errored'; +import VerifyAnonymizeDetails from './VerifyAnonymizeDetails'; +import ReviewSystemGeneratedChecks from './ReviewSystemGeneratedChecks'; +import PerformManualChecks from './PerformManualChecks'; +import AnonymizeAction from './AnonymizeAction'; + +const ANONYMIZATION_QUERY_CLIENT = new QueryClient(); + +export default function AnonymizationTicketWorkbenchForWrt({ userId, wcaId }) { + const { + data, isLoading, isError, + } = useQuery({ + queryKey: ['anonymizeDetails', userId, wcaId], + queryFn: () => getDetailsBeforeAnonymization(userId, wcaId), + enabled: Boolean(userId || wcaId), + }, ANONYMIZATION_QUERY_CLIENT); + + if (isLoading) return ; + if (isError) return ; + + if (!data) { + return No user/person to anonymize.; + } + + return ( + +
Anonymization Dashboard
+ +
Step 1: Verify details to be anonymized
+ + +
Step 2: Review system generated checks
+ + +
Step 3: Perform manual checks
+ + +
Step 4: Anonymize Action
+ + +
+ ); +} diff --git a/app/webpacker/lib/requests/routes.js.erb b/app/webpacker/lib/requests/routes.js.erb index 170118c7a3e..e63b598d902 100644 --- a/app/webpacker/lib/requests/routes.js.erb +++ b/app/webpacker/lib/requests/routes.js.erb @@ -274,9 +274,8 @@ export const actionUrls = { show: (ticketId) => `<%= CGI.unescape(Rails.application.routes.url_helpers.ticket_path("${ticketId}", format: "json")) %>`, updateStatus: (ticketId) => `<%= CGI.unescape(Rails.application.routes.url_helpers.ticket_update_status_path("${ticketId}")) %>`, editPersonValidators: (ticketId) => `<%= CGI.unescape(Rails.application.routes.url_helpers.ticket_edit_person_validators_path("${ticketId}")) %>`, - }, - users: { - anonymize: (userId) => `<%= CGI.unescape(Rails.application.routes.url_helpers.anonymize_user_path("${userId}")) %>`, + getDetailsBeforeAnonymization: (userId, wcaId) => `<%= CGI.unescape(Rails.application.routes.url_helpers.tickets_details_before_anonymization_path) %>?${jsonToQueryString({ userId, wcaId })}`, + anonymize: (userId, wcaId) => `<%= CGI.unescape(Rails.application.routes.url_helpers.tickets_anonymize_path) %>`, }, } @@ -324,7 +323,5 @@ export const fixResultsUrl = `<%= CGI.unescape(Rails.application.routes.url_help export const mergeProfilesUrl = `<%= CGI.unescape(Rails.application.routes.url_helpers.admin_merge_people_path) %>`; -export const anonymizePersonUrl = `<%= CGI.unescape(Rails.application.routes.url_helpers.admin_anonymize_person_path) %>`; - export const reassignConnectedWcaIdUrl = `<%= CGI.unescape(Rails.application.routes.url_helpers.admin_reassign_wca_id_path) %>`; diff --git a/config/locales/en.yml b/config/locales/en.yml index 272d37cc7b7..87764b5f52f 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -516,7 +516,6 @@ en: person: person1_wca_id: "" person2_wca_id: "" - person_wca_id: "" poll: comment: "" multiple: "" diff --git a/config/routes.rb b/config/routes.rb index 5918be0c2e7..4948726f66e 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -60,7 +60,6 @@ post 'users/:id/avatar' => 'users#upload_avatar' patch 'users/:id/avatar' => 'users#update_avatar' delete 'users/:id/avatar' => 'users#delete_avatar' - post 'users/:id/anonymize' => 'users#anonymize', as: :anonymize_user get 'admin/avatars/pending' => 'admin/avatars#pending_avatar_users', as: :pending_avatars post 'admin/avatars' => 'admin/avatars#update_avatar', as: :admin_update_avatar @@ -204,6 +203,10 @@ get 'reassign-connected-wca-id' => 'admin#reassign_wca_id', as: :admin_reassign_wca_id end get 'panel-page/:id' => 'panel#panel_page', as: :panel_page + scope 'tickets' do + get 'details_before_anonymization' => 'tickets#details_before_anonymization', as: :tickets_details_before_anonymization + post 'anonymize' => 'tickets#anonymize', as: :tickets_anonymize + end resources :tickets, only: [:index, :show] do post 'update_status' => 'tickets#update_status', as: :update_status get 'edit_person_validators' => 'tickets#edit_person_validators', as: :edit_person_validators @@ -296,9 +299,8 @@ get '/admin/complete_persons' => 'admin#complete_persons' post '/admin/complete_persons' => 'admin#do_complete_persons' get '/admin/peek_unfinished_results' => 'admin#peek_unfinished_results' - post '/admin/anonymize_person' => 'admin#do_anonymize_person', as: :admin_do_anonymize_person - get '/admin/validate_reassign_wca_id' => 'admin#validate_reassign_wca_id', as: :admin_validate_reassign_wca_id - post '/admin/reassign_wca_id' => 'admin#do_reassign_wca_id', as: :admin_do_reassign_wca_id + get '/admin/validate_reassign_wca_id' => 'admin#validate_reassign_wca_id', as: :admin_do_reassign_wca_id + post '/admin/reassign_wca_id' => 'admin#do_reassign_wca_id' get '/search' => 'search_results#index' diff --git a/spec/controllers/admin_controller_spec.rb b/spec/controllers/admin_controller_spec.rb index 40293ae16ff..7f814b3179b 100644 --- a/spec/controllers/admin_controller_spec.rb +++ b/spec/controllers/admin_controller_spec.rb @@ -17,24 +17,6 @@ end end - describe 'anonymize_person' do - sign_in { FactoryBot.create :admin } - - let(:person) { FactoryBot.create(:person_who_has_competed_once) } - - it 'can anonymize person' do - get :anonymize_person - post :do_anonymize_person, params: { anonymize_person: { person_wca_id: person.wca_id } } - expect(response.status).to eq 200 - expect(response).to render_template :anonymize_person - - post :do_anonymize_person, params: { anonymize_person: { person_wca_id: person.wca_id } } - expect(response.status).to eq 200 - expect(response).to render_template :anonymize_person - expect(flash.now[:success]).to eq "Successfully anonymized #{person.wca_id} to #{person.wca_id[0..3]}ANON01! Don't forget to run Compute Auxiliary Data and Export Public." - end - end - describe 'reassign_wca_id' do sign_in { FactoryBot.create :admin } diff --git a/spec/controllers/tickets_controller_spec.rb b/spec/controllers/tickets_controller_spec.rb new file mode 100644 index 00000000000..d09f5fc5447 --- /dev/null +++ b/spec/controllers/tickets_controller_spec.rb @@ -0,0 +1,89 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe TicketsController do + describe "POST #anonymize" do + sign_in { FactoryBot.create :admin } + + it 'can anonymize person' do + person = FactoryBot.create(:person_who_has_competed_once) + wca_id = person.wca_id + + post :anonymize, params: { wcaId: wca_id } + + expect(person.reload.name).to eq User::ANONYMOUS_NAME + expect(person.reload.wca_id).to include(wca_id.first(4) + 'ANON') + end + + it 'cannot anonymize banned person' do + banned_user = FactoryBot.create(:user, :banned, :wca_id) + banned_person = banned_user.person + + post :anonymize, params: { wcaId: banned_person.wca_id } + + expect(response.status).to eq 422 + expect(JSON.parse(response.body)["error"]).to eq "Error anonymizing: This person is currently banned and cannot be anonymized." + end + + it 'cannot anonymize if both user ID and WCA ID is not provided' do + post :anonymize + + expect(response.status).to eq 422 + expect(JSON.parse(response.body)["error"]).to eq "User ID and WCA ID is not provided." + end + + it 'cannot anonymize if user ID connected with WCA ID is not the user ID provided' do + person = FactoryBot.create(:person_who_has_competed_once) + user = FactoryBot.create(:user) + + post :anonymize, params: { userId: user.id, wcaId: person.wca_id } + + expect(response.status).to eq 422 + expect(JSON.parse(response.body)["error"]).to eq "Person and user not linked." + end + + it 'generates padded wca id for a year with 99 ANON ids already' do + person = FactoryBot.create(:person_who_has_competed_once) + wca_id = person.wca_id + year = wca_id.first(4) + + (1..99).each do |i| + FactoryBot.create(:person_who_has_competed_once, wca_id: year + "ANON" + i.to_s.rjust(2, "0")) + end + + post :anonymize, params: { wcaId: wca_id } + + expect(person.reload.wca_id).to eq year + "ANOU01" # ANON, take the last N, pad with U. + end + + it "can anonymize person and results" do + person = FactoryBot.create(:person_who_has_competed_once) + result = FactoryBot.create(:result, person: person) + + post :anonymize, params: { wcaId: person.wca_id } + + expect(!!response).to eq true + expect(result.reload.personId).to include('ANON') + expect(result.reload.personName).to eq User::ANONYMOUS_NAME + expect(person.reload.wca_id).to include('ANON') + expect(person.reload.name).to eq User::ANONYMOUS_NAME + expect(person.reload.gender).to eq User::ANONYMOUS_GENDER + expect(person.reload.dob).to eq User::ANONYMOUS_DOB.to_date + end + + it "can anonymize account data" do + user = FactoryBot.create(:user_with_wca_id) + FactoryBot.create(:result, person: user.person) + + post :anonymize, params: { userId: user.id } + + expect(!!response).to eq true + expect(user.reload.wca_id).to eq nil + expect(user.reload.email).to eq user.id.to_s + User::ANONYMOUS_ACCOUNT_EMAIL_ID_SUFFIX + expect(user.reload.name).to eq User::ANONYMOUS_NAME + expect(user.reload.dob).to eq User::ANONYMOUS_DOB.to_date + expect(user.reload.gender).to eq User::ANONYMOUS_GENDER + end + end +end diff --git a/spec/models/anonymize_person_spec.rb b/spec/models/anonymize_person_spec.rb deleted file mode 100644 index 9cbf9f7e9c1..00000000000 --- a/spec/models/anonymize_person_spec.rb +++ /dev/null @@ -1,63 +0,0 @@ -# frozen_string_literal: true - -require 'rails_helper' - -RSpec.describe AnonymizePerson do - let(:person) { FactoryBot.create(:person_who_has_competed_once, wca_id: "2020EXAM01") } - let(:anonymize_person) { AnonymizePerson.new(person_wca_id: person.wca_id) } - - let(:user) { FactoryBot.create(:user, :wca_id) } - let(:banned_user) { FactoryBot.create(:user, :wca_id, :banned) } - let(:anonymize_person2) { AnonymizePerson.new(person_wca_id: user.wca_id) } - let(:anonymize_person3) { AnonymizePerson.new(person_wca_id: banned_user.wca_id) } - - it "is valid" do - expect(anonymize_person).to be_valid - end - - it "handles invalid wca_id" do - anonymize_person.person_wca_id = "" - expect(anonymize_person).to be_invalid_with_errors(person_wca_id: ["can't be blank"]) - end - - it "prevents anonymizing banned user" do - expect(anonymize_person3.do_anonymize_person).to eq({ error: "Error anonymizing: This person is currently banned and cannot be anonymized." }) - end - - it "generates a wca id for ANON with the same year" do - expect(anonymize_person.generate_new_wca_id).to eq "2020ANON01" - end - - it "generates padded wca id for a year with 99 ANON ids already" do - (1..99).each do |i| - FactoryBot.create(:person_who_has_competed_once, wca_id: "2020ANON" + i.to_s.rjust(2, "0")) - end - - expect(anonymize_person.generate_new_wca_id).to eq "2020ANOU01" # ANON, take the last N, pad with U. - end - - it "can anonymize person and results" do - result = FactoryBot.create(:result, person: person) - - response = anonymize_person.do_anonymize_person - expect(!!response).to eq true - expect(result.reload.personId).to eq "2020ANON01" - expect(result.reload.personName).to eq "Anonymous" - expect(person.reload.wca_id).to eq "2020ANON01" - expect(person.reload.name).to eq "Anonymous" - expect(person.reload.gender).to eq "o" - expect(person.reload.dob).to eq nil - end - - it "can anonymize account data" do - FactoryBot.create(:result, person: user.person) - - response = anonymize_person2.do_anonymize_person - expect(!!response).to eq true - expect(user.reload.wca_id).to eq nil - expect(user.reload.email).to eq user.id.to_s + "@worldcubeassociation.org" - expect(user.reload.name).to eq "Anonymous" - expect(user.reload.dob).to eq Date.new(1954, 12, 4) - expect(user.reload.gender).to eq "o" - end -end