Skip to content
Snippets Groups Projects
Verified Commit 021d8270 authored by David Beniamine's avatar David Beniamine
Browse files

FIX password and force dt_maj not null

parent 47707ce3
No related branches found
No related tags found
1 merge request!46Tuleap 88 db migration
...@@ -20,7 +20,7 @@ final class Version20211230115034 extends AbstractMigration ...@@ -20,7 +20,7 @@ final class Version20211230115034 extends AbstractMigration
public function up(Schema $schema): void public function up(Schema $schema): void
{ {
// this up() migration is auto-generated, please modify it to your needs // this up() migration is auto-generated, please modify it to your needs
$this->addSql('CREATE TABLE capsule (id INT AUTO_INCREMENT NOT NULL, aut_crea INT DEFAULT NULL, aut_maj INT DEFAULT NULL, nom VARCHAR(255) NOT NULL, dt_crea DATETIME NOT NULL, dt_maj DATETIME NOT NULL, link VARCHAR(255) NOT NULL, edition_link VARCHAR(255) NOT NULL, INDEX IDX_C268A183B11ABDF2 (aut_crea), INDEX IDX_C268A183E5F0D775 (aut_maj), UNIQUE INDEX index_capsule_nom (nom), PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8 COLLATE `utf8_unicode_ci` ENGINE = InnoDB'); $this->addSql('CREATE TABLE capsule (id INT AUTO_INCREMENT NOT NULL, aut_crea INT NOT NULL, aut_maj INT DEFAULT NULL, nom VARCHAR(255) NOT NULL, dt_crea DATETIME NOT NULL, dt_maj DATETIME NOT NULL, link VARCHAR(255) NOT NULL, edition_link VARCHAR(255) NOT NULL, INDEX IDX_C268A183B11ABDF2 (aut_crea), INDEX IDX_C268A183E5F0D775 (aut_maj), UNIQUE INDEX index_capsule_nom (nom), PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8 COLLATE `utf8_unicode_ci` ENGINE = InnoDB');
$this->addSql('CREATE TABLE editeur_capsule (capsule_id INT NOT NULL, user_id INT NOT NULL, INDEX IDX_A18592E2714704E9 (capsule_id), INDEX IDX_A18592E2A76ED395 (user_id), PRIMARY KEY(capsule_id, user_id)) DEFAULT CHARACTER SET utf8 COLLATE `utf8_unicode_ci` ENGINE = InnoDB'); $this->addSql('CREATE TABLE editeur_capsule (capsule_id INT NOT NULL, user_id INT NOT NULL, INDEX IDX_A18592E2714704E9 (capsule_id), INDEX IDX_A18592E2A76ED395 (user_id), PRIMARY KEY(capsule_id, user_id)) DEFAULT CHARACTER SET utf8 COLLATE `utf8_unicode_ci` ENGINE = InnoDB');
$this->addSql('ALTER TABLE capsule ADD CONSTRAINT FK_C268A183B11ABDF2 FOREIGN KEY (aut_crea) REFERENCES `user` (id)'); $this->addSql('ALTER TABLE capsule ADD CONSTRAINT FK_C268A183B11ABDF2 FOREIGN KEY (aut_crea) REFERENCES `user` (id)');
$this->addSql('ALTER TABLE capsule ADD CONSTRAINT FK_C268A183E5F0D775 FOREIGN KEY (aut_maj) REFERENCES `user` (id)'); $this->addSql('ALTER TABLE capsule ADD CONSTRAINT FK_C268A183E5F0D775 FOREIGN KEY (aut_maj) REFERENCES `user` (id)');
......
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20220128162555 extends AbstractMigration
{
public function getDescription(): string
{
return '';
}
public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
$this->addSql('ALTER TABLE user ADD accept_gnl_conditions TINYINT(1) NOT NULL DEFAULT FALSE, ADD inscription_newsletter TINYINT(1) NOT NULL DEFAULT FALSE');
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql('ALTER TABLE `user` DROP accept_gnl_conditions, DROP inscription_newsletter');
}
}
...@@ -4,62 +4,52 @@ namespace App\Builder; ...@@ -4,62 +4,52 @@ namespace App\Builder;
use App\Entity\User; use App\Entity\User;
use App\Helper\ContractHelper; use App\Helper\ContractHelper;
use App\Helper\StringHelper;
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface; use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
class UserBuilder class UserBuilder
{ {
private UserPasswordHasherInterface $password_hasher; private UserPasswordHasherInterface $password_hasher;
public User $user; public User $user;
private bool $hasRequiredEmail = false;
private bool $hasRequiredFirstName = false; public function __construct(UserPasswordHasherInterface $password_hasher, ?User $user = null)
private bool $hasRequiredLastName = false;
private bool $hasRequiredPassword = false;
private bool $hasRequiredSalt = false;
private bool $hasRequiredRoles = false;
private bool $hasRequiredIsVerified = false;
public function __construct(UserPasswordHasherInterface $password_hasher)
{ {
if (!$user instanceof User) {
$this->user = new User(); $this->user = new User();
} else {
$this->user = $user;
}
$this->password_hasher = $password_hasher; $this->password_hasher = $password_hasher;
$this->user->setIsVerified(false);
} }
public function withEmail(string $email): UserBuilder public function withEmail(string $email): UserBuilder
{ {
$this->user->setEmail($email); $this->user->setEmail($email);
$this->hasRequiredEmail = true;
return $this; return $this;
} }
public function withFirstName(string $firstName): UserBuilder public function withFirstName(string $firstName): UserBuilder
{ {
$this->user->setFirstName($firstName); $this->user->setFirstName($firstName);
$this->hasRequiredFirstName = true;
return $this; return $this;
} }
public function withLastName(string $lastName): UserBuilder public function withLastName(string $lastName): UserBuilder
{ {
$this->user->setLastName($lastName); $this->user->setLastName($lastName);
$this->hasRequiredLastName = true;
return $this; return $this;
} }
public function withSalt(string $salt): UserBuilder public function withPassword(string $salt, string $plainPassword): UserBuilder
{
$this->user->setSalt($salt);
$this->hasRequiredSalt = true;
return $this;
}
public function withPassword(string $password): UserBuilder
{ {
ContractHelper::requires( ContractHelper::requires(
$this->hasRequiredSalt, !StringHelper::isNullOrWhitespace($plainPassword),
"The call of UserBuilder::withSalt should be called before UserBuilder::withPassword" 'A user should have none empty password'
); );
$this->user->setPassword($this->password_hasher->hashPassword($this->user, $password)); $this->user->setSalt($salt);
$this->hasRequiredPassword = true; $this->user->setPassword($this->password_hasher->hashPassword($this->user, $plainPassword));
return $this; return $this;
} }
...@@ -73,48 +63,50 @@ class UserBuilder ...@@ -73,48 +63,50 @@ class UserBuilder
} }
$this->user->setRoles($roles); $this->user->setRoles($roles);
$this->hasRequiredRoles = true;
return $this; return $this;
} }
public function withIsVerified(bool $is_verified): UserBuilder public function withIsVerified(bool $is_verified): UserBuilder
{ {
$this->user->setIsVerified($is_verified); $this->user->setIsVerified($is_verified);
$this->hasRequiredIsVerified = true;
return $this; return $this;
} }
public function createUser(): User public function createUser(): User
{ {
ContractHelper::requires( ContractHelper::requires(
$this->hasRequiredEmail, $this->user->getEmail() !== null && filter_var($this->user->getEmail(), FILTER_VALIDATE_EMAIL),
"The call of UserBuilder::withEmail should be called before UserBuilder::create" "A user should have a valid email (current:'" . $this->user->getEmail() . "')"
);
ContractHelper::requires(
$this->hasRequiredLastName,
"The call of UserBuilder::withLastName should be called before UserBuilder::create"
); );
ContractHelper::requires( ContractHelper::requires(
$this->hasRequiredFirstName, !StringHelper::isNullOrWhitespace($this->user->getLastName()),
"The call of UserBuilder::withFirstName should be called before UserBuilder::create" "A user must have a last name (current:'" . $this->user->getLastName() . "')"
); );
ContractHelper::requires( ContractHelper::requires(
$this->hasRequiredPassword, !StringHelper::isNullOrWhitespace($this->user->getFirstName()),
"The call of UserBuilder::withPassword should be called before UserBuilder::create" "A user must have a first name (current:'" . $this->user->getFirstName() . "')"
); );
ContractHelper::requires( ContractHelper::requires(
$this->hasRequiredSalt, !StringHelper::isNullOrWhitespace($this->user->getPassword()),
"The call of UserBuilder::withSalt should be called before UserBuilder::create" "A user must have a have a none empty or whitespace password"
); );
ContractHelper::requires( ContractHelper::requires(
$this->hasRequiredRoles, !empty($this->user->getRoles()),
"The call of UserBuilder::withRoles should be called before UserBuilder::create" "A user must have a have roles"
);
ContractHelper::requires(
$this->hasRequiredIsVerified,
"The call of UserBuilder::withIsVerified should be called before UserBuilder::create"
); );
return $this->user; return $this->user;
} }
public function withAcceptGeneralConditions(bool $value): UserBuilder
{
$this->user->setAcceptGeneralConditions($value);
return $this;
}
public function withNewsLetterSubscription(bool $newsLetterSubscription)
{
$this->user->setSubscribedToNewsLetter($newsLetterSubscription);
}
} }
...@@ -2,6 +2,7 @@ ...@@ -2,6 +2,7 @@
namespace App\Controller; namespace App\Controller;
use App\Builder\UserBuilder;
use App\Entity\Capsule; use App\Entity\Capsule;
use App\Entity\PendingEditorInvitation; use App\Entity\PendingEditorInvitation;
use App\Entity\User; use App\Entity\User;
...@@ -53,17 +54,20 @@ class RegistrationController extends AbstractController ...@@ -53,17 +54,20 @@ class RegistrationController extends AbstractController
$form->handleRequest($request); $form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) { if ($form->isSubmitted() && $form->isValid()) {
$user->setSalt(random_bytes(100)); $user = $form->getData();
$userBuilder = new UserBuilder($userPasswordHasher, $user);
// encode the plain password // Ugly fix because I don't understand why those values aren't set correctly
$user->setPassword( $userBuilder->withAcceptGeneralConditions($form->get('acceptGeneralConditions')->getData());
$userPasswordHasher->hashPassword( $userBuilder->withNewsLetterSubscription($form->get('subscribedToNewsLetter')->getData() ?? false);
$user,
$userBuilder
->withPassword(
random_bytes(100),
$form->get('plainPassword')->getData() $form->get('plainPassword')->getData()
)
); );
$this->entity_manager->persist($user); $this->entity_manager->persist($userBuilder->createUser());
$this->entity_manager->flush(); $this->entity_manager->flush();
// generate a signed url and email it to the user // generate a signed url and email it to the user
......
...@@ -26,8 +26,7 @@ class UserFixtures extends Fixture ...@@ -26,8 +26,7 @@ class UserFixtures extends Fixture
return $builder->withEmail("notVerified@localhost.com") return $builder->withEmail("notVerified@localhost.com")
->withFirstName("Bob") ->withFirstName("Bob")
->withLastName("Smith") ->withLastName("Smith")
->withSalt("") ->withPassword('', 'password')
->withPassword('password')
->withRoles([]) ->withRoles([])
->withIsVerified(false); ->withIsVerified(false);
} }
...@@ -38,10 +37,10 @@ class UserFixtures extends Fixture ...@@ -38,10 +37,10 @@ class UserFixtures extends Fixture
return $builder->withEmail("defaultUser@localhost.com") return $builder->withEmail("defaultUser@localhost.com")
->withFirstName("Alice") ->withFirstName("Alice")
->withLastName("Rango") ->withLastName("Rango")
->withSalt("") ->withPassword('', 'password')
->withPassword('password')
->withRoles([]) ->withRoles([])
->withIsVerified(true); ->withIsVerified(true)
->withAcceptGeneralConditions(true);
} }
); );
...@@ -50,8 +49,7 @@ class UserFixtures extends Fixture ...@@ -50,8 +49,7 @@ class UserFixtures extends Fixture
return $builder->withEmail("defaultUser2@localhost.com") return $builder->withEmail("defaultUser2@localhost.com")
->withFirstName("John") ->withFirstName("John")
->withLastName("Doe") ->withLastName("Doe")
->withSalt("") ->withPassword("", 'password')
->withPassword('password')
->withRoles([]) ->withRoles([])
->withIsVerified(true); ->withIsVerified(true);
} }
......
...@@ -26,6 +26,16 @@ class User implements UserInterface, LegacyPasswordAuthenticatedUserInterface ...@@ -26,6 +26,16 @@ class User implements UserInterface, LegacyPasswordAuthenticatedUserInterface
*/ */
private int $id; private int $id;
/**
* @ORM\Column(type="boolean", name="accept_gnl_conditions")
*/
private bool $acceptGeneralConditions;
/**
* @ORM\Column(type="boolean", name="inscription_newsletter")
*/
private bool $is_subscribed_news_letter;
/** /**
* @ORM\Column(type="string", length=255) * @ORM\Column(type="string", length=255)
* @Assert\Email(message = "The email {{ value }} is not a valid email.") * @Assert\Email(message = "The email {{ value }} is not a valid email.")
...@@ -76,7 +86,7 @@ class User implements UserInterface, LegacyPasswordAuthenticatedUserInterface ...@@ -76,7 +86,7 @@ class User implements UserInterface, LegacyPasswordAuthenticatedUserInterface
/** /**
* @ORM\Column(type="boolean", name="enabled") * @ORM\Column(type="boolean", name="enabled")
*/ */
private bool $isVerified = false; private bool $isVerified;
/** /**
* @ORM\Column(type="string", length=255, name="salt") * @ORM\Column(type="string", length=255, name="salt")
...@@ -92,6 +102,9 @@ class User implements UserInterface, LegacyPasswordAuthenticatedUserInterface ...@@ -92,6 +102,9 @@ class User implements UserInterface, LegacyPasswordAuthenticatedUserInterface
public function __construct() public function __construct()
{ {
$this->capsules = new ArrayCollection(); $this->capsules = new ArrayCollection();
$this->acceptGeneralConditions = false;
$this->isVerified = false;
$this->credentialExpired = false;
} }
public function getId(): int public function getId(): int
...@@ -248,4 +261,36 @@ class User implements UserInterface, LegacyPasswordAuthenticatedUserInterface ...@@ -248,4 +261,36 @@ class User implements UserInterface, LegacyPasswordAuthenticatedUserInterface
$this->capsules->removeElement($capsule); $this->capsules->removeElement($capsule);
return $this; return $this;
} }
/**
* @return bool
*/
public function hasAcceptGeneralConditions(): bool
{
return $this->acceptGeneralConditions;
}
/**
* @param bool $acceptGeneralConditions
*/
public function setAcceptGeneralConditions(bool $acceptGeneralConditions): void
{
$this->acceptGeneralConditions = $acceptGeneralConditions;
}
/**
* @return bool
*/
public function isSubscribedToNewsLetter(): bool
{
return $this->is_subscribed_news_letter;
}
/**
* @param bool $is_subscribed_news_letter
*/
public function setSubscribedToNewsLetter(bool $is_subscribed_news_letter): void
{
$this->is_subscribed_news_letter = $is_subscribed_news_letter;
}
} }
...@@ -5,8 +5,10 @@ namespace App\Form; ...@@ -5,8 +5,10 @@ namespace App\Form;
use App\Entity\User; use App\Entity\User;
use Gregwar\CaptchaBundle\Type\CaptchaType; use Gregwar\CaptchaBundle\Type\CaptchaType;
use Symfony\Component\Form\AbstractType; use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\CallbackTransformer;
use Symfony\Component\Form\Extension\Core\Type\CheckboxType; use Symfony\Component\Form\Extension\Core\Type\CheckboxType;
use Symfony\Component\Form\Extension\Core\Type\EmailType; use Symfony\Component\Form\Extension\Core\Type\EmailType;
use Symfony\Component\Form\Extension\Core\Type\HiddenType;
use Symfony\Component\Form\Extension\Core\Type\PasswordType; use Symfony\Component\Form\Extension\Core\Type\PasswordType;
use Symfony\Component\Form\Extension\Core\Type\RepeatedType; use Symfony\Component\Form\Extension\Core\Type\RepeatedType;
use Symfony\Component\Form\Extension\Core\Type\SubmitType; use Symfony\Component\Form\Extension\Core\Type\SubmitType;
...@@ -75,7 +77,7 @@ class RegistrationFormType extends AbstractType ...@@ -75,7 +77,7 @@ class RegistrationFormType extends AbstractType
] ]
) )
->add( ->add(
'agreeTerms', 'acceptGeneralConditions',
CheckboxType::class, CheckboxType::class,
[ [
'mapped' => false, 'mapped' => false,
...@@ -86,6 +88,7 @@ class RegistrationFormType extends AbstractType ...@@ -86,6 +88,7 @@ class RegistrationFormType extends AbstractType
'label' => 'registration.agreeTerms' 'label' => 'registration.agreeTerms'
] ]
) )
->add('subscribedToNewsLetter', HiddenType::class)
->add( ->add(
'submit', 'submit',
SubmitType::class, SubmitType::class,
......
...@@ -8,4 +8,9 @@ class StringHelper ...@@ -8,4 +8,9 @@ class StringHelper
{ {
return strtoupper(sha1(random_bytes(100))); return strtoupper(sha1(random_bytes(100)));
} }
public static function isNullOrWhitespace(?string $str): bool
{
return null === $str || '' === trim($str);
}
} }
...@@ -17,7 +17,7 @@ ...@@ -17,7 +17,7 @@
{{ form_row(registrationForm.plainPassword.first, {'row_attr': {'class' : 'form-group d-flex flex-column m-auto mb-4 col-12 col-sm-10 col-md-9 col-lg-8 col-xl-7 col-xxl-5'}}) }} {{ form_row(registrationForm.plainPassword.first, {'row_attr': {'class' : 'form-group d-flex flex-column m-auto mb-4 col-12 col-sm-10 col-md-9 col-lg-8 col-xl-7 col-xxl-5'}}) }}
{{ form_row(registrationForm.plainPassword.second, {'row_attr': {'class' : 'form-group d-flex flex-column m-auto mb-4 col-12 col-sm-10 col-md-9 col-lg-8 col-xl-7 col-xxl-5'}}) }} {{ form_row(registrationForm.plainPassword.second, {'row_attr': {'class' : 'form-group d-flex flex-column m-auto mb-4 col-12 col-sm-10 col-md-9 col-lg-8 col-xl-7 col-xxl-5'}}) }}
{{ form_row(registrationForm.captcha, {'row_attr': {'class' : 'form-group d-flex flex-column m-auto mb-5 col-12 col-sm-10 col-md-9 col-lg-8 col-xl-7 col-xxl-5'}}) }} {{ form_row(registrationForm.captcha, {'row_attr': {'class' : 'form-group d-flex flex-column m-auto mb-5 col-12 col-sm-10 col-md-9 col-lg-8 col-xl-7 col-xxl-5'}}) }}
{{ form_row(registrationForm.agreeTerms, {'row_attr': {'class' : 'form-group d-flex flex-column m-auto mb-4 col-auto justify-content-center'}, 'label_attr': { 'class' : 'ms-3'}}) }} {{ form_row(registrationForm.acceptGeneralConditions, {'row_attr': {'class' : 'form-group d-flex flex-column m-auto mb-4 col-auto justify-content-center'}, 'label_attr': { 'class' : 'ms-3'}}) }}
{{ form_row(registrationForm.submit, {'row_attr': {'class' : 'form-group d-flex flex-column m-auto mb-5'}}) }} {{ form_row(registrationForm.submit, {'row_attr': {'class' : 'form-group d-flex flex-column m-auto mb-5'}}) }}
{{ form_end(registrationForm) }} {{ form_end(registrationForm) }}
</div> </div>
......
...@@ -5,6 +5,7 @@ namespace App\Tests\functional; ...@@ -5,6 +5,7 @@ namespace App\Tests\functional;
use App\Entity\Capsule; use App\Entity\Capsule;
use App\Entity\User; use App\Entity\User;
use App\Repository\UserRepository; use App\Repository\UserRepository;
use Exception;
use Symfony\Bundle\FrameworkBundle\KernelBrowser; use Symfony\Bundle\FrameworkBundle\KernelBrowser;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase; use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
use Symfony\Component\DomCrawler\Crawler; use Symfony\Component\DomCrawler\Crawler;
...@@ -21,6 +22,21 @@ class RegistrationControllerTest extends WebTestCase ...@@ -21,6 +22,21 @@ class RegistrationControllerTest extends WebTestCase
$this->client = static::createClient(); $this->client = static::createClient();
} }
public function testNewUserRegistrationShouldAcceptGeneralConditions(): void
{
$userEmail = 'newUser@localhost.com';
$this->registerUser($userEmail, $this->client);
$user = $this->getUserByEmail($userEmail);
$this->assertEquals(
true,
$user->hasAcceptGeneralConditions(),
'The user should accept the general conditions before registration'
);
}
public function testNewUserRegistrationShouldBeNotifiedOfAccountValidationByMail(): void public function testNewUserRegistrationShouldBeNotifiedOfAccountValidationByMail(): void
{ {
$userEmail = 'newUser@localhost.com'; $userEmail = 'newUser@localhost.com';
...@@ -115,6 +131,7 @@ class RegistrationControllerTest extends WebTestCase ...@@ -115,6 +131,7 @@ class RegistrationControllerTest extends WebTestCase
/** /**
* @param string $userEmail The registered user email * @param string $userEmail The registered user email
* @return RawMessage The email message sent to the user * @return RawMessage The email message sent to the user
* @throws Exception
*/ */
private function checkEmailHasBeenSentAndGetEmailMessage(string $userEmail): RawMessage private function checkEmailHasBeenSentAndGetEmailMessage(string $userEmail): RawMessage
{ {
...@@ -126,7 +143,7 @@ class RegistrationControllerTest extends WebTestCase ...@@ -126,7 +143,7 @@ class RegistrationControllerTest extends WebTestCase
$emailMessage = $this->getMailerMessage(0); $emailMessage = $this->getMailerMessage(0);
if (null === $emailMessage) { if (null === $emailMessage) {
throw new \Exception("Email message could not be found"); throw new Exception("Email message could not be found");
} }
$this->assertEmailAddressContains( $this->assertEmailAddressContains(
...@@ -168,7 +185,7 @@ class RegistrationControllerTest extends WebTestCase ...@@ -168,7 +185,7 @@ class RegistrationControllerTest extends WebTestCase
$form['registration_form[email]'] = $userEmail; $form['registration_form[email]'] = $userEmail;
$form['registration_form[plainPassword][first]'] = 'password'; $form['registration_form[plainPassword][first]'] = 'password';
$form['registration_form[plainPassword][second]'] = 'password'; $form['registration_form[plainPassword][second]'] = 'password';
$form['registration_form[agreeTerms]'] = "1"; $form['registration_form[acceptGeneralConditions]'] = "1";
return $client->submit($form); return $client->submit($form);
} }
...@@ -187,7 +204,7 @@ class RegistrationControllerTest extends WebTestCase ...@@ -187,7 +204,7 @@ class RegistrationControllerTest extends WebTestCase
$user = $userRepository->findOneByEmail($userEmail); $user = $userRepository->findOneByEmail($userEmail);
if (! $user instanceof User) { if (! $user instanceof User) {
throw new \Exception("User does not exist."); throw new Exception("User does not exist.");
} }
return $user; return $user;
......
...@@ -3,10 +3,10 @@ general: ...@@ -3,10 +3,10 @@ general:
password: Mot de passe password: Mot de passe
sign_in: Se connecter sign_in: Se connecter
log_out: Se déconnecter log_out: Se déconnecter
link_expire: Le lien expirera dans
greeting: Salutation !
go_back_to_home_page: Page d'accueil go_back_to_home_page: Page d'accueil
cancel_button: Annuler cancel_button: Annuler
link_expire: Le lien expirera dans
greeting: Salutation !
validate: Valider validate: Valider
save: Enregistrer save: Enregistrer
......
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Please register or to comment