rolesRepository->findOneBy(['name' => 'ADMIN']); $uoAdmin = $this->entityManager->getRepository(UsersOrganizations::class)->findOneBy([ 'users' => $user, 'isActive' => true, 'role'=> $roleAdmin]); return $uoAdmin !== null; } /** * 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; } /** * Determines if the currently logged-in user has permission to manage or view a target User. * * Access is granted if: * 1. The current user is a Super Admin. * 2. The current user is the target user itself. * 3. The current user is an active Admin of an organization the target user belongs to. * * @param User $user The target User object we are checking access against. * * @return bool True if access is permitted, false otherwise. * @throws Exception If database or security context issues occur. */ public function hasAccessTo(User $user): bool { if ($this->security->isGranted('ROLE_ADMIN')) { return true; } // S'il s'agit de son propre compte, on lui donne accès if ($user->getUserIdentifier() === $this->security->getUser()->getUserIdentifier()) { return true; } $userOrganizations = $this->entityManager->getRepository(UsersOrganizations::class)->findBy(['users' => $user]); if ($userOrganizations) { foreach ($userOrganizations as $uo) { //l'utilisateur doit être actif dans l'org, avoir le statut ACCEPTED (double vérif) et être admin de l'org if ($uo->getStatut() === "ACCEPTED" && $uo->isActive() && $this->isAdminOfOrganization($uo->getOrganization())) { return true; } } } return false; } /** * Check if the acting user is an admin of the organization * A user is considered an admin of an organization if they have an active UsersOrganizations link with the role of ADMIN for that organization. * * @param Organizations $organizations * @return bool * @throws Exception */ public function isAdminOfOrganization(Organizations $organizations): bool { $actingUser = $this->getUserByIdentifier($this->security->getUser()->getUserIdentifier()); $roleAdmin = $this->entityManager->getRepository(Roles::class)->findOneBy(['name' => 'ADMIN']); $uoAdmin = $this->entityManager->getRepository(UsersOrganizations::class)->findOneBy(['users' => $actingUser, 'organization' => $organizations, 'role'=> $roleAdmin, 'isActive' => true]); return $uoAdmin !== null; } /** * 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, UploadedFile $picture): void { // 1. Define the destination directory (adjust path as needed, e.g., 'public/uploads/profile_pictures') $destinationDir = 'uploads/profile_pictures'; // 2. Create the directory if it doesn't exist if (!file_exists($destinationDir)) { // 0755 is the standard permission (Owner: read/write/exec, Others: read/exec) if (!mkdir($destinationDir, 0755, true) && !is_dir($destinationDir)) { throw new \RuntimeException(sprintf('Directory "%s" was not created', $destinationDir)); } } // 3. Get extension and create a safe filename $extension = $picture->guessExtension(); // Sanitize the filename to remove special characters/spaces to prevent filesystem errors $safeName = preg_replace('/[^a-zA-Z0-9]/', '', $user->getName() . $user->getSurname()); $customFilename = $safeName . '_' . date('dmyHis') . '.' . $extension; try { // 4. Move the file to the destination directory // The move() method is standard in Symfony/Laravel UploadedFile objects $picture->move($destinationDir, $customFilename); // 5. Update the user entity with the relative path // Ensure you store the path relative to your public folder usually $user->setPictureUrl('/'.$destinationDir . '/' . $customFilename); } catch (\Exception $e) { // 6. Log the critical error as requested $this->loggerService->logError('File upload failed',[ 'target_user_id' => $user->getId(), 'message' => $e->getMessage(), 'file_name' => $customFilename, ]); // Optional: Re-throw the exception if you want the controller/user to know the upload failed throw new FileException('File upload failed.'); } } public function deleteProfilePicture(User $user): void { // 1. Get the current picture path from the user entity $currentPicturePath = $user->getPictureUrl(); // If the user doesn't have a picture, simply return (nothing to delete) if (!$currentPicturePath) { return; } try { // 2. Check if the file exists on the server before trying to delete // Note: Ensure $currentPicturePath is relative to the script execution or absolute. if (file_exists($currentPicturePath)) { // 3. Delete the file if (!unlink($currentPicturePath)) { throw new \Exception(sprintf('Could not unlink "%s"', $currentPicturePath)); } } else { // Optional: Log a warning if the DB had a path but the file was missing $this->loggerService->logError('File not found on disk during deletion request', [ 'target_user_id' => $user->getId(), 'missing_path' => $currentPicturePath ]); } // 4. Update the user entity to remove the reference $user->setPictureUrl(""); } catch (\Exception $e) { // 5. Log the critical error // We log it, but strictly speaking, we might still want to nullify the DB // if the file is corrupted/un-deletable to prevent broken images on the frontend. $this->loggerService->logError('File deletion failed', [ 'target_user_id' => $user->getId(), 'message' => $e->getMessage(), 'file_path' => $currentPicturePath, ]); // Re-throw if you want to stop execution/show error to user throw new \RuntimeException('Unable to remove profile picture.'); } } /** * 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(); if ($uo->getStatut() === "INVITED") { $statut = "INVITED"; // if user invited but not accepted in 15 days, set statut to EXPIRED $now = new DateTimeImmutable(); $invitationTime = $uo->getModifiedAt(); $expiryTime = $invitationTime->modify('+15 days'); if ($now > $expiryTime) { $statut = "EXPIRED"; } } else { $statut = $uo->isActive() ? "ACTIVE" : "INACTIVE"; } return [ 'pictureUrl' => $user->getPictureUrl(), '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 = $this->linkUserToOrganization($user, $organization); 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, ): int { try { $uoId = $this->handleExistingUser($existingUser, $org); $actingUser = $this->getUserByIdentifier($this->security->getUser()->getUserIdentifier()); $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); $user->setisActive(false); $this->generatePasswordToken($user); // Set token on the entity $this->entityManager->persist($user); $this->entityManager->flush(); $this->eventDispatcher->dispatch(new UserCreatedEvent($user, $actingUser)); } catch (\Exception $e) { $this->loggerService->logError('Error creating user: ' . $e->getMessage()); throw $e; } } /** * Link newly created user to an organization */ public function linkUserToOrganization( User $user, Organizations $org, ): UsersOrganizations { $actingUser = $this->getUserByIdentifier($this->security->getUser()->getUserIdentifier()); try { $roleUser = $this->rolesRepository->findOneBy(['name' => 'USER']); $uo = new UsersOrganizations(); $uo->setUsers($user); $uo->setOrganization($org); $uo->setStatut("INVITED"); $uo->setIsActive(false); $uo->setRole($roleUser); $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()}" ); $auRoles = $actingUser->getRoles(); if (in_array('ROLE_ADMIN', $auRoles, true)) { $this->loggerService->logSuperAdmin( $user->getId(), $actingUser->getId(), "Admin linked user to organization during creation", $org->getId() ); } $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 public 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(), ]); } } }