Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions app/assets/images/ORCID-iD_icon_unauth_vector.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
4 changes: 4 additions & 0 deletions app/assets/images/ORCID-iD_icon_vector.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
2 changes: 1 addition & 1 deletion app/controllers/application_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ def handle_error(status_code = 500, message = nil)
status_code = status_code.to_i
@message = message
respond_to do |format|
format.html { render 'static/error', status: status_code }
format.html { render 'static/error', status: status_code }
format.json { render json: { error: { message: message, code: status_code } }, status: status_code }
format.json_api { render json: { error: { message: message, code: status_code } }, status: status_code }
format.any { head status_code }
Expand Down
46 changes: 46 additions & 0 deletions app/controllers/orcid_controller.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
class OrcidController < ApplicationController
before_action :authenticate_user!
before_action :set_oauth_client, only: [:authenticate, :callback]

# Faraday::ParsingError occurs in rack-oauth2 if the response does not contain JSON
rescue_from(Rack::OAuth2::Client::Error, Faraday::ParsingError) do
handle_error(:unprocessable_entity, t('orcid.error'))
end

def authenticate
redirect_to @oauth2_client.authorization_uri(scope: '/authenticate'), allow_other_host: true
end

def callback
@oauth2_client.authorization_code = params[:code]
token = Rack::OAuth2::AccessToken::Bearer.new(access_token: @oauth2_client.access_token!)
orcid = token.access_token&.raw_attributes['orcid']
respond_to do |format|
profile = current_user.profile
if orcid.present?
if profile.authenticate_orcid(orcid)
flash[:notice] = t('orcid.authentication_success')
else
flash[:error] = profile.errors.full_messages.join(', ')
end
else
flash[:error] = t('orcid.authentication_failure')
end
format.html { redirect_to current_user }
end
end

private

def set_oauth_client
config = Rails.application.config.secrets.orcid
@oauth2_client ||= Rack::OAuth2::Client.new(
identifier: config[:client_id],
secret: config[:secret],
redirect_uri: config[:redirect_uri].presence || orcid_callback_url,
authorization_endpoint: '/oauth/authorize',
token_endpoint: '/oauth/token',
host: config[:host].presence || (Rails.env.production? ? 'orcid.org' : 'sandbox.orcid.org')
)
end
end
15 changes: 15 additions & 0 deletions app/helpers/users_helper.rb
Original file line number Diff line number Diff line change
Expand Up @@ -5,4 +5,19 @@ def self.user_profile_resource_limit
30
end

def orcid_link(profile, **opts)
content_tag(:span) do
if profile.orcid_authenticated?
concat image_tag('ORCID-iD_icon_vector.svg', size: 16)
concat ' '
concat external_link(profile.orcid, profile.orcid_url, **opts)
elsif profile.orcid.present?
concat image_tag('ORCID-iD_icon_unauth_vector.svg', size: 16)
concat ' '
concat external_link(profile.orcid, profile.orcid_url, **opts)
concat ' (Unauthenticated)'
end
end
end

end
28 changes: 22 additions & 6 deletions app/models/profile.rb
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,11 @@ def full_name
"#{firstname} #{surname}".strip
end

def orcid_url
Comment thread
fbacall marked this conversation as resolved.
return nil if orcid.blank?
"#{OrcidValidator::ORCID_PREFIX}#{orcid}"
end

def merge(*others)
Profile.transaction do
attrs = attributes
Expand All @@ -43,16 +48,27 @@ def merge(*others)
end
end

def authenticate_orcid(orcid)
existing = Profile.where(orcid: orcid, orcid_authenticated: true)
Comment thread
fbacall marked this conversation as resolved.
Comment thread
fbacall marked this conversation as resolved.
self.orcid = orcid
self.orcid_authenticated = true
out = self.save

if out
existing.each do |profile|
next if profile == self
profile.update_column(:orcid_authenticated, false)
end
end

out
end

private

def normalize_orcid
return if orcid.blank?
self.orcid = orcid.strip
if orcid =~ OrcidValidator::ORCID_ID_REGEX
self.orcid = "#{OrcidValidator::ORCID_PREFIX}#{orcid}"
elsif orcid.start_with?(OrcidValidator::ORCID_DOMAIN_REGEX)
self.orcid = orcid.sub(OrcidValidator::ORCID_DOMAIN_REGEX, OrcidValidator::ORCID_PREFIX)
end
self.orcid = orcid.strip.sub(OrcidValidator::ORCID_DOMAIN_REGEX, '')
end

