From 0f866e88033621e8da0825bfe78b620308daf842 Mon Sep 17 00:00:00 2001 From: Dimitris Efstathiou Date: Thu, 26 Sep 2024 21:54:24 +0300 Subject: [PATCH] pkp/pkp-lib#10459 Support for role assignment invitation --- api/v1/invitations/InvitationController.php | 236 ++++++++-- .../core/CreateInvitationController.php | 5 +- .../invitation/core/EmptyInvitePayload.php | 20 + classes/invitation/core/Invitation.php | 407 +++++++++++++----- .../InvitationActionRedirectController.php | 2 +- classes/invitation/core/InvitePayload.php | 57 +++ .../core/ReceiveInvitationController.php | 2 +- .../core/contracts/IApiHandleable.php | 8 +- .../core/contracts/IBackofficeHandleable.php | 2 +- .../core/enums/ValidationContext.php | 24 ++ .../invitation/core/traits/HasMailable.php | 2 +- .../invitation/core/traits/ShouldValidate.php | 75 +++- .../invitations/ChangeProfileEmailInvite.php | 142 ------ .../ChangeProfileEmailInvite.php | 158 +++++++ ...geProfileEmailInviteRedirectController.php | 17 +- .../ChangeProfileEmailInvitePayload.php | 27 ++ .../RegistrationAccessInvite.php | 42 +- ...strationAccessInviteRedirectController.php | 42 +- .../ReviewerAccessInvite.php | 105 +++-- ...ReviewerAccessInviteRedirectController.php | 21 +- .../payload/ReviewerAccessInvitePayload.php | 27 ++ .../UserRoleAssignmentInvite.php | 213 +++++++++ ...RoleAssignmentInviteRedirectController.php | 46 ++ .../UserRoleAssignmentCreateController.php | 134 ++++++ .../UserRoleAssignmentReceiveController.php | 178 ++++++++ .../helpers/UserGroupHelper.php | 57 +++ .../UserRoleAssignmentInvitePayload.php | 179 ++++++++ .../UserRoleAssignmentInviteResource.php | 107 +++++ .../rules/AddUserGroupRule.php | 48 +++ .../rules/AllowedKeysRule.php | 43 ++ .../rules/EmailMustNotExistRule.php | 46 ++ .../rules/NoUserGroupChangesRule.php | 43 ++ .../rules/NotNullIfPresent.php | 31 ++ .../rules/ProhibitedIncludingNull.php | 44 ++ .../rules/RemoveUserGroupRule.php | 48 +++ .../rules/UserGroupExistsRule.php | 36 ++ .../rules/UserMustExistRule.php | 45 ++ .../rules/UsernameExistsRule.php | 41 ++ classes/invitation/models/InvitationModel.php | 62 ++- classes/mail/Mailable.php | 4 + .../ChangeProfileEmailInvitationNotify.php | 2 - .../UserRoleAssignmentInvitationNotify.php | 240 +++++++++++ .../mail/traits/OneClickReviewerAccess.php | 17 +- .../install/InvitationsMigration.php | 8 +- .../v3_5_0/I9197_MigrateAccessKeys.php | 31 +- .../listeners/ValidateRegisteredEmail.php | 2 +- .../authorization/AnonymousUserPolicy.php | 50 +++ classes/submission/action/EditorAction.php | 4 +- classes/template/PKPTemplateManager.php | 2 +- classes/user/form/BaseProfileForm.php | 14 +- classes/user/form/ContactForm.php | 6 +- jobs/email/ReviewReminder.php | 2 +- lib/counterBots | 2 +- locale/en/emails.po | 105 +++++ locale/en/invitation.po | 104 +++++ pages/invitation/InvitationHandler.php | 5 +- pages/user/RegistrationHandler.php | 5 +- styles/mailables/style.css | 13 + 58 files changed, 2979 insertions(+), 459 deletions(-) create mode 100644 classes/invitation/core/EmptyInvitePayload.php create mode 100644 classes/invitation/core/InvitePayload.php create mode 100644 classes/invitation/core/enums/ValidationContext.php delete mode 100644 classes/invitation/invitations/ChangeProfileEmailInvite.php create mode 100644 classes/invitation/invitations/changeProfileEmail/ChangeProfileEmailInvite.php rename classes/invitation/invitations/{ => changeProfileEmail}/handlers/ChangeProfileEmailInviteRedirectController.php (82%) create mode 100644 classes/invitation/invitations/changeProfileEmail/payload/ChangeProfileEmailInvitePayload.php rename classes/invitation/invitations/{ => registrationAccess}/RegistrationAccessInvite.php (74%) rename classes/invitation/invitations/{ => registrationAccess}/handlers/RegistrationAccessInviteRedirectController.php (68%) rename classes/invitation/invitations/{ => reviewerAccess}/ReviewerAccessInvite.php (63%) rename classes/invitation/invitations/{ => reviewerAccess}/handlers/ReviewerAccessInviteRedirectController.php (81%) create mode 100644 classes/invitation/invitations/reviewerAccess/payload/ReviewerAccessInvitePayload.php create mode 100644 classes/invitation/invitations/userRoleAssignment/UserRoleAssignmentInvite.php create mode 100644 classes/invitation/invitations/userRoleAssignment/handlers/UserRoleAssignmentInviteRedirectController.php create mode 100644 classes/invitation/invitations/userRoleAssignment/handlers/api/UserRoleAssignmentCreateController.php create mode 100644 classes/invitation/invitations/userRoleAssignment/handlers/api/UserRoleAssignmentReceiveController.php create mode 100644 classes/invitation/invitations/userRoleAssignment/helpers/UserGroupHelper.php create mode 100644 classes/invitation/invitations/userRoleAssignment/payload/UserRoleAssignmentInvitePayload.php create mode 100644 classes/invitation/invitations/userRoleAssignment/resources/UserRoleAssignmentInviteResource.php create mode 100644 classes/invitation/invitations/userRoleAssignment/rules/AddUserGroupRule.php create mode 100644 classes/invitation/invitations/userRoleAssignment/rules/AllowedKeysRule.php create mode 100644 classes/invitation/invitations/userRoleAssignment/rules/EmailMustNotExistRule.php create mode 100644 classes/invitation/invitations/userRoleAssignment/rules/NoUserGroupChangesRule.php create mode 100644 classes/invitation/invitations/userRoleAssignment/rules/NotNullIfPresent.php create mode 100644 classes/invitation/invitations/userRoleAssignment/rules/ProhibitedIncludingNull.php create mode 100644 classes/invitation/invitations/userRoleAssignment/rules/RemoveUserGroupRule.php create mode 100644 classes/invitation/invitations/userRoleAssignment/rules/UserGroupExistsRule.php create mode 100644 classes/invitation/invitations/userRoleAssignment/rules/UserMustExistRule.php create mode 100644 classes/invitation/invitations/userRoleAssignment/rules/UsernameExistsRule.php create mode 100644 classes/mail/mailables/UserRoleAssignmentInvitationNotify.php create mode 100644 classes/security/authorization/AnonymousUserPolicy.php create mode 100644 locale/en/invitation.po create mode 100644 styles/mailables/style.css diff --git a/api/v1/invitations/InvitationController.php b/api/v1/invitations/InvitationController.php index 4dc5cd71bbe..5176e332c14 100644 --- a/api/v1/invitations/InvitationController.php +++ b/api/v1/invitations/InvitationController.php @@ -19,15 +19,25 @@ use Exception; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; +use Illuminate\Http\Response; use Illuminate\Support\Facades\Route; +use Illuminate\Validation\Rule; use PKP\core\PKPBaseController; use PKP\core\PKPRequest; use PKP\invitation\core\contracts\IApiHandleable; use PKP\invitation\core\CreateInvitationController; +use PKP\invitation\core\enums\InvitationStatus; use PKP\invitation\core\Invitation; use PKP\invitation\core\ReceiveInvitationController; +use PKP\invitation\core\traits\HasMailable; +use PKP\invitation\invitations\userRoleAssignment\resources\UserRoleAssignmentInviteResource; +use PKP\invitation\invitations\userRoleAssignment\rules\EmailMustNotExistRule; +use PKP\invitation\invitations\userRoleAssignment\rules\UserMustExistRule; use PKP\invitation\models\InvitationModel; +use PKP\security\authorization\UserRolesRequiredPolicy; use PKP\security\Role; +use PKP\validation\ValidatorFactory; +use Validator; class InvitationController extends PKPBaseController { @@ -35,6 +45,16 @@ class InvitationController extends PKPBaseController public const PARAM_ID = 'invitationId'; public const PARAM_KEY = 'key'; + public $notNeedAPIHandler = [ + 'getMany', + 'getMailable', + 'cancel', + ]; + + public $noParamRequired = [ + 'getMany', + ]; + public $requiresType = [ 'add', ]; @@ -43,11 +63,13 @@ class InvitationController extends PKPBaseController 'get', 'populate', 'invite', + 'getMailable', + 'cancel' ]; public $requiresIdAndKey = [ 'receive', - 'finalise', + 'finalize', 'refine', 'decline', ]; @@ -91,33 +113,42 @@ public function getRouteGroupMiddleware(): array */ public function getGroupRoutes(): void { - // Get By Id Methods - Route::get('{invitationId}', $this->get(...)) - ->name('invitation.get') - ->whereNumber('invitationId') - ->middleware([ - self::roleAuthorizer(Role::getAllRoles()), - ]); - - Route::post('add/{type}', $this->add(...)) - ->name('invitation.add') - ->middleware([ - self::roleAuthorizer(Role::getAllRoles()), - ]); - - Route::put('{invitationId}/populate', $this->populate(...)) - ->name('invitation.populate') - ->whereNumber('invitationId') - ->middleware([ - self::roleAuthorizer(Role::getAllRoles()), - ]); - - Route::put('{invitationId}/invite', $this->invite(...)) - ->name('invitation.invite') - ->whereNumber('invitationId') - ->middleware([ - self::roleAuthorizer(Role::getAllRoles()), - ]); + Route::middleware([ + 'has.user', + 'has.context', + self::roleAuthorizer([ + Role::ROLE_ID_SITE_ADMIN, + Role::ROLE_ID_MANAGER, + Role::ROLE_ID_SUB_EDITOR, + ROLE::ROLE_ID_ASSISTANT, + ]), + ])->group(function () { + + Route::get('', $this->getMany(...)) + ->name('invitation.getMany'); + + // Get By Id Methods + Route::get('{invitationId}', $this->get(...)) + ->name('invitation.get') + ->whereNumber('invitationId'); + + Route::post('add/{type}', $this->add(...)) + ->name('invitation.add'); + + Route::put('{invitationId}/populate', $this->populate(...)) + ->name('invitation.populate') + ->whereNumber('invitationId'); + + Route::put('{invitationId}/invite', $this->invite(...)) + ->name('invitation.invite') + ->whereNumber('invitationId'); + + Route::get('{invitationId}/getMailable', $this->getMailable(...)) + ->name('invitation.getMailable'); + + Route::put('{invitationId}/cancel', $this->cancel(...)) + ->name('invitation.cancel'); + }); // Get By Key methods. Route::get('{invitationId}/key/{key}', $this->receive(...)) @@ -176,36 +207,86 @@ public function authorize(PKPRequest $request, array &$args, array $roleAssignme $invitation = Repo::invitation()->getByIdAndKey($invitationId, $invitationKey); } - if (!isset($invitation)) { - throw new Exception('Invitation could not be created'); - } + if ($actionName == 'getMany') { + $this->addPolicy(new UserRolesRequiredPolicy($request), true); + } else { + if (!isset($invitation)) { + throw new Exception('Invitation could not be created'); + } - $this->invitation = $invitation; + $this->invitation = $invitation; - if (!$this->invitation instanceof IApiHandleable) { - throw new Exception('This invitation does not support API handling'); - } + if (!in_array($actionName, $this->notNeedAPIHandler)) { + if (!$this->invitation instanceof IApiHandleable) { + throw new Exception('This invitation does not support API handling'); + } - $this->createInvitationHandler = $invitation->getCreateInvitationController(); - $this->receiveInvitationHandler = $invitation->getReceiveInvitationController(); + $this->createInvitationHandler = $invitation->getCreateInvitationController($this->invitation); + $this->receiveInvitationHandler = $invitation->getReceiveInvitationController($this->invitation); - if (!isset($this->createInvitationHandler) || !isset($this->receiveInvitationHandler)) { - throw new Exception('This invitation should have defined its API handling code'); - } + if (!isset($this->createInvitationHandler) || !isset($this->receiveInvitationHandler)) { + throw new Exception('This invitation should have defined its API handling code'); + } - $this->selectedHandler = $this->getHandlerForAction($actionName); + $this->selectedHandler = $this->getHandlerForAction($actionName); - if (!method_exists($this->selectedHandler, $actionName)) { - throw new Exception("The handler does not support the method: {$actionName}"); - } + if (!method_exists($this->selectedHandler, $actionName)) { + throw new Exception("The handler does not support the method: {$actionName}"); + } - $this->selectedHandler->authorize($this, $request, $args, $roleAssignments); + $this->selectedHandler->authorize($this, $request, $args, $roleAssignments); + } + } return parent::authorize($request, $args, $roleAssignments); } public function add(Request $illuminateRequest): JsonResponse { + $reqInput = $illuminateRequest->all(); + $payload = $reqInput['invitationData']; + + $rules = [ + 'userId' => [ + Rule::prohibitedIf(isset($payload['inviteeEmail'])), + 'bail', + 'nullable', + 'required_without:inviteeEmail', + 'integer', + new UserMustExistRule($payload['userId']), + ], + 'inviteeEmail' => [ + Rule::prohibitedIf(isset($payload['userId'])), + 'bail', + 'nullable', + 'required_without:userId', + 'email', + new EmailMustNotExistRule($payload['inviteeEmail']), + ] + ]; + + $messages = [ + 'inviteeEmail.prohibited' => __('invitation.api.error.initialization.noUserIdAndEmailTogether'), + 'userId.prohibited' => __('invitation.api.error.initialization.noUserIdAndEmailTogether') + ]; + + $validator = ValidatorFactory::make( + $payload, + $rules, + $messages + ); + + if ($validator->fails()) { + return response()->json([ + 'errors' => $validator->errors() + ], Response::HTTP_UNPROCESSABLE_ENTITY); + } + + $context = $this->getRequest()->getContext(); + $inviter = $this->getRequest()->getUser(); + + $this->invitation->initialize($payload['userId'], $context->getId(), $payload['inviteeEmail'], $inviter->getId()); + return $this->selectedHandler->add($illuminateRequest); } @@ -243,4 +324,69 @@ public function decline(Request $illuminateRequest): JsonResponse { return $this->selectedHandler->decline($illuminateRequest); } + + public function getMany(Request $illuminateRequest): JsonResponse + { + $context = $illuminateRequest->attributes->get('context'); /** @var \PKP\context\Context $context */ + $invitationType = $this->getParameter(self::PARAM_TYPE); + + $count = $illuminateRequest->query('count', 10); // default count to 10 if not provided + $offset = $illuminateRequest->query('offset', 0); // default offset to 0 if not provided + + $query = InvitationModel::query() + ->when($invitationType, function ($query, $invitationType) { + return $query->byType($invitationType); + }) + ->when($context, function ($query, $context) { + return $query->byContextId($context->getId()); + }) + ->stillActive(); + + $maxCount = $query->count(); + + $invitations = $query->skip($offset) + ->take($count) + ->get(); + + $finalCollection = $invitations->map(function ($invitation) { + $specificInvitation = Repo::invitation()->getById($invitation->id); + return $specificInvitation; + }); + + return response()->json([ + 'itemsMax' => $maxCount, + 'items' => (UserRoleAssignmentInviteResource::collection($finalCollection)), + ], Response::HTTP_OK); + } + + public function getMailable(Request $illuminateRequest): JsonResponse + { + if (in_array(HasMailable::class, class_uses($this->invitation))) { + $mailable = $this->invitation->getMailable(); + + return response()->json([ + 'mailable' => $mailable, + ], Response::HTTP_OK); + } + + return response()->json([ + 'error' => __('invitation.api.error.invitationTypeNotHasMailable'), + ], Response::HTTP_BAD_REQUEST); + } + + public function cancel(Request $illuminateRequest): JsonResponse + { + if (!$this->invitation->isPending()) { + return response()->json([ + 'error' => __('invitation.api.error.invitationCantBeCanceled'), + ], Response::HTTP_BAD_REQUEST); + } + + $this->invitation->updateStatus(InvitationStatus::CANCELLED); + + return response()->json( + (new UserRoleAssignmentInviteResource($this->invitation))->toArray($illuminateRequest), + Response::HTTP_OK + ); + } } diff --git a/classes/invitation/core/CreateInvitationController.php b/classes/invitation/core/CreateInvitationController.php index ec566a0fde2..b712877a014 100644 --- a/classes/invitation/core/CreateInvitationController.php +++ b/classes/invitation/core/CreateInvitationController.php @@ -9,7 +9,7 @@ * * @class CreateInvitationController * - * @brief Interface for all Invitation API Handlers + * @brief Defines the API actions of the "Create" phase of the invitation */ namespace PKP\invitation\core; @@ -19,9 +19,12 @@ use Illuminate\Routing\Controller; use PKP\core\PKPBaseController; use PKP\core\PKPRequest; +use PKP\invitation\core\Invitation; abstract class CreateInvitationController extends Controller { + public ?PKPRequest $request = null; + abstract public function authorize(PKPBaseController $controller, PKPRequest $request, array &$args, array $roleAssignments): bool; abstract public function add(Request $illuminateRequest): JsonResponse; abstract public function populate(Request $illuminateRequest): JsonResponse; diff --git a/classes/invitation/core/EmptyInvitePayload.php b/classes/invitation/core/EmptyInvitePayload.php new file mode 100644 index 00000000000..2b2cada7d51 --- /dev/null +++ b/classes/invitation/core/EmptyInvitePayload.php @@ -0,0 +1,20 @@ +payload)) { + $payloadClass = $this->getPayloadClass(); // Get the class from child + return new $payloadClass(); + } + + return $this->payload; + } + + /** + * This is used during every populate call so that the code can use the currenlty processing properties. + */ + protected array $currentlyFilledFromArgs = []; + public function __construct(?InvitationModel $invitationModel = null) { $this->invitationModel = $invitationModel ?: new InvitationModel([ @@ -54,98 +104,153 @@ public function __construct(?InvitationModel $invitationModel = null) $this->fillFromPayload(); } - public function initialize(?int $userId = null, ?int $contextId = null, ?string $email = null) + /** + * Function that adds the invitation to the database, initialising its main attributes. + * Either userId or email is taken into account - if both are defined, the userId is passed into the invitation. + * It removes all other InvitationStatus::INITIALIZED invitation from the database, that correspont to + * the same main attributes. + */ + public function initialize(?int $userId = null, ?int $contextId = null, ?string $email = null, ?int $inviterId = null): void { if (!isset($userId) && !isset($email)) { - throw new Exception("Invitation should contain at least one user id or an invited email')"); + throw new Exception("Invitation should contain the user id or an invited email.')"); } + if (isset($userId)) { + unset($email); + } + + InvitationModel::byStatus(InvitationStatus::INITIALIZED) + ->when($userId !== null, fn (Builder $q) => $q->byUserId($userId)) + ->when($contextId !== null, fn (Builder $q) => $q->byContextId($contextId)) + ->when($email !== null, fn (Builder $q) => $q->byEmail($email)) + ->byType($this->getType()) + ->delete(); + $this->invitationModel->userId = $userId; $this->invitationModel->contextId = $contextId; $this->invitationModel->email = $email; + $this->invitationModel->inviterId = $inviterId; $this->invitationModel->status = InvitationStatus::INITIALIZED; + $this->invitationModel->payload = $this->payload; + $this->invitationModel->save(); } - public function fillFromArgs(array $args) + /** + * Used to fill the invitation's properties from the model's payload values. + */ + protected function fillFromPayload(): void { - foreach ($args as $propName => $value) { - if ($this->getStatus() == InvitationStatus::INITIALIZED) { - if (in_array($propName, $hiddenBeforeDispatch)) { - continue; - } - } elseif ($this->getStatus() == InvitationStatus::PENDING) { - if (in_array($propName, $hiddenAfterDispatch)) { - continue; - } - } else { - throw new Exception('You can not modify the Invitation in this stage'); - } + $payloadClass = $this->getPayload(); + $this->payload = $payloadClass; - if ($propName !== 'invitationModel' && property_exists($this, $propName)) { - $this->{$propName} = $value; - } + if ($this->invitationModel->payload) { + $this->payload = $payloadClass::fromArray( + $this->invitationModel->payload + ); } } - protected function fillFromPayload() + /** + * Validates the incoming data given the validation context if necessary, + * and fills the invitation payload with the given data. + */ + public function fillFromData(array $data): bool { - if ($this->invitationModel->payload) { - foreach ($this->invitationModel->payload as $key => $value) { - if (property_exists($this, $key)) { - $this->{$key} = $value; - } - } + // Determine the properties that are not allowed to be changed based on the current status + $checkArray = []; + if ($this->getStatus() == InvitationStatus::INITIALIZED) { + $checkArray = $this->getNotAccessibleBeforeInvite(); + } elseif ($this->getStatus() == InvitationStatus::PENDING) { + $checkArray = $this->getNotAccessibleAfterInvite(); + } else { + throw new Exception('You cannot modify the Invitation in this stage.'); } + + // Filter out the properties that are not allowed to change + $filteredArgs = array_diff_key($data, array_flip($checkArray)); + + // Convert the existing payload to an array + $existingData = $this->payload->toArray(); + + // Merge existing payload data with the filtered arguments + $mergedData = array_merge($existingData, $filteredArgs); + + // Update the payload with the filtered arguments using fromArray + $payloadClass = $this->getPayload(); + $this->payload = $payloadClass::fromArray($mergedData); + + // Track which properties have been updated + $this->currentlyFilledFromArgs = array_keys($filteredArgs); + + return true; } - public function updatePayload(): ?bool + /** + * Saves the payload to the database, after it passes a sanity check + * Returns: True : if database update SUCCEEDED or if there is nothing to update + * False: if database update FAILED + * null : if invitation validation failed for properties + */ + public function updatePayload(?ValidationContext $validationContext = null): ?bool { - $payload = $this->invitationModel->payload ?: []; + // Convert the current payload object to an array + $currentPayloadArray = $this->payload->toArray(); - $payloadAccessibleProperties = $this->getPayloadAccessibleProperties(); - if (!empty($payloadAccessibleProperties)) { - foreach ($payloadAccessibleProperties as $payloadAccessibleProperty) { - if ($propName !== 'invitationModel' && property_exists($this, $payloadAccessibleProperty)) { - $payload[$payloadAccessibleProperty] = $this->{$payloadAccessibleProperty}; - } - } - } else { - // Create a ReflectionClass instance for the current object - $reflection = new ReflectionClass($this); + // Get the existing payload from the database + $existingPayloadArray = $this->invitationModel->payload ?? []; - // Get public properties only - $properties = $reflection->getProperties(ReflectionProperty::IS_PUBLIC); + // Compare the current payload with the existing one + $changedData = $this->array_diff_assoc_recursive($currentPayloadArray, $existingPayloadArray); - foreach ($properties as $property) { - $propName = $property->getName(); + // If no changes are detected, return true (no need to update) + if (empty($changedData)) { + return true; + } - if ($propName !== 'invitationModel' && property_exists($this, $propName)) { - $payload[$propName] = $this->{$propName}; - } - } + // Determine which properties are not allowed to be changed based on the current status + $checkArray = []; + if ($this->getStatus() == InvitationStatus::INITIALIZED) { + $checkArray = $this->getNotAccessibleBeforeInvite(); + } elseif ($this->getStatus() == InvitationStatus::PENDING) { + $checkArray = $this->getNotAccessibleAfterInvite(); + } else { + throw new Exception('You cannot modify the Invitation in this stage'); } - if (!$this->validatePayload($this->invitationModel->payload ?? [], $payload)) { - return null; + // Filter out the changes that are not allowed based on the current status + $invalidChanges = array_intersect_key($changedData, array_flip($checkArray)); + + if (!empty($invalidChanges)) { + // Throw an exception or handle the error if there are invalid changes + throw new Exception('The following properties cannot be modified at this stage: ' . implode(', ', array_keys($invalidChanges))); + } + + // Validate only the changed data if the ShouldValidate trait is used + if (in_array(ShouldValidate::class, class_uses($this)) && isset($validationContext)) { + if (!$this->validate($changedData, $validationContext)) { + return null; // Validation failed + } } - // Update the payload attribute on the invitation - $this->invitationModel->setAttribute('payload', $payload); + // Update the payload attribute on the invitation model + $this->invitationModel->setAttribute('payload', $currentPayloadArray); + // Save the updated invitation model to the database return $this->invitationModel->save(); } - public function getHiddenBeforeDispatch(): array + public function getNotAccessibleBeforeInvite(): array { - return $this->hiddenBeforeDispatch; + return $this->notAccessibleBeforeInvite; } - public function getHiddenAfterDispatch(): array + public function getNotAccessibleAfterInvite(): array { - return $this->hiddenAfterDispatch; + return $this->notAccessibleAfterInvite; } public function getPayloadAccessibleProperties(): array @@ -153,7 +258,11 @@ public function getPayloadAccessibleProperties(): array return $this->payloadAccessibleProperties; } - protected function checkForKey() + /** + * The invitation does not have a key before the "invite" action is called. + * This function checks if the key_hash is present and creates it if it is not + */ + protected function checkForKey(): void { if (!isset($this->invitationModel->keyHash)) { if (!isset($this->key)) { @@ -164,7 +273,12 @@ protected function checkForKey() } } - public function setExpiryDate(Carbon $expiryDate) + /** + * The invitation does not have an expiryDate before the "invite" action is called. + * This function sets the expiry date of the invitation. + * This cannot change after the invitation is dispatched. + */ + public function setExpiryDate(Carbon $expiryDate): void { if ($this->getStatus() !== InvitationStatus::INITIALIZED) { throw new Exception('Can not change expiry date at this stage'); @@ -173,14 +287,20 @@ public function setExpiryDate(Carbon $expiryDate) $this->invitationModel->expiryDate = $expiryDate; } + /** + * Call this to trigger the "invite" action of the invitation, which also dispatces it. + */ public function invite(): bool { if ($this->getStatus() !== InvitationStatus::INITIALIZED) { throw new Exception('The invitation can not be dispatched'); } - // Need to return error messages also? - $this->preDispatchActions(); + if (in_array(ShouldValidate::class, class_uses($this))) { + if (!$this->validate([], ValidationContext::VALIDATION_CONTEXT_INVITE)) { + return false; + } + } $this->checkForKey(); @@ -192,8 +312,8 @@ public function invite(): bool if (isset($mailable)) { try { Mail::send($mailable); - } catch (TransportException $e) { - trigger_error('Failed to send email invitation: ' . $e->getMessage(), E_USER_ERROR); + } catch (Exception $e) { + throw $e; } } } @@ -202,10 +322,82 @@ public function invite(): bool $this->invitationModel->save(); + InvitationModel::byStatus(InvitationStatus::PENDING) + ->byType($this->getType()) + ->byNotId($this->getId()) + ->when(isset($this->invitationModel->userId), fn (Builder $q) => $q->byUserId($this->invitationModel->userId)) + ->when(!isset($this->invitationModel->userId) && $this->invitationModel->email, fn (Builder $q) => $q->byEmail($this->invitationModel->email)) + ->when(isset($this->invitationModel->contextId), fn (Builder $q) => $q->byContextId($this->invitationModel->contextId)) + ->delete(); + return true; } - public static function makeKeyHash($key): string + public function getInviter(): ?User + { + if (!isset($this->invitationModel->inviterId)) { + return null; + } + + return Repo::user()->get($this->invitationModel->inviterId); + } + + public function getExistingUser(): ?User + { + if (!isset($this->invitationModel->userId)) { + return null; + } + + return Repo::user()->get($this->invitationModel->userId); + } + + public function getContext(): ?Context + { + if (!isset($this->invitationModel->contextId)) { + return null; + } + + $contextDao = Application::getContextDAO(); + return $contextDao->getById($this->invitationModel->contextId); + } + + public function getMailableReceiver(?string $locale = null): Identity + { + $locale = $this->getUsedLocale($locale); + + $sendIdentity = new Identity(); + $user = null; + if ($this->invitationModel->userId) { + $user = Repo::user()->get($this->invitationModel->userId); + + $sendIdentity->setFamilyName($user->getFamilyName($locale), $locale); + $sendIdentity->setGivenName($user->getGivenName($locale), $locale); + $sendIdentity->setEmail($user->getEmail()); + } else { + $sendIdentity->setEmail($this->invitationModel->email); + } + + return $sendIdentity; + } + + public function getUsedLocale(?string $locale = null): string + { + if (isset($locale)) { + return $locale; + } + + if (isset($this->invitationModel->contextId)) { + $contextDao = Application::getContextDAO(); + $context = $contextDao->getById($this->invitationModel->contextId); + return $context->getPrimaryLocale(); + } + + $request = Application::get()->getRequest(); + $site = $request->getSite(); + return $site->getPrimaryLocale(); + } + + private static function makeKeyHash($key): string { return password_hash($key, PASSWORD_BCRYPT); } @@ -238,50 +430,71 @@ public function getActionURL(InvitationAction $invitationAction): ?string return InvitationHandler::getActionUrl($invitationAction, $this); } - public function validatePayload(array $initialPayload, array $modifiedPayload): bool + public function decline(): void { - $checkArray = null; + $this->invitationModel->markAs(InvitationStatus::DECLINED); + } - if ($this->getStatus() == InvitationStatus::INITIALIZED) { - $checkArray = $this->getHiddenBeforeDispatch(); - } elseif ($this->getStatus() == InvitationStatus::PENDING) { - $checkArray = $this->getHiddenAfterDispatch(); - } else { - throw new Exception('You can not modify the Invitation in this stage'); - } + protected function getExpiryDays(): int + { + return (int) Config::getVar('invitations', 'expiration_days', self::DEFAULT_EXPIRY_DAYS); + } - foreach ($modifiedPayload as $key => $value) { - // Check if the key exists in the initial payload - if (!array_key_exists($key, $initialPayload)) { - // Key does not exist in initial, so this is a modification - if (in_array($key, $checkArray)) { - throw new Exception('The property ' . $key . ' can not be modified in this stage'); - } - } + public function getUserId(): ?int + { + return $this->invitationModel->userId; + } - // The key exists; now compare values - if ($initialPayload[$key] !== $value) { - // Different value detected, this is a modification - if (in_array($key, $checkArray)) { - throw new Exception('The property ' . $key . ' can not be modified in this stage'); + public function getContextId(): ?int + { + return $this->invitationModel->contextId; + } + + public function getEmail(): ?string + { + return $this->invitationModel->email; + } + + protected function array_diff_assoc_recursive($array1, $array2) + { + $difference = []; + + foreach ($array1 as $key => $value) { + if (is_array($value)) { + if (!isset($array2[$key]) || !is_array($array2[$key])) { + // If $array2 doesn't have the key or the corresponding value isn't an array + $difference[$key] = $value; + } else { + // Recursively call the function + $new_diff = $this->array_diff_assoc_recursive($value, $array2[$key]); + if (!empty($new_diff)) { + $difference[$key] = $new_diff; + } } + } elseif (!array_key_exists($key, $array2) || $array2[$key] !== $value) { + // If $array2 doesn't have the key or the values don't match + $difference[$key] = $value; } } - if (in_array(ShouldValidate::class, class_uses($this))) { - return $this->validate(); - } - - return true; + return $difference; } - public function decline(): void + public function updateStatus(InvitationStatus $status): void { - $this->invitationModel->markAs(InvitationStatus::DECLINED); + $this->invitationModel->status = $status; + $this->invitationModel->save(); } - protected function getExpiryDays(): int + public function isPending(): bool { - return (int) Config::getVar('invitations', 'expiration_days', self::DEFAULT_EXPIRY_DAYS); + if ( + $this->getStatus() == InvitationStatus::INITIALIZED || + $this->getStatus() == InvitationStatus::PENDING + ) { + return true; + } + + return false; } } diff --git a/classes/invitation/core/InvitationActionRedirectController.php b/classes/invitation/core/InvitationActionRedirectController.php index 51334bdffd1..645304b30ba 100644 --- a/classes/invitation/core/InvitationActionRedirectController.php +++ b/classes/invitation/core/InvitationActionRedirectController.php @@ -9,7 +9,7 @@ * * @class InvitationActionRedirectController * - * @brief Interface for all Invitation API Handlers + * @brief Declares the accept/decline url handlers. */ namespace PKP\invitation\core; diff --git a/classes/invitation/core/InvitePayload.php b/classes/invitation/core/InvitePayload.php new file mode 100644 index 00000000000..888a76e6711 --- /dev/null +++ b/classes/invitation/core/InvitePayload.php @@ -0,0 +1,57 @@ + $value) { + if (property_exists($this, $key)) { + $this->$key = $value; + } + } + } + + /** + * Create an instance of the Payload from an array. + */ + public static function fromArray(array $data): static + { + $className = get_called_class(); + $classVars = get_class_vars($className); + + $filteredData = array_merge($classVars, Arr::only($data, array_keys($classVars))); + + // Instantiate the subclass with the array, letting the constructor handle the details + return new $className(...$filteredData); + } + + /** + * Convert the Payload instance to an array. + * + * @return array + */ + public function toArray(): array + { + return get_object_vars($this); + } +} diff --git a/classes/invitation/core/ReceiveInvitationController.php b/classes/invitation/core/ReceiveInvitationController.php index a8aac6260df..3114ed20e0d 100644 --- a/classes/invitation/core/ReceiveInvitationController.php +++ b/classes/invitation/core/ReceiveInvitationController.php @@ -9,7 +9,7 @@ * * @class ReceiveInvitationController * - * @brief Interface for all Invitation API Handlers + * @brief Defines the API actions of the "Receive" phase of the invitation */ namespace PKP\invitation\core; diff --git a/classes/invitation/core/contracts/IApiHandleable.php b/classes/invitation/core/contracts/IApiHandleable.php index a9862cf520d..f94d8ab225f 100644 --- a/classes/invitation/core/contracts/IApiHandleable.php +++ b/classes/invitation/core/contracts/IApiHandleable.php @@ -14,8 +14,12 @@ namespace PKP\invitation\core\contracts; +use PKP\invitation\core\CreateInvitationController; +use PKP\invitation\core\Invitation; +use PKP\invitation\core\ReceiveInvitationController; + interface IApiHandleable { - public function getCreateInvitationController(): CreateInvitationController; - public function getReceiveInvitationController(): ReceiveInvitationController; + public function getCreateInvitationController(Invitation $invitation): CreateInvitationController; + public function getReceiveInvitationController(Invitation $invitation): ReceiveInvitationController; } diff --git a/classes/invitation/core/contracts/IBackofficeHandleable.php b/classes/invitation/core/contracts/IBackofficeHandleable.php index 0c5f353e2b9..7d52bb751e7 100644 --- a/classes/invitation/core/contracts/IBackofficeHandleable.php +++ b/classes/invitation/core/contracts/IBackofficeHandleable.php @@ -16,6 +16,6 @@ interface IBackofficeHandleable { - public function finalise(): void; + public function finalize(): void; public function decline(): void; } diff --git a/classes/invitation/core/enums/ValidationContext.php b/classes/invitation/core/enums/ValidationContext.php new file mode 100644 index 00000000000..558d22109e9 --- /dev/null +++ b/classes/invitation/core/enums/ValidationContext.php @@ -0,0 +1,24 @@ + true + ]; - public function isValid(): bool + /** + * Declares an array of validation rules to be applied to provided data. + */ + abstract public function getValidationRules(ValidationContext $validationContext = ValidationContext::VALIDATION_CONTEXT_DEFAULT): array; + + abstract public function getValidationMessages(ValidationContext $validationContext = ValidationContext::VALIDATION_CONTEXT_DEFAULT): array; + + protected function globalTraitValidationData(array $data, ValidationContext $validationContext = ValidationContext::VALIDATION_CONTEXT_DEFAULT): array { - return empty($this->errors); + $data = array_merge($data, $this->globalTraitValidation); + + return $data; } - public function getErrors(): array + /** + * Optionally allows subclasses to modify or add more keys to the data array. + * This method can be overridden in classes using this trait. + */ + protected function prepareValidationData(array $data, ValidationContext $validationContext = ValidationContext::VALIDATION_CONTEXT_DEFAULT): array + { + return $this->globalTraitValidationData($data, $validationContext); + } + + /** + * Checks the validity of the data provided against the provided rules. + * Returns true if everything is valid. + */ + public function validate(array $data = [], ValidationContext $validationContext = ValidationContext::VALIDATION_CONTEXT_DEFAULT): bool + { + $data = $this->prepareValidationData($data, $validationContext); + + // Check if $data contains any keys not present in $globalTraitValidation + $otherFields = array_diff(array_keys($data), array_keys($this->globalTraitValidation)); + + if (empty($otherFields)) { + $data = array_merge($data, get_object_vars($this->getPayload())); // Populate $data with all the properties of the current object + } + + $rules = $this->getValidationRules($validationContext); + $messages = $this->getValidationMessages($validationContext); + + $this->validator = ValidatorFactory::make( + $data, + $rules, + $messages + ); + + return $this->isValid(); + } + + public function isValid(): bool { - return $this->errors; + return !$this->validator->fails(); } - protected function addError(string $error): void + public function getErrors(): MessageBag { - $this->errors[] = $error; + return $this->validator->errors(); } - protected function clearErrors(): void + public function getValidator(): Validator { - $this->errors = []; + return $this->validator; } } diff --git a/classes/invitation/invitations/ChangeProfileEmailInvite.php b/classes/invitation/invitations/ChangeProfileEmailInvite.php deleted file mode 100644 index 561ae7bf190..00000000000 --- a/classes/invitation/invitations/ChangeProfileEmailInvite.php +++ /dev/null @@ -1,142 +0,0 @@ -get($this->invitationModel->userId); - $sendIdentity = new Identity(); - $sendIdentity->setFamilyName($user->getFamilyName(null), null); - $sendIdentity->setGivenName($user->getGivenName(null), null); - $sendIdentity->setEmail($this->newEmail); - - $mailable = new ChangeProfileEmailInvitationNotify(); - $mailable->recipients([$sendIdentity]); - $mailable->sender($user); - - $request = Application::get()->getRequest(); - $site = $request->getSite(); - $sitePrimaryLocale = $site->getPrimaryLocale(); - - $emailTemplate = Repo::emailTemplate()->getByKey(Application::SITE_CONTEXT_ID, $mailable::getEmailTemplateKey()); - $mailable->subject($emailTemplate->getLocalizedData('subject', $sitePrimaryLocale)) - ->body($emailTemplate->getLocalizedData('body', $sitePrimaryLocale)); - - $mailable->setData($sitePrimaryLocale); - - $this->setMailable($mailable); - - $acceptUrl = $this->getActionURL(InvitationAction::ACCEPT); - $declineUrl = $this->getActionURL(InvitationAction::DECLINE); - - $this->mailable->buildViewDataUsing(function () use ($acceptUrl, $declineUrl) { - return [ - 'acceptInvitationUrl' => $acceptUrl, - 'declineInvitationUrl' => $declineUrl, - 'newEmail' => $this->newEmail - ]; - }); - - return $this->mailable; - } - - protected function preDispatchActions(): void - { - // Check if everything is in order regarding the properties - if (!isset($this->newEmail)) { - throw new Exception('The invitation can not be dispatched because the email property is missing'); - } - - // Invalidate any other related invitation - $pendingInvitations = InvitationModel::byStatus(InvitationStatus::PENDING) - ->byType(self::INVITATION_TYPE) - ->byUserId($this->invitationModel->userId) - ->get(); - - foreach($pendingInvitations as $pendingInvitation) { - $pendingInvitation->markAs(InvitationStatus::DECLINED); - } - } - - public function finalise(): void - { - $user = Repo::user()->get($this->invitationModel->userId); - - if (!$user) { - throw new Exception(); - } - - $user->setEmail($this->newEmail); - - Repo::user()->edit($user); - - $this->invitationModel->markAs(InvitationStatus::ACCEPTED); - } - - public function getInvitationActionRedirectController(): ?InvitationActionRedirectController - { - return new ChangeProfileEmailInviteRedirectController($this); - } - - public function validate(): bool - { - if ($this->newEmail) { - if (filter_var($this->newEmail, FILTER_VALIDATE_EMAIL) == false) { - $this->addError('The provided email is not in the correct form'); - } - } - - return $this->isValid(); - } -} diff --git a/classes/invitation/invitations/changeProfileEmail/ChangeProfileEmailInvite.php b/classes/invitation/invitations/changeProfileEmail/ChangeProfileEmailInvite.php new file mode 100644 index 00000000000..556e7370249 --- /dev/null +++ b/classes/invitation/invitations/changeProfileEmail/ChangeProfileEmailInvite.php @@ -0,0 +1,158 @@ +notAccessibleAfterInvite); + } + + public function getMailable(): Mailable + { + $request = Application::get()->getRequest(); + + $receiver = $this->getMailableReceiver(); + + $mailable = new ChangeProfileEmailInvitationNotify(); + $mailable->recipients([$receiver]); + $mailable->sender($request->getUser()); + + $context = $request->getContext(); + + $contextId = null; + $locale = Locale::getLocale(); + $contactName = ''; + if (isset($context)) { + $contextId = $context->getId(); + $locale = $context->getPrimaryLocale(); + $contactName = $context->getContactName(); + } else { + $site = $request->getSite(); + $contactName = $site->getData('contactName'); + } + + $emailTemplate = Repo::emailTemplate()->getByKey($contextId, $mailable::getEmailTemplateKey()); + $mailable->subject($emailTemplate->getLocalizedData('subject', $locale)) + ->body($emailTemplate->getLocalizedData('body', $locale)); + + $mailable->setData($locale); + + $this->setMailable($mailable); + + $acceptUrl = $this->getActionURL(InvitationAction::ACCEPT); + $declineUrl = $this->getActionURL(InvitationAction::DECLINE); + + $this->mailable->buildViewDataUsing(function () use ($acceptUrl, $declineUrl, $contactName) { + return [ + 'acceptInvitationUrl' => $acceptUrl, + 'declineInvitationUrl' => $declineUrl, + 'newEmail' => $this->getPayload()->newEmail, + 'siteContactName' => $contactName + ]; + }); + + return $this->mailable; + } + + public function finalize(): void + { + $user = Repo::user()->get($this->getUserId()); + + if (!$user) { + throw new Exception(); + } + + $user->setEmail($this->getPayload()->newEmail); + + Repo::user()->edit($user); + + $this->invitationModel->markAs(InvitationStatus::ACCEPTED); + } + + public function getInvitationActionRedirectController(): ?InvitationActionRedirectController + { + return new ChangeProfileEmailInviteRedirectController($this); + } + + /** + * @inheritDoc + */ + public function getValidationRules(ValidationContext $validationContext = ValidationContext::VALIDATION_CONTEXT_DEFAULT): array + { + return [ + 'newEmail' => 'required|email', + ]; + } + + /** + * @inheritDoc + */ + public function getValidationMessages(ValidationContext $validationContext = ValidationContext::VALIDATION_CONTEXT_DEFAULT): array + { + return []; + } +} diff --git a/classes/invitation/invitations/handlers/ChangeProfileEmailInviteRedirectController.php b/classes/invitation/invitations/changeProfileEmail/handlers/ChangeProfileEmailInviteRedirectController.php similarity index 82% rename from classes/invitation/invitations/handlers/ChangeProfileEmailInviteRedirectController.php rename to classes/invitation/invitations/changeProfileEmail/handlers/ChangeProfileEmailInviteRedirectController.php index ea2f8203c51..c7ec84f3f54 100644 --- a/classes/invitation/invitations/handlers/ChangeProfileEmailInviteRedirectController.php +++ b/classes/invitation/invitations/changeProfileEmail/handlers/ChangeProfileEmailInviteRedirectController.php @@ -1,7 +1,7 @@ invitation->getStatus() !== InvitationStatus::ACCEPTED) { + if ($this->invitation->getStatus() !== InvitationStatus::PENDING) { $request->getDispatcher()->handle404(); } - $user = Repo::user()->get($this->invitation->invitationModel->userId); + $user = Repo::user()->get($this->invitation->getUserId()); $notificationManager = new NotificationManager(); $notificationManager->createTrivialNotification($user->getId()); @@ -57,11 +56,11 @@ public function acceptHandle(Request $request): void public function declineHandle(Request $request): void { - if ($this->invitation->getStatus() !== InvitationStatus::DECLINED) { + if ($this->invitation->getStatus() !== InvitationStatus::PENDING) { $request->getDispatcher()->handle404(); } - $user = Repo::user()->get($this->invitation->invitationModel->userId); + $user = Repo::user()->get($this->invitation->getUserId()); $notificationManager = new NotificationManager(); $notificationManager->createTrivialNotification($user->getId()); @@ -83,7 +82,7 @@ public function declineHandle(Request $request): void public function preRedirectActions(InvitationAction $action) { if ($action == InvitationAction::ACCEPT) { - $this->getInvitation()->finalise(); + $this->getInvitation()->finalize(); } elseif ($action == InvitationAction::DECLINE) { $this->getInvitation()->decline(); } diff --git a/classes/invitation/invitations/changeProfileEmail/payload/ChangeProfileEmailInvitePayload.php b/classes/invitation/invitations/changeProfileEmail/payload/ChangeProfileEmailInvitePayload.php new file mode 100644 index 00000000000..ade43bd5948 --- /dev/null +++ b/classes/invitation/invitations/changeProfileEmail/payload/ChangeProfileEmailInvitePayload.php @@ -0,0 +1,27 @@ +getActionURL(InvitationAction::ACCEPT); @@ -48,20 +67,7 @@ public function updateMailableWithUrl(Mailable $mailable): void }); } - protected function preDispatchActions(): void - { - $pendingInvitations = InvitationModel::byStatus(InvitationStatus::PENDING) - ->byType(self::INVITATION_TYPE) - ->byContextId($this->invitationModel->contextId) - ->byUserId($this->invitationModel->userId) - ->get(); - - foreach($pendingInvitations as $pendingInvitation) { - $pendingInvitation->markAs(InvitationStatus::CANCELLED); - } - } - - public function finalise(): void + public function finalize(): void { $user = Repo::user()->get($this->invitationModel->userId, true); diff --git a/classes/invitation/invitations/handlers/RegistrationAccessInviteRedirectController.php b/classes/invitation/invitations/registrationAccess/handlers/RegistrationAccessInviteRedirectController.php similarity index 68% rename from classes/invitation/invitations/handlers/RegistrationAccessInviteRedirectController.php rename to classes/invitation/invitations/registrationAccess/handlers/RegistrationAccessInviteRedirectController.php index 204244d660a..5b3a1c851cf 100644 --- a/classes/invitation/invitations/handlers/RegistrationAccessInviteRedirectController.php +++ b/classes/invitation/invitations/registrationAccess/handlers/RegistrationAccessInviteRedirectController.php @@ -1,7 +1,7 @@ invitation->getStatus() !== InvitationStatus::ACCEPTED) { + if ($this->invitation->getStatus() !== InvitationStatus::PENDING) { $request->getDispatcher()->handle404(); } - $user = Repo::user()->get($this->invitation->invitationModel->userId, true); + $user = Repo::user()->get($this->invitation->getUserId(), true); if (!$user) { throw new Exception(); @@ -53,8 +53,9 @@ public function acceptHandle(Request $request): void ] ); - if (isset($this->invitationModel->contextId)) { - $context = $request->getContext(); + $contextId = $this->invitation->getContextId(); + if (isset($contextId)) { + $context = Application::getContextDAO()->getById($contextId); $url = PKPApplication::get()->getDispatcher()->url( PKPApplication::get()->getRequest(), @@ -73,30 +74,43 @@ public function acceptHandle(Request $request): void public function declineHandle(Request $request): void { - if ($this->invitation->getStatus() !== InvitationStatus::DECLINED) { + if ($this->invitation->getStatus() !== InvitationStatus::PENDING) { $request->getDispatcher()->handle404(); } - $context = $request->getContext(); - $url = PKPApplication::get()->getDispatcher()->url( PKPApplication::get()->getRequest(), PKPApplication::ROUTE_PAGE, - $context->getData('urlPath'), + null, 'user', 'login', - null, [ ] ); + $contextId = $this->invitation->getContextId(); + if (isset($contextId)) { + $context = Application::getContextDAO()->getById($contextId); + + $url = PKPApplication::get()->getDispatcher()->url( + PKPApplication::get()->getRequest(), + PKPApplication::ROUTE_PAGE, + $context->getData('urlPath'), + 'user', + 'login', + null, + [ + ] + ); + } + $request->redirectUrl($url); } public function preRedirectActions(InvitationAction $action) { if ($action == InvitationAction::ACCEPT) { - $this->getInvitation()->finalise(); + $this->getInvitation()->finalize(); } elseif ($action == InvitationAction::DECLINE) { $this->getInvitation()->decline(); } diff --git a/classes/invitation/invitations/ReviewerAccessInvite.php b/classes/invitation/invitations/reviewerAccess/ReviewerAccessInvite.php similarity index 63% rename from classes/invitation/invitations/ReviewerAccessInvite.php rename to classes/invitation/invitations/reviewerAccess/ReviewerAccessInvite.php index cc074b57914..fe58f95fdfa 100644 --- a/classes/invitation/invitations/ReviewerAccessInvite.php +++ b/classes/invitation/invitations/reviewerAccess/ReviewerAccessInvite.php @@ -1,7 +1,7 @@ invitationModel) || !isset($this->invitationModel->contextId)) { @@ -59,13 +82,9 @@ protected function getExpiryDays(): int return ($context->getData('numWeeksPerReview') + 4) * 7; } - public function getHiddenAfterDispatch(): array + public function getNotAccessibleAfterInvite(): array { - $baseHiddenItems = parent::getHiddenAfterDispatch(); - - $additionalHiddenItems = ['reviewAssignmentId']; - - return array_merge($baseHiddenItems, $additionalHiddenItems); + return array_merge(parent::getNotAccessibleAfterInvite(), $this->notAccessibleAfterInvite); } public function updateMailableWithUrl(Mailable $mailable): void @@ -79,30 +98,7 @@ public function updateMailableWithUrl(Mailable $mailable): void }); } - public function preDispatchActions(): void - { - if (!isset($this->reviewAssignmentId)) { - throw new Exception('The review assignment id should be declared before dispatch'); - } - - $reviewAssignment = Repo::reviewAssignment()->get($this->reviewAssignmentId); - - if (!$reviewAssignment) { - throw new Exception('The review assignment ID does not correspond to a valid assignment'); - } - - $pendingInvitations = InvitationModel::byStatus(InvitationStatus::PENDING) - ->byType(self::INVITATION_TYPE) - ->byContextId($this->invitationModel->contextId) - ->byUserId($this->invitationModel->userId) - ->get(); - - foreach($pendingInvitations as $pendingInvitation) { - $pendingInvitation->markAs(InvitationStatus::CANCELLED); - } - } - - public function finalise(): void + public function finalize(): void { $contextDao = Application::getContextDAO(); $context = $contextDao->getById($this->invitationModel->contextId); @@ -118,7 +114,7 @@ public function finalise(): void private function _validateAccessKey(): bool { - $reviewAssignment = Repo::reviewAssignment()->get($this->reviewAssignmentId); + $reviewAssignment = Repo::reviewAssignment()->get($this->getPayload()->reviewAssignmentId); if (!$reviewAssignment) { return false; @@ -152,16 +148,35 @@ public function getInvitationActionRedirectController(): ?InvitationActionRedire return new ReviewerAccessInviteRedirectController($this); } - public function validate(): bool + /** + * @inheritDoc + */ + public function getValidationRules(ValidationContext $validationContext = ValidationContext::VALIDATION_CONTEXT_DEFAULT): array { - if (isset($this->reviewAssignmentId)) { - $reviewAssignment = Repo::reviewAssignment()->get($this->reviewAssignmentId); - - if (!$reviewAssignment) { - $this->addError('The review assignment ID does not correspond to a valid assignment'); - } - } + return [ + 'reviewAssignmentId' => [ + 'required', + 'integer', + function ($attribute, $value, $fail) { + $reviewAssignment = Repo::reviewAssignment()->get($value); + + if (!$reviewAssignment) { + $fail(__('invitation.reviewerAccess.validation.error.reviewAssignmentId.notExisting', + [ + 'reviewAssignmentId' => $value + ]) + ); + } + } + ] + ]; + } - return $this->isValid(); + /** + * @inheritDoc + */ + public function getValidationMessages(ValidationContext $validationContext = ValidationContext::VALIDATION_CONTEXT_DEFAULT): array + { + return []; } } diff --git a/classes/invitation/invitations/handlers/ReviewerAccessInviteRedirectController.php b/classes/invitation/invitations/reviewerAccess/handlers/ReviewerAccessInviteRedirectController.php similarity index 81% rename from classes/invitation/invitations/handlers/ReviewerAccessInviteRedirectController.php rename to classes/invitation/invitations/reviewerAccess/handlers/ReviewerAccessInviteRedirectController.php index a231c13ff33..08e9af29fcc 100644 --- a/classes/invitation/invitations/handlers/ReviewerAccessInviteRedirectController.php +++ b/classes/invitation/invitations/reviewerAccess/handlers/ReviewerAccessInviteRedirectController.php @@ -1,7 +1,7 @@ invitation->getStatus() !== InvitationStatus::ACCEPTED) { + if ($this->invitation->getStatus() !== InvitationStatus::PENDING) { $request->getDispatcher()->handle404(); } $context = $request->getContext(); - $reviewAssignment = Repo::reviewAssignment()->get($this->getInvitation()->reviewAssignmentId); + $reviewAssignment = Repo::reviewAssignment()->get($this->getInvitation()->getPayload()->reviewAssignmentId); if (!$reviewAssignment) { throw new Exception(); @@ -57,6 +56,8 @@ public function acceptHandle(Request $request): void ] ); + $this->getInvitation()->finalize(); + $request->redirectUrl($url); } @@ -79,15 +80,13 @@ public function declineHandle(Request $request): void ] ); + $this->getInvitation()->decline(); + $request->redirectUrl($url); } public function preRedirectActions(InvitationAction $action) { - if ($action == InvitationAction::ACCEPT) { - $this->getInvitation()->finalise(); - } elseif ($action == InvitationAction::DECLINE) { - $this->getInvitation()->decline(); - } + return; } } diff --git a/classes/invitation/invitations/reviewerAccess/payload/ReviewerAccessInvitePayload.php b/classes/invitation/invitations/reviewerAccess/payload/ReviewerAccessInvitePayload.php new file mode 100644 index 00000000000..77ad18b5be6 --- /dev/null +++ b/classes/invitation/invitations/reviewerAccess/payload/ReviewerAccessInvitePayload.php @@ -0,0 +1,27 @@ +notAccessibleAfterInvite); + } + + public function getNotAccessibleBeforeInvite(): array + { + return array_merge(parent::getNotAccessibleBeforeInvite(), $this->notAccessibleBeforeInvite); + } + + public function getMailable(): Mailable + { + $contextDao = Application::getContextDAO(); + $context = $contextDao->getById($this->invitationModel->contextId); + $locale = $context->getPrimaryLocale(); + + // Define the Mailable + $mailable = new UserRoleAssignmentInvitationNotify($context, $this); + $mailable->setData($locale); + + // Set the email send data + $emailTemplate = Repo::emailTemplate()->getByKey($context->getId(), $mailable::getEmailTemplateKey()); + + if (!isset($emailTemplate)) { + throw new \Exception('No email template found for key ' . $mailable::getEmailTemplateKey()); + } + + $inviter = $this->getInviter(); + + $reciever = $this->getMailableReceiver($locale); + + $mailable + ->sender($inviter) + ->recipients([$reciever]) + ->subject($emailTemplate->getLocalizedData('subject', $locale)) + ->body($emailTemplate->getLocalizedData('body', $locale)); + + $this->setMailable($mailable); + + return $this->mailable; + } + + public function getMailableReceiver(?string $locale = null): Identity + { + $locale = $this->getUsedLocale($locale); + + $receiver = parent::getMailableReceiver($locale); + + if (isset($this->familyName)) { + $receiver->setFamilyName($this->getPayload()->familyName, $locale); + } + + if (isset($this->givenName)) { + $receiver->setGivenName($this->getPayload()->givenName, $locale); + } + + return $receiver; + } + + public function getInvitationActionRedirectController(): ?InvitationActionRedirectController + { + return new UserRoleAssignmentInviteRedirectController($this); + } + + /** + * @inheritDoc + */ + public function getCreateInvitationController(Invitation $invitation): CreateInvitationController + { + return new UserRoleAssignmentCreateController($invitation); + } + + /** + * @inheritDoc + */ + public function getReceiveInvitationController(Invitation $invitation): ReceiveInvitationController + { + return new UserRoleAssignmentReceiveController($invitation); + } + + public function getValidationRules(ValidationContext $validationContext = ValidationContext::VALIDATION_CONTEXT_DEFAULT): array + { + $invitationValidationRules = []; + + if ( + $validationContext === ValidationContext::VALIDATION_CONTEXT_INVITE || + $validationContext === ValidationContext::VALIDATION_CONTEXT_FINALIZE + ) { + $invitationValidationRules[Invitation::VALIDATION_RULE_GENERIC][] = new NoUserGroupChangesRule( + $this->getPayload()->userGroupsToAdd, + $this->getPayload()->userGroupsToRemove + ); + $invitationValidationRules[Invitation::VALIDATION_RULE_GENERIC][] = new UserMustExistRule($this->getUserId()); + $invitationValidationRules[Invitation::VALIDATION_RULE_GENERIC][] = new EmailMustNotExistRule($this->getEmail()); + } + + $validationRules = array_merge( + $invitationValidationRules, + $this->getPayload()->getValidationRules($this, $validationContext) + ); + + return $validationRules; + } + + /** + * @inheritDoc + */ + public function getValidationMessages(ValidationContext $validationContext = ValidationContext::VALIDATION_CONTEXT_DEFAULT): array + { + $invitationValidationMessages = []; + + $invitationValidationMessages = array_merge( + $invitationValidationMessages, + $this->getPayload()->getValidationMessages($validationContext) + ); + + return $invitationValidationMessages; + } + + /** + * @inheritDoc + */ + public function updatePayload(?ValidationContext $validationContext = null): ?bool + { + // Encrypt the password if it exists + // There is already a validation rule that makes username and password fields interconnected + if (isset($this->getPayload()->username) && isset($this->getPayload()->password) && !$this->getPayload()->passwordHashed) { + $this->getPayload()->password = Validation::encryptCredentials($this->getPayload()->username, $this->getPayload()->password); + $this->getPayload()->passwordHashed = true; + } + + // Call the parent updatePayload method to continue the normal update process + return parent::updatePayload($validationContext); + } + +} diff --git a/classes/invitation/invitations/userRoleAssignment/handlers/UserRoleAssignmentInviteRedirectController.php b/classes/invitation/invitations/userRoleAssignment/handlers/UserRoleAssignmentInviteRedirectController.php new file mode 100644 index 00000000000..c0832538780 --- /dev/null +++ b/classes/invitation/invitations/userRoleAssignment/handlers/UserRoleAssignmentInviteRedirectController.php @@ -0,0 +1,46 @@ +invitation; + } + + public function acceptHandle(Request $request): void + { + $templateMgr = TemplateManager::getManager($request); + + $templateMgr->assign('invitation', $this->invitation); + $templateMgr->display('frontend/pages/invitations.tpl'); + } + + public function declineHandle(Request $request): void + { + return; + } + + public function preRedirectActions(InvitationAction $action) + { + return; + } +} diff --git a/classes/invitation/invitations/userRoleAssignment/handlers/api/UserRoleAssignmentCreateController.php b/classes/invitation/invitations/userRoleAssignment/handlers/api/UserRoleAssignmentCreateController.php new file mode 100644 index 00000000000..d9e6a1b3a36 --- /dev/null +++ b/classes/invitation/invitations/userRoleAssignment/handlers/api/UserRoleAssignmentCreateController.php @@ -0,0 +1,134 @@ +request = $request; + + $controller->addPolicy(new UserRolesRequiredPolicy($request), true); + + $controller->addPolicy(new ContextAccessPolicy($request, $roleAssignments)); + + return true; + } + + /** + * @inheritDoc + */ + public function add(Request $illuminateRequest): JsonResponse + { + if ($this->invitation->getEmail()) { + $this->invitation->getPayload()->sendEmailAddress = $this->invitation->getEmail(); + $this->invitation->updatePayload(); + } + + return response()->json([ + 'invitationId' => $this->invitation->getId() + ], Response::HTTP_OK); + } + + /** + * @inheritDoc + */ + public function populate(Request $illuminateRequest): JsonResponse + { + $reqInput = $illuminateRequest->all(); + $payload = $reqInput['invitationData']; + + if (!$this->invitation->validate($payload, ValidationContext::VALIDATION_CONTEXT_POPULATE)) { + return response()->json([ + 'errors' => $this->invitation->getErrors() + ], Response::HTTP_UNPROCESSABLE_ENTITY); + } + + $this->invitation->fillFromData($payload); + + $this->invitation->updatePayload(); + + // Here we should consider returning a certain json taken from the custom invitation + // in order to be able to fully control the response + return response()->json( + (new UserRoleAssignmentInviteResource($this->invitation))->toArray($illuminateRequest), + Response::HTTP_OK + ); + } + + /** + * @inheritDoc + */ + public function get(Request $illuminateRequest): JsonResponse + { + return response()->json( + (new UserRoleAssignmentInviteResource($this->invitation))->toArray($illuminateRequest), + Response::HTTP_OK + ); + } + + /** + * @inheritDoc + */ + public function invite(Request $illuminateRequest): JsonResponse + { + $this->invitation->getPayload()->sendEmailAddress = $this->invitation->getEmail(); + + $existingUser = $this->invitation->getExistingUser(); + if (isset($existingUser)) { + $this->invitation->getPayload()->sendEmailAddress = $existingUser->getEmail(); + } + + $this->invitation->updatePayload(); + + if (!$this->invitation->validate([], ValidationContext::VALIDATION_CONTEXT_INVITE)) { + return response()->json([ + 'errors' => $this->invitation->getErrors() + ], Response::HTTP_UNPROCESSABLE_ENTITY); + } + + $inviteResult = $this->invitation->invite(); + + if (!isset($inviteResult)) { + return response()->json([ + 'errors' => $this->invitation->getErrors() + ], Response::HTTP_UNPROCESSABLE_ENTITY); + } + + return response()->json( + (new UserRoleAssignmentInviteResource($this->invitation))->toArray($illuminateRequest), + Response::HTTP_OK + ); + } +} diff --git a/classes/invitation/invitations/userRoleAssignment/handlers/api/UserRoleAssignmentReceiveController.php b/classes/invitation/invitations/userRoleAssignment/handlers/api/UserRoleAssignmentReceiveController.php new file mode 100644 index 00000000000..dfaf39094ef --- /dev/null +++ b/classes/invitation/invitations/userRoleAssignment/handlers/api/UserRoleAssignmentReceiveController.php @@ -0,0 +1,178 @@ +invitation->getExistingUser(); + if (!isset($user)) { + $controller->addPolicy(new AnonymousUserPolicy($request)); + } else { + // Register the user object in the session + $reason = null; + Validation::registerUserSession($user, $reason); + + $controller->addPolicy(new UserRequiredPolicy($request)); + } + + return true; + } + + /** + * @inheritDoc + */ + public function decline(Request $illuminateRequest): JsonResponse + { + $this->invitation->decline(); + + return response()->json( + (new UserRoleAssignmentInviteResource($this->invitation))->toArray($illuminateRequest), + Response::HTTP_OK + ); + } + + /** + * @inheritDoc + */ + public function finalize(Request $illuminateRequest): JsonResponse + { + if (!$this->invitation->validate([], ValidationContext::VALIDATION_CONTEXT_FINALIZE)) { + return response()->json([ + 'errors' => $this->invitation->getErrors() + ], Response::HTTP_UNPROCESSABLE_ENTITY); + } + + $user = $this->invitation->getExistingUser(); + + if (!isset($user)) { + $user = Repo::user()->newDataObject(); + + $user->setUsername($this->invitation->getPayload()->username); + + // Set the base user fields (name, etc.) + $user->setGivenName($this->invitation->getPayload()->givenName, null); + $user->setFamilyName($this->invitation->getPayload()->familyName, null); + $user->setEmail($this->invitation->getEmail()); + $user->setCountry($this->invitation->getPayload()->userCountry); + $user->setAffiliation($this->invitation->getPayload()->affiliation, null); + + $user->setOrcid($this->invitation->getPayload()->userOrcid); + + $user->setDateRegistered(Core::getCurrentDate()); + $user->setInlineHelp(1); // default new users to having inline help visible. + $user->setPassword($this->invitation->getPayload()->password); + + Repo::user()->add($user); + } else { + if (empty($user->getOrcid()) && isset($this->invitation->getPayload()->userOrcid)) { + $user->setOrcid($this->invitation->getPayload()->userOrcid); + Repo::user()->edit($user); + } + } + + foreach ($this->invitation->getPayload()->userGroupsToRemove as $userUserGroup) { + $userGroupHelper = UserGroupHelper::fromArray($userUserGroup); + Repo::userGroup()->endAssignments( + $this->invitation->getContextId(), + $user->getId(), + $userGroupHelper->userGroupId + ); + } + + foreach ($this->invitation->getPayload()->userGroupsToAdd as $userUserGroup) { + $userGroupHelper = UserGroupHelper::fromArray($userUserGroup); + + Repo::userGroup()->assignUserToGroup( + $user->getId(), + $userGroupHelper->userGroupId, + $userGroupHelper->dateStart, + $userGroupHelper->dateEnd, + isset($userGroupHelper->masthead) && $userGroupHelper->masthead + ? UserUserGroupMastheadStatus::STATUS_ON + : UserUserGroupMastheadStatus::STATUS_OFF + ); + } + + $this->invitation->invitationModel->markAs(InvitationStatus::ACCEPTED); + + return response()->json( + (new UserRoleAssignmentInviteResource($this->invitation))->toArray($illuminateRequest), + Response::HTTP_OK + ); + } + + /** + * @inheritDoc + */ + public function receive(Request $illuminateRequest): JsonResponse + { + return response()->json( + (new UserRoleAssignmentInviteResource($this->invitation))->toArray($illuminateRequest), + Response::HTTP_OK + ); + } + + /** + * @inheritDoc + */ + public function refine(Request $illuminateRequest): JsonResponse + { + $reqInput = $illuminateRequest->all(); + $payload = $reqInput['invitationData']; + + if (!$this->invitation->validate($payload, ValidationContext::VALIDATION_CONTEXT_REFINE)) { + return response()->json([ + 'errors' => $this->invitation->getErrors() + ], Response::HTTP_UNPROCESSABLE_ENTITY); + } + + $this->invitation->fillFromData($payload); + + $this->invitation->updatePayload(ValidationContext::VALIDATION_CONTEXT_REFINE); + + return response()->json( + (new UserRoleAssignmentInviteResource($this->invitation))->toArray($illuminateRequest), + Response::HTTP_OK + ); + } +} diff --git a/classes/invitation/invitations/userRoleAssignment/helpers/UserGroupHelper.php b/classes/invitation/invitations/userRoleAssignment/helpers/UserGroupHelper.php new file mode 100644 index 00000000000..9908053974d --- /dev/null +++ b/classes/invitation/invitations/userRoleAssignment/helpers/UserGroupHelper.php @@ -0,0 +1,57 @@ +userGroup = Repo::userGroup()->get($this->userGroupId); + } + + public static function fromArray(array $data): self + { + return new self( + $data['userGroupId'], + $data['masthead'], + $data['dateStart'], + $data['dateEnd'] ?? null + ); + } + + public static function fromUserUserGroup(UserUserGroup $userUserGroup): self + { + return new self( + $userUserGroup->userGroupId, + $userUserGroup->masthead, + $userUserGroup->dateStart, + $userUserGroup->dateEnd + ); + } +} diff --git a/classes/invitation/invitations/userRoleAssignment/payload/UserRoleAssignmentInvitePayload.php b/classes/invitation/invitations/userRoleAssignment/payload/UserRoleAssignmentInvitePayload.php new file mode 100644 index 00000000000..c4dc6cebcdd --- /dev/null +++ b/classes/invitation/invitations/userRoleAssignment/payload/UserRoleAssignmentInvitePayload.php @@ -0,0 +1,179 @@ +getContext(); + $allowedLocales = $context->getSupportedFormLocales(); + + $validationRules = [ + 'givenName' => [ + 'bail', + Rule::excludeIf(!is_null($invitation->getUserId()) && $validationContext === ValidationContext::VALIDATION_CONTEXT_FINALIZE), + new ProhibitedIncludingNull(!is_null($invitation->getUserId())), + Rule::when(in_array($validationContext, [ValidationContext::VALIDATION_CONTEXT_INVITE]), ['nullable']), + Rule::requiredIf($validationContext === ValidationContext::VALIDATION_CONTEXT_FINALIZE), + 'sometimes', + 'array', + new AllowedKeysRule($allowedLocales), // Apply the custom rule + ], + 'givenName.*' => [ + 'string', + 'max:255', + ], + 'familyName' => [ + 'bail', + Rule::excludeIf(!is_null($invitation->getUserId()) && $validationContext === ValidationContext::VALIDATION_CONTEXT_FINALIZE), + new ProhibitedIncludingNull(!is_null($invitation->getUserId())), + Rule::when(in_array($validationContext, [ValidationContext::VALIDATION_CONTEXT_INVITE]), ['nullable']), + Rule::requiredIf($validationContext === ValidationContext::VALIDATION_CONTEXT_FINALIZE), + 'sometimes', + 'array', + new AllowedKeysRule($allowedLocales), // Apply the custom rule + ], + 'familyName.*' => [ + 'string', + 'max:255', + ], + 'affiliation' => [ + 'bail', + Rule::excludeIf(!is_null($invitation->getUserId()) && $validationContext === ValidationContext::VALIDATION_CONTEXT_FINALIZE), + new ProhibitedIncludingNull(!is_null($invitation->getUserId())), + Rule::when(in_array($validationContext, [ValidationContext::VALIDATION_CONTEXT_INVITE]), ['nullable']), + Rule::requiredIf($validationContext === ValidationContext::VALIDATION_CONTEXT_FINALIZE), + 'sometimes', + 'array', + new AllowedKeysRule($allowedLocales), // Apply the custom rule + ], + 'affiliation.*' => [ + 'string', + 'max:255', + ], + 'userCountry' => [ + 'bail', + Rule::excludeIf(!is_null($invitation->getUserId()) && $validationContext === ValidationContext::VALIDATION_CONTEXT_FINALIZE), + new ProhibitedIncludingNull(!is_null($invitation->getUserId())), + Rule::when(in_array($validationContext, [ValidationContext::VALIDATION_CONTEXT_INVITE]), ['nullable']), + Rule::requiredIf($validationContext === ValidationContext::VALIDATION_CONTEXT_FINALIZE), + 'string', + 'max:255', + ], + 'username' => [ + 'bail', + Rule::excludeIf(!is_null($invitation->getUserId()) && $validationContext === ValidationContext::VALIDATION_CONTEXT_FINALIZE), + new ProhibitedIncludingNull(!is_null($invitation->getUserId())), + Rule::requiredIf(is_null($invitation->getUserId()) && $validationContext === ValidationContext::VALIDATION_CONTEXT_FINALIZE), + new UsernameExistsRule(), + Rule::when($validationContext === ValidationContext::VALIDATION_CONTEXT_INVITE, ['nullable']), + new NotNullIfPresent(), + 'required_with:password', + 'max:32', + ], + 'password' => [ + 'bail', + Rule::excludeIf(!is_null($invitation->getUserId()) && $validationContext === ValidationContext::VALIDATION_CONTEXT_FINALIZE), + new ProhibitedIncludingNull(!is_null($invitation->getUserId())), + Rule::requiredIf(is_null($invitation->getUserId()) && $validationContext === ValidationContext::VALIDATION_CONTEXT_FINALIZE), + Rule::when($validationContext === ValidationContext::VALIDATION_CONTEXT_INVITE, ['nullable']), + new NotNullIfPresent(), + 'required_with:username', + 'max:255', + ], + 'userGroupsToAdd' => [ + Rule::requiredIf($validationContext === ValidationContext::VALIDATION_CONTEXT_INVITE), + 'sometimes', + 'array', + 'bail', + ], + 'userGroupsToAdd.*' => [ + 'array', + new AllowedKeysRule(['userGroupId', 'masthead', 'dateStart', 'dateEnd']), + ], + 'userGroupsToAdd.*.userGroupId' => [ + 'distinct', + 'required', + 'integer', + new UserGroupExistsRule(), + new AddUserGroupRule($invitation), + ], + 'userGroupsToAdd.*.masthead' => 'required|bool', + 'userGroupsToAdd.*.dateStart' => 'required|date|after_or_equal:today', + 'userGroupsToRemove' => [ + 'sometimes', + 'bail', + new ProhibitedIncludingNull(is_null($invitation->getUserId())), + Rule::when(in_array($validationContext, [ValidationContext::VALIDATION_CONTEXT_INVITE, ValidationContext::VALIDATION_CONTEXT_FINALIZE]), ['nullable']), + ], + 'userGroupsToRemove.*' => [ + 'array', + new AllowedKeysRule(['userGroupId']), + ], + 'userGroupsToRemove.*.userGroupId' => [ + 'distinct', + 'required', + 'integer', + new UserGroupExistsRule(), + new RemoveUserGroupRule($invitation), + ], + 'userOrcid' => [ + Rule::when(in_array($validationContext, [ValidationContext::VALIDATION_CONTEXT_INVITE, ValidationContext::VALIDATION_CONTEXT_FINALIZE]), ['nullable']), + 'orcid' + ], + ]; + + return $validationRules; + } + + public function getValidationMessages(ValidationContext $validationContext = ValidationContext::VALIDATION_CONTEXT_DEFAULT): array + { + return []; + } +} diff --git a/classes/invitation/invitations/userRoleAssignment/resources/UserRoleAssignmentInviteResource.php b/classes/invitation/invitations/userRoleAssignment/resources/UserRoleAssignmentInviteResource.php new file mode 100644 index 00000000000..a6eb4f6a073 --- /dev/null +++ b/classes/invitation/invitations/userRoleAssignment/resources/UserRoleAssignmentInviteResource.php @@ -0,0 +1,107 @@ +invitationModel->toArray(); + + $existingUser = $this->getExistingUser(); + $newUser = null; + + 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()->country); + $newUser->setUsername($this->getPayload()->username); + $newUser->setEmail($this->getPayload()->sendEmailAddress); + } + + // Return specific fields from the UserRoleAssignmentInvite + return array_merge($invitationData, [ + 'orcid' => $this->getPayload()->orcid, + 'givenName' => $this->getPayload()->givenName, + 'familyName' => $this->getPayload()->familyName, + 'affiliation' => $this->getPayload()->affiliation, + 'country' => $this->getPayload()->country, + 'emailSubject' => $this->getPayload()->emailSubject, + 'emailBody' => $this->getPayload()->emailBody, + 'userGroupsToAdd' => $this->transformUserGroups($this->getPayload()->userGroupsToAdd), + 'userGroupsToRemove' => $this->transformUserGroups($this->getPayload()->userGroupsToRemove), + 'username' => $this->getPayload()->username, + 'sendEmailAddress' => $this->getPayload()->sendEmailAddress, + 'existingUser' => $this->transformUser($this->getExistingUser()), + 'newUser' => $this->transformUser($newUser), + ]); + } + + /** + * Transform the userGroupsToAdd or userGroupsToRemove to include related UserGroup data. + * + * @param array|null $userGroups + * @return array + */ + protected function transformUserGroups(?array $userGroups) + { + return collect($userGroups)->map(function ($userGroup) { + $userGroupModel = Repo::userGroup()->get($userGroup['userGroupId']); + + return [ + 'userGroupId' => $userGroup['userGroupId'], + 'userGroupName' => $userGroupModel->getName(null), + 'masthead' => $userGroup['masthead'], + 'dateStart' => $userGroup['dateStart'], + 'dateEnd' => $userGroup['dateEnd'], + ]; + })->toArray(); + } + + /** + * Transform the userGroupsToAdd or userGroupsToRemove to include related UserGroup data. + * + * @param array|null $userGroups + * @return array + */ + protected function transformUser(?User $user): ?array + { + if (!isset($user)) { + return null; + } + + return [ + 'email' => $user->getEmail(), + 'fullName' => $user->getFullName(), + 'familyName' => $user->getFamilyName(null), + 'givenName' => $user->getGivenName(null), + 'country' => $user->getCountry(), + 'affiliation' => $user->getAffiliation(null), + 'orcid' => $user->getOrcid() + ]; + } +} \ No newline at end of file diff --git a/classes/invitation/invitations/userRoleAssignment/rules/AddUserGroupRule.php b/classes/invitation/invitations/userRoleAssignment/rules/AddUserGroupRule.php new file mode 100644 index 00000000000..bbc4ad4bd68 --- /dev/null +++ b/classes/invitation/invitations/userRoleAssignment/rules/AddUserGroupRule.php @@ -0,0 +1,48 @@ +invitation = $invitation; + } + + public function passes($attribute, $value) + { + // At this point, we know the user group exists; check if the user has it assigned + if ($user = $this->invitation->getExistingUser()) { + $userUserGroups = UserUserGroup::withUserId($user->getId()) + ->withUserGroupId($value) // The $value is the userGroupId + ->get(); + + return $userUserGroups->isEmpty(); // Fail if the user does have the group assigned + } + + return true; + } + + public function message() + { + return __('invitation.userRoleAssignment.validation.error.addUserRoles.userGroupAssignedToUser'); + } +} \ No newline at end of file diff --git a/classes/invitation/invitations/userRoleAssignment/rules/AllowedKeysRule.php b/classes/invitation/invitations/userRoleAssignment/rules/AllowedKeysRule.php new file mode 100644 index 00000000000..43d866372d6 --- /dev/null +++ b/classes/invitation/invitations/userRoleAssignment/rules/AllowedKeysRule.php @@ -0,0 +1,43 @@ +allowedKeys = $allowedKeys; + } + + public function passes($attribute, $value) + { + $this->unexpectedKeys = array_diff(array_keys($value), $this->allowedKeys); + return empty($this->unexpectedKeys); + } + + public function message() + { + return __('invitation.userRoleAssignment.validation.error.userRoles.unexpectedProperties', [ + 'attribute' => ':attribute', + 'properties' => implode(', ', $this->unexpectedKeys), + ]); + } +} \ No newline at end of file diff --git a/classes/invitation/invitations/userRoleAssignment/rules/EmailMustNotExistRule.php b/classes/invitation/invitations/userRoleAssignment/rules/EmailMustNotExistRule.php new file mode 100644 index 00000000000..df903529e97 --- /dev/null +++ b/classes/invitation/invitations/userRoleAssignment/rules/EmailMustNotExistRule.php @@ -0,0 +1,46 @@ +email = $email; + } + + public function passes($attribute, $value) + { + if ($this->email) { + $user = Repo::user()->getByEmail($this->email); + return !isset($user); // Fail if the email is already associated with a user + } + + return true; + } + + public function message() + { + return __('invitation.userRoleAssignment.validation.error.user.emailMustNotExist', [ + 'email' => $this->email, + ]); + } +} \ No newline at end of file diff --git a/classes/invitation/invitations/userRoleAssignment/rules/NoUserGroupChangesRule.php b/classes/invitation/invitations/userRoleAssignment/rules/NoUserGroupChangesRule.php new file mode 100644 index 00000000000..e3208e983ff --- /dev/null +++ b/classes/invitation/invitations/userRoleAssignment/rules/NoUserGroupChangesRule.php @@ -0,0 +1,43 @@ +userGroupsToAdd = $userGroupsToAdd; + $this->userGroupsToRemove = $userGroupsToRemove; + } + + public function passes($attribute, $value) + { + return !( + empty($this->userGroupsToAdd) && + empty($this->userGroupsToRemove) + ); + } + + public function message() + { + return __('invitation.userRoleAssignment.validation.error.noUserGroupChanges'); + } +} \ No newline at end of file diff --git a/classes/invitation/invitations/userRoleAssignment/rules/NotNullIfPresent.php b/classes/invitation/invitations/userRoleAssignment/rules/NotNullIfPresent.php new file mode 100644 index 00000000000..31727182b5f --- /dev/null +++ b/classes/invitation/invitations/userRoleAssignment/rules/NotNullIfPresent.php @@ -0,0 +1,31 @@ +condition = $condition; + } + + public function passes($attribute, $value) + { + // If the condition is true, prohibit both null and non-null values + if ($this->condition) { + return $value === null ? false : false; + } + + return true; + } + + public function message() + { + return __('invitation.validation.error.propertyProhibited', [ + 'attribute' => ':attribute', + ]); + } +} \ No newline at end of file diff --git a/classes/invitation/invitations/userRoleAssignment/rules/RemoveUserGroupRule.php b/classes/invitation/invitations/userRoleAssignment/rules/RemoveUserGroupRule.php new file mode 100644 index 00000000000..a5e34704ca0 --- /dev/null +++ b/classes/invitation/invitations/userRoleAssignment/rules/RemoveUserGroupRule.php @@ -0,0 +1,48 @@ +invitation = $invitation; + } + + public function passes($attribute, $value) + { + // At this point, we know the user group exists; check if the user has it assigned + if ($user = $this->invitation->getExistingUser()) { + $userUserGroups = UserUserGroup::withUserId($user->getId()) + ->withUserGroupId($value) // The $value is the userGroupId + ->get(); + + return !$userUserGroups->isEmpty(); // Fail if the user doesn't have the group assigned + } + + return false; // Fail if the user doesn't exist or isn't assigned the group + } + + public function message() + { + return __('invitation.userRoleAssignment.validation.error.removeUserRoles.userGroupNotAssignedToUser'); + } +} \ No newline at end of file diff --git a/classes/invitation/invitations/userRoleAssignment/rules/UserGroupExistsRule.php b/classes/invitation/invitations/userRoleAssignment/rules/UserGroupExistsRule.php new file mode 100644 index 00000000000..775e1d08445 --- /dev/null +++ b/classes/invitation/invitations/userRoleAssignment/rules/UserGroupExistsRule.php @@ -0,0 +1,36 @@ +userGroupId = $value; + $userGroup = Repo::userGroup()->get($value); + return isset($userGroup); + } + + public function message() + { + return __('invitation.userRoleAssignment.validation.error.addUserRoles.userGroupNotExisting', [ + 'userGroupId' => $this->userGroupId, + ]); + } +} \ No newline at end of file diff --git a/classes/invitation/invitations/userRoleAssignment/rules/UserMustExistRule.php b/classes/invitation/invitations/userRoleAssignment/rules/UserMustExistRule.php new file mode 100644 index 00000000000..9a1cd615706 --- /dev/null +++ b/classes/invitation/invitations/userRoleAssignment/rules/UserMustExistRule.php @@ -0,0 +1,45 @@ +userId = $userId; + } + + public function passes($attribute, $value) + { + if (isset($this->userId)) { + $user = Repo::user()->get($this->userId); + return isset($user); // Ensure user exists + } + + return true; + } + + public function message() + { + return __('invitation.userRoleAssignment.validation.error.user.mustExist', [ + 'userId' => $this->userId, + ]); + } +} \ No newline at end of file diff --git a/classes/invitation/invitations/userRoleAssignment/rules/UsernameExistsRule.php b/classes/invitation/invitations/userRoleAssignment/rules/UsernameExistsRule.php new file mode 100644 index 00000000000..1b11e2381c1 --- /dev/null +++ b/classes/invitation/invitations/userRoleAssignment/rules/UsernameExistsRule.php @@ -0,0 +1,41 @@ +username = $value; + $existingUser = Repo::user()->getByUsername($value, true); + return !isset($existingUser); // Fail if the username already exists + } + + return true; + } + + public function message() + { + return __('invitation.userRoleAssignment.validation.error.username.alreadyExisting', [ + 'username' => $this->username, + ]); + } +} \ No newline at end of file diff --git a/classes/invitation/models/InvitationModel.php b/classes/invitation/models/InvitationModel.php index e3f7c44c484..8288ea626b8 100644 --- a/classes/invitation/models/InvitationModel.php +++ b/classes/invitation/models/InvitationModel.php @@ -70,9 +70,10 @@ class InvitationModel extends Model 'createdAt' => 'datetime', 'status' => 'string', 'contextId' => 'int', - 'className' => 'string', + 'type' => 'string', 'email' => 'string', 'id' => 'int', + 'inviterId' => 'int', ]; protected $visible = [ @@ -84,6 +85,7 @@ class InvitationModel extends Model 'contextId', 'expiryDate', 'email', + 'inviterId' ]; @@ -127,14 +129,6 @@ public function scopeByStatus(Builder $query, InvitationStatus $status): Builder return $query->where('status', '=', $status->value); } - /** - * Add a local scope to get invitations that are of certain invitation type - */ - public function scopeByClassName(Builder $query, string $className): Builder - { - return $query->where('class_name', '=', $className); - } - /** * Add a local scope to get invitations that are of certain invitation type */ @@ -148,9 +142,11 @@ public function scopeByType(Builder $query, string $type): Builder */ public function scopeByUserId(Builder $query, ?int $userId): Builder { - return $query->when($userId, function ($query, $userId) { - return $query->where('user_id', '=', $userId); - })->orWhereNull('user_id'); + return $query->when($userId !== null, function ($query) use ($userId) { + return $query->where('user_id', $userId); + }, function ($query) { + return $query->whereNull('user_id'); + }); } /** @@ -158,9 +154,11 @@ public function scopeByUserId(Builder $query, ?int $userId): Builder */ public function scopeByEmail(Builder $query, ?string $email): Builder { - return $query->when($email, function ($query, $email) { - return $query->where('email', '=', $email); - })->orWhereNull('email'); + return $query->when($email !== null, function ($query) use ($email) { + return $query->where('email', $email); + }, function ($query) { + return $query->whereNull('email'); + }); } /** @@ -168,9 +166,11 @@ public function scopeByEmail(Builder $query, ?string $email): Builder */ public function scopeByContextId(Builder $query, ?int $contextId): Builder { - return $query->when($contextId, function ($query, $contextId) { - return $query->where('context_id', '=', $contextId); - })->orWhereNull('context_id'); + return $query->when($contextId !== null, function ($query) use ($contextId) { + return $query->where('context_id', $contextId); + }, function ($query) { + return $query->whereNull('context_id'); + }); } /** @@ -228,4 +228,30 @@ public static function markAllAs(InvitationStatus $status, Collection $ids): int 'updated_at' => Carbon::now() ]); } + + public function scopeById(Builder $query, int $id) + { + return $query->where('invitation_id', $id); + } + + public function scopeByNotId(Builder $query, int $id) + { + return $query->where('invitation_id', '!=', $id); + } + + // Custom toArray method to ensure serialization of attributes + public function toArray() + { + return [ + 'id' => $this->id, + 'status' => $this->status, + 'createdAt' => $this->createdAt, + 'updatedAt' => $this->updatedAt, + 'userId' => $this->userId, + 'contextId' => $this->contextId, + 'expiryDate' => $this->expiryDate, + 'email' => $this->email, + 'inviterId' => $this->inviterId, + ]; + } } diff --git a/classes/mail/Mailable.php b/classes/mail/Mailable.php index c435aee084a..68032c9487b 100644 --- a/classes/mail/Mailable.php +++ b/classes/mail/Mailable.php @@ -67,6 +67,8 @@ class Mailable extends IlluminateMailable { + public const EMAIL_TEMPLATE_STYLE_PROPERTY = 'emailTemplateStyle'; + /** Used internally by Illuminate Mailer. Do not touch. */ public const DATA_KEY_MESSAGE = 'message'; @@ -90,6 +92,8 @@ class Mailable extends IlluminateMailable public const ATTACHMENT_SUBMISSION_FILE = 'submissionFileId'; public const ATTACHMENT_LIBRARY_FILE = 'libraryFileId'; + + /** @var string|null Locale key for the name of this Mailable */ protected static ?string $name = null; diff --git a/classes/mail/mailables/ChangeProfileEmailInvitationNotify.php b/classes/mail/mailables/ChangeProfileEmailInvitationNotify.php index 31d3443c5e8..54ee5006ff9 100644 --- a/classes/mail/mailables/ChangeProfileEmailInvitationNotify.php +++ b/classes/mail/mailables/ChangeProfileEmailInvitationNotify.php @@ -16,13 +16,11 @@ namespace PKP\mail\mailables; -use PKP\context\Context; use PKP\mail\Mailable; use PKP\mail\traits\Configurable; use PKP\mail\traits\Recipient; use PKP\mail\traits\Sender; use PKP\security\Role; -use PKP\user\User; class ChangeProfileEmailInvitationNotify extends Mailable { diff --git a/classes/mail/mailables/UserRoleAssignmentInvitationNotify.php b/classes/mail/mailables/UserRoleAssignmentInvitationNotify.php new file mode 100644 index 00000000000..bf45a7c6bb6 --- /dev/null +++ b/classes/mail/mailables/UserRoleAssignmentInvitationNotify.php @@ -0,0 +1,240 @@ +invitation = $invitation; + } + + /** + * Add description to a new email template variables + */ + public static function getDataDescriptions(): array + { + $variables = parent::getDataDescriptions(); + + $variables[static::$recipientName] = __('emailTemplate.variable.invitation.recipientName'); + $variables[static::$inviterName] = __('emailTemplate.variable.invitation.inviterName'); + $variables[static::$inviterRole] = __('emailTemplate.variable.invitation.inviterRole'); + $variables[static::$rolesAdded] = __('emailTemplate.variable.invitation.rolesAdded'); + $variables[static::$rolesRemoved] = __('emailTemplate.variable.invitation.rolesRemoved'); + $variables[static::$existingRoles] = __('emailTemplate.variable.invitation.existingRoles'); + $variables[static::$acceptUrl] = __('emailTemplate.variable.invitation.acceptUrl'); + $variables[static::$declineUrl] = __('emailTemplate.variable.invitation.declineUrl'); + + return $variables; + } + + private function getAllUserUserGroupSection(array $userUserGroups, ?UserGroup $userGroup = null, Context $context, string $locale, string $title): string + { + $retString = ''; + + $count = 1; + foreach ($userUserGroups as $userUserGroup) { + if ($userUserGroup instanceof UserUserGroup) { + $userGroupHelper = UserGroupHelper::fromUserUserGroup($userUserGroup); + } else { + $userGroupHelper = UserGroupHelper::fromArray($userUserGroup); + } + + if ($count == 1) { + $retString = $title; + } + + $userGroupToUse = $userGroup; + if (!isset($userGroupToUse)) { + $userGroupToUse = Repo::userGroup()->get($userGroupHelper->userGroupId); + } + + $userGroupSection = $this->getUserUserGroupSection($userGroupHelper, $userGroupToUse, $context, $count, $locale); + + $retString .= $userGroupSection; + + $count++; + } + + return $retString; + } + + private function getUserUserGroupSection(UserGroupHelper $userUserGroup, UserGroup $userGroup, Context $context, int $count, string $locale): string + { + $sectionEndingDate = ''; + if (isset($userUserGroup->dateEnd)) { + $sectionEndingDate = __('emails.userRoleAssignmentInvitationNotify.userGroupSectionEndingDate', + [ + 'dateEnd' => $userUserGroup->dateEnd + ]); + } + + $sectionMastheadAppear = __('emails.userRoleAssignmentInvitationNotify.userGroupSectionWillNotAppear', + [ + 'contextName' => $context->getName($locale), + 'sectionName' => $userGroup->getName($locale) + ] + ); + + if (isset($userUserGroup->masthead) && $userUserGroup->masthead) { + $sectionMastheadAppear = __('emails.userRoleAssignmentInvitationNotify.userGroupSectionWillAppear', + [ + 'contextName' => $context->getName($locale), + 'sectionName' => $userGroup->getName($locale) + ] + ); + } + + $userGroupSection = __('emails.userRoleAssignmentInvitationNotify.userGroupSection', + [ + 'sectionNumber' => $count, + 'sectionName' => $userGroup->getName($locale), + 'dateStart' => $userUserGroup->dateStart, + 'sectionEndingDate' => $sectionEndingDate, + 'sectionMastheadAppear' => $sectionMastheadAppear + ]); + + return $userGroupSection; + } + + + /** + * Set localized email template variables + */ + public function setData(?string $locale = null): void + { + parent::setData($locale); + if (is_null($locale)) { + $locale = Locale::getLocale(); + } + + // Invitation User + $sendIdentity = $this->invitation->getMailableReceiver($locale); + + // Inviter + $user = $this->invitation->getExistingUser(); + $inviter = $this->invitation->getInviter(); + + $context = $this->invitation->getContext(); + + // Roles Added + $userGroupsAddedTitle = __('emails.userRoleAssignmentInvitationNotify.newlyAssignedRoles'); + $userGroupsAdded = $this->getAllUserUserGroupSection($this->invitation->getPayload()->userGroupsToAdd, null, $context, $locale, $userGroupsAddedTitle); + + + $existingUserGroupsTitle = __('emails.userRoleAssignmentInvitationNotify.alreadyAssignedRoles'); + $userGroupsRemovedTitle = __('emails.userRoleAssignmentInvitationNotify.removedRoles'); + $existingUserGroups = ''; + $userGroupsRemoved = ''; + + if (isset($user)) { + // Roles Removed + foreach ($this->invitation->getPayload()->userGroupsToRemove as $userUserGroup) { + $userGroupHelper = UserGroupHelper::fromArray($userUserGroup); + + $userGroup = Repo::userGroup()->get($userGroupHelper->userGroupId); + $userUserGroups = UserUserGroup::withUserId($user->getId()) + ->withUserGroupId($userGroup->getId()) + ->withActive() + ->get(); + + $userGroupsRemoved = $this->getAllUserUserGroupSection($userUserGroups->toArray(), $userGroup, $context, $locale, $userGroupsRemovedTitle); + } + + // Existing Roles + $userGroups = Repo::userGroup()->getCollector() + ->filterByContextIds([$this->invitation->getContextId()]) + ->filterByUserIds([$user->getId()]) + ->getMany(); + + foreach ($userGroups as $userGroup) { + $userUserGroups = UserUserGroup::withUserId($user->getId()) + ->withUserGroupId($userGroup->getId()) + ->withActive() + ->get(); + + $existingUserGroups = $this->getAllUserUserGroupSection($userUserGroups->toArray(), $userGroup, $context, $locale, $existingUserGroupsTitle); + } + } + + $targetPath = Core::getBaseDir() . '/lib/pkp/styles/mailables/style.css'; + $emailTemplateStyle = file_get_contents($targetPath); + + $recipientName = !empty($sendIdentity->getFullName()) ? $sendIdentity->getFullName() : $sendIdentity->getEmail(); + + // Set view data for the template + $this->viewData = array_merge( + $this->viewData, + [ + static::$recipientName => $recipientName, + static::$inviterName => $inviter->getFullName(), + static::$acceptUrl => $this->invitation->getActionURL(InvitationAction::ACCEPT), + static::$declineUrl => $this->invitation->getActionURL(InvitationAction::DECLINE), + static::$rolesAdded => $userGroupsAdded, + static::$rolesRemoved => $userGroupsRemoved, + static::$existingRoles => $existingUserGroups, + static::EMAIL_TEMPLATE_STYLE_PROPERTY => $emailTemplateStyle, + ] + ); + } +} diff --git a/classes/mail/traits/OneClickReviewerAccess.php b/classes/mail/traits/OneClickReviewerAccess.php index e1a5c260789..59f33e52aa6 100644 --- a/classes/mail/traits/OneClickReviewerAccess.php +++ b/classes/mail/traits/OneClickReviewerAccess.php @@ -18,7 +18,7 @@ namespace PKP\mail\traits; use PKP\context\Context; -use PKP\invitation\invitations\ReviewerAccessInvite; +use PKP\invitation\invitations\reviewerAccess\ReviewerAccessInvite; use PKP\submission\reviewAssignment\ReviewAssignment; trait OneClickReviewerAccess @@ -32,10 +32,17 @@ protected function setOneClickAccessUrl(Context $context, ReviewAssignment $revi $reviewInvitation = new ReviewerAccessInvite(); $reviewInvitation->initialize($reviewAssignment->getReviewerId(), $context->getId(), null); - $reviewInvitation->reviewAssignmentId = $reviewAssignment->getId(); - $reviewInvitation->updatePayload(); + $reviewInvitation->getPayload()->reviewAssignmentId = $reviewAssignment->getId(); - $reviewInvitation->invite(); - $reviewInvitation->updateMailableWithUrl($this); + $inviteResult = false; + $updateResult = $reviewInvitation->updatePayload(); + if ($updateResult) { + $inviteResult = $reviewInvitation->invite(); + $reviewInvitation->updateMailableWithUrl($this); + } + + if (!$inviteResult) { + throw new \Exception('Invitation could be send'); + } } } diff --git a/classes/migration/install/InvitationsMigration.php b/classes/migration/install/InvitationsMigration.php index 6e3f2f21944..869d22e1b48 100644 --- a/classes/migration/install/InvitationsMigration.php +++ b/classes/migration/install/InvitationsMigration.php @@ -31,10 +31,13 @@ public function up(): void $table->string('type', 255); $table->bigInteger('user_id')->nullable(); + $table->bigInteger('inviter_id')->nullable(); $table->foreign('user_id')->references('user_id')->on('users')->onDelete('cascade'); $table->index(['user_id'], 'invitations_user_id'); + $table->foreign('inviter_id')->references('user_id')->on('users')->onDelete('cascade'); + $table->index(['inviter_id'], 'invitations_inviter_id'); - $table->datetime('expiry_date')->nullable();; + $table->datetime('expiry_date')->nullable(); $table->json('payload')->nullable(); $table->enum( @@ -64,6 +67,9 @@ public function up(): void // Invitations $table->index(['status', 'context_id', 'user_id', 'type']); + + // Expired + $table->index(['expiry_date']); }); } diff --git a/classes/migration/upgrade/v3_5_0/I9197_MigrateAccessKeys.php b/classes/migration/upgrade/v3_5_0/I9197_MigrateAccessKeys.php index 98044fb5ef7..6ddac05ed48 100644 --- a/classes/migration/upgrade/v3_5_0/I9197_MigrateAccessKeys.php +++ b/classes/migration/upgrade/v3_5_0/I9197_MigrateAccessKeys.php @@ -19,8 +19,8 @@ use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Schema; use PKP\install\DowngradeNotSupportedException; -use PKP\invitation\invitations\RegistrationAccessInvite; -use PKP\invitation\invitations\ReviewerAccessInvite; +use PKP\invitation\invitations\registrationAccess\RegistrationAccessInvite; +use PKP\invitation\invitations\reviewerAccess\ReviewerAccessInvite; use PKP\migration\Migration; class I9197_MigrateAccessKeys extends Migration @@ -33,33 +33,30 @@ public function up(): void Schema::create('invitations', function (Blueprint $table) { $table->comment('Invitations are sent to request a person (by email) to allow them to accept or reject an operation or position, such as a board membership or a submission peer review.'); $table->bigInteger('invitation_id')->autoIncrement(); - $table->string('key_hash', 255); + $table->string('key_hash', 255)->nullable(); + $table->string('type', 255); $table->bigInteger('user_id')->nullable(); - $table->foreign('user_id') - ->references('user_id') - ->on('users') - ->onDelete('cascade'); - + $table->bigInteger('inviter_id')->nullable(); + $table->foreign('user_id')->references('user_id')->on('users')->onDelete('cascade'); $table->index(['user_id'], 'invitations_user_id'); + $table->foreign('inviter_id')->references('user_id')->on('users')->onDelete('cascade'); + $table->index(['inviter_id'], 'invitations_inviter_id'); - $table->bigInteger('assoc_id')->nullable(); - $table->datetime('expiry_date'); + $table->datetime('expiry_date')->nullable(); $table->json('payload')->nullable(); - // The values are references to the enum InvitationStatus - // InvitationStatus::PENDING, InvitationStatus::ACCEPTED, - // InvitationStatus::DECLINED, InvitationStatus::CANCELLED $table->enum( 'status', [ + 'INITIALIZED', 'PENDING', 'ACCEPTED', 'DECLINED', - 'CANCELLED' + 'CANCELLED', ] ); - $table->string('class_name'); + $table->string('email')->nullable()->comment('When present, the email address of the invitation recipient; when null, user_id must be set and the email can be fetched from the users table.'); $table->bigInteger('context_id')->nullable(); @@ -89,10 +86,10 @@ public function up(): void if ($accessKey->context == 'RegisterContext') { // Registered User validation Invitation $invitation = new RegistrationAccessInvite(); - $invitation->initialize($accessKey->user_id, null, null); + $invitation->initialize($accessKey->user_id, null, null, null); } elseif (isset($accessKey->context)) { // Reviewer Invitation $invitation = new ReviewerAccessInvite(); - $invitation->initialize($accessKey->user_id, $accessKey->context, null); + $invitation->initialize($accessKey->user_id, $accessKey->context, null, null); $invitation->reviewAssignmentId = $accessKey->assoc_id; $invitation->updatePayload(); diff --git a/classes/observers/listeners/ValidateRegisteredEmail.php b/classes/observers/listeners/ValidateRegisteredEmail.php index f99b2a3591c..2b2087c79fe 100644 --- a/classes/observers/listeners/ValidateRegisteredEmail.php +++ b/classes/observers/listeners/ValidateRegisteredEmail.php @@ -20,7 +20,7 @@ use Illuminate\Events\Dispatcher; use Illuminate\Support\Facades\Mail; use PKP\config\Config; -use PKP\invitation\invitations\RegistrationAccessInvite; +use PKP\invitation\invitations\registrationAccess\RegistrationAccessInvite; use PKP\mail\mailables\ValidateEmailContext as ContextMailable; use PKP\mail\mailables\ValidateEmailSite as SiteMailable; use PKP\observers\events\UserRegisteredContext; diff --git a/classes/security/authorization/AnonymousUserPolicy.php b/classes/security/authorization/AnonymousUserPolicy.php new file mode 100644 index 00000000000..87e4f803658 --- /dev/null +++ b/classes/security/authorization/AnonymousUserPolicy.php @@ -0,0 +1,50 @@ +_request = $request; + } + + + // + // Implement template methods from AuthorizationPolicy + // + /** + * @see AuthorizationPolicy::effect() + */ + public function effect() + { + if ($this->_request->getUser()) { + return AuthorizationPolicy::AUTHORIZATION_DENY; + } else { + return AuthorizationPolicy::AUTHORIZATION_PERMIT; + } + } +} diff --git a/classes/submission/action/EditorAction.php b/classes/submission/action/EditorAction.php index 1acf71a0dbd..6661bcceb54 100644 --- a/classes/submission/action/EditorAction.php +++ b/classes/submission/action/EditorAction.php @@ -25,7 +25,7 @@ use PKP\core\PKPApplication; use PKP\core\PKPRequest; use PKP\db\DAORegistry; -use PKP\invitation\invitations\ReviewerAccessInvite; +use PKP\invitation\invitations\reviewerAccess\ReviewerAccessInvite; use PKP\log\event\PKPSubmissionEventLogEntry; use PKP\log\SubmissionEmailLogEventType; use PKP\mail\mailables\ReviewRequest; @@ -217,7 +217,7 @@ protected function createMail( if ($context->getData('reviewerAccessKeysEnabled')) { $reviewInvitation = new ReviewerAccessInvite(); - $reviewInvitation->initialize($reviewAssignment->getReviewerId(), $context->getId(), null); + $reviewInvitation->initialize($reviewAssignment->getReviewerId(), $context->getId(), null, $sender->getId()); $reviewInvitation->reviewAssignmentId = $reviewAssignment->getId(); $reviewInvitation->updatePayload(); diff --git a/classes/template/PKPTemplateManager.php b/classes/template/PKPTemplateManager.php index f166f11b006..08fd2c5b2b6 100644 --- a/classes/template/PKPTemplateManager.php +++ b/classes/template/PKPTemplateManager.php @@ -748,7 +748,7 @@ public function registerJSLibraryData(): void 'contextPath' => isset($context) ? $context->getPath() : '', 'apiBasePath' => '/api/v1', 'restfulUrlsEnabled' => Config::getVar('general', 'restful_urls') ? true : false, - 'tinyMceContentCSS' => $this->_request->getBaseUrl() . '/plugins/generic/tinymce/styles/content.css', + 'tinyMceContentCSS' => [$this->_request->getBaseUrl() . '/plugins/generic/tinymce/styles/content.css', $this->_request->getBaseUrl() . '/lib/pkp/styles/mailables/style.css'], 'tinyMceOneLineContentCSS' => $this->_request->getBaseUrl() . '/plugins/generic/tinymce/styles/content_oneline.css', ]; diff --git a/classes/user/form/BaseProfileForm.php b/classes/user/form/BaseProfileForm.php index c9325798bd1..c7053040a9c 100644 --- a/classes/user/form/BaseProfileForm.php +++ b/classes/user/form/BaseProfileForm.php @@ -19,7 +19,7 @@ use APP\core\Application; use APP\facades\Repo; use PKP\form\Form; -use PKP\invitation\invitations\ChangeProfileEmailInvite; +use PKP\invitation\invitations\changeProfileEmail\ChangeProfileEmailInvite; use PKP\user\User; abstract class BaseProfileForm extends Form @@ -72,11 +72,17 @@ public function execute(...$functionArgs) $invite->initialize($user->getId()); - $invite->newEmail = $functionArgs['emailUpdated']; + $invite->getPayload()->newEmail = $functionArgs['emailUpdated']; - $invite->updatePayload(); + $inviteResult = false; + $updateResult = $invite->updatePayload(); + if ($updateResult) { + $inviteResult = $invite->invite(); + } - $invite->invite(); + if (!$inviteResult) { + throw new \Exception('Invitation could be send'); + } } } } diff --git a/classes/user/form/ContactForm.php b/classes/user/form/ContactForm.php index 2aa06cc73ef..31a45871b98 100644 --- a/classes/user/form/ContactForm.php +++ b/classes/user/form/ContactForm.php @@ -21,7 +21,7 @@ use APP\template\TemplateManager; use PKP\facades\Locale; use PKP\invitation\core\enums\InvitationStatus; -use PKP\invitation\invitations\ChangeProfileEmailInvite; +use PKP\invitation\invitations\changeProfileEmail\ChangeProfileEmailInvite; use PKP\invitation\models\InvitationModel; use PKP\user\User; @@ -82,7 +82,7 @@ public function fetch($request, $template = null, $display = false) $templateMgr->assign([ 'countries' => $countries, 'availableLocales' => $site->getSupportedLocaleNames(), - 'changeEmailPending' => $invitationModel ? $invitation->newEmail : null, + 'changeEmailPending' => $invitationModel ? $invitation->getPayload()->newEmail : null, ]); return parent::fetch($request, $template, $display); @@ -138,7 +138,7 @@ public function cancelPendingEmail() $invitation = new ChangeProfileEmailInvite($invitationModel); $formPendingEmail = $this->getData('pendingEmail'); - if ($invitation->newEmail == $formPendingEmail) { + if ($invitation->getPayload()->newEmail == $formPendingEmail) { $invitationModel->markAs(InvitationStatus::DECLINED); } } diff --git a/jobs/email/ReviewReminder.php b/jobs/email/ReviewReminder.php index 5506d35dabf..d62c1f5c580 100644 --- a/jobs/email/ReviewReminder.php +++ b/jobs/email/ReviewReminder.php @@ -21,7 +21,7 @@ use PKP\log\event\PKPSubmissionEventLogEntry; use PKP\core\PKPApplication; use PKP\core\Core; -use PKP\invitation\invitations\ReviewerAccessInvite; +use PKP\invitation\invitations\reviewerAccess\ReviewerAccessInvite; use PKP\log\SubmissionEmailLogEventType; use PKP\mail\mailables\ReviewResponseRemindAuto; use PKP\mail\mailables\ReviewRemindAuto; diff --git a/lib/counterBots b/lib/counterBots index 7134f6dbc15..bd5b3fa6e66 160000 --- a/lib/counterBots +++ b/lib/counterBots @@ -1 +1 @@ -Subproject commit 7134f6dbc157ecbf44c8f6829f13b4f873fdd805 +Subproject commit bd5b3fa6e6676f3c265365a653f1133824b143eb diff --git a/locale/en/emails.po b/locale/en/emails.po index 976cc93a134..e780ab5cbf0 100644 --- a/locale/en/emails.po +++ b/locale/en/emails.po @@ -553,3 +553,108 @@ msgstr "orcidRequestAuthorAuthorization" #, fuzzy msgid "orcid.orcidCollectAuthorId.name" msgstr "orcidCollectAuthorId" + +#, fuzzy +msgid "emails.userRoleAssignmentInvitationNotify.description" +msgstr "This email is sent to users that are invited to obtain certain roles" + +#, fuzzy +msgid "emails.userRoleAssignmentInvitationNotify.subject" +msgstr "You are invited to new roles" + +#, fuzzy +msgid "emails.userRoleAssignmentInvitationNotify.body" +msgstr "" +"
" +" " +" " +"
" +"" + +#, fuzzy +msgid "emails.userRoleAssignmentInvitationNotify.userGroupSection" +msgstr "" +"
" +"
{$sectionNumber}.
" +"
" +"

