From 3744d81035cc016994757bb846f54685e9941e7b Mon Sep 17 00:00:00 2001 From: Charles Date: Mon, 17 Nov 2025 11:50:34 +0100 Subject: [PATCH] update project to allow sending of email --- assets/controllers/user_controller.js | 28 ++-- config/packages/security.yaml | 5 + config/packages/twig.yaml | 3 + src/Controller/OrganizationController.php | 7 +- src/Controller/SecurityController.php | 65 ++++++++- src/Controller/UserController.php | 128 ++++++++++++++++-- src/Entity/User.php | 33 ++++- src/Entity/UsersOrganizations.php | 15 ++ src/Service/EmailService.php | 82 +++++++++++ src/Service/UserService.php | 75 +++++++++- .../existing_user_notification.html.twig | 89 ++++++++++++ templates/emails/password_setup.html.twig | 89 ++++++++++++ templates/security/password_setup.html.twig | 40 ++++++ templates/user/index.html.twig | 1 - 14 files changed, 622 insertions(+), 38 deletions(-) create mode 100644 src/Service/EmailService.php create mode 100644 templates/emails/existing_user_notification.html.twig create mode 100644 templates/emails/password_setup.html.twig create mode 100644 templates/security/password_setup.html.twig diff --git a/assets/controllers/user_controller.js b/assets/controllers/user_controller.js index 801a576..52050ac 100644 --- a/assets/controllers/user_controller.js +++ b/assets/controllers/user_controller.js @@ -117,7 +117,7 @@ export default class extends Controller { // Image case: make it fill the same wrapper const img = document.createElement("img"); - img.src = `${this.awsValue || ""}${url}`; + img.src = url; img.alt = initials || "avatar"; img.style.width = "100%"; img.style.height = "100%"; @@ -289,12 +289,6 @@ export default class extends Controller { }; - onSelectChange(row, newValue) { - const data = row.getData(); - - }; - - tableNew() { const columns = [ { @@ -334,7 +328,7 @@ export default class extends Controller { // Image case: make it fill the same wrapper const img = document.createElement("img"); - img.src = `${this.awsValue || ""}${url}`; + img.src = url; img.alt = initials || "avatar"; img.style.width = "100%"; img.style.height = "100%"; @@ -439,7 +433,7 @@ export default class extends Controller { // Image case: make it fill the same wrapper const img = document.createElement("img"); - img.src = `${this.awsValue || ""}${url}`; + img.src = url; img.alt = initials || "avatar"; img.style.width = "100%"; img.style.height = "100%"; @@ -567,7 +561,7 @@ export default class extends Controller { // Image case: make it fill the same wrapper const img = document.createElement("img"); - img.src = `${this.awsValue || ""}${url}`; + img.src = url; img.alt = initials || "avatar"; img.style.width = "100%"; img.style.height = "100%"; @@ -631,6 +625,11 @@ export default class extends Controller { ${sendEmailIcon(userId, orgId)} `; + }if (statut === "INVITED") { + return ` +
+ ${eyeIconLink(url)} +
`; } // Decide which action (deactivate vs activate) for non-expired users @@ -649,14 +648,7 @@ export default class extends Controller { return `
- - - - + ${eyeIconLink(url)} awsService->getPublicUrl($_ENV['S3_PORTAL_BUCKET']) . $org->getLogoUrl(); return [ 'id' => $org->getId(), 'name' => $org->getName(), 'email' => $org->getEmail(), - 'logoUrl' => $org->getLogoUrl() ?: null, + 'logoUrl' => $picture ?: null, 'active' => $org->isActive(), 'showUrl' => $this->generateUrl('organization_show', ['id' => $org->getId()]), ]; diff --git a/src/Controller/SecurityController.php b/src/Controller/SecurityController.php index 145a57c..f38032a 100644 --- a/src/Controller/SecurityController.php +++ b/src/Controller/SecurityController.php @@ -2,7 +2,11 @@ namespace App\Controller; +use App\Repository\UserRepository; +use App\Repository\UsersOrganizationsRepository; use App\Service\AccessTokenService; +use App\Service\UserService; +use Doctrine\ORM\EntityManagerInterface; use Psr\Log\LoggerInterface; use Psr\Log\LogLevel; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; @@ -16,9 +20,14 @@ use App\Service\CguUserService; class SecurityController extends AbstractController { + const NOT_FOUND = "NOT FOUND"; private CguUserService $cguUserService; - public function __construct(CguUserService $cguUserService) + public function __construct(CguUserService $cguUserService, + private readonly UserRepository $userRepository, + private readonly UserService $userService, + private readonly UsersOrganizationsRepository $uoRepository, + private readonly LoggerInterface $logger, private readonly EntityManagerInterface $entityManager) { $this->cguUserService = $cguUserService; } @@ -78,4 +87,58 @@ class SecurityController extends AbstractController return $this->render('security/consent.html.twig'); } + #[Route('/password_setup/{id}', name: 'password_setup', methods: ['GET'])] + public function password_setup(int $id, Request $request): Response + { + $error = $request->get('error'); + $user = $this->userRepository->find($id); + if (!$user) { + throw $this->createNotFoundException(self::NOT_FOUND); + } + $token = $request->get('token'); + if (empty($token) || !$this->userService->isPasswordTokenValid($user, $token)) { + $error = 'Le lien de définition du mot de passe est invalide ou a expiré. Veuillez en demander un nouveau.'; + $this->logger->warning($user->getUserIdentifier(). " tried to use an invalid or expired password setup token."); + } + return $this->render('security/password_setup.html.twig', [ + 'id' => $id, + 'token' => $token, + 'error' => $error, + ]); + } + + #[Route('/password_reset/{id}', name: 'password_reset', methods: ['POST'])] + public function password_reset(int $id): Response + { + $user = $this->userRepository->find($id); + if (!$user) { + throw $this->createNotFoundException(self::NOT_FOUND); + } + $newPassword = $_POST['_password'] ?? null; + $confirmPassword = $_POST['_passwordConfirm'] ?? null; + if ($newPassword !== $confirmPassword) { + $error = 'Les mots de passe ne correspondent pas. Veuillez réessayer.'; + $this->logger->warning($user->getUserIdentifier(). " provided non-matching passwords during password reset."); + return $this->redirectToRoute('password_setup', [ + 'id' => $id, + 'token' => $_POST['token'] ?? '', + 'error'=> $error]); + } + if (!$this->userService->isPasswordStrong($newPassword)) { + $error = 'Le mot de passe ne respecte pas les critères de sécurité. Veuillez en choisir un autre.'; + $this->logger->warning($user->getUserIdentifier(). " provided a weak password during password reset."); + return $this->redirectToRoute('password_setup', ['id' => $id, 'token' => $_POST['token'] ?? '', 'error'=> $error]); + } + $this->userService->updateUserPassword($user, $newPassword); + $orgId = $this->userService->getOrgFromToken( $_POST['token']); + $uo = $this->uoRepository->findOneBy(['users' => $user, 'organization' => $orgId]); + if($uo){ + $uo->setStatut("ACCEPTED"); + $uo->setIsActive(true); + $this->entityManager->persist($uo); + $this->entityManager->flush(); + } + return $this->redirectToRoute('app_index'); + } + } diff --git a/src/Controller/UserController.php b/src/Controller/UserController.php index f5ea550..0fb17e5 100644 --- a/src/Controller/UserController.php +++ b/src/Controller/UserController.php @@ -12,15 +12,21 @@ use App\Repository\OrganizationsRepository; use App\Repository\UserRepository; use App\Repository\UsersOrganizationsRepository; use App\Service\ActionService; +use App\Service\AwsService; +use App\Service\EmailService; use App\Service\UserOrganizationAppService; use App\Service\UserOrganizationService; use App\Service\UserService; use Doctrine\ORM\EntityManagerInterface; use Psr\Log\LoggerInterface; +use Symfony\Bridge\Twig\Mime\TemplatedEmail; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\Mailer\Mailer; +use Symfony\Component\Mailer\MailerInterface; +use Symfony\Component\Mime\Email; use Symfony\Component\Routing\Attribute\Route; #[Route(path: '/user', name: 'user_')] @@ -37,7 +43,7 @@ class UserController extends AbstractController private readonly UserOrganizationService $userOrganizationService, private readonly UserRepository $userRepository, private readonly UsersOrganizationsRepository $uoRepository, - private readonly OrganizationsRepository $organizationRepository, private readonly LoggerInterface $logger, + private readonly OrganizationsRepository $organizationRepository, private readonly LoggerInterface $logger, private readonly EmailService $emailService, private readonly AwsService $awsService, ) { } @@ -82,7 +88,7 @@ class UserController extends AbstractController 'uoActive' => $uoActive ?? null// specific for single organization context and deactivate user from said org ]); } - +//TODO : MONOLOG #[Route('/edit/{id}', name: 'edit', methods: ['GET', 'POST'])] public function edit(int $id, Request $request): Response { @@ -140,6 +146,28 @@ class UserController extends AbstractController $orgId = $request->get('organizationId'); if ($form->isSubmitted() && $form->isValid()) { + $existingUser = $this->userRepository->findOneBy(['email' => $user->getEmail()]); + if ($existingUser && $orgId) { + $org = $this->organizationRepository->find($orgId); + $uo = new UsersOrganizations(); + $uo->setUsers($existingUser); + $uo->setOrganization($org); + $uo->setStatut("INVITED"); + $uo->setIsActive(false); + $uo->setModifiedAt(new \DateTimeImmutable('now')); + $this->entityManager->persist($uo); + $this->entityManager->flush(); + $this->actionService->createAction("Create new user", $existingUser, $org, "Added user to organization" . $existingUser->getUserIdentifier() . " for organization " . $org->getName()); + $this->logger->notice("User added to organization " . $org->getName()); + $this->emailService->sendExistingUserNotificationEmail($existingUser, $org); + $this->logger->notice("Existing user notification email sent to " . $existingUser->getUserIdentifier()); + + return $this->redirectToRoute('organization_show', ['id' => $orgId]); + } + +// capitalize name and surname + $user->setName(ucfirst(strtolower($user->getName()))); + $user->setSurname(ucfirst(strtolower($user->getSurname()))); // Handle file upload $picture = $form->get('pictureUrl')->getData(); @@ -158,15 +186,19 @@ class UserController extends AbstractController $uo->setOrganization($org); $uo->setStatut("INVITED"); $uo->setIsActive(false); + $uo->setModifiedAt(new \DateTimeImmutable('now')); $this->entityManager->persist($uo); $this->actionService->createAction("Create new user", $user, $org, "Added user to organization" . $user->getUserIdentifier() . " for organization " . $org->getName()); $this->logger->notice("User added to organization " . $org->getName()); + $this->emailService->sendPasswordSetupEmail($user, $orgId); + $this->logger->notice("Password setup email sent to " . $user->getUserIdentifier()); } } $this->actionService->createAction("Create new user", $actingUser, null, $user->getUserIdentifier()); $this->logger->notice("User created " . $user->getUserIdentifier()); $this->entityManager->persist($user); $this->entityManager->flush(); + if( $orgId) { return $this->redirectToRoute('user_show', ['id' => $user->getId(), 'organizationId' => $orgId]); } @@ -189,6 +221,7 @@ class UserController extends AbstractController throw $this->createAccessDeniedException(self::ACCESS_DENIED); } + //TODO : MONOLOG #[Route('/deactivate/{id}', name: 'deactivate', methods: ['GET', 'POST'])] public function deactivate(int $id): Response { @@ -215,6 +248,7 @@ class UserController extends AbstractController throw $this->createAccessDeniedException(self::ACCESS_DENIED); } + //TODO : MONOLOG #[Route('/activate/{id}', name: 'activate', methods: ['GET', 'POST'])] public function activate(int $id): Response { @@ -237,6 +271,7 @@ class UserController extends AbstractController throw $this->createAccessDeniedException(self::ACCESS_DENIED); } + //TODO : MONOLOG #[Route('/organization/deactivate/{id}', name: 'deactivate_organization', methods: ['GET', 'POST'])] public function deactivateUserInOrganization(int $id, Request $request): Response { @@ -270,6 +305,7 @@ class UserController extends AbstractController throw $this->createAccessDeniedException(self::ACCESS_DENIED); } + //TODO : MONOLOG #[Route('/organization/activate/{id}', name: 'activate_organization', methods: ['GET', 'POST'])] public function activateUserInOrganization(int $id, Request $request): Response { @@ -301,7 +337,7 @@ class UserController extends AbstractController throw $this->createAccessDeniedException(self::ACCESS_DENIED); } - +//TODO : MONOLOG + remove picture from bucket #[Route('/delete/{id}', name: 'delete', methods: ['GET', 'POST'])] public function delete(int $id, Request $request): Response { @@ -325,6 +361,7 @@ class UserController extends AbstractController return new Response('', Response::HTTP_NO_CONTENT); //204 } + //TODO : MONOLOG #[Route(path: '/application/roles/{id}', name: 'application_role', methods: ['GET', 'POST'])] public function applicationRole(int $id, Request $request): Response { @@ -411,9 +448,10 @@ class UserController extends AbstractController // Map to array $data = array_map(function (User $user) { + $picture = $this->awsService->getPublicUrl($_ENV['S3_PORTAL_BUCKET']) . $user->getPictureUrl(); return [ 'id' => $user->getId(), - 'pictureUrl' => $user->getPictureUrl(), + 'pictureUrl' => $picture, 'name' => $user->getSurname(), 'prenom' => $user->getName(), 'email' => $user->getEmail(), @@ -462,9 +500,10 @@ class UserController extends AbstractController // Map to array (keep isConnected) $data = array_map(function (UsersOrganizations $uo) { $user = $uo->getUsers(); + $picture = $this->awsService->getPublicUrl($_ENV['S3_PORTAL_BUCKET']) . $user->getPictureUrl(); $initials = $user->getName()[0] . $user->getSurname()[0]; return [ - 'pictureUrl' => $user->getPictureUrl(), + 'pictureUrl' => $picture, 'email' => $user->getEmail(), 'isConnected' => $this->userService->isUserConnected($user->getUserIdentifier()), 'showUrl' => $this->generateUrl('user_show', ['id' => $user->getId()]), @@ -504,9 +543,10 @@ class UserController extends AbstractController // Map to array (keep isConnected) $data = array_map(function (UsersOrganizations $uo) { $user = $uo->getUsers(); + $picture = $this->awsService->getPublicUrl($_ENV['S3_PORTAL_BUCKET']) . $user->getPictureUrl(); $initials = $user->getName()[0] . $user->getSurname()[0]; return [ - 'pictureUrl' => $user->getPictureUrl(), + 'pictureUrl' =>$picture, 'email' => $user->getEmail(), 'isConnected' => $this->userService->isUserConnected($user->getUserIdentifier()), 'showUrl' => $this->generateUrl('user_show', ['id' => $user->getId()]), @@ -514,7 +554,6 @@ class UserController extends AbstractController ]; }, $users); - return $this->json([ 'data' => $data, ]); @@ -550,14 +589,14 @@ class UserController extends AbstractController $countQb = clone $qb; $total = (int)$countQb->select('COUNT(uo.id)')->getQuery()->getSingleScalarResult(); - // Pagination + $qb->orderBy('uo.isActive', 'DESC') + ->addOrderBy('CASE WHEN uo.statut = :invited THEN 0 ELSE 1 END', 'ASC') + ->setParameter('invited', 'INVITED'); + $offset = ($page - 1) * $size; $rows = $qb->setFirstResult($offset)->setMaxResults($size)->getQuery()->getResult(); - - // Map to array $data = $this->userService->formatStatutForOrganizations($rows); - // Return Tabulator-compatible response $lastPage = (int)ceil($total / $size); return $this->json([ @@ -570,4 +609,71 @@ class UserController extends AbstractController throw $this->createAccessDeniedException(self::ACCESS_DENIED); } + + #[Route(path: '/organization/resend-invitation/{userId}', name: 'resend_invitation', methods: ['POST'])] + public function resendInvitation(int $userId, Request $request): JsonResponse + { + $this->denyAccessUnlessGranted("ROLE_ADMIN"); + $actingUser = $this->userService->getUserByIdentifier($this->getUser()->getUserIdentifier()); + if ($this->userService->hasAccessTo($actingUser, true)) { + $orgId = $request->get('organizationId'); + $org = $this->organizationRepository->find($orgId); + if (!$org) { + throw $this->createNotFoundException(self::NOT_FOUND); + } + $user = $this->userRepository->find($userId); + if (!$user) { + throw $this->createNotFoundException(self::NOT_FOUND); + } + $uo = $this->uoRepository->findOneBy(['users' => $user, + 'organization' => $org, + 'statut' => "INVITED"]); + if (!$uo) { + throw $this->createNotFoundException(self::NOT_FOUND); + } + $uo->setModifiedAt(new \DateTimeImmutable()); + try { + $this->emailService->sendPasswordSetupEmail($user, $orgId); + $this->logger->info("Invitation email resent to user " . $user->getUserIdentifier() . " for organization " . $org->getName()); + return $this->json(['message' => 'Invitation envoyée avec success.'], Response::HTTP_OK); + } catch (\Exception $e) { + $this->logger->error("Error resending invitation email to user " . $user->getUserIdentifier() . " for organization " . $org->getName() . ": " . $e->getMessage()); + return $this->json(['message' => 'Erreur lors de l\'envoie du mail.'], Response::HTTP_INTERNAL_SERVER_ERROR); + } + } + throw $this->createAccessDeniedException(self::ACCESS_DENIED); + } + + #[Route(path: '/accept-invitation', name: 'accept', methods: ['GET'])] + public function acceptInvitation(Request $request): Response + { + $token = $request->get('token'); + $userId = $request->get('id'); + + if (!$token || !$userId) { + throw $this->createNotFoundException('Invalid invitation link.'); + } + $user = $this->userRepository->find($userId); + if (!$user) { + throw $this->createNotFoundException(self::NOT_FOUND); + } + if (!$this->userService->isPasswordTokenValid($user, $token)) { + throw $this->createNotFoundException('Invalid or expired invitation token.'); + } + $orgId = $this->userService->getOrgFromToken($token); + $uo = $this->uoRepository->findOneBy(['users' => $user, 'organization' => $orgId]); + if (!$uo || $uo->getStatut() !== 'INVITED') { + $this->logger->warning("User " . $user->getUserIdentifier() . " tried to accept an invitation but no pending invitation was found for organization ID " . $orgId); + throw $this->createNotFoundException('No pending invitation found for this user and organization.'); + } + $uo->setModifiedAt(new \DateTimeImmutable()); + $uo->setStatut("ACCEPTED"); + $uo->setIsActive(true); + $this->entityManager->persist($uo); + $this->entityManager->flush(); + $this->logger->info("User " . $user->getUserIdentifier() . " accepted invitation for organization ID " . $orgId); + + return $this->render('user/show.html.twig', ['user' => $user, 'orgId' => $orgId]); + } } + diff --git a/src/Entity/User.php b/src/Entity/User.php index 6d3ad6e..ca9d110 100644 --- a/src/Entity/User.php +++ b/src/Entity/User.php @@ -11,7 +11,6 @@ use Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface; use Symfony\Component\Security\Core\User\UserInterface; use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity; -#[UniqueEntity(fields: ['email'], message: 'This email address is already in use.')] #[ORM\Entity(repositoryClass: UserRepository::class)] #[ORM\Table(name: '`user`')] #[ORM\UniqueConstraint(name: 'UNIQ_IDENTIFIER_EMAIL', fields: ['email'])] @@ -34,7 +33,7 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface /** * @var string The hashed password */ - #[ORM\Column] + #[ORM\Column(nullable: true)] private ?string $password = null; #[ORM\Column(length: 255)] @@ -64,6 +63,12 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface #[ORM\Column(length: 20, nullable: true)] private ?string $phoneNumber = null; + #[ORM\Column(length: 255, nullable: true)] + private ?string $passwordToken = null; + + #[ORM\Column(nullable: true)] + private ?\DateTimeImmutable $tokenExpiry = null; + public function __construct() { $this->createdAt = new \DateTimeImmutable(); @@ -263,4 +268,28 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface return $this; } + + public function getPasswordToken(): ?string + { + return $this->passwordToken; + } + + public function setPasswordToken(?string $passwordToken): static + { + $this->passwordToken = $passwordToken; + + return $this; + } + + public function getTokenExpiry(): ?\DateTimeImmutable + { + return $this->tokenExpiry; + } + + public function setTokenExpiry(?\DateTimeImmutable $tokenExpiry): static + { + $this->tokenExpiry = $tokenExpiry; + + return $this; + } } diff --git a/src/Entity/UsersOrganizations.php b/src/Entity/UsersOrganizations.php index b1a9d8a..d524d46 100644 --- a/src/Entity/UsersOrganizations.php +++ b/src/Entity/UsersOrganizations.php @@ -38,6 +38,9 @@ class UsersOrganizations #[ORM\Column(length: 255, nullable: true)] private ?string $statut = null; + #[ORM\Column(nullable: true)] + private ?\DateTimeImmutable $modifiedAt = null; + public function __construct() { $this->isActive = true; // Default value for isActive @@ -132,4 +135,16 @@ class UsersOrganizations return $this; } + + public function getModifiedAt(): ?\DateTimeImmutable + { + return $this->modifiedAt; + } + + public function setModifiedAt(?\DateTimeImmutable $modifiedAt): static + { + $this->modifiedAt = $modifiedAt; + + return $this; + } } diff --git a/src/Service/EmailService.php b/src/Service/EmailService.php new file mode 100644 index 0000000..d8c942e --- /dev/null +++ b/src/Service/EmailService.php @@ -0,0 +1,82 @@ +userService->generatePasswordToken($user, $orgId); + + // Generate absolute URL for the password setup route + $link = $this->urlGenerator->generate( + 'password_setup', + [ + 'id' => $user->getId(), + 'token' => $token + ], + UrlGeneratorInterface::ABSOLUTE_URL + ); + $this->logger->info("link generated: " . $link); + $email = (new TemplatedEmail()) + ->from('no-reply@sudalys.fr') + ->to($user->getEmail()) + ->subject('Définissez votre mot de passe') + ->htmlTemplate('emails/password_setup.html.twig') + ->context([ + 'user' => $user, + 'token' => $token, + 'linkUrl' => $link, // pass the absolute link to Twig + 'expirationHours'=> 1, //1 hour + ]); + + try { + $this->mailer->send($email); + } catch (\Symfony\Component\Mailer\Exception\TransportExceptionInterface $e) { + $this->logger->error('Failed to send password setup email: ' . $e->getMessage()); + } + } + + public function sendExistingUserNotificationEmail(User $existingUser, Organizations $org): void + { + $token = $this->userService->generatePasswordToken($existingUser, $org->getId()); + $link = $this->urlGenerator->generate('user_accept',[ + 'id' => $existingUser->getId(), + 'token' => $token + ], UrlGeneratorInterface::ABSOLUTE_URL); + $this->logger->info("link generated: " . $link); + $email = (new TemplatedEmail()) + ->from('no-reply@sudalys.fr') + ->to($existingUser->getEmail()) + ->subject("Invitation à rejoindre l'organisation " . $org->getName()) + ->htmlTemplate('emails/existing_user_notification.html.twig') + ->context([ + 'user' => $existingUser, + 'organization' => $org, + 'linkUrl' => $link, + 'expirationDays'=> 15, //15 days + ]); + + try{ + $this->mailer->send($email); + } catch (TransportExceptionInterface $e) { + $this->logger->error('Failed to send existing user notification email: ' . $e->getMessage()); + } + + } +} \ No newline at end of file diff --git a/src/Service/UserService.php b/src/Service/UserService.php index 8eaed71..d78ca4d 100644 --- a/src/Service/UserService.php +++ b/src/Service/UserService.php @@ -10,6 +10,7 @@ use App\Entity\UserOrganizatonApp; use App\Entity\UsersOrganizations; use App\Service\AwsService; use DateTimeImmutable; +use DateTimeZone; use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\EntityNotFoundException; use Exception; @@ -242,7 +243,7 @@ class UserService // Use a fixed key (e.g., 0 or 'none') to avoid collisions with real org IDs return ['none' => $group]; } - +//TODO: reset function public function handleProfilePicture(User $user, $picture): void { // Get file extension @@ -250,6 +251,7 @@ class UserService // Create custom filename: userNameUserSurname_ddmmyyhhmmss $customFilename = $user->getName() . $user->getSurname() . '_' . date('dmyHis') . '.' . $extension; +// $customFilename = $user->getName() . $user->getSurname() . "." .$extension; try{ $this->awsService->PutDocObj($_ENV['S3_PORTAL_BUCKET'], $picture, $customFilename , $extension, 'profile/'); @@ -384,13 +386,21 @@ class UserService { $formatted = array_map(function (UsersOrganizations $uo) { $user = $uo->getUsers(); - if ($uo->getStatut() == "INVITED") { + $picture = $this->awsService->getPublicUrl($_ENV['S3_PORTAL_BUCKET']) . $user->getPictureUrl(); + if ($uo->getStatut() === "INVITED") { $statut = "INVITED"; + // if user invited but not accepted in 1 hour, set statut to EXPIRED + $now = new DateTimeImmutable(); + $invitationTime = $uo->getModifiedAt(); + $expiryTime = $invitationTime->modify('+1 hour'); + if ($now > $expiryTime) { + $statut = "EXPIRED"; + } } else { $statut = $uo->isActive() ? "ACTIVE" : "INACTIVE"; } return [ - 'pictureUrl' => $user->getPictureUrl(), + 'pictureUrl' => $picture, 'name' => $user->getSurname(), 'prenom' => $user->getName(), 'email' => $user->getEmail(), @@ -402,4 +412,63 @@ class UserService }, $rows); return $formatted; } + + public function generatePasswordToken(User $user, int $orgId): string + { + $orgString = "o" . $orgId . "@"; + $token = $orgString . bin2hex(random_bytes(32)); + $user->setPasswordToken($token); + $user->setTokenExpiry(new DateTimeImmutable('+1 hour', new \DateTimeZone('Europe/Paris'))); + $this->entityManager->persist($user); + $this->entityManager->flush(); + return $token; + } + + public function isPasswordTokenValid(User $user, string $token): bool + { + if ($user->getPasswordToken() !== $token || $user->getTokenExpiry() < new DateTimeImmutable()) { + return false; + } + return true; + } + + public function isPasswordStrong(string $newPassword):bool + { + $pewpew = 0; + if (preg_match('/\w/', $newPassword)) { //Find any alphabetical letter (a to Z) and digit (0 to 9) + $pewpew++; + } + if (preg_match('/\W/', $newPassword)) { //Find any non-alphabetical and non-digit character + $pewpew++; + } + if (strlen($newPassword) > 8) { + $pewpew++; + } + return $pewpew >= 3; + } + + public function updateUserPassword(User $user, string $newPassword): void + { + $user->setPassword(password_hash($newPassword, PASSWORD_BCRYPT)); + $user->setModifiedAt(new DateTimeImmutable('now', new DateTimeZone('Europe/Paris'))); + $user->setPasswordToken(null); + $user->setTokenExpiry(null); + $this->entityManager->persist($user); + $this->entityManager->flush(); + } + + public function getOrgFromToken(string $token): ?int + { + if (str_starts_with($token, 'o')) { + $parts = explode('@', $token); + if (count($parts) === 2) { + $orgPart = substr($parts[0], 1); // Remove the leading 'o' + if (is_numeric($orgPart)) { + return (int)$orgPart; + } + } + } + return null; + } + } diff --git a/templates/emails/existing_user_notification.html.twig b/templates/emails/existing_user_notification.html.twig new file mode 100644 index 0000000..c10a1f8 --- /dev/null +++ b/templates/emails/existing_user_notification.html.twig @@ -0,0 +1,89 @@ + + + + + + + + +
+ + +

Bonjour, {{ user.name ?? user.surname ?? user.email }} !

+ +

L'organisme {{ organization.name }} vous a invité à la rejoindre :

+ +

+ Rejoindre +

+

+ Ce lien expirera dans {{ expirationDays }} jour(s). +

+ +

Si vous ou votre administrateur n’êtes pas à l’origine de cette action, ignorez cet email.

+ + +
+ + + \ No newline at end of file diff --git a/templates/emails/password_setup.html.twig b/templates/emails/password_setup.html.twig new file mode 100644 index 0000000..3432a2e --- /dev/null +++ b/templates/emails/password_setup.html.twig @@ -0,0 +1,89 @@ + + + + + + + + +
+ + +

Bienvenue, {{ user.name ?? user.surname ?? user.email }} !

+ +

Veuillez définir votre mot de passe en cliquant sur le bouton ci-dessous :

+ +

+ Définir mon mot de passe +

+

+ Ce lien expirera dans {{ expirationHours }} heure(s). +

+ +

Si vous ou votre administrateur n’êtes pas à l’origine de cette action, ignorez cet email.

+ + +
+ + + \ No newline at end of file diff --git a/templates/security/password_setup.html.twig b/templates/security/password_setup.html.twig new file mode 100644 index 0000000..a7abbbc --- /dev/null +++ b/templates/security/password_setup.html.twig @@ -0,0 +1,40 @@ +{% extends 'publicBase.html.twig' %} + +{% block title %}{{application}} - Mot de Passe{% endblock %} + {% block body %} +
+
+ Logo-application + {% if error %} +
{{ error }}
+ {% endif %} + + + + + + + + + + {# + Uncomment this section and add a remember_me option below your firewall to activate remember me functionality. + See https://symfony.com/doc/current/security/remember_me.html + +
+ + +
+ #} +
+ +
+
+
+ {% endblock %} diff --git a/templates/user/index.html.twig b/templates/user/index.html.twig index 21b685f..f2fb290 100644 --- a/templates/user/index.html.twig +++ b/templates/user/index.html.twig @@ -21,7 +21,6 @@