From a5d44ba80e7e71d9da4c34e6a57d0dafc129c3ed Mon Sep 17 00:00:00 2001 From: Erik Hanson Date: Tue, 3 Dec 2024 11:45:41 -0800 Subject: [PATCH] pkp/pkp-lib#9661 Allow Journal Managers to invite users to adopt a role - ORCiD --- .../invitation/AcceptUserDetailsForm.php | 3 +- .../forms/invitation/UserDetailsForm.php | 29 ++++++----- .../UserRoleAssignmentReceiveController.php | 26 +++++----- .../UserRoleAssignmentInvitePayload.php | 52 ++++++++++++++++--- .../BaseUserRoleAssignmentInviteResource.php | 5 +- ...oleAssignmentInviteManagerDataResource.php | 14 +++-- .../UserRoleAssignmentInviteResource.php | 42 +++++++++------ .../stepTypes/AcceptInvitationStep.php | 14 +++-- classes/orcid/actions/AuthorizeUserData.php | 42 ++++++++++----- classes/orcid/traits/HasOrcid.php | 11 ++++ 10 files changed, 163 insertions(+), 75 deletions(-) diff --git a/classes/components/forms/invitation/AcceptUserDetailsForm.php b/classes/components/forms/invitation/AcceptUserDetailsForm.php index 19e73b0dcae..4cd6c7aa179 100644 --- a/classes/components/forms/invitation/AcceptUserDetailsForm.php +++ b/classes/components/forms/invitation/AcceptUserDetailsForm.php @@ -65,7 +65,7 @@ public function __construct($action, $locales) 'isRequired' => false, 'isMultilingual' => true, 'size' => 'large', - 'value' => '' + 'value' => '', ])) ->addField(new FieldText('affiliation', [ 'label' => __('user.affiliation'), @@ -73,6 +73,7 @@ public function __construct($action, $locales) 'isMultilingual' => true, 'isRequired' => false, 'size' => 'large', + 'value' => '', ])) ->addField(new FieldSelect('userCountry', [ diff --git a/classes/components/forms/invitation/UserDetailsForm.php b/classes/components/forms/invitation/UserDetailsForm.php index c892e0a7c32..8e9940a6177 100644 --- a/classes/components/forms/invitation/UserDetailsForm.php +++ b/classes/components/forms/invitation/UserDetailsForm.php @@ -17,6 +17,7 @@ use PKP\components\forms\FieldHTML; use PKP\components\forms\FieldText; use PKP\components\forms\FormComponent; +use PKP\orcid\OrcidManager; class UserDetailsForm extends FormComponent { @@ -43,20 +44,22 @@ public function __construct(string $action, array $locales) 'description' => __('invitation.email.description'), 'isRequired' => true, 'size' => 'large', + ])); + if (OrcidManager::isEnabled()) { + $this->addField(new FieldHTML('orcid', [ + 'label' => __('user.orcid'), + 'description' => __('invitation.orcid.description'), + 'isRequired' => false, + 'size' => 'large', + ])); + } + $this->addField(new FieldText('givenName', [ + 'label' => __('user.givenName'), + 'description' => __('invitation.givenName.description'), + 'isRequired' => false, + 'isMultilingual' => true, + 'size' => 'large', ])) - ->addField(new FieldHTML('orcid', [ - 'label' => __('user.orcid'), - 'description' => __('invitation.orcid.description'), - 'isRequired' => false, - 'size' => 'large', - ])) - ->addField(new FieldText('givenName', [ - 'label' => __('user.givenName'), - 'description' => __('invitation.givenName.description'), - 'isRequired' => false, - 'isMultilingual' => true, - 'size' => 'large', - ])) ->addField(new FieldText('familyName', [ 'label' => __('user.familyName'), 'description' => __('invitation.familyName.description'), diff --git a/classes/invitation/invitations/userRoleAssignment/handlers/api/UserRoleAssignmentReceiveController.php b/classes/invitation/invitations/userRoleAssignment/handlers/api/UserRoleAssignmentReceiveController.php index 277f42a742f..a0720a6d304 100644 --- a/classes/invitation/invitations/userRoleAssignment/handlers/api/UserRoleAssignmentReceiveController.php +++ b/classes/invitation/invitations/userRoleAssignment/handlers/api/UserRoleAssignmentReceiveController.php @@ -35,14 +35,14 @@ class UserRoleAssignmentReceiveController extends ReceiveInvitationController { - public function __construct(public UserRoleAssignmentInvite $invitation) + public function __construct(public UserRoleAssignmentInvite $invitation) { } /** * @inheritDoc */ - public function authorize(PKPBaseController $controller, PKPRequest $request, array &$args, array $roleAssignments): bool + public function authorize(PKPBaseController $controller, PKPRequest $request, array &$args, array $roleAssignments): bool { $this->invitation->changeInvitationUserIdUsingUserEmail(); @@ -56,14 +56,14 @@ public function authorize(PKPBaseController $controller, PKPRequest $request, ar $controller->addPolicy(new UserRequiredPolicy($request)); } - + return true; } /** * @inheritDoc */ - public function decline(Request $illuminateRequest): JsonResponse + public function decline(Request $illuminateRequest): JsonResponse { $this->invitation->decline(); @@ -76,7 +76,7 @@ public function decline(Request $illuminateRequest): JsonResponse /** * @inheritDoc */ - public function finalize(Request $illuminateRequest): JsonResponse + public function finalize(Request $illuminateRequest): JsonResponse { if (!$this->invitation->validate([], ValidationContext::VALIDATION_CONTEXT_FINALIZE)) { return response()->json([ @@ -98,7 +98,7 @@ public function finalize(Request $illuminateRequest): JsonResponse $user->setCountry($this->invitation->getPayload()->userCountry); $user->setAffiliation($this->invitation->getPayload()->affiliation, null); - $user->setOrcid($this->invitation->getPayload()->userOrcid); + $user->setVerifiedOrcidOAuthData($this->invitation->getPayload()->toArray()); $user->setDateRegistered(Core::getCurrentDate()); $user->setInlineHelp(1); // default new users to having inline help visible. @@ -106,8 +106,8 @@ public function finalize(Request $illuminateRequest): JsonResponse Repo::user()->add($user); } else { - if (empty($user->getOrcid()) && isset($this->invitation->getPayload()->userOrcid)) { - $user->setOrcid($this->invitation->getPayload()->userOrcid); + if (empty($user->getOrcid()) && isset($this->invitation->getPayload()->orcid)) { + $user->setVerifiedOrcidOAuthData($this->invitation->getPayload()->toArray()); Repo::user()->edit($user); } } @@ -126,8 +126,8 @@ public function finalize(Request $illuminateRequest): JsonResponse $userGroupHelper->userGroupId, $effectiveDateStart, $userGroupHelper->dateEnd, - isset($userGroupHelper->masthead) && $userGroupHelper->masthead - ? UserUserGroupMastheadStatus::STATUS_ON + isset($userGroupHelper->masthead) && $userGroupHelper->masthead + ? UserUserGroupMastheadStatus::STATUS_ON : UserUserGroupMastheadStatus::STATUS_OFF ); } @@ -143,10 +143,10 @@ public function finalize(Request $illuminateRequest): JsonResponse /** * @inheritDoc */ - public function receive(Request $illuminateRequest): JsonResponse + public function receive(Request $illuminateRequest): JsonResponse { return response()->json( - (new UserRoleAssignmentInviteResource($this->invitation))->toArray($illuminateRequest), + (new UserRoleAssignmentInviteResource($this->invitation))->toArray($illuminateRequest), Response::HTTP_OK ); } @@ -170,7 +170,7 @@ public function refine(Request $illuminateRequest): JsonResponse $this->invitation->updatePayload(ValidationContext::VALIDATION_CONTEXT_REFINE); return response()->json( - (new UserRoleAssignmentInviteResource($this->invitation))->toArray($illuminateRequest), + (new UserRoleAssignmentInviteResource($this->invitation))->toArray($illuminateRequest), Response::HTTP_OK ); } diff --git a/classes/invitation/invitations/userRoleAssignment/payload/UserRoleAssignmentInvitePayload.php b/classes/invitation/invitations/userRoleAssignment/payload/UserRoleAssignmentInvitePayload.php index 742d80147b4..ea50e8f494a 100644 --- a/classes/invitation/invitations/userRoleAssignment/payload/UserRoleAssignmentInvitePayload.php +++ b/classes/invitation/invitations/userRoleAssignment/payload/UserRoleAssignmentInvitePayload.php @@ -29,17 +29,23 @@ class UserRoleAssignmentInvitePayload extends InvitePayload { public function __construct( - public ?string $userOrcid = null, - public ?array $givenName = null, - public ?array $familyName = null, - public ?array $affiliation = null, + public ?string $orcid = null, + public ?string $orcidAccessDenied = null, + public ?string $orcidAccessExpiresOn = null, + public ?string $orcidAccessScope = null, + public ?string $orcidAccessToken = null, + public ?bool $orcidIsVerified = null, + public ?string $orcidRefreshToken = null, + public ?array $givenName = null, + public ?array $familyName = null, + public ?array $affiliation = null, public ?string $userCountry = null, public ?string $username = null, public ?string $password = null, public ?string $emailSubject = null, public ?string $emailBody = null, - public ?array $userGroupsToAdd = null, - public ?bool $passwordHashed = null, + public ?array $userGroupsToAdd = null, + public ?bool $passwordHashed = null, public ?string $sendEmailAddress = null, public ?array $inviteStagePayload = null, public ?bool $shouldUseInviteData = null, @@ -158,10 +164,40 @@ public function getValidationRules(UserRoleAssignmentInvite $invitation, Validat ], 'userGroupsToAdd.*.masthead' => 'required|bool', 'userGroupsToAdd.*.dateStart' => 'required|date', - 'userOrcid' => [ - Rule::when(in_array($validationContext, [ValidationContext::VALIDATION_CONTEXT_INVITE, ValidationContext::VALIDATION_CONTEXT_FINALIZE]), ['nullable']), + // FIXME: A duplication of existing rules in user schema. Can they be reused? + 'orcid' => [ + 'nullable', 'orcid' ], + 'orcidAccessDenied' => [ + 'nullable', + 'string', + 'max:255', + ], + 'orcidAccessExpiresOn' => [ + 'nullable', + 'string', + 'max:255', + ], + 'orcidAccessScope' => [ + 'nullable', + 'string', + 'max:255', + ], + 'orcidAccessToken' => [ + 'nullable', + 'string', + 'max:255', + ], + 'orcidIsVerified' => [ + 'nullable', + 'boolean', + ], + 'orcidRefreshToken' => [ + 'nullable', + 'string', + 'max:255', + ], 'shouldUseInviteData' => [ new ProhibitedIncludingNull($validationContext === ValidationContext::VALIDATION_CONTEXT_REFINE||$validationContext === ValidationContext::VALIDATION_CONTEXT_POPULATE), ], diff --git a/classes/invitation/invitations/userRoleAssignment/resources/BaseUserRoleAssignmentInviteResource.php b/classes/invitation/invitations/userRoleAssignment/resources/BaseUserRoleAssignmentInviteResource.php index 2a83ded6a71..ad463b1211e 100644 --- a/classes/invitation/invitations/userRoleAssignment/resources/BaseUserRoleAssignmentInviteResource.php +++ b/classes/invitation/invitations/userRoleAssignment/resources/BaseUserRoleAssignmentInviteResource.php @@ -73,7 +73,8 @@ protected function transformUser(?User $user): ?array 'givenName' => $user->getGivenName(null), 'country' => $user->getCountry(), 'affiliation' => $user->getAffiliation(null), - 'orcid' => $user->getOrcid() + 'orcid' => $user->getOrcid(), + 'orcidIsVerified' => $user->hasVerifiedOrcid(), ]; } @@ -90,4 +91,4 @@ protected function createNewUserFromPayload(UserRoleAssignmentInvitePayload $pay return $newUser; } -} \ No newline at end of file +} diff --git a/classes/invitation/invitations/userRoleAssignment/resources/UserRoleAssignmentInviteManagerDataResource.php b/classes/invitation/invitations/userRoleAssignment/resources/UserRoleAssignmentInviteManagerDataResource.php index d846d393819..70eb08acb48 100644 --- a/classes/invitation/invitations/userRoleAssignment/resources/UserRoleAssignmentInviteManagerDataResource.php +++ b/classes/invitation/invitations/userRoleAssignment/resources/UserRoleAssignmentInviteManagerDataResource.php @@ -31,8 +31,8 @@ public function toArray(Request $request) $existingUser = null; $newUser = null; - if ($this->getPayload()->shouldUseInviteData) { - $payload = UserRoleAssignmentInvitePayload::fromArray($this->getPayload()->inviteStagePayload); + if ($payload->shouldUseInviteData) { + $payload = UserRoleAssignmentInvitePayload::fromArray($payload->inviteStagePayload); $newUser = $this->createNewUserFromPayload($payload); } else { @@ -45,7 +45,13 @@ public function toArray(Request $request) // Return specific fields from the UserRoleAssignmentInvite return array_merge($baseData, [ - 'orcid' => $payload->userOrcid, + 'orcid' => $payload->orcid, + 'orcidAccessDenied' => $payload->orcidAccessDenied, + 'orcidAccessExpiresOn' => $payload->orcidAccessExpiresOn, + 'orcidAccessScope' => $payload->orcidAccessScope, + 'orcidAccessToken' => $payload->orcidAccessToken, + 'orcidIsVerified' => $payload->orcidIsVerified, + 'orcidRefreshToken' => $payload->orcidRefreshToken, 'givenName' => $payload->givenName, 'familyName' => $payload->familyName, 'affiliation' => $payload->affiliation, @@ -59,4 +65,4 @@ public function toArray(Request $request) 'newUser' => $this->transformUser($newUser), ]); } -} \ No newline at end of file +} diff --git a/classes/invitation/invitations/userRoleAssignment/resources/UserRoleAssignmentInviteResource.php b/classes/invitation/invitations/userRoleAssignment/resources/UserRoleAssignmentInviteResource.php index ce1bb1b45e8..eb94fc01716 100644 --- a/classes/invitation/invitations/userRoleAssignment/resources/UserRoleAssignmentInviteResource.php +++ b/classes/invitation/invitations/userRoleAssignment/resources/UserRoleAssignmentInviteResource.php @@ -30,31 +30,39 @@ public function toArray(Request $request) $existingUser = $this->getExistingUser(); $newUser = null; + $payload = $this->getPayload(); + if (!isset($existingUser)) { $newUser = new User(); - $newUser->setAffiliation($this->getPayload()->affiliation, null); - $newUser->setFamilyName($this->getPayload()->familyName, null); - $newUser->setGivenName($this->getPayload()->givenName, null); - $newUser->setCountry($this->getPayload()->userCountry); - $newUser->setUsername($this->getPayload()->username); - $newUser->setEmail($this->getPayload()->sendEmailAddress); + $newUser->setAffiliation($payload->affiliation, null); + $newUser->setFamilyName($payload->familyName, null); + $newUser->setGivenName($payload->givenName, null); + $newUser->setCountry($payload->userCountry); + $newUser->setUsername($payload->username); + $newUser->setEmail($payload->sendEmailAddress); } // Return specific fields from the UserRoleAssignmentInvite return array_merge($baseData, [ - 'orcid' => $this->getPayload()->userOrcid, - 'givenName' => $this->getPayload()->givenName, - 'familyName' => $this->getPayload()->familyName, - 'affiliation' => $this->getPayload()->affiliation, - 'country' => $this->getPayload()->userCountry, - 'emailSubject' => $this->getPayload()->emailSubject, - 'emailBody' => $this->getPayload()->emailBody, - 'userGroupsToAdd' => $this->transformUserGroups($this->getPayload()->userGroupsToAdd), - 'username' => $this->getPayload()->username, - 'sendEmailAddress' => $this->getPayload()->sendEmailAddress, + 'orcid' => $payload->orcid, + 'orcidAccessDenied' => $payload->orcidAccessDenied, + 'orcidAccessExpiresOn' => $payload->orcidAccessExpiresOn, + 'orcidAccessScope' => $payload->orcidAccessScope, + 'orcidAccessToken' => $payload->orcidAccessToken, + 'orcidIsVerified' => $payload->orcidIsVerified, + 'orcidRefreshToken' => $payload->orcidRefreshToken, + 'givenName' => $payload->givenName, + 'familyName' => $payload->familyName, + 'affiliation' => $payload->affiliation, + 'country' => $payload->userCountry, + 'emailSubject' => $payload->emailSubject, + 'emailBody' => $payload->emailBody, + 'userGroupsToAdd' => $this->transformUserGroups($payload->userGroupsToAdd), + 'username' => $payload->username, + 'sendEmailAddress' => $payload->sendEmailAddress, 'existingUser' => $this->transformUser($this->getExistingUser()), 'newUser' => $this->transformUser($newUser), ]); } -} \ No newline at end of file +} diff --git a/classes/invitation/stepTypes/AcceptInvitationStep.php b/classes/invitation/stepTypes/AcceptInvitationStep.php index ec1783ab7b3..f4bb91ff1a2 100644 --- a/classes/invitation/stepTypes/AcceptInvitationStep.php +++ b/classes/invitation/stepTypes/AcceptInvitationStep.php @@ -18,6 +18,7 @@ use PKP\invitation\sections\Form; use PKP\invitation\sections\Sections; use PKP\invitation\steps\Step; +use PKP\orcid\OrcidManager; use PKP\user\User; class AcceptInvitationStep extends InvitationStepTypes @@ -33,17 +34,18 @@ public function getSteps(?Invitation $invitation, Context $context, ?User $user) switch ($user) { case !null: - if(!$user->getData('orcidAccessToken')) { + if(!$user->hasVerifiedOrcid() && OrcidManager::isEnabled($context)) { $steps[] = $this->verifyOrcidStep(); - $steps[] = $this->acceptInvitationReviewStep($context); } break; default: - $steps[] = $this->verifyOrcidStep(); + if (OrcidManager::isEnabled($context)) { + $steps[] = $this->verifyOrcidStep(); + } $steps[] = $this->userAccountDetailsStep(); $steps[] = $this->userDetailsStep($context); - $steps[] = $this->acceptInvitationReviewStep($context); } + $steps[] = $this->acceptInvitationReviewStep($context); return $steps; } @@ -62,7 +64,9 @@ private function verifyOrcidStep(): \stdClass $sections->addSection( null, [ - 'validateFields' => ['userOrcid'] + 'validateFields' => ['orcid', 'orcidIsVerified', 'orcidAccessDenied', 'orcidAccessToken', 'orcidAccessScope', 'orcidRefreshToken', 'orcidAccessExpiresOn'], + 'orcidUrl' => OrcidManager::getOrcidUrl(), + 'orcidOAuthUrl' => OrcidManager::buildOAuthUrl('authorizeOrcid', ['targetOp' => 'invitation']), ] ); $step = new Step( diff --git a/classes/orcid/actions/AuthorizeUserData.php b/classes/orcid/actions/AuthorizeUserData.php index d376819393a..003bc40860a 100644 --- a/classes/orcid/actions/AuthorizeUserData.php +++ b/classes/orcid/actions/AuthorizeUserData.php @@ -144,7 +144,8 @@ public function execute(): void case 'profile': $user = $this->request->getUser(); // Store the access token and other data for the user - $user = $this->setOrcidData($user, $orcidUri, $tokenData); + $orcidData = $this->getOrcidOAuthAccessData($orcidUri, $tokenData); + $user->setVerifiedOrcidOAuthData($orcidData); Repo::user()->edit($user, ['orcidAccessDenied', 'orcidAccessToken', 'orcidAccessScope', 'orcidRefreshToken', 'orcidAccessExpiresOn']); // Reload the public profile tab (incl. form) @@ -156,6 +157,16 @@ public function execute(): void '; break; + case 'invitation': + $orcidData = $this->getOrcidOAuthAccessData($orcidUri, $tokenData); + echo ' + + '; + break; default: throw new \Exception('Invalid targetOp'); } @@ -176,23 +187,30 @@ private function renderFrontendErrorNotification(array $errorMessages): string } /** - * Sets ORCID token access data on the provided user or author + * Set ORCID and OAuth response payloads to a single array for further handling as a group. + * + * @param string $orcidUri ORCID ID as a URI + * @param array $orcidResponse OAuth response payload + * @return array */ - private function setOrcidData(Identity $userOrAuthor, string $orcidUri, array $orcidResponse): Identity + private function getOrcidOAuthAccessData(string $orcidUri, array $orcidResponse): array { + $data = []; + // Save the access token $orcidAccessExpiresOn = Carbon::now(); // expires_in field from the response contains the lifetime in seconds of the token // See https://members.orcid.org/api/get-oauthtoken $orcidAccessExpiresOn->addSeconds($orcidResponse['expires_in']); - $userOrAuthor->setOrcid($orcidUri); - $userOrAuthor->setOrcidVerified(true); - // remove the access denied marker, because now the access was granted - $userOrAuthor->setData('orcidAccessDenied', null); - $userOrAuthor->setData('orcidAccessToken', $orcidResponse['access_token']); - $userOrAuthor->setData('orcidAccessScope', $orcidResponse['scope']); - $userOrAuthor->setData('orcidRefreshToken', $orcidResponse['refresh_token']); - $userOrAuthor->setData('orcidAccessExpiresOn', $orcidAccessExpiresOn->toDateTimeString()); - return $userOrAuthor; + + $data['orcid'] = $orcidUri; + $data['orcidIsVerified'] = true; + $data['orcidAccessDenied'] = null; + $data['orcidAccessToken'] = $orcidResponse['access_token']; + $data['orcidAccessScope'] = $orcidResponse['scope']; + $data['orcidRefreshToken'] = $orcidResponse['refresh_token']; + $data['orcidAccessExpiresOn'] = $orcidAccessExpiresOn->toDateTimeString(); + + return $data; } } diff --git a/classes/orcid/traits/HasOrcid.php b/classes/orcid/traits/HasOrcid.php index 7abd0f93b7b..5324c2c250e 100644 --- a/classes/orcid/traits/HasOrcid.php +++ b/classes/orcid/traits/HasOrcid.php @@ -32,4 +32,15 @@ public function setOrcidVerified(bool $status): void { $this->setData('orcidIsVerified', $status); } + + /** + * Sets all ORCID OAuth related fields to the Identity (User or Author) + */ + public function setVerifiedOrcidOAuthData(array $data): void + { + $allowedFields = ['orcid', 'orcidIsVerified', 'orcidAccessDenied', 'orcidAccessToken', 'orcidAccessScope', 'orcidRefreshToken', 'orcidAccessExpiresOn']; + + $items = collect($data); + $items->only($allowedFields)->each(fn (mixed $item, string $key) => $this->setData($key, $item)); + } }