{$sectionName}

" +"

Starting from {$dateStart}

" +"

{$sectionEndingDate}

" +"

{$sectionMastheadAppear}

" +"
" +"
" + +#, fuzzy +msgid "emails.userRoleAssignmentInvitationNotify.userGroupSectionEndingDate" +msgstr "Ending at {$dateEnd}" + +#, fuzzy +msgid "emails.userRoleAssignmentInvitationNotify.userGroupSectionWillNotAppear" +msgstr "Your name will not appear in {$contextName}'s masthead as a {$sectionName}" + +#, fuzzy +msgid "emails.userRoleAssignmentInvitationNotify.userGroupSectionWillAppear" +msgstr "Your name will appear in the {$contextName}'s masthead as a {$sectionName}" + +#, fuzzy +msgid "emails.userRoleAssignmentInvitationNotify.newlyAssignedRoles" +msgstr "

Newly assigned roles

" + +#, fuzzy +msgid "emails.userRoleAssignmentInvitationNotify.alreadyAssignedRoles" +msgstr "

Already assigned roles

" + +#, fuzzy +msgid "emails.userRoleAssignmentInvitationNotify.removedRoles" +msgstr "

Removed roles

" + +#, fuzzy +msgid "emailTemplate.variable.invitation.recipientName" +msgstr "This is the name of the invitation recipient" + +#, fuzzy +msgid "emailTemplate.variable.invitation.inviterName" +msgstr "This is the name of the sender of the invitation" + +#, fuzzy +msgid "emailTemplate.variable.invitation.inviterRole" +msgstr "This is the role of the sender of the invitation" + +#, fuzzy +msgid "emailTemplate.variable.invitation.rolesAdded" +msgstr "This section describes the roles that will be added to the user upon the invitation acceptance" + +#, fuzzy +msgid "emailTemplate.variable.invitation.rolesRemoved" +msgstr "This section describes the roles that will be removed from the user upon the invitation acceptance" + +#, fuzzy +msgid "emailTemplate.variable.invitation.existingRoles" +msgstr "This section describes the roles that the user already has." + +#, fuzzy +msgid "emailTemplate.variable.invitation.acceptUrl" +msgstr "This is the accept invitation URL" + +#, fuzzy +msgid "emailTemplate.variable.invitation.declineUrl" +msgstr "This is the decline invitation URL" + + diff --git a/locale/en/invitation.po b/locale/en/invitation.po new file mode 100644 index 00000000000..853e431cbf4 --- /dev/null +++ b/locale/en/invitation.po @@ -0,0 +1,104 @@ +msgid "" +msgstr "" +"Project-Id-Version: \n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: \n" +"PO-Revision-Date: \n" +"Last-Translator: \n" +"en/>\n" +"Language: en\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=2; plural=n != 1;\n" +"X-Generator: Weblate 4.13.1\n" + +msgid "invitation.userRoleAssignment.validation.error.addUserRoles.notExisting" +msgstr "At least one user group to add must be defined" + +msgid "invitation.userRoleAssignment.validation.error.addUserRoles.userGroupAssignedToUser" +msgstr "The user group is assigned to the invited user" + +msgid "invitation.userRoleAssignment.validation.error.removeUserRoles.notExisting" +msgstr "At least one user group to remove must be defined" + +msgid "invitation.userRoleAssignment.validation.error.removeUserRoles.cantRemoveFromNonExistingUser" +msgstr "Remove user roles can't be defined for not existing users." + +msgid "invitation.userRoleAssignment.validation.error.removeUserRoles.userGroupNotAssignedToUser" +msgstr "The user group is not assigned to the invited user" + +msgid "invitation.userRoleAssignment.validation.error.noUserGroupChanges" +msgstr "The invitation can not be dispatched because you have not defined any user group changes" + +msgid "invitation.userRoleAssignment.validation.error.addUserRoles.duplicateUserGroupId" +msgstr "The provided user groups contain duplicate entries." + +msgid "invitation.userRoleAssignment.validation.error.username.alreadyExisting" +msgstr "There is already an existing user with the username {$username}. Try another one." + +msgid "invitation.userRoleAssignment.validation.error.user.mustExist" +msgstr "When an invitation has a user id ({$userId}) assigned, the corresponding user must exist." + +msgid "invitation.userRoleAssignment.validation.error.user.emailMustNotExist" +msgstr "When an invitation has an email ({$email}) assigned, this email must not be in use by another user." + +msgid "invitation.userRoleAssignment.validation.error.username.mandatory" +msgstr "A username must be provided" + +msgid "invitation.userRoleAssignment.validation.error.password.mandatory" +msgstr "A password must be provided" + +msgid "invitation.userRoleAssignment.validation.error.affiliation.mandatory" +msgstr "An affiliation must be provided" + +msgid "invitation.userRoleAssignment.validation.error.country.mandatory" +msgstr "A country must be provided" + +msgid "invitation.userRoleAssignment.validation.error.givenName.mandatory" +msgstr "A given name must be provided" + +msgid "invitation.userRoleAssignment.validation.error.familyName.mandatory" +msgstr "A family name must be provided" + +msgid "invitation.userRoleAssignment.validation.error.addUserRoles.userGroupIdMandatory" +msgstr "For added user groups, a user group id must be provided" + +msgid "invitation.userRoleAssignment.validation.error.addUserRoles.dateStartMandatory" +msgstr "For added user groups, a date start must be provided" + +msgid "invitation.userRoleAssignment.validation.error.addUserRoles.mastheadMandatory" +msgstr "For added user groups, a masthead must be provided" + +msgid "invitation.api.error.invitationTypeNotHasMailable" +msgstr "This invitation type does not have a mailable defined" + +msgid "invitation.userRoleAssignment.validation.error.addUserRoles.userGroupNotExisting" +msgstr "The provided user group ID {$userGroupId} does not exist" + +msgid "invitation.validation.required" +msgstr "The {$attribute} field is required." + +msgid "invitation.userRoleAssignment.validation.error.userRoles.unexpectedProperties" +msgstr "Unexpected properties found in the item at index {$attribute}: {$properties}." + +msgid "invitation.reviewerAccess.validation.error.reviewAssignmentId.notExisting" +msgstr "The id {reviewAssignmentId} does not correspond to a valid review assignment" + +msgid "invitation.api.error.invitationCantBeCanceled" +msgstr "This invitation can't be cancelled" + +msgid "invitation.api.error.initialization.noUserIdAndEmailTogether" +msgstr "You cannot provide both email and userId together." + +msgid "invitation.userRoleAssignment.error.update.prohibitedForExistingUser" +msgstr "The attribute is not allowed to be access for existing users" + +msgid "invitation.userRoleAssignment.error.update.prohibitedForNonExistingUser" +msgstr "The attribute is not allowed to be access for non existing users" + +msgid "invitation.userRoleAssignment.userGroup.startDate.mustBeAfterToday" +msgstr "This attribute must have a value equal or after today" + +msgid "invitation.validation.error.propertyProhibited" +msgstr "The :attribute field is prohibited" \ No newline at end of file diff --git a/pages/invitation/InvitationHandler.php b/pages/invitation/InvitationHandler.php index 9c0e1fe6ee3..9874438dee5 100644 --- a/pages/invitation/InvitationHandler.php +++ b/pages/invitation/InvitationHandler.php @@ -20,7 +20,6 @@ use APP\core\Request; use APP\facades\Repo; use APP\handler\Handler; -use Exception; use PKP\invitation\core\enums\InvitationAction; use PKP\invitation\core\Invitation; @@ -67,13 +66,13 @@ private function getInvitationByKey(Request $request): Invitation return $invitation; } - public static function getActionUrl(InvitationAction $action, Invitation $invitation): string + public static function getActionUrl(InvitationAction $action, Invitation $invitation): ?string { $invitationId = $invitation->getId(); $invitationKey = $invitation->getKey(); if (!isset($invitationId) || !isset($invitationKey)) { - throw new Exception(); + return null; } $request = Application::get()->getRequest(); diff --git a/pages/user/RegistrationHandler.php b/pages/user/RegistrationHandler.php index 15b6b3bc6d0..87c9324d3ec 100644 --- a/pages/user/RegistrationHandler.php +++ b/pages/user/RegistrationHandler.php @@ -22,6 +22,7 @@ use APP\template\TemplateManager; use PKP\config\Config; use PKP\core\PKPRequest; +use PKP\invitation\core\enums\InvitationAction; use PKP\notification\Notification; use PKP\notification\PKPNotificationManager; use PKP\observers\events\UserRegisteredContext; @@ -158,7 +159,9 @@ public function activateUser($args, $request) ->getByKey($accessKeyCode); if (isset($invitation)) { - $invitation->acceptHandle($request); + $invitationHandler = $invitation->getInvitationActionRedirectController(); + $invitationHandler->preRedirectActions(InvitationAction::ACCEPT); + $invitationHandler->acceptHandle($request); } } elseif (isset($username)) { $user = Repo::user()->getByUsername($username, true); diff --git a/styles/mailables/style.css b/styles/mailables/style.css new file mode 100644 index 00000000000..5057499133f --- /dev/null +++ b/styles/mailables/style.css @@ -0,0 +1,13 @@ +/* email-styles.less */ +.email-container { font-family: Arial, sans-serif; line-height: 1.5; color: #333333; } +.email-header { background-color: #f8f9fa; padding: 20px; text-align: center; } +.email-content { padding: 20px; } +.email-footer { background-color: #f8f9fa; padding: 10px; text-align: center; font-size: 12px; color: #6c757d; } +.btn { display: inline-block; padding: 10px 20px; margin: 10px 5px; color: white; text-decoration: none; border-radius: 5px; font-size: 16px; } +.btn-accept { background-color: #28a745; } +.btn-decline { background-color: #dc3545; } +.section { margin-bottom: 20px; padding: 10px; border: 1px solid #ddd; border-radius: 5px; box-shadow: 0 2px 4px rgba(0,0,0,0.1); display: flex; align-items: flex-start; } +.section-number { font-size: 1em; margin-right: 5px; color: #333; } +.section-content { flex-grow: 1; } +.section-content h2 { font-size: 1em; margin: 0 0 5px 0; color: #333; } +.section-content p { margin: 0 0 5px 0; color: #555; }