Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,11 @@ Every change that reaches `main` adds a line under **Unreleased**. When a versio

### Breaking
- Writing CWA content (pages, layouts, routes, component groups and their order, component positions, every component including `/move`, and every page data class, project classes included) requires `publishable.permission` (or the class's `#[Publishable(isGranted:)]`), ANDed with any `security` the operation already has; opt an operation or resource out with `extraProperties: ['publishable_permission' => false]`. Form `/submit` stays public ([#579](https://github.com/components-web-app/api-components-bundle/pull/579))
- An email change's confirmation link goes to the new address instead of the current one, and setting `newEmailAddress` needs the signed-in user's current password as `oldPassword` on API writes and in `NewEmailAddressType` (a super admin enters their own; a cancel needs none) ([#580](https://github.com/components-web-app/api-components-bundle/pull/580))

### Added
- `emails.logo` (a Twig path, default the bundle's logo) and `emails.logo_width` (pixels, optional) set the logo at the top of every email the bundle sends, whose alt text is now the `website_name` instead of `Logo` ([#576](https://github.com/components-web-app/api-components-bundle/pull/576))
- A notice to the current address when an email change is requested, with the new address masked and no link; `user.emails.change_email_notice` sets its subject or disables it ([#580](https://github.com/components-web-app/api-components-bundle/pull/580))

### Fixed
- A user referenced by IRI from another resource's write is no longer processed as a new registration, which reset their `emailAddressVerified` ([#575](https://github.com/components-web-app/api-components-bundle/pull/575))
Expand Down
3 changes: 3 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -304,6 +304,9 @@ Resources are fetched and cached **individually**. Never embed related data outs
- **Every bundle email extends `emails/_template.html.twig`, so every sender's context carries `website_name`, `email_logo` and `email_logo_width`** (`SilverbackApiComponentsExtension::emailContext()`, #573). `emails.logo` is a Twig path, because `email.image()` loads it through the Twig loader; a filesystem path fails. **Find an embedded image from the `<img>`, never by attachment name**: twig-bridge 7.3 (the lowest-dependency legs) names the part with the whole Twig path and writes `cid:<that path>`, 7.4+ uses the basename and a generated content id, and the lowest CSS inliner puts the element's own `style` before the inlined rules. `Email\EmailLogoTest` follows the `src` to the part whose content id or filename it names.
- **A failed send returns false whether or not a logger is wired** (#340): the logger is optional in `UserMailer`'s service locator, so check `has('logger')`; `get()` on a missing id throws.
- **The `…RequestedAt` timestamp starts the throttle, so `UserMailer` sets it only after `send()` succeeds**, and flushes it with the token then. A failed send (503) saves nothing: the action refreshes the user, so the token is not replaced by one nobody received.
- **An email change is confirmed from the new address and announced to the current one** (#571). `ChangeEmailConfirmationEmailFactory` overrides `AbstractUserEmailFactory::getRecipientAddress()` to send the link to `newEmailAddress`, which proves the address; confirming swaps `emailAddress` and marks it verified. `UserWriteNotifier` sends `ChangeEmailNoticeEmailFactory`'s notice to the current address on the same trigger (a new confirmation token, so not on a resend, a cancel or the confirmation). The notice carries no link, only `masked_new_email_address` (`Utility\MaskedEmailAddress`), so it is sent even when the confirmation's link is refused; `user.emails.change_email_notice.enabled` turns it off.
- **Setting `newEmailAddress` needs the signed-in user's current password** (#571), because the link no longer goes to the old address. `#[NewEmailAddressRequiresPassword]` on `AbstractUser` validates `oldPassword` with `UserPassword`, on the API and in `NewEmailAddressType` alike, only when `newEmailAddress` is non-empty and differs from the stored value (the unit of work's original data), so a cancel or a write that leaves a pending change alone needs none. It is checked against the token user, so a super admin editing another user enters their own password. A constraint validated through `inContext()` takes the caller's group, so the nested `UserPassword` carries the outer constraint's groups; with its default group the form's `User:emailAddress` validation skipped it.
- **`/resend-verify-new-email/{username}` is unauthenticated and now mails the pending new address**, so anyone who knows a username can send a confirmation to the address its owner claimed, at most once per `new_email_confirmation.repeat_ttl_seconds`. That throttle is what bounds it; keep it.
- Actions calling `UserDataProcessor::findUserByUsername()` must catch its `InvalidArgumentException` and return 404; it throws rather than returning null.
- **Code typed against `UserRepositoryInterface` may call only what it declares** (#266, #336). The `@method` tags are not a contract, and PHPStan trusts them, so it will not catch a call to `find()` or `findOneBy()`. `loadUserByIdentifier()` matches username *or* email, trimmed and lower-cased like every `UserRepository` lookup (#574: a mobile keyboard's trailing space), so check the returned username, compared the same way and treat `NonUniqueResultException` as not found; `Repository\User\FindUserByUsernameTrait` does both. Test against `tests/Command/InMemoryUserRepository.php`, which implements only the interface.
- `user:create` throws `ValidationFailedException` rather than writing an invalid or duplicate user (#254). There is no DB unique constraint on username or email.
Expand Down
7 changes: 7 additions & 0 deletions src/DependencyInjection/Configuration.php
Original file line number Diff line number Diff line change
Expand Up @@ -609,6 +609,13 @@ private function addUserNode(ArrayNodeDefinition $rootNode): void
->scalarNode('subject')->cannotBeEmpty()->defaultValue('Your password has been changed')->end()
->end()
->end()
->arrayNode('change_email_notice')
->canBeDisabled()
->addDefaultsIfNotSet()
->children()
->scalarNode('subject')->cannotBeEmpty()->defaultValue('Your email address is being changed')->end()
->end()
->end()
->end()
->end()
->end()
Expand Down
2 changes: 2 additions & 0 deletions src/DependencyInjection/SilverbackApiComponentsExtension.php
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@
use Silverback\ApiComponentsBundle\Factory\OrphanedResource\OrphanedResourcesChangedEmailFactory;
use Silverback\ApiComponentsBundle\Factory\Uploadable\MediaObjectFactory;
use Silverback\ApiComponentsBundle\Factory\User\Mailer\ChangeEmailConfirmationEmailFactory;
use Silverback\ApiComponentsBundle\Factory\User\Mailer\ChangeEmailNoticeEmailFactory;
use Silverback\ApiComponentsBundle\Factory\User\Mailer\PasswordChangedEmailFactory;
use Silverback\ApiComponentsBundle\Factory\User\Mailer\PasswordResetEmailFactory;
use Silverback\ApiComponentsBundle\Factory\User\Mailer\UserEnabledEmailFactory;
Expand Down Expand Up @@ -337,8 +338,9 @@
$definition = $container->findDefinition(UserMailer::class);
$definition->setArgument('$context', self::emailContext($config));

$mapping = [

Check warning on line 341 in src/DependencyInjection/SilverbackApiComponentsExtension.php

View workflow job for this annotation

GitHub Actions / PHPUnit (Symfony 7.4) (PHP 8.5)

Escaped Mutant for Mutator "ArrayItemRemoval": @@ @@ $definition->setArgument('$context', self::emailContext($config)); $mapping = [ - PasswordChangedEmailFactory::class => 'password_changed', ChangeEmailNoticeEmailFactory::class => 'change_email_notice', UserEnabledEmailFactory::class => 'user_enabled', UsernameChangedEmailFactory::class => 'username_changed',
PasswordChangedEmailFactory::class => 'password_changed',
ChangeEmailNoticeEmailFactory::class => 'change_email_notice',
UserEnabledEmailFactory::class => 'user_enabled',
UsernameChangedEmailFactory::class => 'username_changed',
WelcomeEmailFactory::class => 'welcome',
Expand Down
1 change: 1 addition & 0 deletions src/Entity/User/AbstractUser.php
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@
#[UniqueEntity(fields: ['username'], message: 'Sorry, that user already exists in the database.', errorPath: 'username')]
#[UniqueEntity(fields: ['emailAddress'], message: 'Sorry, that email address already exists in the database.', errorPath: 'emailAddress')]
#[AcbAssert\NewEmailAddress(groups: ['User:emailAddress', 'Default'])]
#[AcbAssert\NewEmailAddressRequiresPassword(groups: ['User:emailAddress', 'Default'])]
abstract class AbstractUser implements SymfonyUserInterface, PasswordAuthenticatedUserInterface, JWTUserInterface
{
use IdTrait;
Expand Down
7 changes: 6 additions & 1 deletion src/Factory/User/Mailer/AbstractUserEmailFactory.php
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,7 @@
}

try {
$toEmailAddress = Address::create((string) $this->user->getEmailAddress());
$toEmailAddress = Address::create((string) $this->getRecipientAddress($this->user));

Check warning on line 97 in src/Factory/User/Mailer/AbstractUserEmailFactory.php

View workflow job for this annotation

GitHub Actions / PHPUnit (Symfony 7.4) (PHP 8.5)

Escaped Mutant for Mutator "CastString": @@ @@ } try { - $toEmailAddress = Address::create((string) $this->getRecipientAddress($this->user)); + $toEmailAddress = Address::create($this->getRecipientAddress($this->user)); } catch (SymfonyRfcComplianceException $exception) { $exception = new RfcComplianceException($exception->getMessage()); throw $exception;
} catch (SymfonyRfcComplianceException $exception) {
$exception = new RfcComplianceException($exception->getMessage());
throw $exception;
Expand Down Expand Up @@ -127,6 +127,11 @@
return $event->getEmail();
}

protected function getRecipientAddress(AbstractUser $user): ?string

Check warning on line 130 in src/Factory/User/Mailer/AbstractUserEmailFactory.php

View workflow job for this annotation

GitHub Actions / PHPUnit (Symfony 7.4) (PHP 8.5)

Escaped Mutant for Mutator "ProtectedVisibility": @@ @@ return $event->getEmail(); } - protected function getRecipientAddress(AbstractUser $user): ?string + private function getRecipientAddress(AbstractUser $user): ?string { return $user->getEmailAddress(); }
{
return $user->getEmailAddress();
}

protected function getTokenUrl(string $token, string $username, ?string $newEmail = null): string
{
if (null === $this->defaultRedirectPath && null === $this->redirectPathQueryKey) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,11 @@ public function create(AbstractUser $user, array $context = []): ?RawMessage
return $this->createEmailMessage($context);
}

protected function getRecipientAddress(AbstractUser $user): ?string
{
return $user->getNewEmailAddress();
}

protected static function getContextKeys(): ?array
{
return array_merge(
Expand Down
55 changes: 55 additions & 0 deletions src/Factory/User/Mailer/ChangeEmailNoticeEmailFactory.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
<?php

/*
* This file is part of the Silverback API Components Bundle Project
*
* (c) Daniel West <daniel@silverback.is>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

namespace Silverback\ApiComponentsBundle\Factory\User\Mailer;

use Silverback\ApiComponentsBundle\Entity\User\AbstractUser;
use Silverback\ApiComponentsBundle\Exception\InvalidArgumentException;
use Silverback\ApiComponentsBundle\Utility\MaskedEmailAddress;
use Symfony\Component\Mime\RawMessage;

final class ChangeEmailNoticeEmailFactory extends AbstractUserEmailFactory
{
public const MESSAGE_ID_PREFIX = 'cen';

public function create(AbstractUser $user, array $context = []): ?RawMessage
{
if (!$this->enabled) {
return null;
}

$this->initUser($user);

$newEmailAddress = $user->getNewEmailAddress();
if (null === $newEmailAddress || '' === $newEmailAddress) {
throw new InvalidArgumentException('A `new email address` must be set to send the change email notice');
}

$context['masked_new_email_address'] = MaskedEmailAddress::of($newEmailAddress);

return $this->createEmailMessage($context);
}

protected static function getContextKeys(): array
{
return array_merge(

Check warning on line 43 in src/Factory/User/Mailer/ChangeEmailNoticeEmailFactory.php

View workflow job for this annotation

GitHub Actions / PHPUnit (Symfony 7.4) (PHP 8.5)

Escaped Mutant for Mutator "UnwrapArrayMerge": @@ @@ protected static function getContextKeys(): array { - return array_merge( - parent::getContextKeys(), - [ - 'masked_new_email_address', - ] - ); + return [ + 'masked_new_email_address', + ]; } protected function getTemplate(): string

Check warning on line 43 in src/Factory/User/Mailer/ChangeEmailNoticeEmailFactory.php

View workflow job for this annotation

GitHub Actions / PHPUnit (Symfony 7.4) (PHP 8.5)

Escaped Mutant for Mutator "UnwrapArrayMerge": @@ @@ protected static function getContextKeys(): array { - return array_merge( - parent::getContextKeys(), - [ - 'masked_new_email_address', - ] - ); + return parent::getContextKeys(); } protected function getTemplate(): string
parent::getContextKeys(),
[

Check warning on line 45 in src/Factory/User/Mailer/ChangeEmailNoticeEmailFactory.php

View workflow job for this annotation

GitHub Actions / PHPUnit (Symfony 7.4) (PHP 8.5)

Escaped Mutant for Mutator "ArrayItemRemoval": @@ @@ { return array_merge( parent::getContextKeys(), - [ - 'masked_new_email_address', - ] + [] ); }
'masked_new_email_address',
]
);
}

protected function getTemplate(): string
{
return 'user_change_email_notice.html.twig';
}
}
11 changes: 10 additions & 1 deletion src/Form/Type/User/NewEmailAddressType.php
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
use Silverback\ApiComponentsBundle\Repository\User\UserRepositoryInterface;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\Form\Extension\Core\Type\EmailType;
use Symfony\Component\Form\Extension\Core\Type\PasswordType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;

Expand Down Expand Up @@ -50,7 +51,7 @@ public function buildForm(FormBuilderInterface $builder, array $options): void
}
$help = null;
if ($data instanceof AbstractUser && $data->getNewEmailAddress()) {
$help = \sprintf('You have requested to change your email to `%s`. Please check your inbox to validate this email address.', $data->getNewEmailAddress());
$help = \sprintf('You have requested to change your email to `%s`. Please check the inbox of the new address to confirm it.', $data->getNewEmailAddress());
}
$builder
->add(
Expand All @@ -62,6 +63,14 @@ public function buildForm(FormBuilderInterface $builder, array $options): void
'data' => $data ? $data->getEmailAddress() : '',
'help' => $help,
]
)
->add(
'oldPassword',
PasswordType::class,
[
'label' => 'Current password',
'attr' => ['autocomplete' => 'current-password'],
]
);
}

Expand Down
6 changes: 6 additions & 0 deletions src/Helper/User/UserMailer.php
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
use Silverback\ApiComponentsBundle\Exception\MailerTransportException;
use Silverback\ApiComponentsBundle\Exception\UnparseableRequestHeaderException;
use Silverback\ApiComponentsBundle\Factory\User\Mailer\ChangeEmailConfirmationEmailFactory;
use Silverback\ApiComponentsBundle\Factory\User\Mailer\ChangeEmailNoticeEmailFactory;
use Silverback\ApiComponentsBundle\Factory\User\Mailer\PasswordChangedEmailFactory;
use Silverback\ApiComponentsBundle\Factory\User\Mailer\PasswordResetEmailFactory;
use Silverback\ApiComponentsBundle\Factory\User\Mailer\UserEnabledEmailFactory;
Expand Down Expand Up @@ -79,6 +80,11 @@ public function sendChangeEmailConfirmationEmailAfterWrite(AbstractUser $user):
return $this->afterWrite($user, fn (): bool => $this->sendChangeEmailConfirmationEmail($user));
}

public function sendChangeEmailNoticeEmail(AbstractUser $user): bool
{
return $this->afterWrite($user, fn (): bool => $this->send($this->container->get(ChangeEmailNoticeEmailFactory::class)->create($user, $this->context)));
}

public function sendWelcomeEmail(AbstractUser $user): bool
{
return $this->afterWrite($user, fn (): bool => $this->send($this->container->get(WelcomeEmailFactory::class)->create($user, $this->context)));
Expand Down
1 change: 1 addition & 0 deletions src/Helper/User/UserWriteNotifier.php
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ public function notify(AbstractUser $user, ?AbstractUser $previousUser): void

if (($token = $user->getNewEmailConfirmationToken()) && $token !== $previousUser->getNewEmailConfirmationToken()) {
$this->userMailer->sendChangeEmailConfirmationEmailAfterWrite($user);
$this->userMailer->sendChangeEmailNoticeEmail($user);
}
}
}
21 changes: 21 additions & 0 deletions src/Resources/config/services.php
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,7 @@
use Silverback\ApiComponentsBundle\Factory\Uploadable\UploadableUrlGeneratorInterface;
use Silverback\ApiComponentsBundle\Factory\User\Mailer\AbstractUserEmailFactory;
use Silverback\ApiComponentsBundle\Factory\User\Mailer\ChangeEmailConfirmationEmailFactory;
use Silverback\ApiComponentsBundle\Factory\User\Mailer\ChangeEmailNoticeEmailFactory;
use Silverback\ApiComponentsBundle\Factory\User\Mailer\PasswordChangedEmailFactory;
use Silverback\ApiComponentsBundle\Factory\User\Mailer\PasswordResetEmailFactory;
use Silverback\ApiComponentsBundle\Factory\User\Mailer\UserEnabledEmailFactory;
Expand Down Expand Up @@ -228,6 +229,7 @@
use Silverback\ApiComponentsBundle\Validator\Constraints\AddedComponentPositionsValidator;
use Silverback\ApiComponentsBundle\Validator\Constraints\ComponentPositionValidator;
use Silverback\ApiComponentsBundle\Validator\Constraints\FormTypeClassValidator;
use Silverback\ApiComponentsBundle\Validator\Constraints\NewEmailAddressRequiresPasswordValidator;
use Silverback\ApiComponentsBundle\Validator\Constraints\NewEmailAddressValidator;
use Silverback\ApiComponentsBundle\Validator\Constraints\RequiresUploadedFileValidator;
use Silverback\ApiComponentsBundle\Validator\Constraints\ResourceIriValidator;
Expand Down Expand Up @@ -323,6 +325,13 @@
->tag('kernel.reset', ['method' => 'reset']);
$services->alias(ChangeEmailConfirmationEmailFactory::class, 'silverback.api_components.factory.user.mailer.change_email_confirmation_email');

$services
->set('silverback.api_components.factory.user.mailer.change_email_notice_email')
->class(ChangeEmailNoticeEmailFactory::class)
->parent(AbstractUserEmailFactory::class)
->tag('kernel.reset', ['method' => 'reset']);
$services->alias(ChangeEmailNoticeEmailFactory::class, 'silverback.api_components.factory.user.mailer.change_email_notice_email');

$services
->set('silverback.api_components.form.change_password_type')
->class(ChangePasswordType::class)
Expand Down Expand Up @@ -882,6 +891,17 @@
->tag('validator.constraint_validator');
$services->alias(NewEmailAddressValidator::class, 'silverback.api_components.validator.new_email_address');

$services
->set('silverback.api_components.validator.new_email_address_requires_password')
->class(NewEmailAddressRequiresPasswordValidator::class)
->args(
[
new Reference(ManagerRegistry::class),
]
)
->tag('validator.constraint_validator');
$services->alias(NewEmailAddressRequiresPasswordValidator::class, 'silverback.api_components.validator.new_email_address_requires_password');

$services
->set('silverback.api_components.data_provider.page_data')
->class(PageDataProvider::class)
Expand Down Expand Up @@ -1960,6 +1980,7 @@
service_locator([
PasswordResetEmailFactory::class => new Reference(PasswordResetEmailFactory::class),
ChangeEmailConfirmationEmailFactory::class => new Reference(ChangeEmailConfirmationEmailFactory::class),
ChangeEmailNoticeEmailFactory::class => new Reference(ChangeEmailNoticeEmailFactory::class),
WelcomeEmailFactory::class => new Reference(WelcomeEmailFactory::class),
UserEnabledEmailFactory::class => new Reference(UserEnabledEmailFactory::class),
UsernameChangedEmailFactory::class => new Reference(UsernameChangedEmailFactory::class),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,8 @@
<h1 class="text-center">Confirm Your New Email Address</h1>
<spacer size="16"></spacer>
<p>Hello {{ user.username }},</p>
<p>It looks like you have requested to change your email address to {{ user.newEmailAddress }}.</p>
<p>Click the link below to complete the request and change your email address.</p>
<p>You have requested to change the email address of your {{ website_name }} account to this address.</p>
<p>Click the link below to confirm that this address is yours and complete the change.</p>
<button class="large expand" href="{{ redirect_url }}">Confirm Email Address Change</button>
{% include "@SilverbackApiComponents/emails/_signature.html.twig" %}
{% endblock %}
10 changes: 10 additions & 0 deletions src/Resources/views/emails/user_change_email_notice.html.twig
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
{% extends "@SilverbackApiComponents/emails/_template.html.twig" %}

{% block content %}
<h1 class="text-center">Your Email Address Is Being Changed</h1>
<spacer size="16"></spacer>
<p>Hello {{ user.username }},</p>
<p>A request has been made to change the email address of your {{ website_name }} account to {{ masked_new_email_address }}. A link to confirm the change has been sent to that address.</p>
<p>If this was you no further action is required. If this was not you please use the forgot password feature to reset your password.</p>
{% include "@SilverbackApiComponents/emails/_signature.html.twig" %}
{% endblock %}
25 changes: 25 additions & 0 deletions src/Utility/MaskedEmailAddress.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
<?php

/*
* This file is part of the Silverback API Components Bundle Project
*
* (c) Daniel West <daniel@silverback.is>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

namespace Silverback\ApiComponentsBundle\Utility;

final class MaskedEmailAddress
{
public static function of(string $emailAddress): string
{
$at = strrpos($emailAddress, '@');
if (false === $at) {
return mb_substr($emailAddress, 0, 1) . '***';
}

return mb_substr($emailAddress, 0, 1) . '***' . substr($emailAddress, $at);
}
}
Loading
Loading