From c555bb519c122a4ecda9fd506d9fe8fde0c7ddcb Mon Sep 17 00:00:00 2001 From: Tom Udding Date: Sun, 30 Aug 2026 14:40:01 +0200 Subject: [PATCH 1/5] feat(user): let a member change their own details Closes GH-118 --- config/packages/messenger.yaml | 4 + config/reference.php | 4 +- migrations/database/Version20260923140501.php | 46 ++ phpstan-baseline.neon | 12 - src/Controller/User/EmailChangeController.php | 196 ++++++++ .../User/MemberDetailsController.php | 414 +++++++++++++++++ .../Mailing/MailingListFixture.php | 9 +- .../Mailing/MailingListMembershipFixture.php | 31 ++ src/Entity/Database/ActionLink.php | 1 + src/Entity/Database/AuditAddressChange.php | 69 +++ src/Entity/Database/AuditEmailChange.php | 62 +++ src/Entity/Database/AuditEntry.php | 2 + src/Entity/Database/EmailChangeLink.php | 68 +++ .../Enums/MailingListMemberOrigin.php | 2 + .../Database/Enums/MemberDetailAction.php | 37 ++ src/Entity/Database/MailingList.php | 12 + .../Database/MemberEmailChangeListener.php | 187 ++++++++ src/Form/Database/MailingListLabel.php | 34 +- src/Form/Database/MailingListType.php | 13 + src/Form/Database/MemberListsType.php | 88 +++- src/Form/User/MemberEmailChangeType.php | 65 +++ .../Database/EmailChangeConfirmationEmail.php | 30 ++ .../Database/EmailChangedNoticeEmail.php | 33 ++ .../EmailChangeConfirmationEmailHandler.php | 57 +++ .../EmailChangedNoticeEmailHandler.php | 45 ++ .../Database/ActionLinkRepository.php | 26 ++ .../Database/AuditAddressChangeRepository.php | 23 + .../Database/AuditEmailChangeRepository.php | 23 + .../Database/EmailChangeLinkRepository.php | 40 ++ .../Database/MailingListRepository.php | 11 + src/Service/Database/ActionLinkService.php | 23 + src/Service/Database/Member.php | 144 +++++- .../email/email-change-confirm.html.twig | 24 + .../database/email/email-changed.html.twig | 22 + .../database/mailing/list/form.html.twig | 1 + templates/partials/member-sidebar.html.twig | 1 + templates/user/email-change.html.twig | 40 ++ .../settings/details-address-remove.html.twig | 35 ++ .../user/settings/details-address.html.twig | 41 ++ templates/user/settings/details.html.twig | 142 ++++++ tests/Entity/Database/EmailChangeLinkTest.php | 71 +++ .../User/EmailChangeControllerTest.php | 255 +++++++++++ .../User/MemberDetailsControllerTest.php | 421 ++++++++++++++++++ .../MemberEmailChangeListenerTest.php | 152 +++++++ .../Database/EmailChangeEmailsTest.php | 147 ++++++ .../Database/ActionLinkServiceTest.php | 105 +++++ translations/AutocompleteBundle.en.xlf | 26 ++ translations/AutocompleteBundle.nl.xlf | 26 ++ translations/messages.en.xlf | 112 +++++ translations/messages.nl.xlf | 112 +++++ 50 files changed, 3487 insertions(+), 57 deletions(-) create mode 100644 migrations/database/Version20260923140501.php create mode 100644 src/Controller/User/EmailChangeController.php create mode 100644 src/Controller/User/MemberDetailsController.php create mode 100644 src/Entity/Database/AuditAddressChange.php create mode 100644 src/Entity/Database/AuditEmailChange.php create mode 100644 src/Entity/Database/EmailChangeLink.php create mode 100644 src/Entity/Database/Enums/MemberDetailAction.php create mode 100644 src/EventListener/Database/MemberEmailChangeListener.php create mode 100644 src/Form/User/MemberEmailChangeType.php create mode 100644 src/Message/Database/EmailChangeConfirmationEmail.php create mode 100644 src/Message/Database/EmailChangedNoticeEmail.php create mode 100644 src/MessageHandler/Database/EmailChangeConfirmationEmailHandler.php create mode 100644 src/MessageHandler/Database/EmailChangedNoticeEmailHandler.php create mode 100644 src/Repository/Database/AuditAddressChangeRepository.php create mode 100644 src/Repository/Database/AuditEmailChangeRepository.php create mode 100644 src/Repository/Database/EmailChangeLinkRepository.php create mode 100644 templates/database/email/email-change-confirm.html.twig create mode 100644 templates/database/email/email-changed.html.twig create mode 100644 templates/user/email-change.html.twig create mode 100644 templates/user/settings/details-address-remove.html.twig create mode 100644 templates/user/settings/details-address.html.twig create mode 100644 templates/user/settings/details.html.twig create mode 100644 tests/Entity/Database/EmailChangeLinkTest.php create mode 100644 tests/Integration/Controller/User/EmailChangeControllerTest.php create mode 100644 tests/Integration/Controller/User/MemberDetailsControllerTest.php create mode 100644 tests/Integration/EventListener/Database/MemberEmailChangeListenerTest.php create mode 100644 tests/Integration/MessageHandler/Database/EmailChangeEmailsTest.php create mode 100644 tests/Service/Database/ActionLinkServiceTest.php create mode 100644 translations/AutocompleteBundle.en.xlf create mode 100644 translations/AutocompleteBundle.nl.xlf diff --git a/config/packages/messenger.yaml b/config/packages/messenger.yaml index 16bac33d7..6140825fd 100644 --- a/config/packages/messenger.yaml +++ b/config/packages/messenger.yaml @@ -145,6 +145,10 @@ framework: # which Stripe repeats the same event) or a page that hangs on the MTA. 'App\Message\Database\RegistrationUpdateEmail': high_priority 'App\Message\Database\RefundProblemEmail': high_priority + # A member waiting on a page for a confirmation link, and the address that has just been replaced hearing + # that it was, are both worth little late. + 'App\Message\Database\EmailChangeConfirmationEmail': high_priority + 'App\Message\Database\EmailChangedNoticeEmail': high_priority 'App\Message\User\ExportUserDataMessage': bulk 'App\Message\User\RevokeSessionsRealtimeMessage': high_priority # A security notice is worth little late, and it must not delay whatever raised it. diff --git a/config/reference.php b/config/reference.php index d42deda65..0004a15cb 100644 --- a/config/reference.php +++ b/config/reference.php @@ -1846,8 +1846,8 @@ * } * @psalm-type MercureConfig = array{ * hubs?: arrayaddSql('ALTER TABLE ActionLink ADD newEmail VARCHAR(255) DEFAULT NULL'); + $this->addSql('ALTER TABLE ActionLink ADD previousEmail VARCHAR(255) DEFAULT NULL'); + $this->addSql('ALTER TABLE ActionLink ADD requestedOn TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL'); + $this->addSql('ALTER TABLE AuditEntry ADD addressType VARCHAR(255) DEFAULT NULL'); + $this->addSql('ALTER TABLE AuditEntry ADD detailAction VARCHAR(255) DEFAULT NULL'); + $this->addSql('ALTER TABLE AuditEntry ADD oldEmail VARCHAR(255) DEFAULT NULL'); + $this->addSql('ALTER TABLE AuditEntry ADD newEmail VARCHAR(255) DEFAULT NULL'); + $this->addSql('ALTER TABLE MailingList ADD selfService BOOLEAN DEFAULT false NOT NULL'); + } + + public function down(Schema $schema): void + { + $this->addSql('ALTER TABLE ActionLink DROP newEmail'); + $this->addSql('ALTER TABLE ActionLink DROP previousEmail'); + $this->addSql('ALTER TABLE ActionLink DROP requestedOn'); + $this->addSql('ALTER TABLE AuditEntry DROP addressType'); + $this->addSql('ALTER TABLE AuditEntry DROP detailAction'); + $this->addSql('ALTER TABLE AuditEntry DROP oldEmail'); + $this->addSql('ALTER TABLE AuditEntry DROP newEmail'); + $this->addSql('ALTER TABLE MailingList DROP selfService'); + } +} diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index 2de6d9494..63b24b4f8 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -1164,24 +1164,12 @@ parameters: count: 1 path: src/Service/Database/MailmanService.php - - - message: '#^Cannot access property \$email on App\\Entity\\Database\\MailingListMember\|null\.$#' - identifier: property.nonObject - count: 1 - path: src/Service/Database/Member.php - - message: '#^Cannot access property \$endDate on App\\Entity\\Database\\Membership\|null\.$#' identifier: property.nonObject count: 3 path: src/Service/Database/Member.php - - - message: '#^Cannot access property \$toBeDeleted on App\\Entity\\Database\\MailingListMember\|null\.$#' - identifier: property.nonObject - count: 1 - path: src/Service/Database/Member.php - - message: '#^Cannot access property \$type on App\\Entity\\Database\\Membership\|null\.$#' identifier: property.nonObject diff --git a/src/Controller/User/EmailChangeController.php b/src/Controller/User/EmailChangeController.php new file mode 100644 index 000000000..7643d7813 --- /dev/null +++ b/src/Controller/User/EmailChangeController.php @@ -0,0 +1,196 @@ + '[0-9a-f]{32}\.[0-9a-f]{96}'], + methods: ['GET'], + )] + public function claim(string $token): Response + { + $link = $this->actionLinkService->resolveEmailChange($token); + + if (null === $link) { + return $this->withNoLeakHeaders($this->render( + 'user/email-change.html.twig', + ['link' => null], + )); + } + + return $this->withNoLeakHeaders($this->redirectToRoute( + 'user_email_change_confirm', + ['th' => $this->actionLinkService->claim($link)], + )); + } + + #[Route( + path: '/user/email-change', + name: 'user_email_change_confirm', + methods: ['GET'], + )] + public function confirm(Request $request): Response + { + $tempHash = (string) $request->query->get( + 'th', + '', + ); + + if ('' !== $tempHash) { + $claimed = $this->actionLinkService->findByTempHash($tempHash); + + if ($claimed instanceof EmailChangeLink) { + // Single-use: spent as the page is handed over rather than once it is acted on. + $this->actionLinkService->consumeTempHash($claimed); + + $request->getSession()->set( + self::SESSION_KEY, + $claimed->id, + ); + } + + return $this->withNoLeakHeaders($this->redirectToRoute('user_email_change_confirm')); + } + + $this->denyAccessUnlessGranted( + UserRoles::User->value, + message: 'You are not allowed to change these details.', + ); + + $user = $this->getUser(); + + return $this->withNoLeakHeaders($this->render( + 'user/email-change.html.twig', + [ + 'link' => $user instanceof User + ? $this->pendingLink( + $request, + $user, + ) + : null, + ], + )); + } + + #[IsGranted( + attribute: UserRoles::User->value, + message: 'You are not allowed to change these details.', + )] + #[IsCsrfTokenValid( + id: 'email_change', + tokenKey: '_csrf_token', + )] + #[Route( + path: '/user/email-change', + name: 'user_email_change_apply', + methods: ['POST'], + )] + public function apply( + Request $request, + #[CurrentUser] + User $user, + ): Response { + $link = $this->pendingLink( + $request, + $user, + ); + + if (null === $link) { + return $this->withNoLeakHeaders($this->render( + 'user/email-change.html.twig', + ['link' => null], + )); + } + + $previousEmail = $link->previousEmail; + $member = $this->memberService->confirmEmailChange($link); + + $request->getSession()->remove(self::SESSION_KEY); + + // The replaced address is the one that still reaches a member whose account was taken. + if (null !== $previousEmail) { + $this->bus->dispatch(new EmailChangedNoticeEmail( + $member->lidnr, + $previousEmail, + $link->newEmail, + )); + } + + $this->addFlash( + AlertTypes::Success->value, + $this->translator->trans('Your e-mail address has been changed. You now sign in with it as well.'), + ); + + return $this->redirectToRoute('user_settings_details_index'); + } + + private function pendingLink( + Request $request, + User $user, + ): ?EmailChangeLink { + $linkId = $request->getSession()->get(self::SESSION_KEY); + + if (!is_int($linkId)) { + return null; + } + + $link = $this->emailChangeLinkRepository->find($linkId); + + if ( + null === $link + || $link->used + || $link->linkExpired() + || $link->member->lidnr !== $user->member->lidnr + ) { + $request->getSession()->remove(self::SESSION_KEY); + + return null; + } + + return $link; + } +} diff --git a/src/Controller/User/MemberDetailsController.php b/src/Controller/User/MemberDetailsController.php new file mode 100644 index 000000000..1e3f3e76d --- /dev/null +++ b/src/Controller/User/MemberDetailsController.php @@ -0,0 +1,414 @@ +value, + message: 'You are not allowed to change these details.', +)] +#[Route( + path: '/user/settings/details', + name: 'user_settings_details_', +)] +class MemberDetailsController extends AbstractController +{ + public function __construct( + private readonly TranslatorInterface $translator, + private readonly MemberService $memberService, + private readonly MemberRepository $memberRepository, + private readonly MailingListRepository $mailingListRepository, + private readonly MessageBusInterface $bus, + ) { + } + + #[Route( + path: '', + name: 'index', + methods: [ + 'GET', + 'POST', + ], + )] + public function index( + Request $request, + #[CurrentUser] + User $user, + ): Response { + $member = $this->member($user); + $syncLocked = $this->memberService->isMailingListSyncLocked(); + $selfServiceLists = $this->mailingListRepository->findAllSelfService(); + + $emailForm = $this->createForm(MemberEmailChangeType::class)->handleRequest($request); + + if ($emailForm->isSubmitted()) { + $response = $this->handleEmailChange( + $emailForm, + $member, + ); + + if (null !== $response) { + return $response; + } + } + + $listsForm = $this->createForm( + MemberListsType::class, + null, + [ + 'member' => $member, + 'lists' => $selfServiceLists, + // What a list is for is worth reading before ticking it, and the panel already says these are the + // mailing lists. + 'describe' => true, + ], + )->handleRequest($request); + + if ( + $listsForm->isSubmitted() + && $listsForm->isValid() + ) { + $response = $this->handleSubscriptions( + $listsForm, + $member, + $selfServiceLists, + ); + + if (null !== $response) { + return $response; + } + } + + return $this->render( + 'user/settings/details.html.twig', + [ + 'member' => $member, + 'emailForm' => $emailForm, + 'listsForm' => $listsForm, + // While a synchronisation is running the pending states on the subscriptions are being turned into + // real ones, so what the page would submit could not be saved anyway. + 'syncLocked' => $syncLocked, + 'selfServiceLists' => $selfServiceLists, + 'addresses' => $this->addresses($member), + ], + ); + } + + /** + * The three addresses a member can have on file, in the order the page shows them, each with what is on file for + * it or null. The register holds them as a collection of whichever ones exist, and a page that has to offer + * adding one needs to know which are missing. + * + * @return list + */ + private function addresses(MemberModel $member): array + { + $addresses = []; + + foreach (AddressTypes::cases() as $type) { + $addresses[] = [ + 'type' => $type, + 'address' => $this->memberService->getAddress( + $member, + $type, + ), + ]; + } + + return $addresses; + } + + /** + * Add or correct one of the three addresses. Which one is being written follows from the address of the page, + * never from what is submitted. + */ + #[Route( + path: '/address/{type}', + name: 'address', + requirements: ['type' => 'home|student|mail'], + methods: [ + 'GET', + 'POST', + ], + )] + public function address( + Request $request, + AddressTypes $type, + #[CurrentUser] + User $user, + ): Response { + $member = $this->member($user); + $existing = $this->memberService->getAddress( + $member, + $type, + ); + $address = $existing ?? $this->memberService->getAddress( + $member, + $type, + true, + ); + + $form = $this->createForm( + AddressEditType::class, + $address, + )->handleRequest($request); + + if ( + $form->isSubmitted() + && $form->isValid() + ) { + if (null === $existing) { + $this->memberService->addAddress($form); + } else { + $this->memberService->editAddress($form); + } + + $this->addFlash( + AlertTypes::Success->value, + $this->translator->trans('Your address has been saved.'), + ); + + return $this->redirectToRoute('user_settings_details_index'); + } + + return $this->render( + 'user/settings/details-address.html.twig', + [ + 'add' => null === $existing, + 'addressType' => $type, + 'form' => $form, + ], + ); + } + + #[Route( + path: '/address/{type}/remove', + name: 'address_remove', + requirements: ['type' => 'home|student|mail'], + methods: [ + 'GET', + 'POST', + ], + )] + public function removeAddress( + Request $request, + AddressTypes $type, + #[CurrentUser] + User $user, + ): Response { + $member = $this->member($user); + + if ( + null === $this->memberService->getAddress( + $member, + $type, + ) + ) { + throw $this->createNotFoundException(); + } + + $form = $this->createForm(DeleteAddressType::class)->handleRequest($request); + + // `isValid()` before the button: a clicked button says nothing about the token. + if ( + $form->isSubmitted() + && $form->isValid() + ) { + if ( + SubmitButtons::clicked( + $form, + 'submit_yes', + ) + ) { + $this->memberService->removeAddress( + $member, + $type, + $form, + ); + + $this->addFlash( + AlertTypes::Success->value, + $this->translator->trans('Your address has been removed.'), + ); + } + + return $this->redirectToRoute('user_settings_details_index'); + } + + return $this->render( + 'user/settings/details-address-remove.html.twig', + [ + 'addressType' => $type, + 'form' => $form, + ], + ); + } + + /** + * The member the account belongs to, as the register holds them. + * + * The account carries the projection's member, which is what the website reads; what this page writes is the + * ledger, so it asks the register for the same member. An account without one cannot exist -- it is removed with + * the member -- so a member that is not there is a broken installation rather than a page to render. + */ + private function member(User $user): MemberModel + { + $member = $this->memberRepository->find($user->member->lidnr); + + if (null === $member) { + throw $this->createNotFoundException(); + } + + return $member; + } + + /** + * Answers a response when the page is done with, and null when the form should be rendered again with what is + * wrong with it. + * + * @param FormInterface|null> $form + */ + private function handleEmailChange( + FormInterface $form, + MemberModel $member, + ): ?Response { + if (!$form->isValid()) { + return null; + } + + $email = (string) $form->get('email')->getData(); + + if ($email === $member->email) { + $form->get('email')->addError(new FormError( + $this->translator->trans('This is already your e-mail address.'), + )); + + return null; + } + + // Two records answering to one address cannot both be reached, so an address that is taken is refused. This + // says only that it is taken, not by whom. + if ( + $this->memberService->emailBelongsToSomeoneElse( + $email, + $member, + ) + ) { + $form->get('email')->addError(new FormError( + $this->translator->trans('There already is a member with this e-mail address.'), + )); + + return null; + } + + $link = $this->memberService->requestEmailChange( + $member, + $email, + ); + + // The token exists for this request only: the register keeps a hash of it, so it goes into the message now or + // it is gone. + $this->bus->dispatch(new EmailChangeConfirmationEmail( + $member->lidnr, + $email, + (string) $link->plainToken, + )); + + $this->addFlash( + AlertTypes::Info->value, + $this->translator->trans( + // phpcs:ignore -- user-visible strings should not be split + 'We have sent a message to your new e-mail address. Your address changes once you have followed the link in it.', + ), + ); + + return $this->redirectToRoute('user_settings_details_index'); + } + + /** + * @param FormInterface|null> $form + * @param array $selfServiceLists + */ + private function handleSubscriptions( + FormInterface $form, + MemberModel $member, + array $selfServiceLists, + ): ?Response { + $data = $form->getData(); + /** @var string[] $selected */ + $selected = $data['lists'] ?? []; + + $saved = $this->memberService->updateSubscriptions( + $member, + $selected, + array_map( + static fn (MailingListModel $list): string => $list->name, + $selfServiceLists, + ), + MailingListMemberOrigin::SelfService, + ); + + // A sync that started while the page was open takes precedence, and the subscriptions are left alone. + if (null === $saved) { + $this->addFlash( + AlertTypes::Danger->value, + $this->translator->trans( + // phpcs:ignore -- user-visible strings should not be split + 'Your subscriptions could not be saved because they are being synchronised. Please try again later.', + ), + ); + + return null; + } + + $this->addFlash( + AlertTypes::Success->value, + $this->translator->trans('Your subscriptions have been saved.'), + ); + + return $this->redirectToRoute('user_settings_details_index'); + } +} diff --git a/src/DataFixtures/Mailing/MailingListFixture.php b/src/DataFixtures/Mailing/MailingListFixture.php index c4ee3a43c..e6bf1b912 100644 --- a/src/DataFixtures/Mailing/MailingListFixture.php +++ b/src/DataFixtures/Mailing/MailingListFixture.php @@ -35,8 +35,9 @@ class MailingListFixture extends Fixture implements FixtureGroupInterface public const string REF_LIST_ACTIVITIES = 'list-activities'; /** - * Name, descriptions, whether the registration form offers it, whether it is ticked, and which server it is on: - * the Listmonk id the seed gives it, or null where Mailman has it instead. + * Name, descriptions, whether the registration form offers it, whether it is ticked, whether a member may put + * themselves on it afterwards, and which server it is on: the Listmonk id the seed gives it, or null where + * Mailman has it instead. */ private const array LISTS = [ [ @@ -46,6 +47,7 @@ class MailingListFixture extends Fixture implements FixtureGroupInterface // Not offered as a choice: every member is on it, which is what the registration form says. 'on_form' => false, 'default' => true, + 'self_service' => false, 'listmonk' => null, 'reference' => self::REF_LIST_ANNOUNCEMENTS, ], @@ -55,6 +57,7 @@ class MailingListFixture extends Fixture implements FixtureGroupInterface 'en' => 'Announcements of activities.', 'on_form' => true, 'default' => true, + 'self_service' => true, 'listmonk' => 2, 'reference' => self::REF_LIST_ACTIVITIES, ], @@ -64,6 +67,7 @@ class MailingListFixture extends Fixture implements FixtureGroupInterface 'en' => 'Vacancies and career opportunities from our partners.', 'on_form' => true, 'default' => false, + 'self_service' => true, 'listmonk' => 3, 'reference' => null, ], @@ -89,6 +93,7 @@ public function load(ObjectManager $manager): void $list->setEnDescription($definition['en']); $list->onForm = $definition['on_form']; $list->defaultSub = $definition['default']; + $list->selfService = $definition['self_service']; if (null === $definition['listmonk']) { $mailman = new MailmanMailingList(); diff --git a/src/DataFixtures/Mailing/MailingListMembershipFixture.php b/src/DataFixtures/Mailing/MailingListMembershipFixture.php index e415e090c..3643b2d9c 100644 --- a/src/DataFixtures/Mailing/MailingListMembershipFixture.php +++ b/src/DataFixtures/Mailing/MailingListMembershipFixture.php @@ -5,6 +5,7 @@ namespace App\DataFixtures\Mailing; use App\DataFixtures\Member\MemberFixture; +use App\DataFixtures\Member\MemberPopulationFixture; use App\Entity\Database\MailingList; use App\Entity\Database\MailingListMember; use App\Entity\Database\Member as MemberModel; @@ -16,6 +17,8 @@ use LogicException; use Override; +use function sprintf; + /** * Who is on which list. * @@ -76,6 +79,33 @@ public function load(ObjectManager $manager): void $manager->persist($this->subscribe($activities, $student)); + // 8000 gets one list a member manages and one they do not, so the settings page shows both cases. + $signIn = $this->getReference( + sprintf( + 'ledger-member-%d', + MemberPopulationFixture::ADMIN, + ), + MemberModel::class, + ); + + $carriedAcross = $this->subscribe( + $activities, + $signIn, + ); + $carriedAcross->toBeCreated = false; + $carriedAcross->setLastSyncOn(); + $carriedAcross->lastSyncSuccess = true; + $manager->persist($carriedAcross); + + $announced = $this->subscribe( + $announcements, + $signIn, + ); + $announced->toBeCreated = false; + $announced->setLastSyncOn(); + $announced->lastSyncSuccess = true; + $manager->persist($announced); + $manager->flush(); } @@ -100,6 +130,7 @@ public function getDependencies(): array return [ MailingListFixture::class, MemberFixture::class, + MemberPopulationFixture::class, ]; } diff --git a/src/Entity/Database/ActionLink.php b/src/Entity/Database/ActionLink.php index 39c13240e..41781e9d1 100644 --- a/src/Entity/Database/ActionLink.php +++ b/src/Entity/Database/ActionLink.php @@ -35,6 +35,7 @@ value: [ 'payment' => PaymentLink::class, 'renewal' => RenewalLink::class, + 'email_change' => EmailChangeLink::class, ], )] #[Index( diff --git a/src/Entity/Database/AuditAddressChange.php b/src/Entity/Database/AuditAddressChange.php new file mode 100644 index 000000000..ca20061d7 --- /dev/null +++ b/src/Entity/Database/AuditAddressChange.php @@ -0,0 +1,69 @@ +%s address of %s was %s'; + + #[Column( + type: 'string', + enumType: AddressTypes::class, + )] + public AddressTypes $addressType; + + /** + * Named apart from the `action` of a mailing list subscription, which is a column of the same table. + */ + #[Column( + type: 'string', + enumType: MemberDetailAction::class, + )] + public MemberDetailAction $detailAction; + + public static function create( + Member $member, + AddressTypes $addressType, + MemberDetailAction $action, + ?Member $user = null, + ): self { + $audit = new self(); + $audit->setMember($member); + $audit->addressType = $addressType; + $audit->detailAction = $action; + $audit->user = $user; + + return $audit; + } + + #[Override] + protected function getStringBodyFormatted(): string + { + return self::BODY_FORMAT; + } + + /** + * @return array + */ + #[Override] + protected function getStringArguments(): array + { + return [ + $this->addressType->value, + $this->member?->getFullName() ?? '-', + $this->detailAction->value, + ]; + } +} diff --git a/src/Entity/Database/AuditEmailChange.php b/src/Entity/Database/AuditEmailChange.php new file mode 100644 index 000000000..fb3897195 --- /dev/null +++ b/src/Entity/Database/AuditEmailChange.php @@ -0,0 +1,62 @@ +Changed e-mail address of %s from ' + . '%s to %s'; + + #[Column( + type: 'string', + nullable: true, + )] + public ?string $oldEmail = null; + + #[Column(type: 'string')] + public string $newEmail; + + public static function create( + Member $member, + ?string $oldEmail, + string $newEmail, + ?Member $user = null, + ): self { + $audit = new self(); + $audit->setMember($member); + $audit->oldEmail = $oldEmail; + $audit->newEmail = $newEmail; + $audit->user = $user; + + return $audit; + } + + #[Override] + protected function getStringBodyFormatted(): string + { + return self::BODY_FORMAT; + } + + /** + * @return array + */ + #[Override] + protected function getStringArguments(): array + { + return [ + $this->member?->getFullName() ?? '-', + $this->oldEmail ?? '-', + $this->newEmail, + ]; + } +} diff --git a/src/Entity/Database/AuditEntry.php b/src/Entity/Database/AuditEntry.php index e46d291a0..5d932f7dc 100644 --- a/src/Entity/Database/AuditEntry.php +++ b/src/Entity/Database/AuditEntry.php @@ -34,6 +34,8 @@ )] #[DiscriminatorMap( value: [ + 'address_change' => AuditAddressChange::class, + 'email_change' => AuditEmailChange::class, 'mailing_list_membership' => AuditMailingListMembership::class, 'note' => AuditNote::class, 'renewal' => AuditRenewal::class, diff --git a/src/Entity/Database/EmailChangeLink.php b/src/Entity/Database/EmailChangeLink.php new file mode 100644 index 000000000..527148a18 --- /dev/null +++ b/src/Entity/Database/EmailChangeLink.php @@ -0,0 +1,68 @@ +member = $member; + $this->newEmail = $newEmail; + $this->previousEmail = $member->email; + $this->requestedOn = new DateTimeImmutable(); + } + + public function getExpiresAt(): DateTimeImmutable + { + return $this->requestedOn->add(new DateInterval(self::LIFETIME)); + } + + #[Override] + public function linkExpired(): bool + { + return $this->getExpiresAt() <= new DateTimeImmutable(); + } +} diff --git a/src/Entity/Database/Enums/MailingListMemberOrigin.php b/src/Entity/Database/Enums/MailingListMemberOrigin.php index d25482aaf..14b39a12b 100644 --- a/src/Entity/Database/Enums/MailingListMemberOrigin.php +++ b/src/Entity/Database/Enums/MailingListMemberOrigin.php @@ -12,6 +12,7 @@ enum MailingListMemberOrigin: string implements TranslatableInterface { case Manual = 'manual'; + case SelfService = 'self_service'; case SyncMailman = 'sync_mailman'; case SyncListmonk = 'sync_listmonk'; @@ -23,6 +24,7 @@ public function getName(): TranslatableMessage { return match ($this) { self::Manual => new TranslatableMessage('manual'), + self::SelfService => new TranslatableMessage('by the member'), self::SyncMailman => new TranslatableMessage('mailman sync'), self::SyncListmonk => new TranslatableMessage('listmonk sync'), }; diff --git a/src/Entity/Database/Enums/MemberDetailAction.php b/src/Entity/Database/Enums/MemberDetailAction.php new file mode 100644 index 000000000..5868d03e0 --- /dev/null +++ b/src/Entity/Database/Enums/MemberDetailAction.php @@ -0,0 +1,37 @@ + new TranslatableMessage('Added'), + self::Changed => new TranslatableMessage('Changed'), + self::Removed => new TranslatableMessage('Removed'), + }; + } + + #[Override] + public function trans( + TranslatorInterface $translator, + ?string $locale = null, + ): string { + return $this->getName()->trans( + $translator, + $locale, + ); + } +} diff --git a/src/Entity/Database/MailingList.php b/src/Entity/Database/MailingList.php index d6b0cc0d4..f4877ae71 100644 --- a/src/Entity/Database/MailingList.php +++ b/src/Entity/Database/MailingList.php @@ -59,6 +59,16 @@ class MailingList #[Column(type: Types::BOOLEAN)] public bool $defaultSub; + /** + * Whether a member may manage their own subscription. Separate from being on the sign-up form: a list for one + * year is offered when somebody joins, but is not one they may put themselves on later. + */ + #[Column( + type: 'boolean', + options: ['default' => false], + )] + public bool $selfService = false; + /** * The corresponding mailman mailing list */ @@ -188,6 +198,7 @@ public function getAuditEntries(): Collection * en_description: string, * defaultSub: bool, * onForm: bool, + * selfService: bool, * mailmanList: ?string, * listmonkList: ?int, * } @@ -200,6 +211,7 @@ public function toArray(): array 'en_description' => $this->getEnDescription(), 'defaultSub' => $this->defaultSub, 'onForm' => $this->onForm, + 'selfService' => $this->selfService, 'mailmanList' => $this->mailmanList?->mailmanId, 'listmonkList' => $this->listmonkList?->listmonkId, ]; diff --git a/src/EventListener/Database/MemberEmailChangeListener.php b/src/EventListener/Database/MemberEmailChangeListener.php new file mode 100644 index 000000000..64c016c13 --- /dev/null +++ b/src/EventListener/Database/MemberEmailChangeListener.php @@ -0,0 +1,187 @@ +getObjectManager(); + $unitOfWork = $entityManager->getUnitOfWork(); + $metadata = $entityManager->getClassMetadata(MailingListMember::class); + + foreach ($unitOfWork->getScheduledEntityUpdates() as $entity) { + if (!$entity instanceof Member) { + continue; + } + + $changeSet = $unitOfWork->getEntityChangeSet($entity); + + if (!isset($changeSet['email'])) { + continue; + } + + $oldEmail = $changeSet['email'][0] ?? null; + $newEmail = $changeSet['email'][1] ?? null; + + // A member whose address is taken away is being cleared rather than reached somewhere else. + if ( + !is_string($newEmail) + || $oldEmail === $newEmail + ) { + continue; + } + + if (!is_string($oldEmail)) { + // Nothing to carry over, but still something to record. + $this->audit( + $entity, + null, + $newEmail, + $entityManager, + $unitOfWork, + ); + + continue; + } + + $this->repoint( + $entity, + $oldEmail, + $newEmail, + $entityManager, + $unitOfWork, + $metadata, + ); + + $this->audit( + $entity, + $oldEmail, + $newEmail, + $entityManager, + $unitOfWork, + ); + } + } + + /** + * @param ClassMetadata $metadata + */ + private function repoint( + Member $member, + string $oldEmail, + string $newEmail, + EntityManagerInterface $entityManager, + UnitOfWork $unitOfWork, + ClassMetadata $metadata, + ): void { + // Read once: the loop adds to this collection. + $subscriptions = $member->getMailingListMemberships()->toArray(); + + /** @var array $underNewEmail */ + $underNewEmail = []; + foreach ($subscriptions as $subscription) { + if ($newEmail !== $subscription->email) { + continue; + } + + $underNewEmail[$subscription->mailingList->name] = $subscription; + } + + foreach ($subscriptions as $subscription) { + if ( + $oldEmail !== $subscription->email + || $subscription->toBeDeleted + ) { + continue; + } + + $subscription->toBeDeleted = true; + $unitOfWork->recomputeSingleEntityChangeSet( + $metadata, + $subscription, + ); + + $list = $subscription->mailingList; + $existing = $underNewEmail[$list->name] ?? null; + + // The pair (list, address) identifies a row, so one held before is revived rather than written again. + if (null !== $existing) { + $existing->toBeDeleted = false; + $existing->toBeCreated = true; + $unitOfWork->recomputeSingleEntityChangeSet( + $metadata, + $existing, + ); + + continue; + } + + $carried = new MailingListMember(); + $carried->mailingList = $list; + // Sets the address from the member, which by now is the new one. + $carried->setMember($member); + + $entityManager->persist($carried); + $unitOfWork->computeChangeSet( + $metadata, + $carried, + ); + } + } + + private function audit( + Member $member, + ?string $oldEmail, + string $newEmail, + EntityManagerInterface $entityManager, + UnitOfWork $unitOfWork, + ): void { + $user = $this->security->getUser(); + $audit = AuditEmailChange::create( + $member, + $oldEmail, + $newEmail, + $user instanceof User + ? $this->memberRepository->find($user->member->lidnr) + : null, + ); + + $entityManager->persist($audit); + $unitOfWork->computeChangeSet( + $entityManager->getClassMetadata(AuditEmailChange::class), + $audit, + ); + } +} diff --git a/src/Form/Database/MailingListLabel.php b/src/Form/Database/MailingListLabel.php index 7bc2d06a9..857f77277 100644 --- a/src/Form/Database/MailingListLabel.php +++ b/src/Form/Database/MailingListLabel.php @@ -16,18 +16,23 @@ use const ENT_SUBSTITUTE; /** - * Label of a mailing list checkbox on the registration form. + * Label of a mailing list checkbox on the registration form and on a member's own settings page. * * The name goes on its own line above a muted description, so the two read as a heading and its explanation rather * than as one run-on sentence. Both come from the database and are escaped here, as the label is rendered as HTML. * * The description is not a translatable string but a pair of columns on the list itself, so which one to show can * only be decided once the locale the form renders in is known. + * + * A subscription that is waiting to be carried to Mailman or Listmonk says so after the name, because that is why + * the checkbox beside it cannot be moved. */ final readonly class MailingListLabel implements TranslatableInterface { - public function __construct(private MailingList $list) - { + public function __construct( + private MailingList $list, + private ?TranslatableInterface $state = null, + ) { } #[Override] @@ -39,12 +44,27 @@ public function trans( ? $this->list->getEnDescription() : $this->list->getNlDescription(); + $name = htmlspecialchars( + $this->list->name, + ENT_QUOTES | ENT_SUBSTITUTE, + ); + + if (null !== $this->state) { + $name .= sprintf( + ' (%s)', + htmlspecialchars( + $this->state->trans( + $translator, + $locale, + ), + ENT_QUOTES | ENT_SUBSTITUTE, + ), + ); + } + return sprintf( '%s%s', - htmlspecialchars( - $this->list->name, - ENT_QUOTES | ENT_SUBSTITUTE, - ), + $name, htmlspecialchars( $description, ENT_QUOTES | ENT_SUBSTITUTE, diff --git a/src/Form/Database/MailingListType.php b/src/Form/Database/MailingListType.php index 4e6cc42f4..6571facb0 100644 --- a/src/Form/Database/MailingListType.php +++ b/src/Form/Database/MailingListType.php @@ -104,6 +104,19 @@ public function buildForm( ], ); + $builder->add( + 'selfService', + CheckboxType::class, + [ + 'label' => t('Members may manage their own subscription'), + 'help' => t( + // phpcs:ignore -- user-visible strings should not be split + 'Leave this off for a list that belongs to one year or one group, which is offered when somebody joins but is not a list anybody may put themselves on later.', + ), + 'required' => false, + ], + ); + $builder->add( 'mailmanList', EntityType::class, diff --git a/src/Form/Database/MemberListsType.php b/src/Form/Database/MemberListsType.php index 12386e50e..3eacc2de3 100644 --- a/src/Form/Database/MemberListsType.php +++ b/src/Form/Database/MemberListsType.php @@ -15,11 +15,15 @@ use Symfony\Component\Form\FormBuilderInterface; use Symfony\Component\Form\FormEvents; use Symfony\Component\OptionsResolver\OptionsResolver; +use Symfony\Component\Translation\TranslatableMessage; +use Symfony\Contracts\Translation\TranslatableInterface; use Symfony\Contracts\Translation\TranslatorInterface; use function array_combine; +use function array_flip; +use function array_intersect; +use function array_intersect_key; use function array_keys; -use function array_map; use function array_unique; use function array_values; use function in_array; @@ -46,18 +50,32 @@ public function buildForm( FormBuilderInterface $builder, array $options, ): void { + $describe = true === $options['describe']; $subscriptions = $this->subscriptionStates($options['member']); $locked = $this->lockedLists($subscriptions); - $listNames = array_map( - static fn (MailingList $list): string => $list->name, - $this->mailingListRepository->findAll(), + $byName = []; + + foreach ($options['lists'] ?? $this->mailingListRepository->findAll() as $list) { + $byName[$list->name] = $list; + } + + $listNames = array_keys($byName); + // A list that is not on offer is not theirs to leave either, so it is never read as an unsubscribe. + $locked = array_values(array_intersect( + $locked, + $listNames, + )); + $subscriptions = array_intersect_key( + $subscriptions, + array_flip($listNames), ); $builder->add( 'lists', ChoiceType::class, [ - 'label' => t('Lists'), + // The member's own page says what the panel around it is for, so a legend there would say it twice. + 'label' => $describe ? false : t('Lists'), 'expanded' => true, 'multiple' => true, 'required' => false, @@ -66,13 +84,31 @@ public function buildForm( $listNames, ), 'data' => array_keys($subscriptions), + 'label_html' => $describe, // Resolved while the view is built rather than at build time, so the state follows the request locale. - 'choice_label' => function (string $name) use ($subscriptions): string { - if (!isset($subscriptions[$name])) { + 'choice_label' => function ( + string $name, + ) use ( + $subscriptions, + $byName, + $describe, + ): string|TranslatableInterface { + $state = isset($subscriptions[$name]) + ? $this->stateMessage(...$subscriptions[$name]) + : null; + + if ($describe) { + return new MailingListLabel( + $byName[$name], + $state, + ); + } + + if (null === $state) { return $name; } - return $name . ' (' . $this->stateLabel(...$subscriptions[$name]) . ')'; + return $name . ' (' . $state->trans($this->translator) . ')'; }, 'choice_attr' => static function (string $name) use ($locked): array { if ( @@ -87,7 +123,8 @@ public function buildForm( return ['disabled' => true]; }, - 'choice_translation_domain' => false, + // A described label is a translatable object, and only the theme's `trans` renders one. + 'choice_translation_domain' => $describe ? null : false, ], ); @@ -128,6 +165,27 @@ public function configureOptions(OptionsResolver $resolver): void 'member', Member::class, ); + + $resolver->setDefault( + 'describe', + false, + ); + $resolver->setAllowedTypes( + 'describe', + 'bool', + ); + + $resolver->setDefault( + 'lists', + null, + ); + $resolver->setAllowedTypes( + 'lists', + [ + 'null', + MailingList::class . '[]', + ], + ); } /** @@ -173,25 +231,25 @@ private function lockedLists(array $subscriptions): array return $locked; } - private function stateLabel( + private function stateMessage( bool $toBeCreated, bool $toBeDeleted, - ): string { + ): TranslatableMessage { if ( $toBeCreated && $toBeDeleted ) { - return $this->translator->trans('email address change pending'); + return new TranslatableMessage('email address change pending'); } if ($toBeDeleted) { - return $this->translator->trans('to be deleted'); + return new TranslatableMessage('to be deleted'); } if ($toBeCreated) { - return $this->translator->trans('to be created'); + return new TranslatableMessage('to be created'); } - return $this->translator->trans('synced'); + return new TranslatableMessage('synced'); } } diff --git a/src/Form/User/MemberEmailChangeType.php b/src/Form/User/MemberEmailChangeType.php new file mode 100644 index 000000000..e2edce790 --- /dev/null +++ b/src/Form/User/MemberEmailChangeType.php @@ -0,0 +1,65 @@ +|null> + */ +class MemberEmailChangeType extends AbstractType +{ + public function __construct(private readonly LowercaseTransformer $lowercaseTransformer) + { + } + + /** + * @param array $options + */ + #[Override] + public function buildForm( + FormBuilderInterface $builder, + array $options, + ): void { + $builder->add( + 'email', + EmailType::class, + [ + 'label' => t('New e-mail address'), + 'help' => t('We send a message to this address to confirm that it reaches you.'), + 'constraints' => [ + new Assert\NotBlank(), + new Assert\Email(), + new Assert\Length(max: 255), + ], + ], + ); + + $builder->get('email')->addModelTransformer($this->lowercaseTransformer); + + $builder->add( + 'submit', + SubmitType::class, + ['label' => t('Change e-mail address')], + ); + } + + #[Override] + public function configureOptions(OptionsResolver $resolver): void + { + $resolver->setDefaults(['data_class' => null]); + } +} diff --git a/src/Message/Database/EmailChangeConfirmationEmail.php b/src/Message/Database/EmailChangeConfirmationEmail.php new file mode 100644 index 000000000..5b3652bba --- /dev/null +++ b/src/Message/Database/EmailChangeConfirmationEmail.php @@ -0,0 +1,30 @@ +lidnr; + } + + public function getNewEmail(): string + { + return $this->newEmail; + } + + public function getToken(): string + { + return $this->token; + } +} diff --git a/src/Message/Database/EmailChangedNoticeEmail.php b/src/Message/Database/EmailChangedNoticeEmail.php new file mode 100644 index 000000000..505337611 --- /dev/null +++ b/src/Message/Database/EmailChangedNoticeEmail.php @@ -0,0 +1,33 @@ +lidnr; + } + + public function getPreviousEmail(): string + { + return $this->previousEmail; + } + + public function getNewEmail(): string + { + return $this->newEmail; + } +} diff --git a/src/MessageHandler/Database/EmailChangeConfirmationEmailHandler.php b/src/MessageHandler/Database/EmailChangeConfirmationEmailHandler.php new file mode 100644 index 000000000..458e8557f --- /dev/null +++ b/src/MessageHandler/Database/EmailChangeConfirmationEmailHandler.php @@ -0,0 +1,57 @@ +memberRepository->findSimple($message->getLidnr()); + + // Gone between the asking and now: there is no longer anybody to confirm anything for. + if (null === $member) { + return; + } + + $this->emailService->send( + new Address( + $message->getNewEmail(), + $member->getFullName(), + ), + 'Confirm your e-mail address (' . $member->lidnr . ')', + 'database/email/email-change-confirm.html.twig', + [ + 'firstName' => $member->firstName, + 'newEmail' => $message->getNewEmail(), + // The message is in English, so ask for the English page rather than whatever the router holds. + 'url' => $this->urlGenerator->generate( + 'user_email_change_claim', + [ + '_locale' => Languages::English->getLangParam(), + 'token' => $message->getToken(), + ], + UrlGeneratorInterface::ABSOLUTE_URL, + ), + ], + $this->emailService->secretary(), + ); + } +} diff --git a/src/MessageHandler/Database/EmailChangedNoticeEmailHandler.php b/src/MessageHandler/Database/EmailChangedNoticeEmailHandler.php new file mode 100644 index 000000000..b9729288b --- /dev/null +++ b/src/MessageHandler/Database/EmailChangedNoticeEmailHandler.php @@ -0,0 +1,45 @@ +memberRepository->findSimple($message->getLidnr()); + + if (null === $member) { + return; + } + + $this->emailService->send( + new Address( + $message->getPreviousEmail(), + $member->getFullName(), + ), + 'Your e-mail address was changed (' . $member->lidnr . ')', + 'database/email/email-changed.html.twig', + [ + 'firstName' => $member->firstName, + 'previousEmail' => $message->getPreviousEmail(), + 'newEmail' => $message->getNewEmail(), + ], + $this->emailService->secretary(), + ); + } +} diff --git a/src/Repository/Database/ActionLinkRepository.php b/src/Repository/Database/ActionLinkRepository.php index c5bc9aad0..37325d7f0 100644 --- a/src/Repository/Database/ActionLinkRepository.php +++ b/src/Repository/Database/ActionLinkRepository.php @@ -5,6 +5,7 @@ namespace App\Repository\Database; use App\Entity\Database\ActionLink; +use App\Entity\Database\EmailChangeLink; use App\Entity\Database\Member; use App\Entity\Database\PaymentLink; use App\Entity\Database\RenewalLink; @@ -94,6 +95,31 @@ public function findRenewalBySelector(string $selector): ?RenewalLink return $qb->getQuery()->getOneOrNullResult(); } + /** + * As {@see self::findPaymentBySelector()}, for a change of e-mail address. + */ + public function findEmailChangeBySelector(string $selector): ?EmailChangeLink + { + $qb = $this->getEntityManager()->createQueryBuilder(); + $qb->select('el, m') + ->from( + EmailChangeLink::class, + 'el', + ) + ->leftJoin( + 'el.member', + 'm', + ) + ->where('el.selector = :selector'); + + $qb->setParameter( + 'selector', + $selector, + ); + + return $qb->getQuery()->getOneOrNullResult(); + } + public function findByTempHash(string $tempHash): ?ActionLink { return $this->findOneBy(['tempHash' => $tempHash]); diff --git a/src/Repository/Database/AuditAddressChangeRepository.php b/src/Repository/Database/AuditAddressChangeRepository.php new file mode 100644 index 000000000..5ea70a685 --- /dev/null +++ b/src/Repository/Database/AuditAddressChangeRepository.php @@ -0,0 +1,23 @@ + + */ +class AuditAddressChangeRepository extends ServiceEntityRepository +{ + public function __construct(ManagerRegistry $registry) + { + parent::__construct( + $registry, + AuditAddressChange::class, + ); + } +} diff --git a/src/Repository/Database/AuditEmailChangeRepository.php b/src/Repository/Database/AuditEmailChangeRepository.php new file mode 100644 index 000000000..1fc090574 --- /dev/null +++ b/src/Repository/Database/AuditEmailChangeRepository.php @@ -0,0 +1,23 @@ + + */ +class AuditEmailChangeRepository extends ServiceEntityRepository +{ + public function __construct(ManagerRegistry $registry) + { + parent::__construct( + $registry, + AuditEmailChange::class, + ); + } +} diff --git a/src/Repository/Database/EmailChangeLinkRepository.php b/src/Repository/Database/EmailChangeLinkRepository.php new file mode 100644 index 000000000..14cfd5af5 --- /dev/null +++ b/src/Repository/Database/EmailChangeLinkRepository.php @@ -0,0 +1,40 @@ + + */ +class EmailChangeLinkRepository extends ServiceEntityRepository +{ + public function __construct(ManagerRegistry $registry) + { + parent::__construct( + $registry, + EmailChangeLink::class, + ); + } + + /** + * A member who asks again supersedes what they asked before. + */ + public function removeAllForMember(Member $member): void + { + $this->createQueryBuilder('l') + ->delete() + ->where('l.member = :member') + ->setParameter( + 'member', + $member, + ) + ->getQuery() + ->execute(); + } +} diff --git a/src/Repository/Database/MailingListRepository.php b/src/Repository/Database/MailingListRepository.php index b09f003b7..55f16e9e6 100644 --- a/src/Repository/Database/MailingListRepository.php +++ b/src/Repository/Database/MailingListRepository.php @@ -106,6 +106,17 @@ public function findAllOnForm(): array ); } + /** + * @return array + */ + public function findAllSelfService(): array + { + return $this->findBy( + ['selfService' => true], + ['name' => 'ASC'], + ); + } + /** * Find all default * diff --git a/src/Service/Database/ActionLinkService.php b/src/Service/Database/ActionLinkService.php index 43914e60d..eccd75cec 100644 --- a/src/Service/Database/ActionLinkService.php +++ b/src/Service/Database/ActionLinkService.php @@ -5,6 +5,7 @@ namespace App\Service\Database; use App\Entity\Database\ActionLink; +use App\Entity\Database\EmailChangeLink; use App\Entity\Database\PaymentLink; use App\Entity\Database\RenewalLink; use App\Repository\Database\ActionLinkRepository; @@ -75,6 +76,28 @@ public function resolvePayment(string $token): ?PaymentLink return $link; } + public function resolveEmailChange(string $token): ?EmailChangeLink + { + $split = SplitToken::split($token); + + if (null === $split) { + return null; + } + + $link = $this->actionLinkRepository->findEmailChangeBySelector($split['selector']); + + if ( + null === $link + || $link->used + || $link->linkExpired() + || !$link->tokenMatches($split['verifier']) + ) { + return null; + } + + return $link; + } + public function claim(ActionLink $link): string { $tempHash = bin2hex(random_bytes(32)); diff --git a/src/Service/Database/Member.php b/src/Service/Database/Member.php index 734f455ac..fefc47ca3 100644 --- a/src/Service/Database/Member.php +++ b/src/Service/Database/Member.php @@ -5,18 +5,22 @@ namespace App\Service\Database; use App\Entity\Database\Address as AddressModel; +use App\Entity\Database\AuditAddressChange; use App\Entity\Database\AuditEntry as AuditEntryModel; use App\Entity\Database\AuditMailingListMembership; use App\Entity\Database\AuditNote as AuditNoteModel; use App\Entity\Database\AuditRenewal as AuditRenewalModel; +use App\Entity\Database\EmailChangeLink as EmailChangeLinkModel; use App\Entity\Database\Enums\AddressTypes; use App\Entity\Database\Enums\AttentionReasons; use App\Entity\Database\Enums\MailingListMemberAction; use App\Entity\Database\Enums\MailingListMemberOrigin; +use App\Entity\Database\Enums\MemberDetailAction; use App\Entity\Database\Enums\MembershipTypes; use App\Entity\Database\Enums\PostalRegions; use App\Entity\Database\Enums\ProspectiveMemberFilter; use App\Entity\Database\Enums\Studies; +use App\Entity\Database\MailingList as MailingListModel; use App\Entity\Database\MailingListMember as MailingListMemberModel; use App\Entity\Database\Member as MemberModel; use App\Entity\Database\Membership as MembershipModel; @@ -30,6 +34,7 @@ use App\Message\Database\RegistrationUpdateEmail; use App\Repository\Database\ActionLinkRepository; use App\Repository\Database\AuditEntryRepository; +use App\Repository\Database\EmailChangeLinkRepository; use App\Repository\Database\MailingListMemberRepository; use App\Repository\Database\MailingListRepository; use App\Repository\Database\MemberRepository; @@ -45,6 +50,7 @@ use function array_diff; use function array_intersect; +use function array_map; use function array_merge; use function array_unique; use function array_values; @@ -62,6 +68,7 @@ public function __construct( private readonly ActionLinkRepository $actionLinkRepository, private readonly AuditEntryRepository $auditEntryRepository, private readonly MemberRepository $memberRepository, + private readonly EmailChangeLinkRepository $emailChangeLinkRepository, private readonly ProspectiveMemberRepository $prospectiveMemberRepository, private readonly MailingListService $mailingListService, private readonly RenewalService $renewalService, @@ -761,7 +768,10 @@ public function expiration( */ public function editAddress(FormInterface $form): ?AddressModel { - return $this->persistAddressFromForm($form); + return $this->persistAddressFromForm( + $form, + MemberDetailAction::Changed, + ); } /** @@ -771,7 +781,10 @@ public function editAddress(FormInterface $form): ?AddressModel */ public function addAddress(FormInterface $form): ?AddressModel { - return $this->persistAddressFromForm($form); + return $this->persistAddressFromForm( + $form, + MemberDetailAction::Added, + ); } /** @@ -792,6 +805,15 @@ public function removeAddress( ); $this->memberRepository->removeAddress($address); + $this->auditService->persist( + AuditAddressChange::create( + $member, + $type, + MemberDetailAction::Removed, + $this->auditUser(), + ), + ); + return $member; } @@ -806,27 +828,55 @@ public function isMailingListSyncLocked(): bool return $this->mailingListService->isSyncLocked(); } - /** - * Update mailing list subscriptions of a member - */ public function subscribeLists( MemberModel $member, FormInterface $form, ): ?MemberModel { - // Check if we are performing a sync or not. - if ($this->mailingListService->isSyncLocked()) { - return null; - } - $data = $form->getData(); /** @var string[] $selectedLists */ $selectedLists = $data['lists'] ?: []; - $currentLists = $member->getMailingListMemberships()->map( - static function (MailingListMemberModel $subscription) { - return $subscription->mailingList->name; - }, - )->toArray(); + + return $this->updateSubscriptions( + $member, + $selectedLists, + array_map( + static fn (MailingListModel $list): string => $list->name, + $this->mailingListRepository->findAll(), + ), + MailingListMemberOrigin::Manual, + ); + } + + /** + * Nothing outside the candidates is changed, or a page that offers some of the lists would unsubscribe the rest. + * Returns null while a synchronisation is running. + * + * @param string[] $selectedLists + * @param string[] $candidateLists + */ + public function updateSubscriptions( + MemberModel $member, + array $selectedLists, + array $candidateLists, + MailingListMemberOrigin $origin, + ): ?MemberModel { + if ($this->mailingListService->isSyncLocked()) { + return null; + } + + $selectedLists = array_values(array_intersect( + $selectedLists, + $candidateLists, + )); + $currentLists = array_values(array_intersect( + $member->getMailingListMemberships()->map( + static function (MailingListMemberModel $subscription) { + return $subscription->mailingList->name; + }, + )->toArray(), + $candidateLists, + )); // Determine which mailing lists the member should be (un)subscribed from/to. $intersection = array_intersect( @@ -855,12 +905,17 @@ static function (MailingListMemberModel $subscription) { $list, $member, ); + + if (null === $membership) { + continue; + } + $membership->toBeDeleted = true; $this->auditService->persist( AuditMailingListMembership::create( MailingListMemberAction::Remove, - MailingListMemberOrigin::Manual, + $origin, $member, $list, $membership->email, @@ -886,7 +941,7 @@ static function (MailingListMemberModel $subscription) { $this->auditService->persist( AuditMailingListMembership::create( MailingListMemberAction::Add, - MailingListMemberOrigin::Manual, + $origin, $member, $list, $mailingListMember->email, @@ -1142,8 +1197,10 @@ private function applyMembershipChange( return $member; } - private function persistAddressFromForm(FormInterface $form): ?AddressModel - { + private function persistAddressFromForm( + FormInterface $form, + MemberDetailAction $action, + ): ?AddressModel { if (!$form->isValid()) { return null; } @@ -1153,6 +1210,19 @@ private function persistAddressFromForm(FormInterface $form): ?AddressModel $this->memberRepository->persistAddress($address); + $member = $address->getMember(); + + if (null !== $member) { + $this->auditService->persist( + AuditAddressChange::create( + $member, + $address->type, + $action, + $this->auditUser(), + ), + ); + } + return $address; } @@ -1296,6 +1366,42 @@ static function (MemberModel $a, MemberModel $b) { ]; } + /** + * Nothing is changed by asking: the register keeps the old address until the new one is confirmed. + */ + public function requestEmailChange( + MemberModel $member, + string $newEmail, + ): EmailChangeLinkModel { + $this->emailChangeLinkRepository->removeAllForMember($member); + + $link = new EmailChangeLinkModel( + $member, + $newEmail, + ); + $this->actionLinkRepository->persist($link); + + return $link; + } + + /** + * Only the address is written; the subscriptions and the audit entry are + * {@see \App\EventListener\Database\MemberEmailChangeListener}'s. + */ + public function confirmEmailChange(EmailChangeLinkModel $link): MemberModel + { + $member = $link->member; + + $member->setEmail($link->newEmail); + $member->changedOn = new DateTimeImmutable(); + + $link->used = true; + $this->actionLinkRepository->persist($link); + $this->memberRepository->persist($member); + + return $member; + } + /** * Whether this address already belongs to someone else (a member or an applicant). * diff --git a/templates/database/email/email-change-confirm.html.twig b/templates/database/email/email-change-confirm.html.twig new file mode 100644 index 000000000..77ce1ab1e --- /dev/null +++ b/templates/database/email/email-change-confirm.html.twig @@ -0,0 +1,24 @@ +{% extends 'database/email/_base.html.twig' %} + +{% block emailTitle %}Confirm your e-mail address{% endblock %} +{% block preheader %}{% endblock %} +{% block heading %}Membership notification{% endblock %} +{% block subheading %}Confirm your e-mail address{% endblock %} +{% block body %} +

