From c1aba40d38abf8215095f9191dd8d2220c8f0ed5 Mon Sep 17 00:00:00 2001 From: BentiGorlich Date: Sun, 24 Nov 2024 19:01:45 +0100 Subject: [PATCH] [Feature] Admin approval (#1232) Co-authored-by: TheVillageGuy <47496248+TheVillageGuy@users.noreply.github.com> --- .env.example | 3 + .env.example_docker | 3 + composer.json | 2 +- composer.lock | 18 +- config/kbin_routes/admin.yaml | 15 ++ config/kbin_routes/admin_api.yaml | 18 ++ config/packages/doctrine.yaml | 1 + config/services.yaml | 3 + .../03-optional-features/user_application.md | 10 + migrations/Version20241104162329.php | 30 +++ src/Command/UserCommand.php | 9 +- .../ActivityPub/User/UserController.php | 5 + .../Admin/AdminSignupRequestsController.php | 50 ++++ src/Controller/Api/BaseApi.php | 4 + .../Api/User/Admin/UserApplicationApi.php | 213 ++++++++++++++++++ .../Security/RegisterController.php | 11 +- .../ResendActivationEmailController.php | 5 +- src/Controller/User/UserFrontController.php | 5 + src/DTO/SettingsDto.php | 3 + src/DTO/UserDto.php | 3 + .../DBAL/Types/EnumApplicationStatus.php | 20 ++ .../DBAL/Types/EnumType.php | 47 ++++ src/Entity/User.php | 23 +- src/Enums/EApplicationStatus.php | 34 +++ .../User/UserApplicationApprovedEvent.php | 15 ++ .../User/UserApplicationRejectedEvent.php | 15 ++ .../User/UserApplicationSubscriber.php | 41 ++++ src/Form/SettingsType.php | 1 + src/Form/UserRegisterType.php | 8 + src/Message/UserApplicationAnswerMessage.php | 16 ++ src/MessageHandler/DeleteUserHandler.php | 2 +- .../SendApplicationAnswerMailHandler.php | 72 ++++++ .../SentUserConfirmationEmailHandler.php | 1 + src/Repository/UserRepository.php | 45 +++- src/Security/UserChecker.php | 12 + src/Service/ActivityPubManager.php | 3 +- src/Service/SettingsManager.php | 16 ++ src/Service/UserManager.php | 41 +++- src/Twig/Extension/AdminExtension.php | 1 + src/Twig/Runtime/AdminExtensionRuntime.php | 10 + .../_email/application_approved.html.twig | 19 ++ .../_email/application_rejected.html.twig | 11 + templates/_email/confirmation_email.html.twig | 7 +- templates/admin/_options.html.twig | 8 + templates/admin/settings.html.twig | 4 + templates/admin/signup_requests.html.twig | 52 +++++ templates/user/register.html.twig | 5 + tests/FactoryTrait.php | 2 +- translations/messages.en.yaml | 15 ++ 49 files changed, 932 insertions(+), 25 deletions(-) create mode 100644 docs/02-admin/03-optional-features/user_application.md create mode 100644 migrations/Version20241104162329.php create mode 100644 src/Controller/Admin/AdminSignupRequestsController.php create mode 100644 src/Controller/Api/User/Admin/UserApplicationApi.php create mode 100644 src/DoctrineExtensions/DBAL/Types/EnumApplicationStatus.php create mode 100644 src/DoctrineExtensions/DBAL/Types/EnumType.php create mode 100644 src/Enums/EApplicationStatus.php create mode 100644 src/Event/User/UserApplicationApprovedEvent.php create mode 100644 src/Event/User/UserApplicationRejectedEvent.php create mode 100644 src/EventSubscriber/User/UserApplicationSubscriber.php create mode 100644 src/Message/UserApplicationAnswerMessage.php create mode 100644 src/MessageHandler/SendApplicationAnswerMailHandler.php create mode 100644 templates/_email/application_approved.html.twig create mode 100644 templates/_email/application_rejected.html.twig create mode 100644 templates/admin/signup_requests.html.twig diff --git a/.env.example b/.env.example index 991e08da9..c56d04ce1 100644 --- a/.env.example +++ b/.env.example @@ -73,6 +73,9 @@ S3_VERSION= # Only let admins generated oauth clients KBIN_ADMIN_ONLY_OAUTH_CLIENTS=false +# Manually approve every new user +MBIN_NEW_USERS_NEED_APPROVAL=false + # oAuth (optional) OAUTH_AZURE_ID= OAUTH_AZURE_SECRET= diff --git a/.env.example_docker b/.env.example_docker index 370f106ca..98d888678 100644 --- a/.env.example_docker +++ b/.env.example_docker @@ -70,6 +70,9 @@ S3_VERSION= # Only let admins generate oauth clients KBIN_ADMIN_ONLY_OAUTH_CLIENTS=false +# Manually approve every new user +MBIN_NEW_USERS_NEED_APPROVAL=false + # oAuth (optional) OAUTH_AZURE_ID= OAUTH_AZURE_SECRET= diff --git a/composer.json b/composer.json index c4be322d4..ab2320983 100644 --- a/composer.json +++ b/composer.json @@ -111,7 +111,7 @@ "twig/extra-bundle": "^3.10.0", "twig/html-extra": "^3.10.0", "twig/intl-extra": "^3.10.0", - "twig/twig": "^3.10.3", + "twig/twig": "^3.15.0", "webmozart/assert": "^1.11.0", "wohali/oauth2-discord-new": "^1.2.1" }, diff --git a/composer.lock b/composer.lock index e04b3e44a..eb2e9f545 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "4b66e16b3d61acf4006bbcacb3a1af7a", + "content-hash": "4412672911f84ad7d4f12c4bcdd288fb", "packages": [ { "name": "aws/aws-crt-php", @@ -14643,16 +14643,16 @@ }, { "name": "twig/twig", - "version": "v3.14.2", + "version": "v3.15.0", "source": { "type": "git", "url": "https://github.com/twigphp/Twig.git", - "reference": "0b6f9d8370bb3b7f1ce5313ed8feb0fafd6e399a" + "reference": "2d5b3964cc21d0188633d7ddce732dc8e874db02" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/twigphp/Twig/zipball/0b6f9d8370bb3b7f1ce5313ed8feb0fafd6e399a", - "reference": "0b6f9d8370bb3b7f1ce5313ed8feb0fafd6e399a", + "url": "https://api.github.com/repos/twigphp/Twig/zipball/2d5b3964cc21d0188633d7ddce732dc8e874db02", + "reference": "2d5b3964cc21d0188633d7ddce732dc8e874db02", "shasum": "" }, "require": { @@ -14706,7 +14706,7 @@ ], "support": { "issues": "https://github.com/twigphp/Twig/issues", - "source": "https://github.com/twigphp/Twig/tree/v3.14.2" + "source": "https://github.com/twigphp/Twig/tree/v3.15.0" }, "funding": [ { @@ -14718,7 +14718,7 @@ "type": "tidelift" } ], - "time": "2024-11-07T12:36:22+00:00" + "time": "2024-11-17T15:59:19+00:00" }, { "name": "web-token/jwt-library", @@ -17846,7 +17846,7 @@ ], "aliases": [], "minimum-stability": "stable", - "stability-flags": [], + "stability-flags": {}, "prefer-stable": true, "prefer-lowest": false, "platform": { @@ -17862,6 +17862,6 @@ "ext-openssl": "*", "ext-redis": "*" }, - "platform-dev": [], + "platform-dev": {}, "plugin-api-version": "2.6.0" } diff --git a/config/kbin_routes/admin.yaml b/config/kbin_routes/admin.yaml index 0c6dcd6c6..201e8f8a3 100644 --- a/config/kbin_routes/admin.yaml +++ b/config/kbin_routes/admin.yaml @@ -77,6 +77,21 @@ admin_magazine_ownership_requests_reject: path: /admin/magazine_ownership/{name}/{username}/reject methods: [POST] +admin_signup_requests: + controller: App\Controller\Admin\AdminSignupRequestsController::requests + path: /admin/signup_requests + methods: [ GET ] + +admin_signup_requests_approve: + controller: App\Controller\Admin\AdminSignupRequestsController::approve + path: /admin/signup_requests/{id}/approve + methods: [ POST ] + +admin_signup_requests_reject: + controller: App\Controller\Admin\AdminSignupRequestsController::reject + path: /admin/signup_requests/{id}/reject + methods: [ POST ] + admin_cc: controller: App\Controller\Admin\AdminClearCacheController path: /admin/cc diff --git a/config/kbin_routes/admin_api.yaml b/config/kbin_routes/admin_api.yaml index f2b0cd19f..eb198b1fa 100644 --- a/config/kbin_routes/admin_api.yaml +++ b/config/kbin_routes/admin_api.yaml @@ -111,3 +111,21 @@ api_admin_purge_magazine: path: /api/admin/magazine/{magazine_id}/purge methods: [ DELETE ] format: json + +api_admin_view_user_applications: + controller: App\Controller\Api\User\Admin\UserApplicationApi::retrieve + path: /api/admin/users/applications + methods: [ GET ] + format: json + +api_admin_view_user_application_approve: + controller: App\Controller\Api\User\Admin\UserApplicationApi::approve + path: /api/admin/users/applications/{user_id}/approve + methods: [ GET ] + format: json + +api_admin_view_user_application_reject: + controller: App\Controller\Api\User\Admin\UserApplicationApi::reject + path: /api/admin/users/applications/{user_id}/reject + methods: [ GET ] + format: json diff --git a/config/packages/doctrine.yaml b/config/packages/doctrine.yaml index a5126459e..2f891dc79 100644 --- a/config/packages/doctrine.yaml +++ b/config/packages/doctrine.yaml @@ -3,6 +3,7 @@ doctrine: url: '%env(resolve:DATABASE_URL)%' types: citext: App\DoctrineExtensions\DBAL\Types\Citext + enumApplicationStatus: App\DoctrineExtensions\DBAL\Types\EnumApplicationStatus mapping_types: user_type: string citext: citext diff --git a/config/services.yaml b/config/services.yaml index 7d622e14f..0a7dce5d7 100644 --- a/config/services.yaml +++ b/config/services.yaml @@ -113,6 +113,8 @@ parameters: mbin_downvotes_mode_default: 'enabled' mbin_downvotes_mode: '%env(enum:\App\Utils\DownvotesMode:default:mbin_downvotes_mode_default:MBIN_DOWNVOTES_MODE)%' + mbin_new_users_need_approval: '%env(bool:default::MBIN_NEW_USERS_NEED_APPROVAL)%' + services: # default configuration for services in *this* file _defaults: @@ -182,6 +184,7 @@ services: $mbinSsoOnlyMode: '%sso_only_mode%' $maxImageBytes: '%max_image_bytes%' $mbinDownvotesMode: '%mbin_downvotes_mode%' + $mbinNewUsersNeedApproval: '%mbin_new_users_need_approval%' # Markdown App\Markdown\Factory\EnvironmentFactory: diff --git a/docs/02-admin/03-optional-features/user_application.md b/docs/02-admin/03-optional-features/user_application.md new file mode 100644 index 000000000..03b937ce7 --- /dev/null +++ b/docs/02-admin/03-optional-features/user_application.md @@ -0,0 +1,10 @@ +# Manually Approving New Users + +If you want to manually approve users before they can log into your server, +you can either tick the checkbox in the admin settings put this in the `.env` file: +```dotenv +MBIN_NEW_USERS_NEED_APPROVAL=true +``` + +You will then see a new admin panel called `Applications` where new users will appear until you approve or deny them. +When you have decided on one or the other, the user will get an email notification about it. diff --git a/migrations/Version20241104162329.php b/migrations/Version20241104162329.php new file mode 100644 index 000000000..cb6f02383 --- /dev/null +++ b/migrations/Version20241104162329.php @@ -0,0 +1,30 @@ +addSql('CREATE TYPE enumApplicationStatus AS ENUM (\'Approved\', \'Rejected\', \'Pending\')'); + $this->addSql('ALTER TABLE "user" ADD application_text TEXT DEFAULT NULL'); + $this->addSql('ALTER TABLE "user" ADD application_status enumApplicationStatus DEFAULT \'Approved\' NOT NULL'); + } + + public function down(Schema $schema): void + { + $this->addSql('ALTER TABLE "user" DROP application_text'); + $this->addSql('ALTER TABLE "user" DROP application_status'); + $this->addSql('DROP TYPE enumApplicationStatus'); + } +} diff --git a/src/Command/UserCommand.php b/src/Command/UserCommand.php index 5513cf2b6..92a530a62 100644 --- a/src/Command/UserCommand.php +++ b/src/Command/UserCommand.php @@ -35,6 +35,7 @@ protected function configure(): void $this->addArgument('username', InputArgument::REQUIRED) ->addArgument('email', InputArgument::REQUIRED) ->addArgument('password', InputArgument::REQUIRED) + ->addOption('applicationText', 'a', InputOption::VALUE_REQUIRED, 'The application text of the user, if set the user will not be pre-approved') ->addOption('remove', 'r', InputOption::VALUE_NONE, 'Remove user') ->addOption('admin', null, InputOption::VALUE_NONE, 'Grant administrator privileges') ->addOption('moderator', null, InputOption::VALUE_NONE, 'Grant global moderator privileges'); @@ -69,10 +70,14 @@ protected function execute(InputInterface $input, OutputInterface $output): int private function createUser(InputInterface $input, SymfonyStyle $io): void { - $dto = (new UserDto())->create($input->getArgument('username'), $input->getArgument('email')); + $applicationText = $input->getOption('applicationText'); + if ('' === $applicationText) { + $applicationText = null; + } + $dto = (new UserDto())->create($input->getArgument('username'), $input->getArgument('email'), applicationText: $applicationText); $dto->plainPassword = $input->getArgument('password'); - $user = $this->manager->create($dto, false, false); + $user = $this->manager->create($dto, false, false, preApprove: null === $applicationText); if ($input->getOption('admin')) { $user->setOrRemoveAdminRole(); diff --git a/src/Controller/ActivityPub/User/UserController.php b/src/Controller/ActivityPub/User/UserController.php index a0550ab20..df8e404fa 100644 --- a/src/Controller/ActivityPub/User/UserController.php +++ b/src/Controller/ActivityPub/User/UserController.php @@ -6,6 +6,7 @@ use App\Controller\AbstractController; use App\Entity\User; +use App\Enums\EApplicationStatus; use App\Factory\ActivityPub\PersonFactory; use App\Factory\ActivityPub\TombstoneFactory; use Symfony\Component\HttpFoundation\JsonResponse; @@ -25,6 +26,10 @@ public function __invoke(User $user, Request $request): JsonResponse throw $this->createNotFoundException(); } + if (EApplicationStatus::Approved !== $user->getApplicationStatus()) { + throw $this->createNotFoundException(); + } + if (!$user->isDeleted || null !== $user->markedForDeletionAt) { $response = new JsonResponse($this->personFactory->create($user, true)); } else { diff --git a/src/Controller/Admin/AdminSignupRequestsController.php b/src/Controller/Admin/AdminSignupRequestsController.php new file mode 100644 index 000000000..e8af01be7 --- /dev/null +++ b/src/Controller/Admin/AdminSignupRequestsController.php @@ -0,0 +1,50 @@ +repository->findAllSignupRequestsPaginated($page); + + return $this->render('admin/signup_requests.html.twig', [ + 'requests' => $requests, + 'page' => $page, + ]); + } + + #[IsGranted('ROLE_ADMIN')] + public function approve(#[MapQueryParameter] int $page, #[MapEntity(id: 'id')] User $user): Response + { + $this->userManager->approveUserApplication($user); + + return $this->redirectToRoute('admin_signup_requests', ['page' => $page]); + } + + #[IsGranted('ROLE_ADMIN')] + public function reject(#[MapQueryParameter] int $page, #[MapEntity(id: 'id')] User $user): Response + { + $this->userManager->rejectUserApplication($user); + + return $this->redirectToRoute('admin_signup_requests', ['page' => $page]); + } +} diff --git a/src/Controller/Api/BaseApi.php b/src/Controller/Api/BaseApi.php index 4485c7143..fbc63903d 100644 --- a/src/Controller/Api/BaseApi.php +++ b/src/Controller/Api/BaseApi.php @@ -40,10 +40,12 @@ use App\Repository\PostCommentRepository; use App\Repository\PostRepository; use App\Repository\TagLinkRepository; +use App\Repository\UserRepository; use App\Schema\PaginationSchema; use App\Service\BookmarkManager; use App\Service\IpResolver; use App\Service\ReportManager; +use App\Service\UserManager; use Doctrine\ORM\EntityManagerInterface; use League\Bundle\OAuth2ServerBundle\Model\AccessToken; use League\Bundle\OAuth2ServerBundle\Security\Authentication\Token\OAuth2Token; @@ -91,6 +93,8 @@ public function __construct( protected readonly BookmarkListRepository $bookmarkListRepository, protected readonly BookmarkRepository $bookmarkRepository, protected readonly BookmarkManager $bookmarkManager, + protected readonly UserManager $userManager, + protected readonly UserRepository $userRepository, private readonly ImageRepository $imageRepository, private readonly ReportManager $reportManager, private readonly OAuth2ClientAccessRepository $clientAccessRepository, diff --git a/src/Controller/Api/User/Admin/UserApplicationApi.php b/src/Controller/Api/User/Admin/UserApplicationApi.php new file mode 100644 index 000000000..a0ed2b8a7 --- /dev/null +++ b/src/Controller/Api/User/Admin/UserApplicationApi.php @@ -0,0 +1,213 @@ +rateLimit($apiReadLimiter, $anonymousApiReadLimiter); + $users = $this->userRepository->findAllSignupRequestsPaginated($p); + + $dtos = []; + foreach ($users->getCurrentPageResults() as $value) { + \assert($value instanceof User); + $dtos[] = $this->serializeUser($userFactory->createDto($value)); + } + + return new JsonResponse( + $this->serializePaginated($dtos, $users), + headers: $headers + ); + } + + #[OA\Response( + response: 200, + description: 'Returns nothing on success', + headers: [ + new OA\Header(header: 'X-RateLimit-Remaining', description: 'Number of requests left until you will be rate limited', schema: new OA\Schema(type: 'integer')), + new OA\Header(header: 'X-RateLimit-Retry-After', description: 'Unix timestamp to retry the request after', schema: new OA\Schema(type: 'integer')), + new OA\Header(header: 'X-RateLimit-Limit', description: 'Number of requests available', schema: new OA\Schema(type: 'integer')), + ], + content: null + )] + #[OA\Response( + response: 401, + description: 'Permission denied due to missing or expired token', + content: new OA\JsonContent(ref: new Model(type: UnauthorizedErrorSchema::class)) + )] + #[OA\Response( + response: 403, + description: 'You are not authorized to verify this user', + content: new OA\JsonContent(ref: new Model(type: ForbiddenErrorSchema::class)) + )] + #[OA\Response( + response: 404, + description: 'User not found', + content: new OA\JsonContent(ref: new Model(type: NotFoundErrorSchema::class)) + )] + #[OA\Response( + response: 429, + description: 'You are being rate limited', + headers: [ + new OA\Header(header: 'X-RateLimit-Remaining', description: 'Number of requests left until you will be rate limited', schema: new OA\Schema(type: 'integer')), + new OA\Header(header: 'X-RateLimit-Retry-After', description: 'Unix timestamp to retry the request after', schema: new OA\Schema(type: 'integer')), + new OA\Header(header: 'X-RateLimit-Limit', description: 'Number of requests available', schema: new OA\Schema(type: 'integer')), + ], + content: new OA\JsonContent(ref: new Model(type: TooManyRequestsErrorSchema::class)) + )] + #[OA\Parameter( + name: 'user_id', + description: 'The user to approve', + in: 'path', + schema: new OA\Schema(type: 'integer', minimum: 1) + )] + #[OA\Tag(name: 'admin/user')] + #[IsGranted('ROLE_ADMIN')] + #[Security(name: 'oauth2', scopes: ['admin:user:application'])] + #[IsGranted('ROLE_OAUTH2_ADMIN:USER:APPLICATION')] + public function approve( + RateLimiterFactory $apiReadLimiter, + RateLimiterFactory $anonymousApiReadLimiter, + #[MapEntity(id: 'user_id')] User $user, + ): JsonResponse { + $headers = $this->rateLimit($apiReadLimiter, $anonymousApiReadLimiter); + $this->userManager->approveUserApplication($user); + + return new JsonResponse(null, headers: $headers); + } + + #[OA\Response( + response: 200, + description: 'Returns nothing on success', + headers: [ + new OA\Header(header: 'X-RateLimit-Remaining', description: 'Number of requests left until you will be rate limited', schema: new OA\Schema(type: 'integer')), + new OA\Header(header: 'X-RateLimit-Retry-After', description: 'Unix timestamp to retry the request after', schema: new OA\Schema(type: 'integer')), + new OA\Header(header: 'X-RateLimit-Limit', description: 'Number of requests available', schema: new OA\Schema(type: 'integer')), + ], + content: null + )] + #[OA\Response( + response: 401, + description: 'Permission denied due to missing or expired token', + content: new OA\JsonContent(ref: new Model(type: UnauthorizedErrorSchema::class)) + )] + #[OA\Response( + response: 403, + description: 'You are not authorized to verify this user', + content: new OA\JsonContent(ref: new Model(type: ForbiddenErrorSchema::class)) + )] + #[OA\Response( + response: 404, + description: 'User not found', + content: new OA\JsonContent(ref: new Model(type: NotFoundErrorSchema::class)) + )] + #[OA\Response( + response: 429, + description: 'You are being rate limited', + headers: [ + new OA\Header(header: 'X-RateLimit-Remaining', description: 'Number of requests left until you will be rate limited', schema: new OA\Schema(type: 'integer')), + new OA\Header(header: 'X-RateLimit-Retry-After', description: 'Unix timestamp to retry the request after', schema: new OA\Schema(type: 'integer')), + new OA\Header(header: 'X-RateLimit-Limit', description: 'Number of requests available', schema: new OA\Schema(type: 'integer')), + ], + content: new OA\JsonContent(ref: new Model(type: TooManyRequestsErrorSchema::class)) + )] + #[OA\Parameter( + name: 'user_id', + description: 'The user to reject', + in: 'path', + schema: new OA\Schema(type: 'integer', minimum: 1) + )] + #[OA\Tag(name: 'admin/user')] + #[IsGranted('ROLE_ADMIN')] + #[Security(name: 'oauth2', scopes: ['admin:user:application'])] + #[IsGranted('ROLE_OAUTH2_ADMIN:USER:APPLICATION')] + public function reject( + RateLimiterFactory $apiReadLimiter, + RateLimiterFactory $anonymousApiReadLimiter, + #[MapEntity(id: 'user_id')] User $user, + ): JsonResponse { + $headers = $this->rateLimit($apiReadLimiter, $anonymousApiReadLimiter); + $this->userManager->rejectUserApplication($user); + + return new JsonResponse(null, headers: $headers); + } +} diff --git a/src/Controller/Security/RegisterController.php b/src/Controller/Security/RegisterController.php index 0e69971bb..95e0276f2 100644 --- a/src/Controller/Security/RegisterController.php +++ b/src/Controller/Security/RegisterController.php @@ -5,6 +5,7 @@ namespace App\Controller\Security; use App\Controller\AbstractController; +use App\DTO\UserDto; use App\Form\UserRegisterType; use App\Service\IpResolver; use App\Service\SettingsManager; @@ -39,6 +40,7 @@ public function __invoke(Request $request): Response $form->handleRequest($request); if ($form->isSubmitted() && $form->isValid()) { + /** @var UserDto $dto */ $dto = $form->getData(); $dto->ip = $this->ipResolver->resolve(); @@ -49,7 +51,14 @@ public function __invoke(Request $request): Response 'flash_register_success' ); - return $this->redirectToRoute('front'); + if ($this->settingsManager->getNewUsersNeedApproval()) { + $this->addFlash( + 'success', + 'flash_application_info' + ); + } + + return $this->redirectToRoute('app_login'); } elseif ($form->isSubmitted() && !$form->isValid()) { $this->logger->error('Registration form submission was invalid.', [ 'errors' => $form->getErrors(true, false), diff --git a/src/Controller/Security/ResendActivationEmailController.php b/src/Controller/Security/ResendActivationEmailController.php index 579b83d3e..e109d24bc 100644 --- a/src/Controller/Security/ResendActivationEmailController.php +++ b/src/Controller/Security/ResendActivationEmailController.php @@ -9,13 +9,15 @@ use App\Form\ResendEmailActivationFormType; use App\MessageHandler\SentUserConfirmationEmailHandler; use Doctrine\ORM\EntityManagerInterface; +use Psr\Log\LoggerInterface; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; class ResendActivationEmailController extends AbstractController { public function __construct( - private readonly EntityManagerInterface $entityManager + private readonly EntityManagerInterface $entityManager, + private readonly LoggerInterface $logger, ) { } @@ -43,6 +45,7 @@ public function resend(Request $request, SentUserConfirmationEmailHandler $confi return $this->redirectToRoute('app_resend_email_activation'); } catch (\Exception $e) { + $this->logger->error('There was an exception trying to re-send the activation email to: {u} - {mail}: {e} - {msg}', ['u' => $user->username, 'mail' => $user->email, 'e' => \get_class($e), 'msg' => $e->getMessage()]); $this->addFlash('error', 'resend_account_activation_email_error'); return $this->redirectToRoute('app_resend_email_activation'); diff --git a/src/Controller/User/UserFrontController.php b/src/Controller/User/UserFrontController.php index fe8354035..ab2783007 100644 --- a/src/Controller/User/UserFrontController.php +++ b/src/Controller/User/UserFrontController.php @@ -6,6 +6,7 @@ use App\Controller\AbstractController; use App\Entity\User; +use App\Enums\EApplicationStatus; use App\PageView\EntryCommentPageView; use App\PageView\EntryPageView; use App\PageView\MagazinePageView; @@ -40,6 +41,10 @@ public function front(User $user, Request $request, UserRepository $repository): $requestedByUser = $this->getUser(); $hideAdult = (!$requestedByUser || $requestedByUser->hideAdult); + if (EApplicationStatus::Approved !== $user->getApplicationStatus()) { + throw $this->createNotFoundException(); + } + if ($user->isDeleted && (!$requestedByUser || (!$requestedByUser->isAdmin() && !$requestedByUser->isModerator()) || null === $user->markedForDeletionAt)) { throw $this->createNotFoundException(); } diff --git a/src/DTO/SettingsDto.php b/src/DTO/SettingsDto.php index d298bf40e..d278ef5ee 100644 --- a/src/DTO/SettingsDto.php +++ b/src/DTO/SettingsDto.php @@ -38,6 +38,7 @@ public function __construct( public bool $MBIN_SSO_SHOW_FIRST, public int $MAX_IMAGE_BYTES, public string $MBIN_DOWNVOTES_MODE, + public bool $MBIN_NEW_USERS_NEED_APPROVAL, ) { } @@ -70,6 +71,7 @@ public function mergeIntoDto(SettingsDto $dto): SettingsDto $dto->MBIN_SSO_SHOW_FIRST = $this->MBIN_SSO_SHOW_FIRST ?? $dto->MBIN_SSO_SHOW_FIRST; $dto->MAX_IMAGE_BYTES = $this->MAX_IMAGE_BYTES ?? $dto->MAX_IMAGE_BYTES; $dto->MBIN_DOWNVOTES_MODE = $this->MBIN_DOWNVOTES_MODE ?? $dto->MBIN_DOWNVOTES_MODE; + $dto->MBIN_NEW_USERS_NEED_APPROVAL = $this->MBIN_NEW_USERS_NEED_APPROVAL ?? $dto->MBIN_NEW_USERS_NEED_APPROVAL; return $dto; } @@ -104,6 +106,7 @@ public function jsonSerialize(): mixed 'MBIN_SSO_SHOW_FIRST' => $this->MBIN_SSO_SHOW_FIRST, 'MAX_IMAGE_BYTES' => $this->MAX_IMAGE_BYTES, 'MBIN_DOWNVOTES_MODE' => $this->MBIN_DOWNVOTES_MODE, + 'MBIN_NEW_USERS_NEED_APPROVAL' => $this->MBIN_NEW_USERS_NEED_APPROVAL, ]; } } diff --git a/src/DTO/UserDto.php b/src/DTO/UserDto.php index 8ee3fa090..cb963ae60 100644 --- a/src/DTO/UserDto.php +++ b/src/DTO/UserDto.php @@ -49,6 +49,7 @@ class UserDto implements UserDtoInterface public ?string $totpSecret = null; public ?string $serverSoftware = null; public ?string $serverSoftwareVersion = null; + public ?string $applicationText = null; #[Assert\Callback] public function validate( @@ -91,6 +92,7 @@ public static function create( ?bool $isBot = null, ?bool $isAdmin = null, ?bool $isGlobalModerator = null, + ?string $applicationText = null, ): self { $dto = new UserDto(); $dto->id = $id; @@ -107,6 +109,7 @@ public static function create( $dto->isBot = $isBot; $dto->isAdmin = $isAdmin; $dto->isGlobalModerator = $isGlobalModerator; + $dto->applicationText = $applicationText; return $dto; } diff --git a/src/DoctrineExtensions/DBAL/Types/EnumApplicationStatus.php b/src/DoctrineExtensions/DBAL/Types/EnumApplicationStatus.php new file mode 100644 index 000000000..7b6865d52 --- /dev/null +++ b/src/DoctrineExtensions/DBAL/Types/EnumApplicationStatus.php @@ -0,0 +1,20 @@ +getValues()); + + return 'ENUM('.implode(', ', $values).')'; + } + + public function convertToPHPValue($value, AbstractPlatform $platform) + { + return $value; + } + + public function convertToDatabaseValue($value, AbstractPlatform $platform) + { + if (!\in_array($value, $this->getValues())) { + throw new \InvalidArgumentException("Invalid '".$this->getName()."' value."); + } + + return $value; + } + + public function requiresSQLCommentHint(AbstractPlatform $platform): bool + { + return true; + } +} diff --git a/src/Entity/User.php b/src/Entity/User.php index 8095a2300..bd2c7e504 100644 --- a/src/Entity/User.php +++ b/src/Entity/User.php @@ -10,6 +10,7 @@ use App\Entity\Traits\ActivityPubActorTrait; use App\Entity\Traits\CreatedAtTrait; use App\Entity\Traits\VisibilityTrait; +use App\Enums\EApplicationStatus; use App\Repository\UserRepository; use App\Service\ActivityPub\ApHttpClient; use Doctrine\Common\Collections\ArrayCollection; @@ -240,13 +241,21 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface, Visibil #[Column(type: 'string', nullable: false, options: ['default' => self::USER_TYPE_PERSON])] public string $type; + #[Column(type: 'text', nullable: true)] + public string $applicationText; + + #[Column(type: 'enumApplicationStatus', nullable: false, options: ['default' => EApplicationStatus::Approved->value])] + private string $applicationStatus; + public function __construct( string $email, string $username, string $password, string $type, ?string $apProfileId = null, - ?string $apId = null + ?string $apId = null, + EApplicationStatus $applicationStatus = EApplicationStatus::Approved, + ?string $applicationText = null, ) { $this->email = $email; $this->password = $password; @@ -280,6 +289,8 @@ public function __construct( $this->lastActive = new \DateTime(); $this->createdAtTraitConstruct(); $this->oAuth2UserConsents = new ArrayCollection(); + $this->setApplicationStatus($applicationStatus); + $this->applicationText = $applicationText; } public function getId(): int @@ -896,4 +907,14 @@ public function canUpdateUser(User $actor): bool return $this->apDomain === $actor->apDomain; } } + + public function getApplicationStatus(): EApplicationStatus + { + return EApplicationStatus::getFromString($this->applicationStatus); + } + + public function setApplicationStatus(EApplicationStatus $applicationStatus): void + { + $this->applicationStatus = $applicationStatus->value; + } } diff --git a/src/Enums/EApplicationStatus.php b/src/Enums/EApplicationStatus.php new file mode 100644 index 000000000..f18291658 --- /dev/null +++ b/src/Enums/EApplicationStatus.php @@ -0,0 +1,34 @@ +value => self::Approved, + self::Rejected->value => self::Rejected, + self::Pending->value => self::Pending, + default => null, + }; + } + + /** + * @return string[] + */ + public static function getValues(): array + { + return [ + EApplicationStatus::Approved->value, + EApplicationStatus::Rejected->value, + EApplicationStatus::Pending->value, + ]; + } +} diff --git a/src/Event/User/UserApplicationApprovedEvent.php b/src/Event/User/UserApplicationApprovedEvent.php new file mode 100644 index 000000000..a735c5458 --- /dev/null +++ b/src/Event/User/UserApplicationApprovedEvent.php @@ -0,0 +1,15 @@ + 'onUserApplicationRejected', + UserApplicationApprovedEvent::class => 'onUserApplicationApproved', + ]; + } + + public function onUserApplicationApproved(UserApplicationApprovedEvent $event): void + { + $this->logger->debug('Got a UserApplicationApprovedEvent for {u}', ['u' => $event->user->username]); + $this->bus->dispatch(new UserApplicationAnswerMessage($event->user->getId(), approved: true)); + } + + public function onUserApplicationRejected(UserApplicationRejectedEvent $event): void + { + $this->logger->debug('Got a UserApplicationRejectedEvent for {u}', ['u' => $event->user->username]); + $this->bus->dispatch(new UserApplicationAnswerMessage($event->user->getId(), approved: false)); + } +} diff --git a/src/Form/SettingsType.php b/src/Form/SettingsType.php index 561450dc2..8804c3304 100644 --- a/src/Form/SettingsType.php +++ b/src/Form/SettingsType.php @@ -59,6 +59,7 @@ public function buildForm(FormBuilderInterface $builder, array $options): void $dto->MBIN_DOWNVOTES_MODE => ['checked' => true], ], ]) + ->add('MBIN_NEW_USERS_NEED_APPROVAL', CheckboxType::class, ['required' => false]) ->add('submit', SubmitType::class); } diff --git a/src/Form/UserRegisterType.php b/src/Form/UserRegisterType.php index 0da509738..314246091 100644 --- a/src/Form/UserRegisterType.php +++ b/src/Form/UserRegisterType.php @@ -9,12 +9,14 @@ use App\Form\EventListener\CaptchaListener; use App\Form\EventListener\DisableFieldsOnUserEdit; use App\Form\EventListener\ImageListener; +use App\Service\SettingsManager; use Symfony\Component\Form\AbstractType; use Symfony\Component\Form\Extension\Core\Type\CheckboxType; use Symfony\Component\Form\Extension\Core\Type\EmailType; use Symfony\Component\Form\Extension\Core\Type\PasswordType; use Symfony\Component\Form\Extension\Core\Type\RepeatedType; use Symfony\Component\Form\Extension\Core\Type\SubmitType; +use Symfony\Component\Form\Extension\Core\Type\TextareaType; use Symfony\Component\Form\FormBuilderInterface; use Symfony\Component\OptionsResolver\OptionsResolver; @@ -25,6 +27,7 @@ public function __construct( private readonly AddFieldsOnUserEdit $addAvatarFieldOnUserEdit, private readonly DisableFieldsOnUserEdit $disableUsernameFieldOnUserEdit, private readonly CaptchaListener $captchaListener, + private readonly SettingsManager $settingsManager, ) { } @@ -64,6 +67,11 @@ public function buildForm(FormBuilderInterface $builder, array $options): void ) ->add('submit', SubmitType::class); + if ($this->settingsManager->getNewUsersNeedApproval()) { + $builder + ->add('applicationText', TextareaType::class, ['required' => true]); + } + $builder->addEventSubscriber($this->disableUsernameFieldOnUserEdit); $builder->addEventSubscriber($this->captchaListener); $builder->addEventSubscriber($this->addAvatarFieldOnUserEdit); diff --git a/src/Message/UserApplicationAnswerMessage.php b/src/Message/UserApplicationAnswerMessage.php new file mode 100644 index 000000000..b86b6e116 --- /dev/null +++ b/src/Message/UserApplicationAnswerMessage.php @@ -0,0 +1,16 @@ +entityManager->flush(); // recreate a user with the same name, so this handle is blocked - $user = $this->userManager->create($userDto, verifyUserEmail: false, rateLimit: false); + $user = $this->userManager->create($userDto, verifyUserEmail: false, rateLimit: false, preApprove: true); $user->isDeleted = true; $user->markedForDeletionAt = null; $user->isVerified = false; diff --git a/src/MessageHandler/SendApplicationAnswerMailHandler.php b/src/MessageHandler/SendApplicationAnswerMailHandler.php new file mode 100644 index 000000000..df5792f52 --- /dev/null +++ b/src/MessageHandler/SendApplicationAnswerMailHandler.php @@ -0,0 +1,72 @@ +entityManager); + } + + public function __invoke(UserApplicationAnswerMessage $message): void + { + $this->workWrapper($message); + } + + public function doWork(MessageInterface $message): void + { + if (!($message instanceof UserApplicationAnswerMessage)) { + throw new \LogicException(); + } + $user = $this->repository->find($message->userId); + if (!$user) { + throw new UnrecoverableMessageHandlingException('User not found'); + } + + $this->sendAnswerMail($user, $message->approved); + } + + public function sendAnswerMail(User $user, bool $approved): void + { + $mail = (new TemplatedEmail()) + ->from( + new Address($this->settingsManager->get('KBIN_SENDER_EMAIL'), $this->params->get('kbin_domain')) + ) + ->to($user->email); + + if ($approved) { + $mail->subject($this->translator->trans('email_application_approved_title')) + ->htmlTemplate('_email/application_approved.html.twig') + ->context(['user' => $user]); + } else { + $mail->subject($this->translator->trans('email_application_rejected_title')) + ->htmlTemplate('_email/application_rejected.html.twig') + ->context(['user' => $user]); + } + $this->mailer->send($mail); + } +} diff --git a/src/MessageHandler/SentUserConfirmationEmailHandler.php b/src/MessageHandler/SentUserConfirmationEmailHandler.php index 5185b611b..0c7b2bfec 100644 --- a/src/MessageHandler/SentUserConfirmationEmailHandler.php +++ b/src/MessageHandler/SentUserConfirmationEmailHandler.php @@ -68,6 +68,7 @@ public function sendConfirmationEmail(User $user): void ->to($user->email) ->subject($this->translator->trans('email_confirm_title')) ->htmlTemplate('_email/confirmation_email.html.twig') + ->context(['user' => $user]) ); } catch (\Exception $e) { throw $e; diff --git a/src/Repository/UserRepository.php b/src/Repository/UserRepository.php index 2b714b151..ecf000c01 100644 --- a/src/Repository/UserRepository.php +++ b/src/Repository/UserRepository.php @@ -12,6 +12,7 @@ use App\Entity\PostComment; use App\Entity\User; use App\Entity\UserFollow; +use App\Enums\EApplicationStatus; use App\Service\SettingsManager; use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository; use Doctrine\DBAL\Result; @@ -252,6 +253,8 @@ public function findAllActivePaginated(int $page, bool $onlyLocal = false): Page ->andWhere('u.visibility = :visibility') ->andWhere('u.isDeleted = false') ->andWhere('u.isBanned = false') + ->andWhere('u.applicationStatus = :status') + ->setParameter('status', EApplicationStatus::Approved->value) ->setParameter('visibility', VisibilityInterface::VISIBILITY_VISIBLE) ->orderBy('u.createdAt', 'ASC') ->getQuery(); @@ -281,6 +284,8 @@ public function findAllInactivePaginated(int $page): PagerfantaInterface ->andWhere('u.isVerified = false') ->andWhere('u.isDeleted = false') ->andWhere('u.isBanned = false') + ->andWhere('u.applicationStatus = :status') + ->setParameter('status', EApplicationStatus::Approved->value) ->setParameter('visibility', VisibilityInterface::VISIBILITY_VISIBLE) ->orderBy('u.createdAt', 'ASC') ->getQuery(); @@ -403,6 +408,8 @@ public function findOneByUsername(string $username): ?User { return $this->createQueryBuilder('u') ->Where('LOWER(u.username) = LOWER(:username)') + ->andWhere('u.applicationStatus = :status') + ->setParameter('status', EApplicationStatus::Approved->value) ->setParameter('username', $username) ->getQuery() ->getOneOrNullResult(); @@ -412,6 +419,8 @@ public function findByUsernames(array $users): array { return $this->createQueryBuilder('u') ->where('u.username IN (?1)') + ->andWhere('u.applicationStatus = :status') + ->setParameter('status', EApplicationStatus::Approved->value) ->setParameter(1, $users) ->getQuery() ->getResult(); @@ -474,6 +483,8 @@ private function findUsersQueryBuilder(string $group, ?bool $recentlyActive = tr return $qb ->andWhere('u.isDeleted = false') + ->andWhere('u.applicationStatus = :status') + ->setParameter('status', EApplicationStatus::Approved->value) ->orderBy('u.lastActive', 'DESC'); } @@ -521,7 +532,10 @@ private function findQueryBuilder(string $group, ?string $query, bool $needsAbou break; } - return $qb->orderBy('u.lastActive', 'DESC'); + return $qb + ->andWhere('u.applicationStatus = :status') + ->setParameter('status', EApplicationStatus::Approved->value) + ->orderBy('u.lastActive', 'DESC'); } public function findUsersForGroup(string $group = self::USERS_ALL, ?bool $recentlyActive = true): array @@ -576,6 +590,8 @@ public function findAdmin(): User $result = $this->createQueryBuilder('u') ->andWhere("JSONB_CONTAINS(u.roles, '\"".'ROLE_ADMIN'."\"') = true") ->andWhere('u.isDeleted = false') + ->andWhere('u.applicationStatus = :status') + ->setParameter('status', EApplicationStatus::Approved->value) ->getQuery() ->getResult(); if (0 === \sizeof($result)) { @@ -593,6 +609,8 @@ public function findAllAdmins(): array return $this->createQueryBuilder('u') ->andWhere("JSONB_CONTAINS(u.roles, '\"".'ROLE_ADMIN'."\"') = true") ->andWhere('u.isDeleted = false') + ->andWhere('u.applicationStatus = :status') + ->setParameter('status', EApplicationStatus::Approved->value) ->getQuery() ->getResult(); } @@ -606,7 +624,8 @@ public function findUsersSuggestions(string $query): array ->orWhere($qb->expr()->like('u.email', ':query')) ->andWhere('u.isBanned = false') ->andWhere('u.isDeleted = false') - ->setParameters(['query' => "{$query}%"]) + ->andWhere('u.applicationStatus = :status') + ->setParameters(['query' => "{$query}%", 'status' => EApplicationStatus::Approved->value]) ->setMaxResults(5) ->getQuery() ->getResult(); @@ -647,6 +666,7 @@ public function findUsersForMagazine(Magazine $magazine, ?bool $federated = fals $qb->andWhere($qb->expr()->in('u.id', $user)) ->andWhere('u.isBanned = false') ->andWhere('u.isDeleted = false') + ->andWhere('u.applicationStatus = :status') ->andWhere('u.visibility = :visibility') ->andWhere('u.apDeletedAt IS NULL') ->andWhere('u.apTimeoutAt IS NULL'); @@ -665,6 +685,7 @@ public function findUsersForMagazine(Magazine $magazine, ?bool $federated = fals } $qb->setParameter('visibility', VisibilityInterface::VISIBILITY_VISIBLE) + ->setParameter('status', EApplicationStatus::Approved->value) ->setMaxResults($limit); try { @@ -692,6 +713,7 @@ public function findActiveUsers(?Magazine $magazine = null) $results = $this->findUsersForMagazine($magazine, null, 35, true, true); } else { $results = $this->createQueryBuilder('u') + ->andWhere('u.applicationStatus = :status') ->andWhere('u.lastActive >= :lastActive') ->andWhere('u.isBanned = false') ->andWhere('u.isDeleted = false') @@ -705,7 +727,7 @@ public function findActiveUsers(?Magazine $magazine = null) $results = $results->join('u.avatar', 'a') ->orderBy('u.lastActive', 'DESC') - ->setParameters(['lastActive' => (new \DateTime())->modify('-7 days'), 'visibility' => VisibilityInterface::VISIBILITY_VISIBLE]) + ->setParameters(['lastActive' => (new \DateTime())->modify('-7 days'), 'visibility' => VisibilityInterface::VISIBILITY_VISIBLE, 'status' => EApplicationStatus::Approved->value]) ->setMaxResults(35) ->getQuery() ->getResult(); @@ -761,4 +783,21 @@ public function findAllModerators(): array ->getResult() ; } + + public function findAllSignupRequestsPaginated(int $page = 1): PagerfantaInterface + { + $query = $this->createQueryBuilder('u') + ->where('u.applicationStatus = :status') + ->andWhere('u.apId IS NULL') + ->andWhere('u.isDeleted = false') + ->andWhere('u.markedForDeletionAt IS NULL') + ->setParameter('status', EApplicationStatus::Pending->value) + ->getQuery(); + + $fanta = new Pagerfanta(new QueryAdapter($query)); + $fanta->setCurrentPage($page); + $fanta->setMaxPerPage(self::PER_PAGE); + + return $fanta; + } } diff --git a/src/Security/UserChecker.php b/src/Security/UserChecker.php index 945bfae53..8df19035c 100644 --- a/src/Security/UserChecker.php +++ b/src/Security/UserChecker.php @@ -5,6 +5,7 @@ namespace App\Security; use App\Entity\User as AppUser; +use App\Enums\EApplicationStatus; use App\Service\UserManager; use Symfony\Component\Routing\Generator\UrlGeneratorInterface; use Symfony\Component\Security\Core\Exception\BadCredentialsException; @@ -40,6 +41,17 @@ public function checkPreAuth(UserInterface $user): void } } + $applicationStatus = $user->getApplicationStatus(); + if (EApplicationStatus::Approved !== $applicationStatus) { + if (EApplicationStatus::Pending === $applicationStatus) { + throw new CustomUserMessageAccountStatusException($this->translator->trans('your_account_is_not_yet_approved')); + } elseif (EApplicationStatus::Rejected === $applicationStatus) { + throw new BadCredentialsException(); + } else { + throw new \LogicException("Unrecognized application status $applicationStatus->value"); + } + } + if (!$user->isVerified) { $resendEmailActivationUrl = $this->urlGenerator->generate('app_resend_email_activation'); throw new CustomUserMessageAccountStatusException($this->translator->trans('your_account_is_not_active', ['%link_target%' => $resendEmailActivationUrl])); diff --git a/src/Service/ActivityPubManager.php b/src/Service/ActivityPubManager.php index ec10cf293..76b34fdf1 100644 --- a/src/Service/ActivityPubManager.php +++ b/src/Service/ActivityPubManager.php @@ -315,7 +315,8 @@ private function createUser(string $actorUrl): ?User $this->userManager->create( $this->userFactory->createDtoFromAp($actorUrl, $webfinger->getHandle()), false, - false + false, + preApprove: true, ); return $this->updateUser($actorUrl); diff --git a/src/Service/SettingsManager.php b/src/Service/SettingsManager.php index b7cebbe0b..d814b7ae6 100644 --- a/src/Service/SettingsManager.php +++ b/src/Service/SettingsManager.php @@ -39,6 +39,7 @@ public function __construct( private readonly bool $mbinSsoOnlyMode, private readonly int $maxImageBytes, private readonly DownvotesMode $mbinDownvotesMode, + private readonly bool $mbinNewUsersNeedApproval, ) { if (!self::$dto) { $results = $this->repository->findAll(); @@ -48,6 +49,15 @@ public function __construct( $maxImageBytesEdited = $this->maxImageBytes; } + $newUsersNeedApprovalDb = $this->find($results, 'MBIN_NEW_USERS_NEED_APPROVAL'); + if ('true' === $newUsersNeedApprovalDb) { + $newUsersNeedApprovalEdited = true; + } elseif ('false' === $newUsersNeedApprovalDb) { + $newUsersNeedApprovalEdited = false; + } else { + $newUsersNeedApprovalEdited = $this->mbinNewUsersNeedApproval; + } + self::$dto = new SettingsDto( $this->kbinDomain, $this->find($results, 'KBIN_TITLE') ?? $this->kbinTitle, @@ -84,6 +94,7 @@ public function __construct( $this->find($results, 'MBIN_SSO_SHOW_FIRST', FILTER_VALIDATE_BOOLEAN) ?? false, $maxImageBytesEdited, $this->find($results, 'MBIN_DOWNVOTES_MODE') ?? $this->mbinDownvotesMode->value, + $newUsersNeedApprovalEdited, ); } } @@ -163,6 +174,11 @@ public function getDownvotesMode(): DownvotesMode return DownvotesMode::from($this->get('MBIN_DOWNVOTES_MODE')); } + public function getNewUsersNeedApproval(): bool + { + return $this->get('MBIN_NEW_USERS_NEED_APPROVAL'); + } + public function set(string $name, $value): void { self::$dto->{$name} = $value; diff --git a/src/Service/UserManager.php b/src/Service/UserManager.php index a6a2c8a73..2ba9d9ef3 100644 --- a/src/Service/UserManager.php +++ b/src/Service/UserManager.php @@ -8,6 +8,9 @@ use App\Entity\Contracts\VisibilityInterface; use App\Entity\User; use App\Entity\UserFollowRequest; +use App\Enums\EApplicationStatus; +use App\Event\User\UserApplicationApprovedEvent; +use App\Event\User\UserApplicationRejectedEvent; use App\Event\User\UserBlockEvent; use App\Event\User\UserEditedEvent; use App\Event\User\UserFollowEvent; @@ -29,6 +32,7 @@ use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\Query\ResultSetMapping; use Psr\EventDispatcher\EventDispatcherInterface; +use Psr\Log\LoggerInterface; use Symfony\Bundle\SecurityBundle\Security; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\RequestStack; @@ -58,7 +62,10 @@ public function __construct( private ImageRepository $imageRepository, private Security $security, private CacheInterface $cache, - private ReputationRepository $reputationRepository + private ReputationRepository $reputationRepository, + private SettingsManager $settingsManager, + private EventDispatcherInterface $eventDispatcher, + private LoggerInterface $logger, ) { } @@ -143,7 +150,7 @@ public function unfollow(User $follower, User $following): void $this->dispatcher->dispatch(new UserFollowEvent($follower, $following, true)); } - public function create(UserDto $dto, bool $verifyUserEmail = true, $rateLimit = true): User + public function create(UserDto $dto, bool $verifyUserEmail = true, $rateLimit = true, ?bool $preApprove = null): User { if ($rateLimit) { $limiter = $this->userRegisterLimiter->create($dto->ip); @@ -151,8 +158,12 @@ public function create(UserDto $dto, bool $verifyUserEmail = true, $rateLimit = throw new TooManyRequestsHttpException(); } } + $status = EApplicationStatus::Approved; + if (true !== $preApprove && $this->settingsManager->getNewUsersNeedApproval()) { + $status = EApplicationStatus::Pending; + } - $user = new User($dto->email, $dto->username, '', ($dto->isBot) ? 'Service' : 'Person', $dto->apProfileId, $dto->apId); + $user = new User($dto->email, $dto->username, '', ($dto->isBot) ? 'Service' : 'Person', $dto->apProfileId, $dto->apId, applicationStatus: $status, applicationText: $dto->applicationText); $user->setPassword($this->passwordHasher->hashPassword($user, $dto->plainPassword)); if (!$dto->apId) { @@ -420,6 +431,30 @@ public function getUsersMarkedForDeletionBefore(?\DateTime $dateTime = null): ar ->getResult(); } + public function rejectUserApplication(User $user): void + { + if (EApplicationStatus::Rejected === $user->getApplicationStatus()) { + return; + } + $user->setApplicationStatus(EApplicationStatus::Rejected); + $this->entityManager->persist($user); + $this->entityManager->flush(); + $this->logger->debug('Rejecting application for {u}', ['u' => $user->username]); + $this->eventDispatcher->dispatch(new UserApplicationRejectedEvent($user)); + } + + public function approveUserApplication(User $user): void + { + if (EApplicationStatus::Approved === $user->getApplicationStatus()) { + return; + } + $user->setApplicationStatus(EApplicationStatus::Approved); + $this->entityManager->persist($user); + $this->entityManager->flush(); + $this->logger->debug('Approving application for {u}', ['u' => $user->username]); + $this->eventDispatcher->dispatch(new UserApplicationApprovedEvent($user)); + } + public function getAllInboxesOfInteractions(User $user): array { $sql = ' diff --git a/src/Twig/Extension/AdminExtension.php b/src/Twig/Extension/AdminExtension.php index cd0649c29..261c7e6ec 100644 --- a/src/Twig/Extension/AdminExtension.php +++ b/src/Twig/Extension/AdminExtension.php @@ -15,6 +15,7 @@ public function getFunctions(): array return [ new TwigFunction('is_admin_panel_page', [AdminExtensionRuntime::class, 'isAdminPanelPage']), new TwigFunction('is_tag_banned', [AdminExtensionRuntime::class, 'isTagBanned']), + new TwigFunction('do_new_users_need_approval', [AdminExtensionRuntime::class, 'doNewUsersNeedApproval']), ]; } } diff --git a/src/Twig/Runtime/AdminExtensionRuntime.php b/src/Twig/Runtime/AdminExtensionRuntime.php index 6221c2424..0d6c4cb08 100644 --- a/src/Twig/Runtime/AdminExtensionRuntime.php +++ b/src/Twig/Runtime/AdminExtensionRuntime.php @@ -5,6 +5,8 @@ namespace App\Twig\Runtime; use App\Repository\TagRepository; +use App\Repository\UserRepository; +use App\Service\SettingsManager; use Symfony\Bundle\SecurityBundle\Security; use Symfony\Component\Security\Core\Exception\AccessDeniedException; use Twig\Extension\RuntimeExtensionInterface; @@ -14,6 +16,8 @@ public function __construct( private Security $security, private TagRepository $tagRepository, + private SettingsManager $settingsManager, + private UserRepository $userRepository, ) { } @@ -30,4 +34,10 @@ public function isTagBanned(string $tag): bool return $hashtag->banned; } + + public function doNewUsersNeedApproval(): bool + { + // show the signup requests page even if they are deactivated if there are any remaining + return $this->settingsManager->getNewUsersNeedApproval() || $this->userRepository->findAllSignupRequestsPaginated()->count() > 0; + } } diff --git a/templates/_email/application_approved.html.twig b/templates/_email/application_approved.html.twig new file mode 100644 index 000000000..7d5dc2d92 --- /dev/null +++ b/templates/_email/application_approved.html.twig @@ -0,0 +1,19 @@ +{% extends '_email/email_base.html.twig' %} + +{%- block title -%} + {{- 'email_application_approved_title'|trans }} +{%- endblock -%} + +{% block body %} +

+ {{ 'email_application_approved_body'|trans({ + '%link%': url('app_login'), + '%siteName%': kbin_domain(), + })|raw }} +

+ {% if user.isVerified is same as false %} +

+ {{ 'email_verification_pending'|trans }} +

+ {% endif %} +{% endblock %} diff --git a/templates/_email/application_rejected.html.twig b/templates/_email/application_rejected.html.twig new file mode 100644 index 000000000..b6c2af98c --- /dev/null +++ b/templates/_email/application_rejected.html.twig @@ -0,0 +1,11 @@ +{% extends '_email/email_base.html.twig' %} + +{%- block title -%} + {{- 'email_application_rejected_title'|trans }} +{%- endblock -%} + +{% block body %} +

+ {{ 'email_application_rejected_body'|trans }} +

+{% endblock %} diff --git a/templates/_email/confirmation_email.html.twig b/templates/_email/confirmation_email.html.twig index 6dd61d421..25ad777dd 100644 --- a/templates/_email/confirmation_email.html.twig +++ b/templates/_email/confirmation_email.html.twig @@ -10,6 +10,11 @@

{{ 'email_verify'|trans }}

+ {% if user.getApplicationStatus() is not same as enum('App\\Enums\\EApplicationStatus').Approved %} +

+ {{ 'email_application_pending'|trans }} +

+ {% endif %}

{{ 'email_confirm_expire'|trans }}

Cheers!

-{% endblock %} \ No newline at end of file +{% endblock %} diff --git a/templates/admin/_options.html.twig b/templates/admin/_options.html.twig index 29fdb21b6..d03643605 100644 --- a/templates/admin/_options.html.twig +++ b/templates/admin/_options.html.twig @@ -41,6 +41,14 @@ {{ 'ownership_requests'|trans }} + {% if do_new_users_need_approval() %} +
  • + +
  • + {% endif %}
  • diff --git a/templates/admin/settings.html.twig b/templates/admin/settings.html.twig index 80dc0d6ad..6c081bd88 100644 --- a/templates/admin/settings.html.twig +++ b/templates/admin/settings.html.twig @@ -91,6 +91,10 @@ {{ form_label(form.MBIN_SSO_SHOW_FIRST, 'sso_show_first') }} {{ form_widget(form.MBIN_SSO_SHOW_FIRST) }} +
    + {{ form_label(form.MBIN_NEW_USERS_NEED_APPROVAL, 'new_users_need_approval') }} + {{ form_widget(form.MBIN_NEW_USERS_NEED_APPROVAL) }} +
    {{ form_row(form.submit, {label: 'save', attr: {class: 'btn btn__primary'}}) }}
    diff --git a/templates/admin/signup_requests.html.twig b/templates/admin/signup_requests.html.twig new file mode 100644 index 000000000..b4d325adb --- /dev/null +++ b/templates/admin/signup_requests.html.twig @@ -0,0 +1,52 @@ +{% extends 'base.html.twig' %} + +{%- block title -%} + {{- 'signup_requests'|trans }} - {{ parent() -}} +{%- endblock -%} + +{% block mainClass %}page-admin-federation{% endblock %} + +{% block header_nav %} +{% endblock %} + +{% block sidebar_top %} +{% endblock %} + +{% block body %} + {% include 'admin/_options.html.twig' %} +
    +

    {{ 'signup_requests_header'|trans }}

    +

    {{ 'signup_requests_paragraph'|trans }}

    +
    + {% if requests|length %} + {% for request in requests %} +
    +
    + {{ component('user_inline', {user: request}) }}, + {{ component('date', {date: request.createdAt}) }} +
    +
    + {{ request.applicationText }} +
    +
    +
    + + +
    +
    + + +
    +
    +
    + {% endfor %} + {% else %} + + {% endif %} +{% endblock %} diff --git a/templates/user/register.html.twig b/templates/user/register.html.twig index ae2561e98..e0ba605dc 100644 --- a/templates/user/register.html.twig +++ b/templates/user/register.html.twig @@ -27,6 +27,11 @@ {{ form_row(form.username, { label: 'username', }) }} + {% if do_new_users_need_approval() %} + {{ form_row(form.applicationText, { + label: 'application_text', + }) }} + {% endif %} {{ form_row(form.email, { label: 'email' }) }} diff --git a/tests/FactoryTrait.php b/tests/FactoryTrait.php index 91f595fb7..c57b828d5 100644 --- a/tests/FactoryTrait.php +++ b/tests/FactoryTrait.php @@ -185,7 +185,7 @@ public static function createOAuth2ClientCredsClient(): void $userDto->email = 'test@kbin.test'; $userDto->plainPassword = hash('sha512', random_bytes(32)); $userDto->isBot = true; - $user = $userManager->create($userDto, false, false); + $user = $userManager->create($userDto, false, false, true); $client->setUser($user); $client->setDescription('An OAuth2 client for testing purposes'); diff --git a/translations/messages.en.yaml b/translations/messages.en.yaml index f43495b9a..a5952aad2 100644 --- a/translations/messages.en.yaml +++ b/translations/messages.en.yaml @@ -437,6 +437,8 @@ your_account_is_not_active: Your account has not been activated. Please check yo email for account activation instructions or
    request a new account activation email. your_account_has_been_banned: Your account has been banned +your_account_is_not_yet_approved: Your account has not been approved yet. + We will send you an email as soon as the admins have processed your signup request. toolbar.bold: Bold toolbar.italic: Italic toolbar.strikethrough: Strikethrough @@ -919,3 +921,16 @@ search_type_all: Threads + Microblogs search_type_entry: Threads search_type_post: Microblogs select_user: Choose a user +new_users_need_approval: New users have to be approved by an admin before they can log in. +signup_requests: Signup requests +application_text: Application text +signup_requests_header: Signup Requests +signup_requests_paragraph: These users would like to join your server. They cannot log in until you've approved their signup request. +flash_application_info: An admin needs to approve your account before you can log in. + You will receive an email when they processed your signup request. +email_application_approved_title: Your signup request was approved +email_application_approved_body: Your signup request was approved by the admins. You can log into the server at %siteName%. +email_application_rejected_title: Your signup request was declined +email_application_rejected_body: Thank you for your interest, but we regret to inform you that your signup request has been declined. +email_application_pending: Your account requires admin approval before you can log in. +email_verification_pending: You have to verify your email address before you can log in.