diff --git a/app/controllers/concerns/org_creation_with_orion.rb b/app/controllers/concerns/org_creation_with_orion.rb new file mode 100644 index 0000000000..a7390a2eae --- /dev/null +++ b/app/controllers/concerns/org_creation_with_orion.rb @@ -0,0 +1,146 @@ +# frozen_string_literal: true + +# Provides methods to handle the org_id hash returned to the controller +# for pages that use the Org selection autocomplete widget +# +# This Concern handles the incoming params from a page that has one of the +# Org Typeahead boxes found in app/views/shared/org_selectors/. +# +# The incoming hash looks like this: +# { +# "org_name"=>"Portland State University (PDX)", +# "org_sources"=>"[ +# \"3E (Belgium) (3e.eu)\", +# \"etc.\" +# ]", +# "org_crosswalk"=>"[ +# { +# \"id\":1574, +# \"name\":\"3E (Belgium) (3e.eu)\", +# \"sort_name\":\"3E\", +# \"ror\":\"https://ror.org/03d33vh19\" +# }, +# { +# "etc." +# }]", +# "id"=>"{ +# \"id\":62, +# \"name\":\"Portland State University (PDX)\", +# \"sort_name\":\"Portland State University\", +# \"ror\":\"https://ror.org/00yn2fy02\", +# \"fundref\":\"https://doi.org/10.13039/100007083\" +# } +# } +# +# The :org_name, :org_sources, :org_crosswalk are all relics of the JS involved in +# handling the request/response from OrgsController#search AJAX action that is +# used to search both the local DB and the ROR API as the user types. +# :org_name = the value the user has types in +# :org_sources = the pick list of Org names returned by the OrgsController#search action +# :org_crosswalk = all of the info about each Org returned by the OrgsController#search action +# there is JS that takes the value in :org_name and then sets the :id param +# to the matching Org in the :org_crosswalk on form submission +# +# They are typically removed from the incoming params hash prior to doing a :save or :update +# by the :remove_org_selection_params below. +# TODO: Consider adding a JS method that strips those 3 params out prior to form submission +# since we only need the contents of the :id param here +# +# The contents of :id are then used to either Create or Find the Org from the DB. +# if id: { :id } is present then the Org was one pulled from the DB. If it is not +# present then it is one of the following: +# if :ror or :fundref are present then it was one retrieved from the ROR API +# otherwise it is a free text value entered by the user +# +# See the comments on OrgsController#search for more info on how the typeaheads work +module OrgCreationWithOrion + extend ActiveSupport::Concern + + # rubocop:disable Metrics/BlockLength + included do + private + + # Converts the incoming params_into an Org by either locating it + # via its id, identifier and/or name, or initializing a new one + # the default allow_create is based off restrict_orgs + def org_from_params(params_in:, + allow_create: !Rails.configuration.x.application.restrict_orgs) + # params_in = params_in.with_indifferent_access + return nil unless params_in[:org_id].present? && + params_in[:org_id].is_a?(String) + + hash = org_hash_from_params(params_in: params_in) + return nil unless hash.present? + + org_from_hash = OrgSelection::HashToOrgService.to_org(hash: hash, + allow_create: allow_create) + org = allow_create ? create_org(org: org_from_hash, params_in: params_in) : org_from_hash + # No longer creating domain as it could have issues with cases where multiple ROR orgs have same domain. + # create_org_domain_if_absent(org: org, params_in: params_in) # No longer creating domain as it could have issues with case + org + end + + # Converts the incoming params_into an array of Identifiers + def identifiers_from_params(params_in:) + # params_in = params_in.to_h.with_indifferent_access + return [] unless params_in[:org_id].present? && + params_in[:org_id].is_a?(String) + + hash = org_hash_from_params(params_in: params_in) + return [] unless hash.present? + + OrgSelection::HashToOrgService.to_identifiers(hash: hash) + end + + # Remove the extraneous Org Selector hidden fields so that they don't get + # passed on to any save methods + def remove_org_selection_params(params_in:) + params_in.delete(:org_id) + params_in.delete(:org_name) + params_in.delete(:org_sources) + params_in.delete(:org_crosswalk) + params_in + end + + # Just does a JSON parse of the org_id hash + def org_hash_from_params(params_in:) + JSON.parse(params_in[:org_id]) # .with_indifferent_access + rescue JSON::ParserError => e + Rails.logger.error "Unable to parse Org Selection JSON: #{e.message}" + Rails.logger.error params_in.inspect + {} + end + + # Saves the org if its a new record + def create_org(org:, params_in:) + return org unless org.present? && org.new_record? + + # Save the Org before attaching identifiers + org.save + identifiers_from_params(params_in: params_in).each do |identifier| + next unless identifier.value.present? + + identifier.identifiable = org + identifier.save + end + org.reload + end + end + + # Creates an OrgDomain record if it does not already exist + # rubocop:disable Metrics/AbcSize + # def create_org_domain_if_absent(org:, params_in:) + # return unless org.present? && params_in[:email].present? + + # domain = params_in[:email].split('@', 2)[1].downcase.strip + # puts domain + # return if domain.blank? + # return if org.org_domains.exists?(domain: domain) + + # org.org_domains.create(domain: domain) + # rescue StandardError => e + # Rails.logger.error "Error creating OrgDomain for #{org.name} with domain #{domain}: #{e.message}" + # end + + # rubocop:enable Metrics/AbcSize, Metrics/BlockLength +end diff --git a/app/controllers/org_domain_controller.rb b/app/controllers/org_domain_controller.rb new file mode 100644 index 0000000000..3f61574345 --- /dev/null +++ b/app/controllers/org_domain_controller.rb @@ -0,0 +1,189 @@ +# frozen_string_literal: true + +# Controller for API routes that return orgs by domain. +class OrgDomainController < ApplicationController + + # PUTS /orgs-by-domain with parameter email. + # TBD: Change these Rubocop Cops + # rubocop:disable Metrics/AbcSize, Metrics/MethodLength, Metrics/PerceivedComplexity, Metrics/CyclomaticComplexity + def index + email_param = search_params[:email] + email_domain = email_param.split('@').last if email_param.present? && email_param.include?('@') + render json: [], status: :ok if email_domain.blank? + + # check if org exists already using domain provided + org_results = OrgDomain.search_with_org_info(email_domain) + result = org_results.map { |record| + org_id_new_format = { id: record.id, name: record.org_name }.to_json + + { + id: org_id_new_format, + org_name: record.org_name, + domain: record.domain, + } + } + + unless result.empty? + # Add Other org to end of array + result << other_org_json + puts "result: #{result}" + render json: result, status: :ok + return + end + + # if org doesn't exist already call Orion API by passing domain + begin + full_org_json = ::ExternalApis::OrionService.search_by_domain(email_domain) + puts "full_org_json: #{full_org_json}" + + # If no orgs found, retry with higher level domain by removing subdomains + split_domain = email_domain.split('.') + + while !full_org_json&.key?('orgs') && split_domain.length > 2 + split_domain.shift + domain_to_search = split_domain.join('.') + puts "Retrying with #{domain_to_search}" + full_org_json = ::ExternalApis::OrionService.search_by_domain(domain_to_search) + puts "Retry full_org_json with #{domain_to_search}: #{full_org_json}" + end + + unless full_org_json&.key?('orgs') + puts 'Invalid response or no orgs key found' + # Add Other org + result = [other_org_json] + render json: result, status: :ok + return + end + + # Extract the values from API result + result = full_org_json['orgs'].map do |org| + # The ror_display value will be in the language of the country, and should always be present. + ror_display_name_json = org['names'].find { |n| n['lang'] && n['types']&.include?('ror_display') } + # puts "ror_display_name_json: #{ror_display_name_json}" + org_name = ror_display_name_json ? ror_display_name_json['value'] : nil + puts "org_name: #{org_name}" + + # If org_name is nil, skip this org + break if org_name.nil? + + org_id_new_format = { name: org_name }.to_json + { + id: org_id_new_format, + org_name: org_name, + domain: '' + } + + rescue StandardError => e + puts "Failed request: #{e.message}" + end + + # In case result is nil, we need to set it to an empty array + result = [] if result.nil? + # Add Other org to end of array. + result << other_org_json + end + render json: result, status: :ok + end + + # rubocop:enable Metrics/AbcSize, Metrics/MethodLength, Metrics/PerceivedComplexity, Metrics/CyclomaticComplexity + + def show + redirect_to root_path, alert: "You are not authorized to view this page." unless current_user.can_org_admin? || current_user.can_super_admin? + @org_domains = OrgDomain.where(org_id: current_user.org_id).order(:domain) + end + + def new + redirect_to root_path, alert: "You are not authorized to view this page." unless current_user.can_org_admin? || current_user.can_super_admin? + @org_domain = OrgDomain.new + end + + # rubocop:disable Metrics/AbcSize + def create + redirect_to root_path, alert: "You are not authorized to view this page." unless current_user.can_org_admin? || current_user.can_super_admin? + domain_input = params[:org_domain][:domain].to_s.downcase.gsub(/\s+/, '') + + if domain_input.blank? + flash.now[:alert] = 'Domain cannot be blank.' + @org_domain = OrgDomain.new + render :new and return + end + + @org_domain = OrgDomain.new(domain: domain_input, org_id: current_user.org_id) + @org_domain.org_id = current_user.org_id + + if @org_domain.save + redirect_to org_domain_show_path, notice: 'Domain created successfully.' + else + render :new + end + end + # rubocop:enable Metrics/AbcSize + + def edit + redirect_to root_path, alert: "You are not authorized to view this page." unless current_user.can_org_admin? || current_user.can_super_admin? + @org_domain = OrgDomain.find(params[:id]) + redirect_to org_domain_show_path, alert: 'Unauthorized' unless @org_domain.org_id == current_user.org_id + end + + # rubocop:disable Metrics/AbcSize + def update + redirect_to root_path, alert: "You are not authorized to view this page." unless current_user.can_org_admin? || current_user.can_super_admin? + @org_domain = OrgDomain.find(params[:id]) + + if @org_domain.org_id == current_user.org_id + domain_input = params[:org_domain][:domain].to_s.downcase.gsub(/\s+/, '') + + if domain_input.blank? + flash.now[:alert] = 'Domain cannot be blank.' + render :edit and return + end + + if @org_domain.update(domain: domain_input) + redirect_to org_domain_show_path, notice: 'Domain updated successfully.' + else + render :edit + end + else + redirect_to org_domain_show_path, alert: 'Unauthorized' + end + end + # rubocop:enable Metrics/AbcSize + + def destroy + redirect_to root_path, alert: "You are not authorized to view this page." unless current_user.can_org_admin? || current_user.can_super_admin? + @org_domain = OrgDomain.find(params[:id]) + + if @org_domain.org_id != current_user.org_id + redirect_to org_domain_show_path, alert: 'Unauthorized' + return + end + + if @org_domain.destroy + redirect_to org_domain_show_path, notice: 'Domain deleted successfully.' + else + redirect_to org_domain_show_path, alert: 'Failed to delete domain.' + end + end + + private + + # Using Strong Parameters ensure only domain is permitted + def search_params + params.permit(:email, :format, :org_domain) + end + + def org_domain_params + params.require(:org_domain).permit(:domain) + end + + def other_org_json + other_org = Org.find_other_org + # add if condition here to check if other_org is nil or present + org_id_new_format = other_org.present? ? { id: other_org.id, name: other_org.name }.to_json : { name: 'Other' }.to_json + { + id: org_id_new_format, + org_name: other_org ? other_org.name : 'Other', + domain: '' + } + end +end diff --git a/app/controllers/registrations_controller.rb b/app/controllers/registrations_controller.rb index 53492a3f9e..09dda0e80a 100644 --- a/app/controllers/registrations_controller.rb +++ b/app/controllers/registrations_controller.rb @@ -2,7 +2,8 @@ # Controller that handles user account creation and changes from the edit profile page class RegistrationsController < Devise::RegistrationsController - include OrgSelectable + # include OrgSelectable + include OrgCreationWithOrion def edit @user = current_user diff --git a/app/javascript/src/shared/createAccountForm.js b/app/javascript/src/shared/createAccountForm.js index 6c17fe7c4b..1b57f115c3 100644 --- a/app/javascript/src/shared/createAccountForm.js +++ b/app/javascript/src/shared/createAccountForm.js @@ -1,10 +1,107 @@ -import { initAutocomplete, scrubOrgSelectionParamsOnSubmit } from '../utils/autoComplete'; -import { togglisePasswords } from '../utils/passwordHelper'; - $(() => { - initAutocomplete('#create-account-org-controls .autocomplete'); - // Scrub out the large arrays of data used for the Org Selector JS so that they - // are not a part of the form submissiomn - scrubOrgSelectionParamsOnSubmit('#create_account_form'); - togglisePasswords({ selector: '#create_account_form' }); + const createAccountForm = document.getElementById('create_account_form'); + + if (!createAccountForm) { + return; + } + + const emailField = createAccountForm.querySelector('input[type="email"]'); + const orgSelect = createAccountForm.querySelector('select[name*="org"]'); + + if (!emailField || !orgSelect) { + return; + } + + let currentEmail = ''; + let debounceTimer = null; + + // Handle email input changes + emailField.addEventListener('input', function () { + const email = this.value; + + if (email && email.includes('@')) { + if (email !== currentEmail) { + currentEmail = email; + + // Clear previous timer + if (debounceTimer) { + clearTimeout(debounceTimer); + } + + // Debounce API calls to avoid too many requests + debounceTimer = setTimeout(() => { + fetchOrganizations(email); + }, 500); + } + } else { + // Clear organizations if email is invalid + currentEmail = ''; + resetOrgSelect(); + } + }); + + function fetchOrganizations(email) { + // fetch(`/api/orgs-by-domain?email=${encodeURIComponent(email)}`) + // .then(response => response.json()) + // .then(data => { + // populateOrgSelect(data); + // }) + // .catch(error => { + // console.error('Error fetching organizations:', error); + // resetOrgSelect(); + // }); + // Prepare header and body information for a POST request + // Retrieve CSRF token stored in tag + const csrftoken = document.querySelector('meta[name="csrf-token"]').getAttribute('content'); + const requestOptions = { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + // Add X-CSRF-Token header for protection against CRSF attacks. + 'X-CSRF-Token': csrftoken, + }, + body: JSON.stringify({ email }) + }; + + // Use Fetch API with POST configuration included in requestOptions + fetch('/orgs-by-domain', requestOptions) + .then(response => response.json()) + .then(data => { + populateOrgSelect(data); + }) + .catch(error => { + console.error('Error fetching organizations:', error); + resetOrgSelect(); + }); + } + + function populateOrgSelect(orgs) { + // Clear existing options + orgSelect.innerHTML = ''; + + if (orgs.length > 1) { + // Add prompt option + const promptOption = document.createElement('option'); + promptOption.value = ''; + promptOption.textContent = 'Select an organisation'; + orgSelect.appendChild(promptOption); + } + + // Add organization options + orgs.forEach(function (org) { + const option = document.createElement('option'); + option.value = org.id || org.ror_id; + option.textContent = org.org_name; + orgSelect.appendChild(option); + }); + // Only select option if only one + if (orgs.length === 1) { + orgSelect.selectedIndex = 0; + } + } + + function resetOrgSelect() { + orgSelect.innerHTML = ''; + } }); + diff --git a/app/models/org.rb b/app/models/org.rb index 92122900e7..7868001904 100644 --- a/app/models/org.rb +++ b/app/models/org.rb @@ -88,6 +88,8 @@ class Org < ApplicationRecord has_many :departments + has_many :org_domains + # =============== # = Validations = # =============== @@ -175,13 +177,17 @@ def check_for_missing_logo_file 6 => :school, column: 'org_type', check_for_column: !Rails.env.test? - # The default Org is the one whose guidance is auto-attached to # plans when a plan is created def self.default_orgs where(abbreviation: Rails.configuration.x.organisation.abbreviation) end + # Returns the Org record with the name 'Other' + def self.find_other_org + where("LOWER(name) = ?", 'other').first + end + # The managed flag is set by a Super Admin. A managed org typically has # at least one Org Admini and can have associated Guidance and Templates scope :managed, -> { where(managed: true) } diff --git a/app/models/org_domain.rb b/app/models/org_domain.rb new file mode 100644 index 0000000000..b12da73d42 --- /dev/null +++ b/app/models/org_domain.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +# == Schema Information +# +# Table name: org_domains +# +# id :bigint(8) not null, primary key +# domain :text not null +# created_at :datetime not null +# updated_at :datetime not null +# org_id :bigint(8) not null +# +# Indexes +# +# index_org_domains_on_org_id (org_id) +# +# Foreign Keys +# +# fk_rails_... (org_id => orgs.id) +# +class OrgDomain < ApplicationRecord + belongs_to :org + + def self.search_with_org_info(domain) + pattern = "#{domain.downcase}" + joins(:org) + .where("LOWER(org_domains.domain) = ?", pattern) + .select("orgs.id AS id, orgs.name AS org_name, org_domains.domain") + end + +end diff --git a/app/models/org_ror.rb b/app/models/org_ror.rb new file mode 100644 index 0000000000..c1f1f736f5 --- /dev/null +++ b/app/models/org_ror.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +# == Schema Information +# +# Table name: org_rors +# +# id :bigint(8) not null, primary key +# created_at :datetime not null +# updated_at :datetime not null +# org_id :bigint(8) not null +# ror_id :text not null +# +# Indexes +# +# index_org_rors_on_org_id (org_id) +# +# Foreign Keys +# +# fk_rails_... (org_id => orgs.id) +# +class OrgRor < ApplicationRecord + belongs_to :org + + +end diff --git a/app/services/external_apis/orion_service.rb b/app/services/external_apis/orion_service.rb new file mode 100644 index 0000000000..7e4d27e3fc --- /dev/null +++ b/app/services/external_apis/orion_service.rb @@ -0,0 +1,50 @@ +# app/services/external_api/orion_service.rb + +require 'net/http' +require 'uri' +require 'json' + +module ExternalApis + class OrionService + ORION_URL = "http://srv01.screco.org:8080/submit" + + def self.search_by_ror_id(ror_id) + return { error: 'Missing ROR ID' } if ror_id.blank? + + payload = { + cmd: "search_by_ror_id", + value: [ror_id] + } + + post_to_orion(payload) + end + + def self.search_by_domain(domain) + return { error: 'Missing domain' } if domain.blank? + + payload = { + cmd: "search_by_domain", + value: domain + } + + post_to_orion(payload) + end + + def self.post_to_orion(payload) + uri = URI.parse(ORION_URL) + + response = Net::HTTP.post( + uri, + payload.to_json, + { "Content-Type" => "application/json" } + ) + + # puts ">>>>>>>:#{response.body}" + JSON.parse(response.body) + rescue JSON::ParserError + { error: 'Invalid response from Orion' } + rescue => e + { error: e.message } + end + end +end diff --git a/app/services/org_selection/hash_to_org_service.rb b/app/services/org_selection/hash_to_org_service.rb index 14ca1263e4..bd5d154f51 100644 --- a/app/services/org_selection/hash_to_org_service.rb +++ b/app/services/org_selection/hash_to_org_service.rb @@ -91,7 +91,7 @@ def initialize_org(hash:) links: links_from_hash(name: hash[:name], website: hash[:url]), language: language_from_hash(hash: hash), target_url: hash[:url], - institution: true, + organisation: true, is_other: false, abbreviation: abbreviation_from_hash(hash: hash) ) diff --git a/app/views/layouts/_branding.html.erb b/app/views/layouts/_branding.html.erb index 62ff6493f8..cf7d1cde97 100644 --- a/app/views/layouts/_branding.html.erb +++ b/app/views/layouts/_branding.html.erb @@ -98,6 +98,11 @@ <% end %> <% end %> + <% if current_user.can_org_admin? || current_user.can_super_admin? %> +
| Domain | +Created at | +Updated at | +Actions | +
|---|---|---|---|
| <%= domain.domain %> | +<%= domain.created_at.strftime("%Y-%m-%d %H:%M:%S") %> | +<%= domain.updated_at.strftime("%Y-%m-%d %H:%M:%S") %> | +
+
+
+
+
+ |
+
No domains found.
+<% end %> + +- <%= _('or') %> -
-