631 lines
22 KiB
PHP
631 lines
22 KiB
PHP
<?php
|
||
|
||
namespace App\Service;
|
||
|
||
|
||
use App\Entity\Organizations;
|
||
use App\Entity\Roles;
|
||
use App\Entity\User;
|
||
use App\Entity\UserOrganizatonApp;
|
||
use App\Entity\UsersOrganizations;
|
||
use DateTimeImmutable;
|
||
use DateTimeZone;
|
||
use Doctrine\ORM\EntityManagerInterface;
|
||
use Doctrine\ORM\EntityNotFoundException;
|
||
use Exception;
|
||
use League\Bundle\OAuth2ServerBundle\Model\AccessToken;
|
||
use Random\RandomException;
|
||
use RuntimeException;
|
||
use Symfony\Bundle\SecurityBundle\Security;
|
||
use Symfony\Component\HttpFoundation\File\Exception\FileException;
|
||
use App\Event\UserCreatedEvent;
|
||
use Symfony\Contracts\EventDispatcher\EventDispatcherInterface;
|
||
|
||
class UserService
|
||
{
|
||
|
||
public const NOT_FOUND = 'Entity not found';
|
||
|
||
public function __construct(private readonly EntityManagerInterface $entityManager,
|
||
private readonly Security $security,
|
||
private readonly AwsService $awsService,
|
||
private readonly LoggerService $loggerService,
|
||
private readonly ActionService $actionService,
|
||
private readonly EmailService $emailService,
|
||
private readonly OrganizationsService $organizationsService,
|
||
private readonly EventDispatcherInterface $eventDispatcher
|
||
)
|
||
{
|
||
|
||
}
|
||
|
||
/**
|
||
* Generate a random password for a new user until they set their own.
|
||
* @throws RandomException
|
||
*/
|
||
public function generateRandomPassword(): string
|
||
{
|
||
return bin2hex(random_bytes(32));
|
||
}
|
||
|
||
|
||
/**
|
||
* Check if the user is currently connected.
|
||
* This method check if the user is currently connected to one of the applications.
|
||
*
|
||
* @param String $userIdentifier
|
||
* @return bool
|
||
*/
|
||
public function isUserConnected(string $userIdentifier): bool
|
||
{
|
||
$now = new DateTimeImmutable();
|
||
$tokens = $this->entityManager->getRepository(AccessToken::class)->findBy([
|
||
'userIdentifier' => $userIdentifier,
|
||
'revoked' => false
|
||
]);
|
||
|
||
foreach ($tokens as $token) {
|
||
// Assuming $token->getExpiry() returns a DateTimeInterface
|
||
if ($token->getExpiry() > $now) {
|
||
return true;
|
||
}
|
||
}
|
||
|
||
return false;
|
||
}
|
||
|
||
/**
|
||
* Check if the user have the rights to access the page
|
||
* Self check can be skipped when checking access for the current user
|
||
*
|
||
* @param User $user
|
||
* @param bool $skipSelfCheck
|
||
* @return bool
|
||
* @throws Exception
|
||
*/
|
||
public function hasAccessTo(User $user, bool $skipSelfCheck = false): bool
|
||
{
|
||
if ($this->security->isGranted('ROLE_SUPER_ADMIN')) {
|
||
return true;
|
||
}
|
||
if (!$skipSelfCheck && $user->getUserIdentifier() === $this->security->getUser()->getUserIdentifier()) {
|
||
return true;
|
||
}
|
||
$userOrganizations = $this->entityManager->getRepository(UsersOrganizations::class)->findBy(['users' => $user]);
|
||
if ($userOrganizations) {
|
||
foreach ($userOrganizations as $uo) {
|
||
if ($this->isAdminOfOrganization($uo->getOrganization()) && $uo->getStatut() === "ACCEPTED" && $uo->isActive()) {
|
||
return true;
|
||
}
|
||
}
|
||
}
|
||
return false;
|
||
|
||
}
|
||
|
||
/**
|
||
* Check if the user is an admin of the organization
|
||
* A user is considered an admin of an organization if they have the 'ROLE_ADMIN' AND have the link to the
|
||
* entity role 'ROLE_ADMIN' in the UsersOrganizationsApp entity
|
||
* (if he is admin for any application of the organization).
|
||
*
|
||
* @param Organizations $organizations
|
||
* @return bool
|
||
* @throws Exception
|
||
*/
|
||
public function isAdminOfOrganization(Organizations $organizations): bool
|
||
{
|
||
$actingUser = $this->getUserByIdentifier($this->security->getUser()->getUserIdentifier());
|
||
$uo = $this->entityManager->getRepository(UsersOrganizations::class)->findOneBy(['users' => $actingUser, 'organization' => $organizations]);
|
||
$roleAdmin = $this->entityManager->getRepository(Roles::class)->findOneBy(['name' => 'ADMIN']);
|
||
if ($uo) {
|
||
$uoa = $this->entityManager->getRepository(UserOrganizatonApp::class)->findOneBy(['userOrganization' => $uo,
|
||
'role' => $roleAdmin,
|
||
'isActive' => true]);
|
||
if ($uoa && $this->security->isGranted('ROLE_ADMIN')) {
|
||
return true;
|
||
}
|
||
}
|
||
return false;
|
||
}
|
||
|
||
|
||
/**
|
||
* Get the user by their identifier.
|
||
*
|
||
* @param string $userIdentifier
|
||
* @return User|null
|
||
* @throws Exception
|
||
*/
|
||
public function getUserByIdentifier(string $userIdentifier): ?User
|
||
{
|
||
$user = $this->entityManager->getRepository(User::class)->findOneBy(['email' => $userIdentifier]);
|
||
if (!$user) {
|
||
$this->loggerService->logEntityNotFound('User', ['user_identifier' => $userIdentifier], null);
|
||
throw new EntityNotFoundException(self::NOT_FOUND);
|
||
}
|
||
return $user;
|
||
}
|
||
|
||
|
||
/**
|
||
* Format users without organization for admin view.
|
||
*
|
||
* @param array $users
|
||
* @return array
|
||
*/
|
||
public function formatNoOrgUsersAsAssoc(array $noOrgUsers): array
|
||
{
|
||
$group = [
|
||
'id' => null,
|
||
'name' => 'Utilisateurs',
|
||
'users' => [],
|
||
];
|
||
|
||
foreach ($noOrgUsers as $user) {
|
||
$group['users'][] = [
|
||
'entity' => $user,
|
||
'connected' => $this->isUserConnected($user->getUserIdentifier()),
|
||
];
|
||
}
|
||
|
||
// Use a fixed key (e.g., 0 or 'none') to avoid collisions with real org IDs
|
||
return ['none' => $group];
|
||
}
|
||
|
||
public function handleProfilePicture(User $user, $picture): void
|
||
{
|
||
// Get file extension
|
||
$extension = $picture->guessExtension();
|
||
|
||
// Create custom filename: userNameUserSurname_dmyHis
|
||
$customFilename = $user->getName() . $user->getSurname() . '_' . date('dmyHis') . '.' . $extension;
|
||
try {
|
||
$this->awsService->PutDocObj($_ENV['S3_PORTAL_BUCKET'], $picture, $customFilename, $extension, 'profile/');
|
||
$this->loggerService->logAWSAction(
|
||
'Profile picture uploaded to S3', [
|
||
'user_id' => $user->getId(),
|
||
'filename' => $customFilename,
|
||
]);
|
||
$user->setPictureUrl('profile/' . $customFilename);
|
||
} catch (FileException $e) {
|
||
// Handle upload error
|
||
throw new FileException('File upload failed: ' . $e->getMessage());
|
||
}
|
||
|
||
//
|
||
//
|
||
}
|
||
|
||
/**
|
||
* Format users of a specific organization.
|
||
*
|
||
* @param UsersOrganizations[] $userOrganizations
|
||
* @return array
|
||
*/
|
||
public function formatOrgUsers(array $userOrganizations): array
|
||
{
|
||
$users = [];
|
||
|
||
foreach ($userOrganizations as $uo) {
|
||
$user = $uo->getUsers();
|
||
|
||
$users[] = [
|
||
'entity' => $user,
|
||
'connected' => $this->isUserConnected($user->getUserIdentifier()),
|
||
'isActive' => (bool)$uo->isActive(),
|
||
];
|
||
}
|
||
|
||
return $users;
|
||
}
|
||
|
||
/**
|
||
* Handle user's role synchronization
|
||
*
|
||
* @param User $user
|
||
* @param string $role
|
||
* @param boolean $add
|
||
* @return void
|
||
*/
|
||
public function syncUserRoles(User $user, string $role, bool $add): void
|
||
{
|
||
$roleFormatted = $this->formatRoleString($role);
|
||
|
||
if ($add) {
|
||
// Add the main role if not already present
|
||
if (!in_array($roleFormatted, $user->getRoles(), true)) {
|
||
$user->setRoles(array_merge($user->getRoles(), [$roleFormatted]));
|
||
}
|
||
|
||
// If SUPER ADMIN is given → ensure ADMIN is also present
|
||
if ($roleFormatted === 'ROLE_SUPER_ADMIN' && !in_array('ROLE_ADMIN', $user->getRoles(), true)) {
|
||
$user->setRoles(array_merge($user->getRoles(), ['ROLE_ADMIN']));
|
||
}
|
||
$this->loggerService->logRoleAssignment(
|
||
'Role assigned to user',
|
||
[
|
||
'user_id' => $user->getId(),
|
||
'role' => $roleFormatted,
|
||
]
|
||
);
|
||
} else {
|
||
// Remove the role if present and not used elsewhere
|
||
if (in_array($roleFormatted, $user->getRoles(), true)) {
|
||
$uos = $this->entityManager->getRepository(UsersOrganizations::class)
|
||
->findBy(['users' => $user, 'isActive' => true]);
|
||
$hasRole = false;
|
||
foreach ($uos as $uo) {
|
||
$uoa = $this->entityManager->getRepository(UserOrganizatonApp::class)
|
||
->findBy([
|
||
'userOrganization' => $uo,
|
||
'isActive' => true,
|
||
'role' => $this->entityManager->getRepository(Roles::class)
|
||
->findOneBy(['name' => $role]),
|
||
]);
|
||
if ($uoa) {
|
||
$hasRole = true;
|
||
break;
|
||
}
|
||
}
|
||
|
||
// Only remove globally if no other app gives this role
|
||
if (!$hasRole) {
|
||
$roles = $user->getRoles();
|
||
$roles = array_filter($roles, fn($r) => $r !== $roleFormatted);
|
||
$user->setRoles($roles);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Format role string to match the ROLE_ convention
|
||
*/
|
||
public function formatRoleString(string $role): string
|
||
{
|
||
$role = str_replace(' ', '_', trim($role));
|
||
$role = strtoupper($role);
|
||
if (str_starts_with($role, 'ROLE_')) {
|
||
return $role;
|
||
}
|
||
return 'ROLE_' . $role;
|
||
}
|
||
|
||
|
||
public function formatStatutForOrganizations(array $rows): array
|
||
{
|
||
$formatted = array_map(function (UsersOrganizations $uo) {
|
||
$user = $uo->getUsers();
|
||
$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' => $picture,
|
||
'name' => $user->getSurname(),
|
||
'prenom' => $user->getName(),
|
||
'email' => $user->getEmail(),
|
||
'isConnected' => $this->isUserConnected($user->getUserIdentifier()),
|
||
'statut' => $statut,
|
||
'showUrl' => '/user/view/' . $user->getId() . '?organizationId=' . $uo->getOrganization()->getId(),
|
||
'id' => $user->getId(),
|
||
];
|
||
}, $rows);
|
||
return $formatted;
|
||
}
|
||
|
||
public function generatePasswordToken(User $user, int $orgId = null): 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;
|
||
}
|
||
|
||
/**
|
||
* Get roles array for a user, optionally including super admin roles.
|
||
* ViewSAdminRoles flag determines if super admin roles should be included.
|
||
*
|
||
* @param User $actingUser
|
||
* @param bool $viewSAdminRoles
|
||
* @return array
|
||
*/
|
||
public function getRolesArrayForUser(User $actingUser, bool $viewSAdminRoles = false): array
|
||
{
|
||
$roles = $this->entityManager->getRepository(Roles::class)->findAll();
|
||
$rolesArray = [];
|
||
foreach ($roles as $role) {
|
||
if (!$viewSAdminRoles && $role->getName() === 'SUPER ADMIN') {
|
||
continue;
|
||
}
|
||
$rolesArray[] = [
|
||
'id' => $role->getId(),
|
||
'name' => $role->getName(),
|
||
];
|
||
}
|
||
|
||
return $rolesArray;
|
||
}
|
||
|
||
public function canEditRolesCheck(User $actingUser, User $user, bool $isAdmin, UsersOrganizations $uo = null, $org = null): bool
|
||
{
|
||
$userRoles = $user->getRoles();
|
||
$actingUserRoles = $actingUser->getRoles();
|
||
// if acting user is admin, he can´t edit super admin roles
|
||
if (!in_array('ROLE_SUPER_ADMIN', $actingUserRoles, true) && in_array('ROLE_SUPER_ADMIN', $userRoles, true)) {
|
||
return false;
|
||
}
|
||
if ($uo && $this->isAdminOfOrganization($uo->getOrganization())) {
|
||
return true;
|
||
}
|
||
return $isAdmin && !empty($org);
|
||
|
||
}
|
||
|
||
/**
|
||
* Handle already existing user when creating a user.
|
||
* If the user exists but is inactive, reactivate them.
|
||
*
|
||
* @param User $user
|
||
* @param Organizations $organization
|
||
* @return void
|
||
*/
|
||
public function handleExistingUser(User $user, Organizations $organization): int
|
||
{
|
||
if (!$user->isActive()) {
|
||
$user->setIsActive(true);
|
||
$this->entityManager->persist($user);
|
||
}
|
||
$uo = new UsersOrganizations();
|
||
$uo->setUsers($user);
|
||
$uo->setOrganization($organization);
|
||
$uo->setStatut("INVITED");
|
||
$uo->setIsActive(false);
|
||
$uo->setModifiedAt(new \DateTimeImmutable('now'));
|
||
$this->entityManager->persist($uo);
|
||
$this->entityManager->flush();
|
||
|
||
return $uo->getId();
|
||
}
|
||
|
||
/**
|
||
* Format new user data.
|
||
* Capitalize name and surname.
|
||
* Trim strings.
|
||
* Set random password
|
||
* Handle picture if provided
|
||
*
|
||
* @param User $user
|
||
* @return void
|
||
*/
|
||
public function formatUserData(User $user, $picture, bool $setPassword = false): void
|
||
{
|
||
// capitalize name and surname
|
||
$user->setName(ucfirst(strtolower($user->getName())));
|
||
$user->setSurname(ucfirst(strtolower($user->getSurname())));
|
||
|
||
// trim strings
|
||
$user->setName(trim($user->getName()));
|
||
$user->setSurname(trim($user->getSurname()));
|
||
$user->setEmail(trim($user->getEmail()));
|
||
if ($setPassword) {
|
||
//FOR SETTING A DEFAULT RANDOM PASSWORD OF 50 CHARACTERS until user set his own password
|
||
try {
|
||
$user->setPassword(bin2hex(random_bytes(50)));
|
||
} catch (RandomException $e) {
|
||
$this->loggerService->logError('Error generating random password: ' . $e->getMessage(), [
|
||
'target_user_id' => $user->getId(),
|
||
]);
|
||
throw new RuntimeException('Error generating random password: ' . $e->getMessage());
|
||
}
|
||
|
||
}
|
||
if ($picture) {
|
||
$this->handleProfilePicture($user, $picture);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Handle existing user being added to an organization
|
||
*/
|
||
public function addExistingUserToOrganization(
|
||
User $existingUser,
|
||
Organizations $org,
|
||
User $actingUser,
|
||
): int
|
||
{
|
||
try {
|
||
$uoId = $this->handleExistingUser($existingUser, $org);
|
||
|
||
$this->loggerService->logExistingUserAddedToOrg(
|
||
$existingUser->getId(),
|
||
$org->getId(),
|
||
$actingUser->getId(),
|
||
$uoId,
|
||
);
|
||
$this->actionService->createAction(
|
||
"Add existing user to organization",
|
||
$actingUser,
|
||
$org,
|
||
"Added user {$existingUser->getUserIdentifier()} to {$org->getName()}"
|
||
);
|
||
$this->sendExistingUserNotifications($existingUser, $org, $actingUser);
|
||
|
||
return $uoId;
|
||
} catch (\Exception $e) {
|
||
$this->loggerService->logError('Error linking existing user to organization: ' . $e->getMessage(), [
|
||
'target_user_id' => $existingUser->getId(),
|
||
'organization_id' => $org->getId(),
|
||
'acting_user_id' => $actingUser->getId(),
|
||
]);
|
||
throw $e;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Create a brand-new user
|
||
*/
|
||
public function createNewUser(User $user, User $actingUser, $picture): void
|
||
{
|
||
try {
|
||
$this->formatUserData($user, $picture, true);
|
||
|
||
// Generate token here if it's part of the user persistence flow
|
||
$token = $this->generatePasswordToken($user);
|
||
|
||
$this->entityManager->persist($user);
|
||
$this->entityManager->flush();
|
||
|
||
$this->eventDispatcher->dispatch(new UserCreatedEvent($user, $actingUser));
|
||
|
||
} catch (\Exception $e) {
|
||
// Error logging remains here because the event won't fire if exception occurs
|
||
$this->loggerService->logError('Error creating new user: ' . $e->getMessage(), [
|
||
'target_user_email' => $user->getEmail(),
|
||
'acting_user_id' => $actingUser->getId(),
|
||
]);
|
||
throw $e;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Link newly created user to an organization
|
||
*/
|
||
public function linkUserToOrganization(
|
||
User $user,
|
||
Organizations $org,
|
||
User $actingUser,
|
||
): UsersOrganizations
|
||
{
|
||
try {
|
||
$uo = new UsersOrganizations();
|
||
$uo->setUsers($user);
|
||
$uo->setOrganization($org);
|
||
$uo->setStatut("INVITED");
|
||
$uo->setIsActive(false);
|
||
$uo->setModifiedAt(new \DateTimeImmutable('now'));
|
||
$this->entityManager->persist($uo);
|
||
$this->entityManager->flush();
|
||
|
||
$this->loggerService->logUserOrganizationLinkCreated(
|
||
$user->getId(),
|
||
$org->getId(),
|
||
$actingUser->getId(),
|
||
$uo->getId(),
|
||
|
||
);
|
||
|
||
$this->actionService->createAction(
|
||
"Link user to organization",
|
||
$actingUser,
|
||
$org,
|
||
"Added {$user->getUserIdentifier()} to {$org->getName()}"
|
||
);
|
||
|
||
$this->sendNewUserNotifications($user, $org, $actingUser);
|
||
|
||
return $uo;
|
||
} catch (\Exception $e) {
|
||
$this->loggerService->logError('Error linking user to organization: ' . $e->getMessage(), [
|
||
'target_user_id' => $user->getId(),
|
||
'organization_id' => $org->getId(),
|
||
'acting_user_id' => $actingUser->getId(),
|
||
]);
|
||
throw $e;
|
||
}
|
||
}
|
||
|
||
// Private helpers for email notifications
|
||
private function sendExistingUserNotifications(User $user, Organizations $org, User $actingUser): void
|
||
{
|
||
try {
|
||
$token = $this->generatePasswordToken($user, $org->getId());
|
||
$this->emailService->sendExistingUserNotificationEmail($user, $org, $token);
|
||
$this->loggerService->logExistingUserNotificationSent($user->getId(), $org->getId());
|
||
$this->organizationsService->notifyOrganizationAdmins(['user' => $user, 'acting_user_id' => $actingUser->getId(),
|
||
'organization' => $org], 'USER_INVITED');
|
||
} catch (\Exception $e) {
|
||
$this->loggerService->logError("Error sending existing user notification: " . $e->getMessage(), [
|
||
'target_user_id' => $user->getId(),
|
||
'organization_id' => $org->getId(),
|
||
]);
|
||
}
|
||
}
|
||
|
||
private function sendNewUserNotifications(User $user, Organizations $org, User $actingUser): void
|
||
{
|
||
try {
|
||
$token = $this->generatePasswordToken($user, $org->getId());
|
||
$this->emailService->sendPasswordSetupEmail($user, $token);
|
||
$this->organizationsService->notifyOrganizationAdmins(['user' => $user, 'acting_user_id' => $actingUser->getId(),
|
||
'organization' => $org], 'USER_INVITED');
|
||
} catch (\Exception $e) {
|
||
$this->loggerService->logError("Error sending password setup email: " . $e->getMessage(), [
|
||
'target_user_id' => $user->getId(),
|
||
'organization_id' => $org->getId(),
|
||
]);
|
||
}
|
||
}
|
||
|
||
}
|