def check_public
Expand Down
2 changes: 1 addition & 1 deletion app/views/trainers/partials/_trainer_schemaorg.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
<%= content_tag 'span', langs, { itemprop: 'knowsLanguage', content: langs, class: 'schemaorg-element' } %>
<% end %>
<%= content_tag :span, trainer.website, { itemprop: 'url', content: trainer.website, class: 'schemaorg-element' } %>
<%= content_tag :span, trainer.orcid, { itemprop: 'identifier', content: trainer.orcid, class: 'schemaorg-element' } %>
<%= content_tag :span, trainer.orcid_url, { itemprop: 'identifier', content: trainer.orcid_url, class: 'schemaorg-element' } %>
<% if trainer.image_url and not trainer.image_url.empty? %>
<%= content_tag :span, trainer.image_url, { itemprop: 'image', content: trainer.image_url, class: 'shemaorg-element' } %>
<% end %>
Expand Down
2 changes: 1 addition & 1 deletion app/views/trainers/show.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@
<!-- Field: orcid -->
<% if @trainer.orcid.present? %>
<div class="url-wrap">
<%= external_link @trainer.orcid, @trainer.orcid, class: 'h5', target: '_blank', rel: 'noopener', track: true %>
<%= orcid_link(@trainer, class: 'h5', track: true) %>
</div>
<% end %>

Expand Down
5 changes: 4 additions & 1 deletion app/views/users/show.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,10 @@
<% if @user.profile.orcid.blank? %>
<span class="empty">None specified</span>
<% else %>
<%= link_to @user.profile.orcid, @user.profile.orcid, rel: 'nofollow', target: '_blank' %>
<%= orcid_link(@user.profile) %>
<% if current_user == @user && !@user.profile.orcid_authenticated? %>
<%= button_to t('orcid.authenticate'), authenticate_orcid_path, class: 'btn btn-default' %>
<% end %>
<% end %>
</p>

Expand Down
7 changes: 7 additions & 0 deletions config/locales/en.yml
Original file line number Diff line number Diff line change
Expand Up @@ -839,6 +839,8 @@ en:
422: "The request you sent was well-formed but the change you wanted was rejected (422 Unprocessable Entity)."
404: "The requested page could not be found - you may have mistyped the address or the page may have moved (404 Not Found)."
406: "The requested format is not available (406 Not Acceptable)."
messages:
orcid_taken: 'has already been linked to another profile.'
warnings:
link_broken: >
%{site_name} has been unable to access this %{resource_type}'s URL since %{fail_date} - the page may have been moved.
Expand Down Expand Up @@ -1114,3 +1116,8 @@ en:
title: What are spaces?
description: |
Spaces are customizable, community-managed sub-portals within %{site_name}, each with their own catalogue of training content.
orcid:
error: 'An error occurred whilst trying to authenticate your ORCID.'
authenticate: 'Authenticate your ORCID'
authentication_success: 'You have successfully authenticated your ORCID.'
authentication_failure: 'Failed to authenticate your ORCID.'
3 changes: 3 additions & 0 deletions config/routes.rb
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,9 @@

match 'oai-pmh', to: "oai#index", via: [:get, :post]

post 'orcid/authenticate' => 'orcid#authenticate', as: :authenticate_orcid
get 'orcid/callback' => 'orcid#callback', as: :orcid_callback

# The priority is based upon order of creation: first created -> highest priority.
# See how all your routes lay out with "rake routes".

Expand Down
4 changes: 4 additions & 0 deletions config/secrets.example.yml
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,10 @@ external_api_keys: &external_api_keys
password:
gpt_api_key:
willma_api_key:
orcid:
client_id:
secret:
host:

#Internal config
development:
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
class AddOrcidAuthenticatedToProfiles < ActiveRecord::Migration[7.2]
def change
add_column :profiles, :orcid_authenticated, :boolean, default: false
end
end
14 changes: 14 additions & 0 deletions db/migrate/20251128160923_update_orcids_in_profiles.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
class UpdateOrcidsInProfiles < ActiveRecord::Migration[7.2]
ORCID_PREFIX = 'https://orcid.org/'.freeze
def up
Profile.where('orcid IS NOT NULL').find_each do |profile|
profile.update_column(:orcid, profile.orcid.gsub(ORCID_PREFIX, '')) if profile.orcid.start_with?(ORCID_PREFIX)
end
end