Dear {{ firstName }},

+

+ You asked us to reach you at {{ newEmail }} from now on. Before we change anything, we would like to be + sure that this message arrived, so please click + here + within 24 hours to confirm. You will be asked to sign in with your GEWIS account first. +

+

+ Until you do, we keep writing to the address we have. If you did not ask for this, you can ignore this message + and nothing will change. +

+

+ Kind regards, +
The Secretary of GEWIS +

+{% endblock %} +{% block footerNote %}You receive this message because this address was entered as the new e-mail address of a GEWIS member. You can not opt-out of these emails.{% endblock %} diff --git a/templates/database/email/email-changed.html.twig b/templates/database/email/email-changed.html.twig new file mode 100644 index 000000000..5bb42fe8e --- /dev/null +++ b/templates/database/email/email-changed.html.twig @@ -0,0 +1,22 @@ +{% extends 'database/email/_base.html.twig' %} + +{% block emailTitle %}Your e-mail address was changed{% endblock %} +{% block preheader %}{% endblock %} +{% block heading %}Membership notification{% endblock %} +{% block subheading %}Your e-mail address was changed{% endblock %} +{% block body %} +

Dear {{ firstName }},

+

+ The e-mail address of your GEWIS membership was changed from {{ previousEmail }} to + {{ newEmail }}. From now on we write to the new address, and you sign in with it as well. +

