Easy_solution/src/Service/UserService.php

736 lines
26 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?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 App\Repository\RolesRepository;
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\Component\HttpFoundation\File\UploadedFile;
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 LoggerService $loggerService,
private readonly ActionService $actionService,
private readonly EmailService $emailService,
private readonly OrganizationsService $organizationsService,
private readonly EventDispatcherInterface $eventDispatcher, private readonly RolesRepository $rolesRepository
)
{
}
/**
* 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 admin in any organization.
* Return true if the user is admin in at least one organization, false otherwise.
*
* @param User $user
* @return bool
* @throws Exception
*/
// TODO: pas sur de l'utiliser, à vérifier
public function isAdminInAnyOrganization(User $user): bool
{
$roleAdmin = $this->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;
}
/* Return if the current user is an admin of the target user.
* This is true if the current user is an admin of at least one organization that the target user belongs to.
*
* @param User $user
* @return bool
* @throws Exception
*/
public function isAdminOfUser(User $user): bool
{
$actingUser = $this->security->getUser();
if (!$actingUser instanceof User) {
return false;
}
// Reuse the cached/fetched role
$adminRole = $this->rolesRepository->findOneBy(['name' => 'ADMIN']);
if (!$adminRole) {
return false;
}
// Call the optimized repository method
return $this->entityManager
->getRepository(UsersOrganizations::class)
->isUserAdminOfTarget($actingUser, $user, $adminRole);
}
/**
* 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->security->getUser();
$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(),
]);
}
}
}