def down
Profile.where('orcid IS NOT NULL').find_each do |profile|
profile.update_column(:orcid, "#{ORCID_PREFIX}#{profile.orcid}") unless profile.orcid.start_with?(ORCID_PREFIX)
end
Comment thread
fbacall marked this conversation as resolved.
end
end
5 changes: 5 additions & 0 deletions db/migrate/20251201143501_add_index_on_orcid_to_profiles.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
class AddIndexOnOrcidToProfiles < ActiveRecord::Migration[7.2]
def change
add_index :profiles, :orcid
end
end
4 changes: 3 additions & 1 deletion db/schema.rb
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.

ActiveRecord::Schema[7.2].define(version: 2025_03_25_151745) do
ActiveRecord::Schema[7.2].define(version: 2025_12_01_143501) do
# These are extensions that must be enabled in order to support this database
enable_extension "plpgsql"

Expand Down Expand Up @@ -452,6 +452,8 @@
t.string "social_media", default: [], array: true
t.string "type", default: "Profile"
t.string "fields", default: [], array: true
t.boolean "orcid_authenticated", default: false
t.index ["orcid"], name: "index_profiles_on_orcid"
t.index ["slug"], name: "index_profiles_on_slug", unique: true
end

Expand Down
3 changes: 3 additions & 0 deletions test/config/test_secrets.yml
Original file line number Diff line number Diff line change
Expand Up @@ -19,3 +19,6 @@ eventbrite_api_v3:
fairsharing:
username: tess-test-bot
password: abc123
orcid:
client_id: test
secret: test
124 changes: 124 additions & 0 deletions test/controllers/orcid_controller_test.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
require 'test_helper'

class OrcidControllerTest < ActionController::TestCase
include Devise::Test::ControllerHelpers

test 'authenticate orcid logged-in user' do
sign_in users(:regular_user)

post :authenticate

assert_redirected_to /https:\/\/sandbox\.orcid\.org\/oauth\/authorize\?.+/
end

test 'do not authenticate orcid if user not logged-in' do
post :authenticate

assert_redirected_to new_user_session_path
end

test 'handle callback and assign orcid if free' do
mock_images
user = users(:regular_user)
sign_in user

VCR.use_cassette('orcid/get_token_free_orcid') do
get :callback, params: { code: '123xyz' }
end

profile = user.profile.reload
assert_equal '0009-0006-0987-5702', profile.orcid
assert profile.orcid_authenticated?
assert_redirected_to user
end

test 'handle callback and assign orcid if unauthenticated' do
mock_images
user = users(:regular_user)
sign_in user

VCR.use_cassette('orcid/get_token_unauth_orcid') do
get :callback, params: { code: '123xyz' }
end

profile = user.profile.reload
assert_equal '0000-0002-0048-3300', profile.orcid
assert profile.orcid_authenticated?
assert_redirected_to user
end

test 'handle callback and assign orcid even if already used' do
mock_images
user = users(:regular_user)
existing_orcid_user = users(:trainer_user)
assert existing_orcid_user.profile.orcid_authenticated?
sign_in user

VCR.use_cassette('orcid/get_token_existing_orcid') do
get :callback, params: { code: '123xyz' }
end

profile = user.profile.reload
assert_equal '0000-0002-1825-0097', profile.orcid
assert profile.orcid_authenticated?
refute existing_orcid_user.reload.profile.orcid_authenticated?
assert_redirected_to user
assert flash[:error].blank?
end

test 'do not handle callback if not logged-in' do
mock_images

get :callback, params: { code: '123xyz' }

assert_redirected_to new_user_session_path
end

test 'handle unauth error during callback' do
mock_images
user = users(:regular_user)
sign_in user

VCR.use_cassette('orcid/error_unauth') do
get :callback, params: { code: '123xyz' }
end

assert_response :unprocessable_entity
assert_select '#error-message', text: /error occurred.+ORCID/
profile = user.profile.reload
assert profile.orcid.blank?
refute profile.orcid_authenticated?
end

test 'handle unexpected error during callback' do
mock_images
user = users(:regular_user)
sign_in user

VCR.use_cassette('orcid/error_500') do
get :callback, params: { code: '123xyz' }
end

assert_response :unprocessable_entity
assert_select '#error-message', text: /error occurred.+ORCID/
profile = user.profile.reload
assert profile.orcid.blank?
refute profile.orcid_authenticated?
end

test 'handle missing orcid during callback' do
mock_images
user = users(:regular_user)
sign_in user

VCR.use_cassette('orcid/get_token_orcid_missing') do
get :callback, params: { code: '123xyz' }
end

assert_redirected_to user
assert_includes flash[:error], 'Failed to authenticat'
profile = user.profile.reload
assert profile.orcid.blank?
refute profile.orcid_authenticated?
end
end
Comment thread
fbacall marked this conversation as resolved.
Loading
Loading