{$sectionName}
" +"Starting from {$dateStart}
" +"{$sectionEndingDate}
" +"{$sectionMastheadAppear}
" +"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 "" +"
Dear {$recipientName},
" +"In light of your expertise, you have been invited by {$inviterName} to take on new roles at {$contextName}
" +"At {$contextName}, we value your privacy. As such, we have taken steps to ensure that we are fully GDPR compliant. These steps include you being accountable to enter your own data and choosing who can see what information. or additional information on how we handled your data, please refer to our Privacy Policy.
" +"On accepting the invite, you will be redirected to {$contextName}
" +"Feel free to contact me with any questions about the process.
" +" " +" " +"Kind regards,
" +"{$contextName}
" +"Starting from {$dateStart}
" +"{$sectionEndingDate}
" +"{$sectionMastheadAppear}
" +"