feat: Mail integration (#259)

* feat: composer require symfony/mailer

* feat: add entities for massive emailing

* feat: expose mail API resources

* feat: add BatchStatus::SentErrors

* fix: show client status after consent command

* feat: define shape of audiences criteria

* fix: missing map entity provider for mail entities

* fix: unsupported array/object conversion of audience criterias

* fix: rename MailBatch to MailBroadcast and MailBatchRecipient to MailDispatch

* feat: add command to process broadcasts

* feat: add lock to broadcast process command

* fix: remove dispatchCursor from Broadcast

* fix: make broadcasts-process command fast

* feat: update mailpit config for local dev

* feat: add app:mail:dispatches-process command

* feat: it was fun to play with a massive mailing prototype

* feat: composer require symfonycasts/reset-password-bundle

* feat: make reset-password

* fix: bad ALTER on migration down op

* feat: add migration for reset password bundle

* fix: update forms style

* feat: configure mailer address via env var

* fix: remove missing asset reference

* feat: translate /auth/reset-password

* feat: translate email message

* fix: calendar minimum optimum deadline not being set when release was given

* feat: translate /check-email

* feat: translate /reset

* fix: rename main SecurityController path to /auth

* feat: add known oauth2 clients controllr

* fix: deprecated custom responses openapi context definitions

* fix: projects not in campaign can still have a calendar

* fix: weird af regression out of the blue, fuck software

* feat: fuck my life
This commit is contained in:
2026-10-26 12:52:19 +01:00
committed by GitHub
parent 0bc664a224
commit eb822d7ff1
31 changed files with 2763 additions and 1134 deletions
+5
View File
@@ -44,6 +44,11 @@ DOCTRINE_ENCRYPTION_BLIND_INDEX_KEY=""
CORS_ALLOW_ORIGIN='^https?://(localhost|127\.0\.0\.1)(:[0-9]+)?$'
###< nelmio/cors-bundle ###
###> symfony/mailer ###
MAILER_DSN=smtp://mailer:1025
MAILER_ADDRESS=system@example.com
###< symfony/mailer ###
###> App\Gateway\Stripe\StripeGateway ###
STRIPE_API_KEY="!ChangeMe!"
STRIPE_WEBHOOK_SECRET=""
+1 -1
View File
@@ -41,6 +41,6 @@ jobs:
php -r 'echo "PHP_VERSION=", PHP_VERSION, PHP_EOL;'
- name: PHPUnit
run: php -d memory_limit=128M bin/phpunit -c phpunit.xml.dist
run: XDEBUG_MODE=off php -d memory_limit=128M bin/phpunit -c phpunit.xml.dist
env:
DATABASE_URL: mysql://root:goteo@127.0.0.1:${{ job.services.mariadb.ports['3306'] }}/goteo
+3
View File
@@ -40,9 +40,11 @@
"symfony/dotenv": "6.4.*",
"symfony/expression-language": "6.4.*",
"symfony/flex": "^2",
"symfony/form": "6.4.*",
"symfony/framework-bundle": "6.4.*",
"symfony/http-client": "6.4.*",
"symfony/intl": "6.4.*",
"symfony/mailer": "6.4.*",
"symfony/maker-bundle": "^1.62",
"symfony/monolog-bundle": "^3.10",
"symfony/property-access": "6.4.*",
@@ -58,6 +60,7 @@
"symfony/validator": "6.4.*",
"symfony/webpack-encore-bundle": "^2.4",
"symfony/yaml": "6.4.*",
"symfonycasts/reset-password-bundle": "^1.25",
"zenstruck/foundry": "^2.4"
},
"config": {
Generated
+2003 -1040
View File
File diff suppressed because it is too large Load Diff
+1
View File
@@ -19,4 +19,5 @@ return [
Symfony\WebpackEncoreBundle\WebpackEncoreBundle::class => ['all' => true],
KnpU\OAuth2ClientBundle\KnpUOAuth2ClientBundle::class => ['all' => true],
Kyzegs\DoctrineEncryptionBundle\DoctrineEncryptionBundle::class => ['all' => true],
SymfonyCasts\Bundle\ResetPassword\SymfonyCastsResetPasswordBundle::class => ['all' => true],
];
+3
View File
@@ -0,0 +1,3 @@
framework:
mailer:
dsn: '%env(MAILER_DSN)%'
+2
View File
@@ -0,0 +1,2 @@
symfonycasts_reset_password:
request_password_repository: App\Repository\ResetPasswordRequestRepository
+12
View File
@@ -10,3 +10,15 @@ services:
ports:
- "9000:9000"
- "9003:9003"
###> symfony/mailer ###
mailer:
image: axllent/mailpit
container_name: goteo-api-mailpit
ports:
- "1025:1025"
- "8025:8025"
environment:
MP_SMTP_AUTH_ACCEPT_ANY: 1
MP_SMTP_AUTH_ALLOW_INSECURE: 1
###< symfony/mailer ###
+1 -1
View File
@@ -25,6 +25,6 @@ final class Version20260812124026 extends AbstractMigration
{
$this->addSql('ALTER TABLE user CHANGE territory_address territory_address VARCHAR(255) DEFAULT NULL');
$this->addSql('ALTER TABLE project CHANGE territory_address territory_address VARCHAR(255) DEFAULT NULL');
$this->addSql('ALTER TABLE ext_translations CHANGE foreign_key foreign_key INT NOT NULL');
$this->addSql('ALTER TABLE match_call CHANGE territory_address territory_address VARCHAR(255) DEFAULT NULL');
}
}
+28
View File
@@ -0,0 +1,28 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20261021102456 extends AbstractMigration
{
public function getDescription(): string
{
return 'Add reset_password_request table';
}
public function up(Schema $schema): void
{
$this->addSql('CREATE TABLE reset_password_request (id INT AUTO_INCREMENT NOT NULL, user_id INT NOT NULL, selector VARCHAR(20) NOT NULL, hashed_token VARCHAR(100) NOT NULL, requested_at DATETIME NOT NULL COMMENT \'(DC2Type:datetime_immutable)\', expires_at DATETIME NOT NULL COMMENT \'(DC2Type:datetime_immutable)\', INDEX IDX_7CE748AA76ED395 (user_id), PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB');
$this->addSql('ALTER TABLE reset_password_request ADD CONSTRAINT FK_7CE748AA76ED395 FOREIGN KEY (user_id) REFERENCES user (id)');
}
public function down(Schema $schema): void
{
$this->addSql('ALTER TABLE reset_password_request DROP FOREIGN KEY FK_7CE748AA76ED395');
$this->addSql('DROP TABLE reset_password_request');
}
}
@@ -36,17 +36,17 @@ use Symfony\Component\Validator\Constraints as Assert;
summary: 'Get charges totals',
description: 'Returns a single ChargesTotals object with totalized metrics for the charges matching the same filters as the charges collection.',
responses: [
'200' => [
'description' => 'Charges totals',
'content' => [
'200' => new \ApiPlatform\OpenApi\Model\Response(
description: 'Charges totals',
content: new \ArrayObject([
'application/json' => [
'schema' => [
'$ref' => '#/components/schemas/GatewayCharge.ChargesTotalsDto',
],
],
],
],
]
]),
),
],
),
)]
#[API\Get()]
@@ -40,17 +40,17 @@ use Symfony\Component\Validator\Constraints as Assert;
summary: 'Get money total',
description: 'Returns a single TotalizedMoney object',
responses: [
'200' => [
'description' => 'Totalized money',
'content' => [
'200' => new \ApiPlatform\Openapi\Model\Response(
description: 'Totalized money',
content: new \ArrayObject([
'application/json' => [
'schema' => [
'$ref' => '#/components/schemas/ProjectSupport.TotalizedMoney',
],
],
],
],
]
]),
),
],
)
)]
#[API\Get()]
@@ -54,6 +54,7 @@ class OAuth2ConsentClientCommand extends Command
$this->clientManager->save($client);
$io->success('OAuth2 client updated successfully.');
$io->table(['Identifier', 'Is consented?'], [[$client->getIdentifier(), $client->isConsented()]]);
return 0;
}
+62
View File
@@ -0,0 +1,62 @@
<?php
namespace App\Controller;
use GuzzleHttp\Psr7\Uri;
use League\Bundle\OAuth2ServerBundle\Manager\ClientManagerInterface;
use League\Bundle\OAuth2ServerBundle\Repository\ClientRepository;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
#[Route('/')]
final class OAuthClientsController extends AbstractController
{
public function __construct(
private ClientManagerInterface $clientManager,
private ClientRepository $clientRepository,
) {}
#[Route('/', name: 'app_clients')]
public function authorizationStart(): Response
{
$clients = $this->getActiveClients();
return $this->render('clients/clients.html.twig', [
'oauth_clients' => $clients,
]);
}
private function getActiveClients(): array
{
$clients = [];
foreach ($this->clientManager->list(null) as $managedClient) {
$activeClient = $this->clientRepository->getClientEntity($managedClient->getIdentifier());
if (!$activeClient) {
continue;
}
$clients[] = [
'name' => $activeClient->getName(),
'identifier' => $activeClient->getIdentifier(),
'baseUri' => $this->getBaseUri($activeClient->getRedirectUri()),
];
}
return $clients;
}
private function getBaseUri(string|array $url): string
{
if (is_array($url)) {
$url = $url[0];
}
$uri = new Uri($url);
return (string) $uri
->withPath('')
->withQuery('')
->withFragment('');
}
}
+181
View File
@@ -0,0 +1,181 @@
<?php
namespace App\Controller;
use App\Entity\User\User;
use App\Form\ChangePasswordFormType;
use App\Form\ResetPasswordRequestFormType;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bridge\Twig\Mime\TemplatedEmail;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Mailer\MailerInterface;
use Symfony\Component\Mime\Address;
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Contracts\Translation\TranslatorInterface;
use SymfonyCasts\Bundle\ResetPassword\Controller\ResetPasswordControllerTrait;
use SymfonyCasts\Bundle\ResetPassword\Exception\ResetPasswordExceptionInterface;
use SymfonyCasts\Bundle\ResetPassword\ResetPasswordHelperInterface;
#[Route('/auth/reset-password')]
class ResetPasswordController extends AbstractController
{
use ResetPasswordControllerTrait;
public function __construct(
#[Autowire(env: 'MAILER_ADDRESS')]
private string $mailerAddress,
private ResetPasswordHelperInterface $resetPasswordHelper,
private EntityManagerInterface $entityManager,
) {}
/**
* Display & process form to request a password reset.
*/
#[Route('', name: 'app_forgot_password_request')]
public function request(Request $request, MailerInterface $mailer, TranslatorInterface $translator): Response
{
$form = $this->createForm(ResetPasswordRequestFormType::class);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
/** @var string $email */
$email = $form->get('email')->getData();
return $this->processSendingPasswordResetEmail(
$email,
$mailer,
$translator
);
}
return $this->render('reset_password/request.html.twig', [
'requestForm' => $form,
]);
}
/**
* Confirmation page after a user has requested a password reset.
*/
#[Route('/check-email', name: 'app_check_email')]
public function checkEmail(): Response
{
// Generate a fake token if the user does not exist or someone hit this page directly.
// This prevents exposing whether or not a user was found with the given email address or not
if (null === ($resetToken = $this->getTokenObjectFromSession())) {
$resetToken = $this->resetPasswordHelper->generateFakeResetToken();
}
return $this->render('reset_password/check_email.html.twig', [
'resetToken' => $resetToken,
'mailerAddress' => $this->mailerAddress,
]);
}
/**
* Validates and process the reset URL that the user clicked in their email.
*/
#[Route('/reset/{token}', name: 'app_reset_password')]
public function reset(Request $request, UserPasswordHasherInterface $passwordHasher, TranslatorInterface $translator, ?string $token = null): Response
{
if ($token) {
// We store the token in session and remove it from the URL, to avoid the URL being
// loaded in a browser and potentially leaking the token to 3rd party JavaScript.
$this->storeTokenInSession($token);
return $this->redirectToRoute('app_reset_password');
}
$token = $this->getTokenFromSession();
if ($token === null) {
throw $this->createNotFoundException('No reset password token found in the URL or in the session.');
}
try {
/** @var User $user */
$user = $this->resetPasswordHelper->validateTokenAndFetchUser($token);
} catch (ResetPasswordExceptionInterface $e) {
$this->addFlash('reset_password_error', sprintf(
'%s - %s',
$translator->trans(ResetPasswordExceptionInterface::MESSAGE_PROBLEM_VALIDATE, [], 'ResetPasswordBundle'),
$translator->trans($e->getReason(), [], 'ResetPasswordBundle')
));
return $this->redirectToRoute('app_forgot_password_request');
}
// The token is valid; allow the user to change their password.
$form = $this->createForm(ChangePasswordFormType::class);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
// A password reset token should be used only once, remove it.
$this->resetPasswordHelper->removeResetRequest($token);
/** @var string $plainPassword */
$plainPassword = $form->get('plainPassword')->getData();
// Encode(hash) the plain password, and set it.
$user->setPassword($passwordHasher->hashPassword($user, $plainPassword));
$this->entityManager->flush();
// The session is cleaned up after the password has been changed.
$this->cleanSessionAfterReset();
return $this->redirectToRoute('app_clients');
}
return $this->render('reset_password/reset.html.twig', [
'resetForm' => $form,
]);
}
private function processSendingPasswordResetEmail(string $emailFormData, MailerInterface $mailer, TranslatorInterface $translator): RedirectResponse
{
$user = $this->entityManager->getRepository(User::class)->findOneBy([
'email' => $emailFormData,
]);
// Do not reveal whether a user account was found or not.
if (!$user) {
return $this->redirectToRoute('app_check_email');
}
try {
$resetToken = $this->resetPasswordHelper->generateResetToken($user);
} catch (ResetPasswordExceptionInterface $e) {
// If you want to tell the user why a reset email was not sent, uncomment
// the lines below and change the redirect to 'app_forgot_password_request'.
// Caution: This may reveal if a user is registered or not.
//
// $this->addFlash('reset_password_error', sprintf(
// '%s - %s',
// $translator->trans(ResetPasswordExceptionInterface::MESSAGE_PROBLEM_HANDLE, [], 'ResetPasswordBundle'),
// $translator->trans($e->getReason(), [], 'ResetPasswordBundle')
// ));
return $this->redirectToRoute('app_check_email');
}
$email = (new TemplatedEmail())
->from(new Address($this->mailerAddress, 'Goteo'))
->to((string) $user->getEmail())
->subject($translator->trans('password_reset.email.subject'))
->htmlTemplate('reset_password/email.html.twig')
->context([
'user' => $user,
'resetToken' => $resetToken,
]);
$mailer->send($email);
// Store the token object in session for retrieval in check-email route.
$this->setTokenObjectInSession($resetToken);
return $this->redirectToRoute('app_check_email');
}
}
+1 -1
View File
@@ -11,7 +11,7 @@ use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Authentication\AuthenticationUtils;
#[Route('/security')]
#[Route('/auth')]
class SecurityController extends AbstractController
{
public function __construct(
+40
View File
@@ -0,0 +1,40 @@
<?php
namespace App\Entity;
use App\Entity\User\User;
use App\Repository\ResetPasswordRequestRepository;
use Doctrine\ORM\Mapping as ORM;
use SymfonyCasts\Bundle\ResetPassword\Model\ResetPasswordRequestInterface;
use SymfonyCasts\Bundle\ResetPassword\Model\ResetPasswordRequestTrait;
#[ORM\Entity(repositoryClass: ResetPasswordRequestRepository::class)]
class ResetPasswordRequest implements ResetPasswordRequestInterface
{
use ResetPasswordRequestTrait;
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
private ?int $id = null;
#[ORM\ManyToOne]
#[ORM\JoinColumn(nullable: false)]
private ?User $user = null;
public function __construct(User $user, \DateTimeInterface $expiresAt, string $selector, string $hashedToken)
{
$this->user = $user;
$this->initialize($expiresAt, $selector, $hashedToken);
}
public function getId(): ?int
{
return $this->id;
}
public function getUser(): User
{
return $this->user;
}
}
+62
View File
@@ -0,0 +1,62 @@
<?php
namespace App\Form;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\PasswordType;
use Symfony\Component\Form\Extension\Core\Type\RepeatedType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
use Symfony\Component\Validator\Constraints\Length;
use Symfony\Component\Validator\Constraints\NotBlank;
use Symfony\Component\Validator\Constraints\NotCompromisedPassword;
use Symfony\Component\Validator\Constraints\PasswordStrength;
use Symfony\Contracts\Translation\TranslatorInterface;
class ChangePasswordFormType extends AbstractType
{
public function __construct(
private TranslatorInterface $translator,
) {}
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder
->add('plainPassword', RepeatedType::class, [
'type' => PasswordType::class,
'options' => [
'attr' => [
'autocomplete' => 'new-password',
],
],
'first_options' => [
'constraints' => [
new NotBlank(
message: $this->translator->trans('password_reset.reset.errorEmpty'),
),
new Length(
min: 8,
minMessage: $this->translator->trans('password_reset.reset.errorLength'),
// max length allowed by Symfony for security reasons
max: 4096,
),
new PasswordStrength(),
new NotCompromisedPassword(),
],
'label' => $this->translator->trans('password_reset.reset.labelNew'),
],
'second_options' => [
'label' => $this->translator->trans('password_reset.reset.labelRepeat'),
],
'invalid_message' => $this->translator->trans('password_reset.reset.errorNoMatch'),
// Instead of being set onto the object directly,
// this is read and encoded in the controller
'mapped' => false,
]);
}
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults([]);
}
}
+31
View File
@@ -0,0 +1,31 @@
<?php
namespace App\Form;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\EmailType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
use Symfony\Component\Validator\Constraints\NotBlank;
class ResetPasswordRequestFormType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder
->add('email', EmailType::class, [
'attr' => ['autocomplete' => 'email'],
'constraints' => [
new NotBlank(
message: 'Please enter your email',
),
],
])
;
}
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults([]);
}
}
@@ -0,0 +1,32 @@
<?php
namespace App\Repository;
use App\Entity\ResetPasswordRequest;
use App\Entity\User\User;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
use SymfonyCasts\Bundle\ResetPassword\Model\ResetPasswordRequestInterface;
use SymfonyCasts\Bundle\ResetPassword\Persistence\Repository\ResetPasswordRequestRepositoryTrait;
use SymfonyCasts\Bundle\ResetPassword\Persistence\ResetPasswordRequestRepositoryInterface;
/**
* @extends ServiceEntityRepository<ResetPasswordRequest>
*/
class ResetPasswordRequestRepository extends ServiceEntityRepository implements ResetPasswordRequestRepositoryInterface
{
use ResetPasswordRequestRepositoryTrait;
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, ResetPasswordRequest::class);
}
/**
* @param User $user
*/
public function createResetPasswordRequest(object $user, \DateTimeInterface $expiresAt, string $selector, string $hashedToken): ResetPasswordRequestInterface
{
return new ResetPasswordRequest($user, $expiresAt, $selector, $hashedToken);
}
}
+6 -4
View File
@@ -66,12 +66,14 @@ class ProjectStateProcessor implements ProcessorInterface
$project->setOwner($owner);
if (!isset($data->calendar->release)) {
$project->setCalendar($this->calendarService->makeCalendar(
$project->getDeadline(),
new \DateTimeImmutable('+28 days')
));
$data->calendar->release = new \DateTimeImmutable('+28 days');
}
$project->setCalendar($this->calendarService->makeCalendar(
$project->getDeadline(),
$data->calendar->release,
));
return $project;
}
+24
View File
@@ -211,6 +211,18 @@
"config/packages/lock.yaml"
]
},
"symfony/mailer": {
"version": "6.4",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
"version": "4.3",
"ref": "09051cfde49476e3c12cd3a0e44289ace1c75a4f"
},
"files": [
"config/packages/mailer.yaml"
]
},
"symfony/maker-bundle": {
"version": "1.51",
"recipe": {
@@ -352,6 +364,18 @@
"webpack.config.js"
]
},
"symfonycasts/reset-password-bundle": {
"version": "1.25",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
"version": "1.0",
"ref": "97c1627c0384534997ae1047b93be517ca16de43"
},
"files": [
"config/packages/reset_password.yaml"
]
},
"zenstruck/foundry": {
"version": "2.4",
"recipe": {
+25
View File
@@ -0,0 +1,25 @@
{% extends 'base.html.twig' %}
{% block title %}
{{ 'clients.title' | trans }}
{% endblock %}
{% block body %}
<div class="mx-auto max-w-360 px-10 mb-20">
<ul class="mx-auto max-w-122 flex flex-col gap-4">
<h1 class="text-black text-2xl font-bold">{{ 'clients.title' | trans }}</h1>
<p class="text-content">{{ 'clients.description' | trans | raw }}</p>
<div class="flex gap-6">
{% for client in oauth_clients %}
<li>
<div class="bg-white border border-grey rounded-2xl p-4 shadow-sm">
<a href="{{ client.baseUri }}">
<img class="max-h-12 h-full" src="/oauth2/clients/{{ client.identifier }}.png" alt={{ client.name }}>
</a>
</div>
</li>
{% endfor %}
</div>
</ul>
</div>
{% endblock %}
+1 -1
View File
@@ -1,7 +1,7 @@
<footer class="text-variant1" role="contentinfo" aria-labelledby="footer-heading">
<div
class="bg-secondary relative rounded-t-3xl bg-size-[116%_114%] bg-position-[-127%_42%] bg-no-repeat min-[700px]:bg-size-[64%_108%] min-[700px]:bg-position-[114%]" style="background-image: url('/path2.svg');">
class="bg-secondary relative rounded-t-3xl bg-size-[116%_114%] bg-position-[-127%_42%] bg-no-repeat min-[700px]:bg-size-[64%_108%] min-[700px]:bg-position-[114%]">
<!-- Main Footer Content -->
<section class="relative z-10 py-6 sm:py-8 md:py-10" aria-labelledby="footer-navigation-heading">
<div class="mx-auto max-w-360 px-4 md:px-8">
@@ -0,0 +1,21 @@
{% extends 'base.html.twig' %}
{% block title %}
{{ 'password_reset.check_email.title' | trans }}
{% endblock %}
{% block body %}
<div class="mx-auto max-w-360 px-10 mb-20">
<div class="mx-auto max-w-122 flex flex-col gap-6">
<p>
{{ 'password_reset.check_email.description' | trans }}
</p>
<p>
{{ 'password_reset.check_email.timeToLive' | trans({ '%ttl%': resetToken.expirationMessageKey|trans(resetToken.expirationMessageData, 'ResetPasswordBundle') }) }}
</p>
<p>
{{ 'password_reset.check_email.checkSpam' | trans({ '%address%': mailerAddress }) }}
</p>
</div>
</div>
{% endblock %}
+8
View File
@@ -0,0 +1,8 @@
<h1>{{ 'greeting' | trans({ '%name%': user.person.firstName }) }}</h1>
<p>{{ 'password_reset.email.description' | trans }}</p>
<p>{{ 'password_reset.email.beforeLink' | trans }}</p>
<a href="{{ url('app_reset_password', {token: resetToken.token}) }}">{{ url('app_reset_password', {token: resetToken.token}) }}</a>
<p>{{ 'password_reset.email.timeToLive' | trans({ '%ttl%': resetToken.expirationMessageKey|trans(resetToken.expirationMessageData, 'ResetPasswordBundle') }) }}</p>
@@ -0,0 +1,62 @@
{% extends 'base.html.twig' %}
{% block title %}
{{ 'password_reset.request.title' | trans }}
{% endblock %}
{% block body %}
<div class="mx-auto max-w-360 px-10 mb-20">
<div class="mx-auto max-w-122 flex flex-col gap-6">
{% for flash_error in app.flashes('reset_password_error') %}
<div class="alert alert-danger" role="alert">
{{ flash_error }}
</div>
{% endfor %}
<h1 class="text-2xl font-bold tracking-[0%] text-black mb-8 md:text-3xl md:leading-tight lg:text-[40px] lg:leading-12">
{{ 'password_reset.request.title' | trans }}
</h1>
{{ form_start(requestForm, {
attr: {
class: 'flex flex-col gap-6'
}
}) }}
<div class="relative">
{{ form_label(requestForm.email, null, {
label_attr: {
class: 'absolute -top-2 left-4 -translate-y-1/2 transform text-sm font-medium text-gray-500 transition-all'
}
}) }}
{{ form_widget(requestForm.email, {
attr: {
class: 'form-control peer border-secondary focus:ring-tertiary w-full rounded-md border bg-white p-4 text-base text-gray-700 placeholder-gray-400 focus:ring-1 focus:outline-none',
placeholder: 'password_reset.request.email.placeholder' | trans,
autocomplete: 'email'
}
}) }}
{{ form_errors(requestForm.email) }}
</div>
<p class="text-content">
{{ 'password_reset.request.description' | trans }}
</p>
<div class="flex flex-col items-end">
<button
class="btn btn-lg btn-primary text-secondary disabled:bg-grey flex items-center justify-center gap-2 font-bold transition hover:cursor-pointer px-8 py-4 rounded-3xl bg-primary w-fit"
type="submit"
>
{{ 'password_reset.request.submit' | trans }}
</button>
</div>
{{ form_end(requestForm) }}
</div>
</div>
{% endblock %}
+75
View File
@@ -0,0 +1,75 @@
{% extends 'base.html.twig' %}
{% block title %}
{{ 'password_reset.reset.title' | trans }}
{% endblock %}
{% block body %}
<div class="mx-auto max-w-360 px-10 mb-20">
<div class="mx-auto max-w-122 flex flex-col gap-6">
<h1 class="text-2xl font-bold tracking-[0%] text-black mb-8 md:text-3xl md:leading-tight lg:text-[40px] lg:leading-12">
{{ 'password_reset.reset.title' | trans }}
</h1>
{{ form_start(resetForm, {
attr: {
class: 'flex flex-col gap-6'
}
}) }}
{# New password #}
<div class="relative">
{{ form_label(resetForm.plainPassword.first, null, {
label_attr: {
class: 'absolute -top-2 left-4 -translate-y-1/2 transform text-sm font-medium text-gray-500 transition-all'
}
}) }}
{{ form_widget(resetForm.plainPassword.first, {
attr: {
class: 'form-control peer border-secondary focus:ring-tertiary w-full rounded-md border bg-white p-4 text-base text-gray-700 placeholder-gray-400 focus:ring-1 focus:outline-none',
placeholder: 'login.password.placeholder' | trans,
autocomplete: 'new-password'
}
}) }}
{{ form_errors(resetForm.plainPassword.first) }}
</div>
{# Repeat password #}
<div class="relative">
{{ form_label(resetForm.plainPassword.second, null, {
label_attr: {
class: 'absolute -top-2 left-4 -translate-y-1/2 transform text-sm font-medium text-gray-500 transition-all'
}
}) }}
{{ form_widget(resetForm.plainPassword.second, {
attr: {
class: 'form-control peer border-secondary focus:ring-tertiary w-full rounded-md border bg-white p-4 text-base text-gray-700 placeholder-gray-400 focus:ring-1 focus:outline-none',
placeholder: 'login.password.placeholder' | trans,
autocomplete: 'new-password'
}
}) }}
{{ form_errors(resetForm.plainPassword.second) }}
</div>
{# Password mismatch errors #}
{{ form_errors(resetForm.plainPassword) }}
<div class="flex flex-col items-end">
<button
class="btn btn-lg btn-primary text-secondary disabled:bg-grey flex items-center justify-center gap-2 font-bold transition hover:cursor-pointer px-8 py-4 rounded-3xl bg-primary w-fit"
type="submit"
>
{{ 'password_reset.reset.submit' | trans }}
</button>
</div>
{{ form_end(resetForm) }}
</div>
</div>
{% endblock %}
+16 -39
View File
@@ -8,6 +8,7 @@ use App\Entity\Project\ProjectDeadline;
use App\Entity\Project\ProjectStatus;
use App\Entity\User\User;
use App\Factory\Project\ProjectFactory;
use App\Factory\User\UserFactory;
use App\Service\Project\ProjectService;
use Doctrine\ORM\EntityManagerInterface;
use Zenstruck\Foundry\Test\Factories;
@@ -22,25 +23,22 @@ class ProjectCalendarTest extends ApiTestCase
private ProjectService $projectService;
private User $owner;
public function setUp(): void
protected function setUp(): void
{
self::bootKernel();
parent::setUp();
$this->entityManager = static::getContainer()->get(EntityManagerInterface::class);
$this->projectService = static::getContainer()->get(ProjectService::class);
$this->owner = $this->createTestUser();
$this->entityManager->persist($this->owner);
$this->entityManager->flush();
}
private function createTestUser(string $handle = 'test_user', string $email = 'testuser@example.com'): User
{
$user = new User();
$user->setHandle($handle);
$user->setEmail($email);
$user->setPassword('projectapitestpassword');
return $user;
return UserFactory::new([
'handle' => $handle,
'email' => $email,
'password' => 'projectapitestpassword',
])->create()->_real();
}
private function createTestProject(ProjectDeadline $deadline = ProjectDeadline::Minimum): Project
@@ -52,17 +50,10 @@ class ProjectCalendarTest extends ApiTestCase
])->_real();
}
private function createProjectAndSetToInCampaign(
ProjectDeadline $deadline = ProjectDeadline::Minimum,
): Project {
private function createProjectAndSetToInCampaign(ProjectDeadline $deadline = ProjectDeadline::Minimum): Project
{
$project = $this->createTestProject($deadline);
$this->entityManager->persist($project);
$this->entityManager->flush();
$project = $this->projectService->transition($project, ProjectStatus::InCampaign);
$this->entityManager->persist($project);
$this->entityManager->flush();
return $project;
@@ -71,46 +62,32 @@ class ProjectCalendarTest extends ApiTestCase
public function testReleaseDateUpdatesOnCampaignStart(): void
{
$project = $this->createProjectAndSetToInCampaign();
$today = new \DateTime();
$today->setTime(0, 0, 0, 0);
$today = new \DateTimeImmutable('today');
$releaseDate = $project->getCalendar()->release;
$this->assertNotNull($project->getCalendar()->release);
$this->assertGreaterThan($today, $project->getCalendar()->release);
$this->assertNotNull($releaseDate);
$this->assertGreaterThan($today, $releaseDate);
}
public function testMinimumDeadlineIsSetCorrectly(): void
{
$project = $this->createProjectAndSetToInCampaign(ProjectDeadline::Minimum);
$releaseDate = $project->getCalendar()->release;
$minimumDeadline = $project->getCalendar()->minimum;
$this->assertNotNull($releaseDate);
$this->assertNotNull($minimumDeadline);
$this->assertGreaterThan($releaseDate, $minimumDeadline);
$this->assertEquals($releaseDate->modify('+40 days'), $minimumDeadline);
}
public function testOptimumDeadlineIsSetCorrectly(): void
{
$project = $this->createProjectAndSetToInCampaign(ProjectDeadline::Optimum);
$minimumDeadline = $project->getCalendar()->minimum;
$optimumDeadline = $project->getCalendar()->optimum;
$this->assertNotNull($minimumDeadline);
$this->assertNotNull($optimumDeadline);
$this->assertGreaterThan($minimumDeadline, $optimumDeadline);
$this->assertEquals($minimumDeadline->modify('+40 days'), $optimumDeadline);
}
public function testProjectNotInCampaignHasNoCalendar(): void
{
$project = $this->createTestProject();
$this->entityManager->persist($project);
$this->entityManager->flush();
$this->assertNull($project->getCalendar()->release);
$this->assertNull($project->getCalendar()->minimum);
$this->assertNull($project->getCalendar()->optimum);
}
}
+14 -35
View File
@@ -9,61 +9,41 @@ use App\Entity\Project\ProjectStatus;
use App\Entity\Project\Review;
use App\Entity\Project\ReviewType;
use App\Entity\User\User;
use App\Factory\Project\ProjectFactory;
use App\Service\Project\ProjectService;
use Doctrine\ORM\EntityManagerInterface;
use Zenstruck\Foundry\Test\Factories;
use Zenstruck\Foundry\Test\ResetDatabase;
class ProjectCampaignReviewTest extends ApiTestCase
{
use ResetDatabase;
use Factories;
private EntityManagerInterface $entityManager;
private ProjectService $projectService;
private User $owner;
public function setUp(): void
protected function setUp(): void
{
self::bootKernel();
parent::setUp();
$this->entityManager = static::getContainer()->get(EntityManagerInterface::class);
$this->projectService = static::getContainer()->get(ProjectService::class);
$this->owner = $this->createTestUser();
$this->owner = new User();
$this->owner->setHandle('test_user');
$this->owner->setEmail('testuser@example.com');
$this->owner->setPassword('projectapitestpassword');
$this->entityManager->persist($this->owner);
$this->entityManager->flush();
}
private function createTestUser(string $handle = 'test_user', string $email = 'testuser@example.com'): User
{
$user = new User();
$user->setHandle($handle);
$user->setEmail($email);
$user->setPassword('projectapitestpassword');
return $user;
}
private function createTestProject(ProjectDeadline $deadline = ProjectDeadline::Minimum): Project
{
return ProjectFactory::createOne([
'owner' => $this->owner,
'deadline' => $deadline,
'status' => ProjectStatus::InDraft,
])->_real();
}
private function createProjectAndSetToInCampaignReview(): Project
{
$project = $this->createTestProject();
$project = new Project();
$project->setTitle('test project title');
$project->setSubtitle('test project subtitle');
$project->setOwner($this->owner);
$project->setStatus(ProjectStatus::InDraft);
$project->setDeadline(ProjectDeadline::Minimum);
$this->entityManager->persist($project);
$this->entityManager->flush();
$project = $this->projectService->transition($project, ProjectStatus::ToCampaignReview);
$this->entityManager->persist($project);
$this->entityManager->flush();
return $project;
@@ -74,8 +54,7 @@ class ProjectCampaignReviewTest extends ApiTestCase
$project = $this->createProjectAndSetToInCampaignReview();
$reviews = $this->entityManager->getRepository(Review::class)->findBy(['project' => $project->getId()]);
$this->assertNotEmpty($reviews);
$this->assertEquals(1, \count($reviews));
$this->assertEquals(ReviewType::Campaign->value, $reviews[0]->getType()->value);
$this->assertCount(1, $reviews);
$this->assertSame(ReviewType::Campaign->value, $reviews[0]->getType()->value);
}
}
+30
View File
@@ -21,6 +21,36 @@ consent:
consent: Yes, allow access
submit: Continue
password_reset:
request:
title: Reset your password
description: Enter your email address, and we will send you a link to reset your password.
email:
placeholder: johndoe@example.com
submit: Send password reset email
email:
subject: Password reset request
description: A password reset has been requested for your account. If you do not recognize this activity please ignore this message.
beforeLink: Click (or copy-and-paste) the following link to reset your password.
timeToLive: This link will expire in %ttl%.
check_email:
title: Password reset email sent
description: If an account matching your email exists, then an email was just sent that contains a link that you can use to reset your password.
timeToLive: That link will expire in %ttl%.
checkSpam: If you don't see the email please check your spam folder and look for messages from %address%.
reset:
title: Reset your password
labelNew: New password
labelRepeat: Repeat new password
errorEmpty: Please enter a password
errorLength: Your password should be at least {{ limit }} characters
errorNoMatch: The password fields must match
submit: Reset password
clients:
title: Goteo sites
description: This site is an instance of <a class="text-primary" href="/v4">the Goteo v4 API</a>, these clients are connected to it.
footer:
legal:
termsOfUse: Terms of use