update project to allow sending of email
This commit is contained in:
parent
a6fdb59521
commit
3744d81035
|
|
@ -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)}
|
||||
</div>
|
||||
`;
|
||||
}if (statut === "INVITED") {
|
||||
return `
|
||||
<div class="d-flex gap-2 align-content-center">
|
||||
${eyeIconLink(url)}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// Decide which action (deactivate vs activate) for non-expired users
|
||||
|
|
@ -649,14 +648,7 @@ export default class extends Controller {
|
|||
|
||||
return `
|
||||
<div class="d-flex gap-2 align-content-center">
|
||||
<a href="${url}" class="p-3 align-middle color-primary" title="Voir">
|
||||
<svg xmlns="http://www.w3.org/2000/svg"
|
||||
width="35px"
|
||||
height="35px"
|
||||
viewBox="0 0 576 512">
|
||||
<path fill="currentColor"
|
||||
d="M288 80c-65.2 0-118.8 29.6-159.9 67.7C89.6 183.5 63 226 49.4 256C63 286 89.6 328.5 128 364.3c41.2 38.1 94.8 67.7 160 67.7s118.8-29.6 159.9-67.7C486.4 328.5 513 286 526.6 256c-13.6-30-40.2-72.5-78.6-108.3C406.8 109.6 353.2 80 288 80M95.4 112.6C142.5 68.8 207.2 32 288 32s145.5 36.8 192.6 80.6c46.8 43.5 78.1 95.4 93 131.1c3.3 7.9 3.3 16.7 0 24.6c-14.9 35.7-46.2 87.7-93 131.1C433.5 443.2 368.8 480 288 480s-145.5-36.8-192.6-80.6C48.6 356 17.3 304 2.5 268.3c-3.3-7.9-3.3-16.7 0-24.6C17.3 208 48.6 156 95.4 112.6M288 336c44.2 0 80-35.8 80-80s-35.8-80-80-80h-2c1.3 5.1 2 10.5 2 16c0 35.3-28.7 64-64 64c-5.5 0-10.9-.7-16-2v2c0 44.2 35.8 80 80 80m0-208a128 128 0 1 1 0 256a128 128 0 1 1 0-256"/></svg>
|
||||
</a>
|
||||
${eyeIconLink(url)}
|
||||
|
||||
<a href="#"
|
||||
class="${actionColorClass} ${actionClass} pt-3"
|
||||
|
|
|
|||
|
|
@ -35,6 +35,9 @@ security:
|
|||
security: true
|
||||
stateless: true
|
||||
oauth2: true
|
||||
password_setup:
|
||||
pattern: ^/password_setup
|
||||
stateless: true
|
||||
main:
|
||||
user_checker: App\Security\UserChecker
|
||||
lazy: true
|
||||
|
|
@ -59,6 +62,8 @@ security:
|
|||
# Note: Only the *first* access control that matches will be used
|
||||
access_control:
|
||||
- { path: ^/login, roles: PUBLIC_ACCESS }
|
||||
- { path: ^/password_setup, roles: PUBLIC_ACCESS }
|
||||
- { path: ^/password_reset, roles: PUBLIC_ACCESS }
|
||||
- { path: ^/sso_logout, roles: IS_AUTHENTICATED_FULLY }
|
||||
- { path: ^/token, roles: PUBLIC_ACCESS }
|
||||
- { path: ^/oauth2/revoke_tokens, roles: PUBLIC_ACCESS }
|
||||
|
|
|
|||
|
|
@ -7,6 +7,9 @@ twig:
|
|||
aws_url: '%env(AWS_S3_PORTAL_URL)%'
|
||||
version: '0.4'
|
||||
|
||||
paths:
|
||||
'%kernel.project_dir%/assets/img': images
|
||||
|
||||
|
||||
|
||||
when@test:
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ use App\Entity\UsersOrganizations;
|
|||
use App\Form\OrganizationForm;
|
||||
use App\Repository\OrganizationsRepository;
|
||||
use App\Service\ActionService;
|
||||
use App\Service\AwsService;
|
||||
use App\Service\OrganizationsService;
|
||||
use App\Service\UserOrganizationService;
|
||||
use App\Service\UserService;
|
||||
|
|
@ -35,7 +36,8 @@ class OrganizationController extends AbstractController
|
|||
private readonly OrganizationsService $organizationsService,
|
||||
private readonly ActionService $actionService,
|
||||
private readonly UserOrganizationService $userOrganizationService,
|
||||
private readonly OrganizationsRepository $organizationsRepository,)
|
||||
private readonly OrganizationsRepository $organizationsRepository,
|
||||
private readonly AwsService $awsService)
|
||||
{
|
||||
}
|
||||
|
||||
|
|
@ -310,11 +312,12 @@ class OrganizationController extends AbstractController
|
|||
|
||||
// Map to array
|
||||
$data = array_map(function (Organizations $org) {
|
||||
$picture = $this->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()]),
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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]);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,82 @@
|
|||
<?php
|
||||
|
||||
namespace App\Service;
|
||||
|
||||
use App\Entity\Organizations;
|
||||
use App\Entity\User;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Symfony\Bridge\Twig\Mime\TemplatedEmail;
|
||||
use Symfony\Component\Mailer\Exception\TransportExceptionInterface;
|
||||
use Symfony\Component\Mailer\MailerInterface;
|
||||
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
|
||||
|
||||
class EmailService
|
||||
{
|
||||
public function __construct(
|
||||
private readonly MailerInterface $mailer,
|
||||
private readonly UserService $userService,
|
||||
private readonly LoggerInterface $logger,
|
||||
private UrlGeneratorInterface $urlGenerator
|
||||
) {}
|
||||
|
||||
public function sendPasswordSetupEmail(User $user, int $orgId): void
|
||||
{
|
||||
$token = $this->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());
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,89 @@
|
|||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<style>
|
||||
:root{
|
||||
--primary-blue-light : #086572;
|
||||
--primary-blue-dark : #094754;
|
||||
}
|
||||
body {
|
||||
font-family: lato, sans-serif;
|
||||
line-height: 1.6;
|
||||
color: #333;
|
||||
background-color: #f4f4f4;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
width: 100%;
|
||||
}
|
||||
.container {
|
||||
max-width: 600px;
|
||||
margin: 2rem auto;
|
||||
background: #EEF0FD;
|
||||
border-radius: 8px;
|
||||
text-align: center;
|
||||
padding: 2rem 1.5rem;
|
||||
box-shadow: 0 2px 6px rgba(0,0,0,0.08);
|
||||
}
|
||||
.logo {
|
||||
display: block;
|
||||
margin: 0 auto 1.5rem auto;
|
||||
max-height: 80px;
|
||||
}
|
||||
h1 {
|
||||
color: var(--primary-blue-dark);
|
||||
}
|
||||
p {
|
||||
margin-bottom: 1.25rem;
|
||||
font-size: 15px;
|
||||
}
|
||||
a {
|
||||
text-decoration: none;
|
||||
color: #fff;
|
||||
}
|
||||
.btn-primary {
|
||||
display: inline-block;
|
||||
background: var(--primary-blue-light);
|
||||
color: #fff !important;
|
||||
border-radius: 6px;
|
||||
padding: 12px 28px;
|
||||
font-weight: bold;
|
||||
text-decoration: none;
|
||||
margin: 1rem 0;
|
||||
}
|
||||
.btn-primary:hover {
|
||||
background: var(--primary-blue-dark);
|
||||
}
|
||||
.footer {
|
||||
text-align: center;
|
||||
font-size: 13px;
|
||||
color: #999;
|
||||
margin-top: 1.5rem;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div class="container">
|
||||
<img src="{{ email.image('@images/logo-solutions.png') }}" alt="Logo" class="logo">
|
||||
|
||||
<h1 class="color-primary">Bonjour, {{ user.name ?? user.surname ?? user.email }} !</h1>
|
||||
|
||||
<p>L'organisme {{ organization.name }} vous a invité à la rejoindre :</p>
|
||||
|
||||
<p>
|
||||
<a href="{{ linkUrl }}" class="btn-primary">Rejoindre</a>
|
||||
</p>
|
||||
<p>
|
||||
Ce lien expirera dans {{ expirationDays }} jour(s).
|
||||
</p>
|
||||
|
||||
<p>Si vous ou votre administrateur n’êtes pas à l’origine de cette action, ignorez cet email.</p>
|
||||
|
||||
<div class="footer">
|
||||
© {{ "now"|date("Y") }} Sudalys. Tous droits réservés.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -0,0 +1,89 @@
|
|||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<style>
|
||||
:root{
|
||||
--primary-blue-light : #086572;
|
||||
--primary-blue-dark : #094754;
|
||||
}
|
||||
body {
|
||||
font-family: lato, sans-serif;
|
||||
line-height: 1.6;
|
||||
color: #333;
|
||||
background-color: #f4f4f4;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
width: 100%;
|
||||
}
|
||||
.container {
|
||||
max-width: 600px;
|
||||
margin: 2rem auto;
|
||||
background: #EEF0FD;
|
||||
border-radius: 8px;
|
||||
text-align: center;
|
||||
padding: 2rem 1.5rem;
|
||||
box-shadow: 0 2px 6px rgba(0,0,0,0.08);
|
||||
}
|
||||
.logo {
|
||||
display: block;
|
||||
margin: 0 auto 1.5rem auto;
|
||||
max-height: 80px;
|
||||
}
|
||||
h1 {
|
||||
color: var(--primary-blue-dark);
|
||||
}
|
||||
p {
|
||||
margin-bottom: 1.25rem;
|
||||
font-size: 15px;
|
||||
}
|
||||
a {
|
||||
text-decoration: none;
|
||||
color: #fff;
|
||||
}
|
||||
.btn-primary {
|
||||
display: inline-block;
|
||||
background: var(--primary-blue-light);
|
||||
color: #fff !important;
|
||||
border-radius: 6px;
|
||||
padding: 12px 28px;
|
||||
font-weight: bold;
|
||||
text-decoration: none;
|
||||
margin: 1rem 0;
|
||||
}
|
||||
.btn-primary:hover {
|
||||
background: var(--primary-blue-dark);
|
||||
}
|
||||
.footer {
|
||||
text-align: center;
|
||||
font-size: 13px;
|
||||
color: #999;
|
||||
margin-top: 1.5rem;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div class="container">
|
||||
<img src="{{ email.image('@images/logo-solutions.png') }}" alt="Logo" class="logo">
|
||||
|
||||
<h1 class="color-primary">Bienvenue, {{ user.name ?? user.surname ?? user.email }} !</h1>
|
||||
|
||||
<p>Veuillez définir votre mot de passe en cliquant sur le bouton ci-dessous :</p>
|
||||
|
||||
<p>
|
||||
<a href="{{ linkUrl }}" class="btn-primary">Définir mon mot de passe</a>
|
||||
</p>
|
||||
<p>
|
||||
Ce lien expirera dans {{ expirationHours }} heure(s).
|
||||
</p>
|
||||
|
||||
<p>Si vous ou votre administrateur n’êtes pas à l’origine de cette action, ignorez cet email.</p>
|
||||
|
||||
<div class="footer">
|
||||
© {{ "now"|date("Y") }} Sudalys. Tous droits réservés.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -0,0 +1,40 @@
|
|||
{% extends 'publicBase.html.twig' %}
|
||||
|
||||
{% block title %}{{application}} - Mot de Passe{% endblock %}
|
||||
{% block body %}
|
||||
<div class="col col-sm-10 col-md-7 col-lg-4 col-xl-3 col-xxl-3 shadow rounded px-4 py-3">
|
||||
<form method="post" class='d-flex flex-column' action="{{ path('password_reset', {id: id}) }}">
|
||||
<img src="{{ aws_url }}application/LogoSolution.png" class="img-fluid rounded-top m-auto" alt="Logo-application" />
|
||||
{% if error %}
|
||||
<div class="alert alert-danger">{{ error }}</div>
|
||||
{% endif %}
|
||||
<label class="form-label" for="password">Password</label>
|
||||
<input type="password" name="_password" id="password" class="form-control" autocomplete="current-password" required {{ stimulus_controller('symfony/ux-toggle-password/toggle-password', {
|
||||
visibleLabel: 'Show',
|
||||
hiddenLabel: 'Hide',
|
||||
buttonClasses: ['toggle-password'],}) }}>
|
||||
|
||||
<label class="form-label" for="passwordConfirm">Confirm Password</label>
|
||||
<input type="password" name="_passwordConfirm" id="passwordConfirm" class="form-control" autocomplete="current-password" required {{ stimulus_controller('symfony/ux-toggle-password/toggle-password', {
|
||||
visibleLabel: 'Show',
|
||||
hiddenLabel: 'Hide',
|
||||
buttonClasses: ['toggle-password'],}) }}>
|
||||
<input hidden name="token" value="{{ token }}">
|
||||
|
||||
<input type="hidden" name="_csrf_token" value="{{ csrf_token('authenticate') }}">
|
||||
|
||||
{#
|
||||
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
|
||||
|
||||
<div class="checkbox mb-3">
|
||||
<input type="checkbox" name="_remember_me" id="_remember_me">
|
||||
<label for="_remember_me">Remember me</label>
|
||||
</div>
|
||||
#}
|
||||
<div class="mt-3 mx-auto ">
|
||||
<button class="btn btn-primary" type="submit">Confirmer</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
|
@ -21,7 +21,6 @@
|
|||
<div class="card border-0">
|
||||
<div class="card-body">
|
||||
<div id="tabulator-userList" data-controller="user"
|
||||
data-user-aws-value="{{ aws_url }}"
|
||||
data-user-list-value="true">
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
Loading…
Reference in New Issue