-
+ {% for maintainerField in transferPackageForm.maintainers %}
+
- + {{ form_errors(maintainerField) }} + {{ form_widget(maintainerField) }} + + {% endfor %} +
diff --git a/css/app.scss b/css/app.scss index ebd46c0e3..ef9131739 100644 --- a/css/app.scss +++ b/css/app.scss @@ -1082,16 +1082,11 @@ input:focus:invalid:focus, textarea:focus:invalid:focus, select:focus:invalid:fo margin-bottom: 4px; margin-right: 4px; } -.package .details #add-maintainer { - margin-top: 3px; - margin-bottom: 7px; - float: right; - font-size: 34px; -} -.package .details #remove-maintainer { - float: right; - font-size: 35px; - margin-top: 5px; +.package .details .maintainer-actions { + a { + font-size: 34px; + cursor: pointer; + } } .package .details .canonical, .package .funding p { @@ -1896,3 +1891,25 @@ body { } } } + +#transfer-package-form { + .maintainers-collection-wrapper { + margin-bottom: 15px; + + .maintainers-list { + list-style: none; + padding: 0; + + li { + display: flex; + gap: 10px; + margin-bottom: 10px; + align-items: center; + + .btn-danger { + padding: 5px 10px; + } + } + } + } +} diff --git a/js/view.js b/js/view.js index d411a4ec9..558916774 100644 --- a/js/view.js +++ b/js/view.js @@ -7,14 +7,23 @@ const init = function ($) { var versionCache = {}, ongoingRequest = false; - $('#add-maintainer').on('click', function (e) { + const togglePackageForm = function (selector) { $('#remove-maintainer-form').addClass('hidden'); - $('#add-maintainer-form').removeClass('hidden'); + $('#add-maintainer-form').addClass('hidden'); + $('#transfer-package-form').addClass('hidden'); + $(selector).removeClass('hidden'); + } + + $('#add-maintainer').on('click', function (e) { + togglePackageForm('#add-maintainer-form'); e.preventDefault(); }); $('#remove-maintainer').on('click', function (e) { - $('#add-maintainer-form').addClass('hidden'); - $('#remove-maintainer-form').removeClass('hidden'); + togglePackageForm('#remove-maintainer-form'); + e.preventDefault(); + }); + $('#transfer-package').on('click', function (e) { + togglePackageForm('#transfer-package-form'); e.preventDefault(); }); @@ -227,6 +236,38 @@ const init = function ($) { $(versionsList).css('max-height', 'inherit'); }); } + + // Handle add/remove buttons for transfer package form + $('.add-maintainer-item').on('click', function (e) { + e.preventDefault(); + + var list = $('.maintainers-list'); + var prototype = list.data('prototype'); + var index = list.find('li').length + 1; + + var newForm = prototype.replace(/__name__/g, index); + var newItem = $('
').append(newForm); + addMaintainerRemoveButton(newItem); + list.append(newItem); + }); + + $('.maintainers-list').find('li').each(function(index) { + addMaintainerRemoveButton($(this)); + }); + + function addMaintainerRemoveButton(item) { + var removeButton = $(''); + removeButton.on('click', function(e) { + e.preventDefault(); + + if ($('.maintainers-list').find('li').length === 1) { + return; + } + + item.remove(); + }); + item.append(removeButton); + } }; if (document.querySelector('#view-package-page')) { diff --git a/src/Command/TransferOwnershipCommand.php b/src/Command/TransferOwnershipCommand.php index c1cb01146..feb9ac50c 100644 --- a/src/Command/TransferOwnershipCommand.php +++ b/src/Command/TransferOwnershipCommand.php @@ -15,6 +15,7 @@ use App\Entity\AuditRecord; use App\Entity\Package; use App\Entity\User; +use App\Model\PackageManager; use App\Util\DoctrineTrait; use Composer\Console\Input\InputOption; use Doctrine\Persistence\ManagerRegistry; @@ -30,6 +31,7 @@ class TransferOwnershipCommand extends Command public function __construct( private readonly ManagerRegistry $doctrine, + private readonly PackageManager $packageManager, ) { parent::__construct(); @@ -87,7 +89,7 @@ private function queryAndValidateMaintainers(InputInterface $input, OutputInterf $usernames = array_map('mb_strtolower', $input->getArgument('maintainers')); sort($usernames); - $maintainers = $this->getEM()->getRepository(User::class)->findUsersByUsername($usernames, ['usernameCanonical' => 'ASC']); + $maintainers = $this->getEM()->getRepository(User::class)->findEnabledUsersByUsername($usernames, ['usernameCanonical' => 'ASC']); if (array_keys($maintainers) === $usernames) { return $maintainers; @@ -165,24 +167,8 @@ private function outputPackageTable(OutputInterface $output, array $packages, ar */ private function transferOwnership(array $packages, array $maintainers): void { - $normalizedMaintainers = array_values(array_map(fn (User $user) => $user->getId(), $maintainers)); - sort($normalizedMaintainers, SORT_NUMERIC); - foreach ($packages as $package) { - $oldMaintainers = $package->getMaintainers()->toArray(); - - $normalizedOldMaintainers = array_values(array_map(fn (User $user) => $user->getId(), $oldMaintainers)); - sort($normalizedOldMaintainers, SORT_NUMERIC); - if ($normalizedMaintainers === $normalizedOldMaintainers) { - continue; - } - - $package->getMaintainers()->clear(); - foreach ($maintainers as $maintainer) { - $package->addMaintainer($maintainer); - } - - $this->doctrine->getManager()->persist(AuditRecord::packageTransferred($package, null, $oldMaintainers, array_values($maintainers))); + $this->packageManager->transferPackage($package, $maintainers, false); } $this->doctrine->getManager()->flush(); diff --git a/src/Controller/FeedController.php b/src/Controller/FeedController.php index 1c4e9c82d..016d1fbae 100644 --- a/src/Controller/FeedController.php +++ b/src/Controller/FeedController.php @@ -130,7 +130,7 @@ public function extensionReleasesAction(Request $req): Response return $this->buildResponse($req, $feed); } - #[Route(path: '/package.{package}.{_format}', name: 'feed_package', requirements: ['_format' => '(rss|atom)', 'package' => '[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+'], methods: ['GET'])] + #[Route(path: '/package.{package}.{_format}', name: 'feed_package', requirements: ['_format' => '(rss|atom)', 'package' => Package::PACKAGE_NAME_REGEX], methods: ['GET'])] public function packageAction(Request $req, string $package): Response { $repo = $this->doctrine->getRepository(Version::class); diff --git a/src/Controller/PackageController.php b/src/Controller/PackageController.php index f7e18ac87..2f6e0ce8f 100644 --- a/src/Controller/PackageController.php +++ b/src/Controller/PackageController.php @@ -27,10 +27,12 @@ use App\Entity\Vendor; use App\Entity\Version; use App\Form\Model\MaintainerRequest; +use App\Form\Model\TransferPackageRequest; use App\Form\Type\AbandonedType; use App\Form\Type\AddMaintainerRequestType; use App\Form\Type\PackageType; use App\Form\Type\RemoveMaintainerRequestType; +use App\Form\Type\TransferPackageRequestType; use App\Model\DownloadManager; use App\Model\FavoriteManager; use App\Model\PackageManager; @@ -666,6 +668,7 @@ public function viewPackageAction(Request $req, string $name, CsrfTokenManagerIn $data['addMaintainerForm'] = $this->createAddMaintainerForm($package)->createView(); $data['removeMaintainerForm'] = $this->createRemoveMaintainerForm($package)->createView(); + $data['transferPackageForm'] = $this->createTransferPackageForm($package)->createView(); $data['deleteForm'] = $this->createDeletePackageForm($package)->createView(); } else { $data['hasVersionSecurityAdvisories'] = []; @@ -801,7 +804,7 @@ public function deletePackageVersionAction(Request $req, int $versionId): Respon return new Response('', 204); } - #[Route(path: '/packages/{name}', name: 'update_package', requirements: ['name' => '[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+'], defaults: ['_format' => 'json'], methods: ['PUT'])] + #[Route(path: '/packages/{name}', name: 'update_package', requirements: ['name' => Package::PACKAGE_NAME_REGEX], defaults: ['_format' => 'json'], methods: ['PUT'])] public function updatePackageAction(Request $req, string $name, #[CurrentUser] User $user): Response { try { @@ -853,7 +856,7 @@ public function updatePackageAction(Request $req, string $name, #[CurrentUser] U return new JsonResponse(['status' => 'error', 'message' => 'Package was already updated in the last 24 hours'], 404); } - #[Route(path: '/packages/{name}', name: 'delete_package', requirements: ['name' => '[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+'], methods: ['DELETE'])] + #[Route(path: '/packages/{name}', name: 'delete_package', requirements: ['name' => Package::PACKAGE_NAME_REGEX], methods: ['DELETE'])] public function deletePackageAction(Request $req, string $name): Response { $package = $this->getPartialPackageWithVersions($req, $name); @@ -878,7 +881,7 @@ public function deletePackageAction(Request $req, string $name): Response return new Response('Invalid form input', 400); } - #[Route(path: '/packages/{name:package}/maintainers/', name: 'add_maintainer', requirements: ['name' => '[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+'])] + #[Route(path: '/packages/{name:package}/maintainers/', name: 'add_maintainer', requirements: ['name' => Package::PACKAGE_NAME_REGEX])] public function createMaintainerAction(Request $req, #[MapEntity] Package $package, #[CurrentUser] User $user, LoggerInterface $logger): RedirectResponse { $this->denyAccessUnlessGranted(PackageActions::AddMaintainer->value, $package); @@ -916,7 +919,7 @@ public function createMaintainerAction(Request $req, #[MapEntity] Package $packa return $this->redirectToRoute('view_package', ['name' => $package->getName()]); } - #[Route(path: '/packages/{name:package}/maintainers/delete', name: 'remove_maintainer', requirements: ['name' => '[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+'])] + #[Route(path: '/packages/{name:package}/maintainers/delete', name: 'remove_maintainer', requirements: ['name' => Package::PACKAGE_NAME_REGEX])] public function removeMaintainerAction(Request $req, #[MapEntity] Package $package, #[CurrentUser] User $user, LoggerInterface $logger): Response { $this->denyAccessUnlessGranted(PackageActions::RemoveMaintainer->value, $package); @@ -960,6 +963,48 @@ public function removeMaintainerAction(Request $req, #[MapEntity] Package $packa ]); } + + #[Route(path: '/packages/{name:package}/transfer/', name: 'transfer_package', requirements: ['name' => Package::PACKAGE_NAME_REGEX], methods: ['GET', 'POST'])] + public function transferPackageAction(Request $req, #[MapEntity] Package $package, #[CurrentUser] User $user, LoggerInterface $logger): RedirectResponse + { + $this->denyAccessUnlessGranted(PackageActions::TransferPackage->value, $package); + + $form = $this->createTransferPackageForm($package); + $form->handleRequest($req); + + if (!$form->isSubmitted()) { + return $this->redirectToRoute('view_package', ['name' => $package->getName()]); + } + + if (!$form->isValid()) { + foreach ($form->getErrors(true, true) as $error) { + $this->addFlash('error', $error->getMessage()); + } + + return $this->redirectToRoute('view_package', ['name' => $package->getName()]); + } + + try { + $newMaintainers = $form->getData()->maintainers->toArray(); + $result = $this->packageManager->transferPackage($package, $newMaintainers, true); + $this->getEM()->flush(); + + if ($result) { + $usernames = array_map(fn (User $user) => $user->getUsername(), $newMaintainers); + $this->addFlash('success', sprintf('Package has been transferred to %s', implode(', ', $usernames))); + } else { + $this->addFlash('warning', 'Package maintainers are identical and have not been changed'); + } + + return $this->redirectToRoute('view_package', ['name' => $package->getName()]); + } catch (\Exception $e) { + $logger->critical($e->getMessage(), ['exception', $e]); + $this->addFlash('error', 'The package could not be transferred.'); + } + + return $this->redirectToRoute('view_package', ['name' => $package->getName()]); + } + #[Route(path: '/packages/{name:package}/edit', name: 'edit_package', requirements: ['name' => '[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+?'])] public function editAction(Request $req, #[MapEntity] Package $package, #[CurrentUser] ?User $user = null): Response { @@ -1608,6 +1653,16 @@ private function createRemoveMaintainerForm(Package $package): FormInterface ]); } + /** + * @return FormInterface+ {% if is_granted('add_maintainer', package) %}{% endif %} + {% if is_granted('remove_maintainer', package) %}{% endif %} + {% if is_granted('transfer_package', package) %}{% endif %} +
+ {% endif %} +@@ -319,6 +326,36 @@ {% endif %} + {% if is_granted('transfer_package', package) %} +