chore: sync main to open-core develop
This commit is contained in:
@@ -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=""
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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],
|
||||
];
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
framework:
|
||||
mailer:
|
||||
dsn: '%env(MAILER_DSN)%'
|
||||
@@ -0,0 +1,2 @@
|
||||
symfonycasts_reset_password:
|
||||
request_password_repository: App\Repository\ResetPasswordRequestRepository
|
||||
@@ -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 ###
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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('');
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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([]);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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,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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user