+

+ This message goes to your previous address so that you hear about the change even if somebody else made it. + If that is the case, reply to this message and the secretary will put it right. +

+

+ Kind regards, +
The Secretary of GEWIS +

+{% endblock %} +{% block footerNote %}You receive this message because the e-mail address of your GEWIS membership was changed. You can not opt-out of these emails.{% endblock %} diff --git a/templates/database/mailing/list/form.html.twig b/templates/database/mailing/list/form.html.twig index 4fa695c29..b4781a418 100644 --- a/templates/database/mailing/list/form.html.twig +++ b/templates/database/mailing/list/form.html.twig @@ -34,6 +34,7 @@ {{ form_row(form.en_description) }} {{ form_row(form.onForm) }} {{ form_row(form.defaultSub) }} + {{ form_row(form.selfService) }} {# Mailman is being phased out: a list can still be shown and unbound, but a new binding is not offered. #} {% if form.mailmanList.vars.value is not empty %} diff --git a/templates/partials/member-sidebar.html.twig b/templates/partials/member-sidebar.html.twig index c06525e2b..480de5dfa 100644 --- a/templates/partials/member-sidebar.html.twig +++ b/templates/partials/member-sidebar.html.twig @@ -26,6 +26,7 @@ {{ sidebar.group('Settings'|trans) }}