Merge branch 'dev/organization/picture-feature-1' into 'develop'

Dev/organization/picture feature 1

See merge request easy-solutions/apps/easyportal!9
This commit is contained in:
Charles-Edouard MARGUERITE 2026-01-28 09:28:26 +00:00
commit 85e3de647c
10 changed files with 311 additions and 145 deletions

View File

@ -72,7 +72,7 @@ export default class extends Controller {
formatterParams: { formatterParams: {
height: "50px", height: "50px",
width: "50px", width: "50px",
urlPrefix: "", urlPrefix: "/",
urlSuffix: "", urlSuffix: "",
}, },
width: 100, width: 100,

View File

@ -88,24 +88,27 @@ export default class extends Controller {
headerSort: false, headerSort: false,
formatter: (cell) => { formatter: (cell) => {
const data = cell.getRow().getData(); const data = cell.getRow().getData();
const url = cell.getValue(); let url = cell.getValue(); // use 'let' so we can modify it
// 1. GENERATE INITIALS
const first = (s) => (typeof s === "string" && s.length ? s.trim()[0].toUpperCase() : ""); const first = (s) => (typeof s === "string" && s.length ? s.trim()[0].toUpperCase() : "");
const initials = `${first(data.name)}${first(data.prenom)}`; const initials = `${first(data.name)}${first(data.prenom)}`;
// wrapper is for centering and circle clipping // 2. CREATE WRAPPER
const wrapper = document.createElement("div"); const wrapper = document.createElement("div");
wrapper.className = "avatar-wrapper"; wrapper.className = "avatar-wrapper";
// same size for both cases
wrapper.style.width = "40px"; wrapper.style.width = "40px";
wrapper.style.height = "40px"; wrapper.style.height = "40px";
wrapper.style.display = "flex"; wrapper.style.display = "flex";
wrapper.style.alignItems = "center"; wrapper.style.alignItems = "center";
wrapper.style.justifyContent = "center"; wrapper.style.justifyContent = "center";
wrapper.style.borderRadius = "50%"; wrapper.style.borderRadius = "50%";
wrapper.style.overflow = "hidden"; // ensure image clips to circle wrapper.style.overflow = "hidden";
if (!url) { // Helper to render fallback initials
wrapper.style.background = "#6c757d"; // gray background const renderFallback = () => {
wrapper.innerHTML = ""; // clear any broken img
wrapper.style.background = "#6c757d";
const span = document.createElement("span"); const span = document.createElement("span");
span.className = "avatar-initials"; span.className = "avatar-initials";
span.style.color = "#fff"; span.style.color = "#fff";
@ -113,31 +116,36 @@ export default class extends Controller {
span.style.fontSize = "14px"; span.style.fontSize = "14px";
span.textContent = initials || "•"; span.textContent = initials || "•";
wrapper.appendChild(span); wrapper.appendChild(span);
};
// 3. IF NO URL, RENDER FALLBACK IMMEDIATELY
if (!url) {
renderFallback();
return wrapper; return wrapper;
} }
// Image case: make it fill the same wrapper // --- THE FIX: HANDLE RELATIVE PATHS ---
// If the path doesn't start with 'http' or '/', add a leading '/'
// This ensures 'uploads/file.jpg' becomes '/uploads/file.jpg'
if (!url.startsWith('http') && !url.startsWith('/')) {
url = '/' + url;
}
// 4. CREATE IMAGE
const img = document.createElement("img"); const img = document.createElement("img");
img.src = url; img.src = url;
img.alt = initials || "avatar"; img.alt = initials || "avatar";
img.style.width = "100%"; img.style.width = "100%";
img.style.height = "100%"; img.style.height = "100%";
img.style.objectFit = "cover"; // keep aspect and cover circle img.style.objectFit = "cover";
// 5. ERROR HANDLING (triggers your fallback)
img.onerror = () => {
console.warn("Image failed to load:", url); // Debug log to see the wrong path
renderFallback();
};
wrapper.appendChild(img); wrapper.appendChild(img);
// Optional: fallback if image fails
img.addEventListener("error", () => {
wrapper.innerHTML = "";
wrapper.style.background = "#6c757d";
const span = document.createElement("span");
span.className = "avatar-initials";
span.style.color = "#fff";
span.style.fontWeight = "600";
span.style.fontSize = "12px";
span.textContent = initials || "•";
wrapper.appendChild(span);
});
return wrapper; return wrapper;
}, },
}, },
@ -305,23 +313,27 @@ export default class extends Controller {
headerSort: false, headerSort: false,
formatter: (cell) => { formatter: (cell) => {
const data = cell.getRow().getData(); const data = cell.getRow().getData();
const url = cell.getValue(); let url = cell.getValue(); // use 'let' so we can modify it
const first = (s) => (typeof s === "string" && s.length ? s.trim()[0].toUpperCase() : "");
const initials = `${data.initials}`;
// 1. GENERATE INITIALS
const first = (s) => (typeof s === "string" && s.length ? s.trim()[0].toUpperCase() : "");
const initials = `${first(data.name)}${first(data.prenom)}`;
// 2. CREATE WRAPPER
const wrapper = document.createElement("div"); const wrapper = document.createElement("div");
wrapper.className = "avatar-wrapper"; wrapper.className = "avatar-wrapper";
// same size for both cases
wrapper.style.width = "40px"; wrapper.style.width = "40px";
wrapper.style.height = "40px"; wrapper.style.height = "40px";
wrapper.style.display = "flex"; wrapper.style.display = "flex";
wrapper.style.alignItems = "center"; wrapper.style.alignItems = "center";
wrapper.style.justifyContent = "center"; wrapper.style.justifyContent = "center";
wrapper.style.borderRadius = "50%"; wrapper.style.borderRadius = "50%";
wrapper.style.overflow = "hidden"; // ensure image clips to circle wrapper.style.overflow = "hidden";
if (!url) { // Helper to render fallback initials
wrapper.style.background = "#6c757d"; // gray background const renderFallback = () => {
wrapper.innerHTML = ""; // clear any broken img
wrapper.style.background = "#6c757d";
const span = document.createElement("span"); const span = document.createElement("span");
span.className = "avatar-initials"; span.className = "avatar-initials";
span.style.color = "#fff"; span.style.color = "#fff";
@ -329,31 +341,36 @@ export default class extends Controller {
span.style.fontSize = "14px"; span.style.fontSize = "14px";
span.textContent = initials || "•"; span.textContent = initials || "•";
wrapper.appendChild(span); wrapper.appendChild(span);
};
// 3. IF NO URL, RENDER FALLBACK IMMEDIATELY
if (!url) {
renderFallback();
return wrapper; return wrapper;
} }
// Image case: make it fill the same wrapper // --- THE FIX: HANDLE RELATIVE PATHS ---
// If the path doesn't start with 'http' or '/', add a leading '/'
// This ensures 'uploads/file.jpg' becomes '/uploads/file.jpg'
if (!url.startsWith('http') && !url.startsWith('/')) {
url = '/' + url;
}
// 4. CREATE IMAGE
const img = document.createElement("img"); const img = document.createElement("img");
img.src = url; img.src = url;
img.alt = initials || "avatar"; img.alt = initials || "avatar";
img.style.width = "100%"; img.style.width = "100%";
img.style.height = "100%"; img.style.height = "100%";
img.style.objectFit = "cover"; // keep aspect and cover circle img.style.objectFit = "cover";
// 5. ERROR HANDLING (triggers your fallback)
img.onerror = () => {
console.warn("Image failed to load:", url); // Debug log to see the wrong path
renderFallback();
};
wrapper.appendChild(img); wrapper.appendChild(img);
// Optional: fallback if image fails
img.addEventListener("error", () => {
wrapper.innerHTML = "";
wrapper.style.background = "#6c757d";
const span = document.createElement("span");
span.className = "avatar-initials";
span.style.color = "#fff";
span.style.fontWeight = "600";
span.style.fontSize = "12px";
span.textContent = initials || "•";
wrapper.appendChild(span);
});
return wrapper; return wrapper;
}, },
}, },
@ -411,23 +428,27 @@ export default class extends Controller {
headerSort: false, headerSort: false,
formatter: (cell) => { formatter: (cell) => {
const data = cell.getRow().getData(); const data = cell.getRow().getData();
const url = cell.getValue(); let url = cell.getValue(); // use 'let' so we can modify it
const first = (s) => (typeof s === "string" && s.length ? s.trim()[0].toUpperCase() : "");
const initials = `${data.initials}`;
// 1. GENERATE INITIALS
const first = (s) => (typeof s === "string" && s.length ? s.trim()[0].toUpperCase() : "");
const initials = `${first(data.name)}${first(data.prenom)}`;
// 2. CREATE WRAPPER
const wrapper = document.createElement("div"); const wrapper = document.createElement("div");
wrapper.className = "avatar-wrapper"; wrapper.className = "avatar-wrapper";
// same size for both cases
wrapper.style.width = "40px"; wrapper.style.width = "40px";
wrapper.style.height = "40px"; wrapper.style.height = "40px";
wrapper.style.display = "flex"; wrapper.style.display = "flex";
wrapper.style.alignItems = "center"; wrapper.style.alignItems = "center";
wrapper.style.justifyContent = "center"; wrapper.style.justifyContent = "center";
wrapper.style.borderRadius = "50%"; wrapper.style.borderRadius = "50%";
wrapper.style.overflow = "hidden"; // ensure image clips to circle wrapper.style.overflow = "hidden";
if (!url) { // Helper to render fallback initials
wrapper.style.background = "#6c757d"; // gray background const renderFallback = () => {
wrapper.innerHTML = ""; // clear any broken img
wrapper.style.background = "#6c757d";
const span = document.createElement("span"); const span = document.createElement("span");
span.className = "avatar-initials"; span.className = "avatar-initials";
span.style.color = "#fff"; span.style.color = "#fff";
@ -435,31 +456,36 @@ export default class extends Controller {
span.style.fontSize = "14px"; span.style.fontSize = "14px";
span.textContent = initials || "•"; span.textContent = initials || "•";
wrapper.appendChild(span); wrapper.appendChild(span);
};
// 3. IF NO URL, RENDER FALLBACK IMMEDIATELY
if (!url) {
renderFallback();
return wrapper; return wrapper;
} }
// Image case: make it fill the same wrapper // --- THE FIX: HANDLE RELATIVE PATHS ---
// If the path doesn't start with 'http' or '/', add a leading '/'
// This ensures 'uploads/file.jpg' becomes '/uploads/file.jpg'
if (!url.startsWith('http') && !url.startsWith('/')) {
url = '/' + url;
}
// 4. CREATE IMAGE
const img = document.createElement("img"); const img = document.createElement("img");
img.src = url; img.src = url;
img.alt = initials || "avatar"; img.alt = initials || "avatar";
img.style.width = "100%"; img.style.width = "100%";
img.style.height = "100%"; img.style.height = "100%";
img.style.objectFit = "cover"; // keep aspect and cover circle img.style.objectFit = "cover";
// 5. ERROR HANDLING (triggers your fallback)
img.onerror = () => {
console.warn("Image failed to load:", url); // Debug log to see the wrong path
renderFallback();
};
wrapper.appendChild(img); wrapper.appendChild(img);
// Optional: fallback if image fails
img.addEventListener("error", () => {
wrapper.innerHTML = "";
wrapper.style.background = "#6c757d";
const span = document.createElement("span");
span.className = "avatar-initials";
span.style.color = "#fff";
span.style.fontWeight = "600";
span.style.fontSize = "12px";
span.textContent = initials || "•";
wrapper.appendChild(span);
});
return wrapper; return wrapper;
}, },
}, },
@ -539,23 +565,27 @@ export default class extends Controller {
headerSort: false, headerSort: false,
formatter: (cell) => { formatter: (cell) => {
const data = cell.getRow().getData(); const data = cell.getRow().getData();
const url = cell.getValue(); let url = cell.getValue(); // use 'let' so we can modify it
// 1. GENERATE INITIALS
const first = (s) => (typeof s === "string" && s.length ? s.trim()[0].toUpperCase() : ""); const first = (s) => (typeof s === "string" && s.length ? s.trim()[0].toUpperCase() : "");
const initials = `${first(data.name)}${first(data.prenom)}`; const initials = `${first(data.name)}${first(data.prenom)}`;
// 2. CREATE WRAPPER
const wrapper = document.createElement("div"); const wrapper = document.createElement("div");
wrapper.className = "avatar-wrapper"; wrapper.className = "avatar-wrapper";
// same size for both cases
wrapper.style.width = "40px"; wrapper.style.width = "40px";
wrapper.style.height = "40px"; wrapper.style.height = "40px";
wrapper.style.display = "flex"; wrapper.style.display = "flex";
wrapper.style.alignItems = "center"; wrapper.style.alignItems = "center";
wrapper.style.justifyContent = "center"; wrapper.style.justifyContent = "center";
wrapper.style.borderRadius = "50%"; wrapper.style.borderRadius = "50%";
wrapper.style.overflow = "hidden"; // ensure image clips to circle wrapper.style.overflow = "hidden";
if (!url) { // Helper to render fallback initials
wrapper.style.background = "#6c757d"; // gray background const renderFallback = () => {
wrapper.innerHTML = ""; // clear any broken img
wrapper.style.background = "#6c757d";
const span = document.createElement("span"); const span = document.createElement("span");
span.className = "avatar-initials"; span.className = "avatar-initials";
span.style.color = "#fff"; span.style.color = "#fff";
@ -563,31 +593,36 @@ export default class extends Controller {
span.style.fontSize = "14px"; span.style.fontSize = "14px";
span.textContent = initials || "•"; span.textContent = initials || "•";
wrapper.appendChild(span); wrapper.appendChild(span);
};
// 3. IF NO URL, RENDER FALLBACK IMMEDIATELY
if (!url) {
renderFallback();
return wrapper; return wrapper;
} }
// Image case: make it fill the same wrapper // --- THE FIX: HANDLE RELATIVE PATHS ---
// If the path doesn't start with 'http' or '/', add a leading '/'
// This ensures 'uploads/file.jpg' becomes '/uploads/file.jpg'
if (!url.startsWith('http') && !url.startsWith('/')) {
url = '/' + url;
}
// 4. CREATE IMAGE
const img = document.createElement("img"); const img = document.createElement("img");
img.src = url; img.src = url;
img.alt = initials || "avatar"; img.alt = initials || "avatar";
img.style.width = "100%"; img.style.width = "100%";
img.style.height = "100%"; img.style.height = "100%";
img.style.objectFit = "cover"; // keep aspect and cover circle img.style.objectFit = "cover";
// 5. ERROR HANDLING (triggers your fallback)
img.onerror = () => {
console.warn("Image failed to load:", url); // Debug log to see the wrong path
renderFallback();
};
wrapper.appendChild(img); wrapper.appendChild(img);
// Optional: fallback if image fails
img.addEventListener("error", () => {
wrapper.innerHTML = "";
wrapper.style.background = "#6c757d";
const span = document.createElement("span");
span.className = "avatar-initials";
span.style.color = "#fff";
span.style.fontWeight = "600";
span.style.fontSize = "12px";
span.textContent = initials || "•";
wrapper.appendChild(span);
});
return wrapper; return wrapper;
}, },
}, },

View File

@ -6,6 +6,7 @@
parameters: parameters:
aws_url: '%env(AWS_ENDPOINT)%' aws_url: '%env(AWS_ENDPOINT)%'
aws_public_url: '%env(AWS_ENDPOINT)%' aws_public_url: '%env(AWS_ENDPOINT)%'
aws_bucket: '%env(S3_PORTAL_BUCKET)%'
logos_directory: '%kernel.project_dir%/public/uploads/logos' logos_directory: '%kernel.project_dir%/public/uploads/logos'
services: services:

View File

@ -232,6 +232,7 @@ class OrganizationController extends AbstractController
try { try {
$organization->setIsActive(false); $organization->setIsActive(false);
$organization->setIsDeleted(true); $organization->setIsDeleted(true);
$this->organizationsService->deleteLogo($organization);
// Deactivate all associated UsersOrganizations // Deactivate all associated UsersOrganizations
$this->userOrganizationService->deactivateAllUserOrganizationLinks($actingUser, null, $organization); $this->userOrganizationService->deactivateAllUserOrganizationLinks($actingUser, null, $organization);
@ -344,12 +345,12 @@ class OrganizationController extends AbstractController
// Map to array // Map to array
$data = array_map(function (Organizations $org) { $data = array_map(function (Organizations $org) {
$picture = $this->awsService->getPublicUrl($_ENV['S3_PORTAL_BUCKET']) . $org->getLogoUrl();
return [ return [
'id' => $org->getId(), 'id' => $org->getId(),
'name' => $org->getName(), 'name' => $org->getName(),
'email' => $org->getEmail(), 'email' => $org->getEmail(),
'logoUrl' => $picture ?: null, 'logoUrl' => $org->getLogoUrl() ?: null,
'active' => $org->isActive(), 'active' => $org->isActive(),
'showUrl' => $this->generateUrl('organization_show', ['id' => $org->getId()]), 'showUrl' => $this->generateUrl('organization_show', ['id' => $org->getId()]),
]; ];

View File

@ -128,12 +128,15 @@ class UserController extends AbstractController
'isActive' => true, 'isActive' => true,
]); ]);
if (!$uoList) { if (!$uoList) {
$this->loggerService->logEntityNotFound('UsersOrganization', [ $data['rolesArray'] = $this->userService->getRolesArrayForUser($actingUser, true);
'user_id' => $user->getId(), return $this->render('user/show.html.twig', [
'organization_id' => $orgId], 'user' => $user,
$actingUser->getId()); 'organizationId' => $orgId ?? null,
$this->addFlash('error', "L'utilisateur n'est pas actif dans une organisation."); 'uoActive' => $uoActive ?? null,
throw $this->createNotFoundException(self::NOT_FOUND); 'apps' => $apps ?? [],
'data' => $data ?? [],
'canEdit' => false,
]);
} }
} }
// Charger les liens UserOrganizationApp (UOA) actifs pour les UO trouvées // Charger les liens UserOrganizationApp (UOA) actifs pour les UO trouvées
@ -168,7 +171,11 @@ class UserController extends AbstractController
$canEdit = $this->userService->canEditRolesCheck($actingUser, $user,$this->isGranted('ROLE_ADMIN'), $singleUo, $organization); $canEdit = $this->userService->canEditRolesCheck($actingUser, $user,$this->isGranted('ROLE_ADMIN'), $singleUo, $organization);
} catch (\Exception $e) { } catch (\Exception $e) {
$this->errorLogger->error($e->getMessage()); $this->loggerService->logError('error while loading user information', [
'target_user_id' => $id,
'acting_user_id' => $actingUser->getId(),
'error' => $e->getMessage(),
]);
$this->addFlash('error', 'Une erreur est survenue lors du chargement des informations utilisateur.'); $this->addFlash('error', 'Une erreur est survenue lors du chargement des informations utilisateur.');
$referer = $request->headers->get('referer'); $referer = $request->headers->get('referer');
return $this->redirect($referer ?? $this->generateUrl('app_index')); return $this->redirect($referer ?? $this->generateUrl('app_index'));
@ -183,7 +190,7 @@ class UserController extends AbstractController
]); ]);
} }
#[Route('/edit/{id}', name: 'edit', methods: ['GET', 'POST'])] #[Route('/edit/{id}', name: 'edit', methods: ['GET','POST'])]
public function edit(int $id, Request $request): Response public function edit(int $id, Request $request): Response
{ {
$this->denyAccessUnlessGranted('ROLE_USER'); $this->denyAccessUnlessGranted('ROLE_USER');
@ -199,13 +206,12 @@ class UserController extends AbstractController
$form = $this->createForm(UserForm::class, $user); $form = $this->createForm(UserForm::class, $user);
$form->handleRequest($request); $form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) { if ($form->isSubmitted() && $form->isValid()) {
// Handle user edit // Handle user edit
$picture = $form->get('pictureUrl')->getData(); $picture = $form->get('pictureUrl')->getData();;
$this->userService->formatUserData($user, $picture); $this->userService->formatUserData($user, $picture);
$user->setModifiedAt(new \DateTimeImmutable('now')); $user->setModifiedAt(new \DateTimeImmutable('now'));
$this->entityManager->persist($user); $this->entityManager->persist($user);
$this->entityManager->flush(); $this->entityManager->flush();
@ -571,6 +577,7 @@ class UserController extends AbstractController
$user->setIsActive(false); $user->setIsActive(false);
$user->setIsDeleted(true); $user->setIsDeleted(true);
$this->userService->deleteProfilePicture($user);
$user->setModifiedAt(new \DateTimeImmutable('now')); $user->setModifiedAt(new \DateTimeImmutable('now'));
// Deactivate all org links // Deactivate all org links
$this->userOrganizationService->deactivateAllUserOrganizationLinks($actingUser, $user); $this->userOrganizationService->deactivateAllUserOrganizationLinks($actingUser, $user);
@ -731,10 +738,9 @@ class UserController extends AbstractController
// Map to array // Map to array
$data = array_map(function (User $user) { $data = array_map(function (User $user) {
$picture = $this->awsService->getPublicUrl($_ENV['S3_PORTAL_BUCKET']) . $user->getPictureUrl();
return [ return [
'id' => $user->getId(), 'id' => $user->getId(),
'pictureUrl' => $picture, 'pictureUrl' => $user->getPictureUrl(),
'name' => $user->getSurname(), 'name' => $user->getSurname(),
'prenom' => $user->getName(), 'prenom' => $user->getName(),
'email' => $user->getEmail(), 'email' => $user->getEmail(),
@ -786,10 +792,9 @@ class UserController extends AbstractController
// Map to array (keep isConnected) // Map to array (keep isConnected)
$data = array_map(function (UsersOrganizations $uo) { $data = array_map(function (UsersOrganizations $uo) {
$user = $uo->getUsers(); $user = $uo->getUsers();
$picture = $this->awsService->getPublicUrl($_ENV['S3_PORTAL_BUCKET']) . $user->getPictureUrl();
$initials = $user->getName()[0] . $user->getSurname()[0]; $initials = $user->getName()[0] . $user->getSurname()[0];
return [ return [
'pictureUrl' => $picture, 'pictureUrl' =>$user->getPictureUrl(),
'email' => $user->getEmail(), 'email' => $user->getEmail(),
'isConnected' => $this->userService->isUserConnected($user->getUserIdentifier()), 'isConnected' => $this->userService->isUserConnected($user->getUserIdentifier()),
'showUrl' => $this->generateUrl('user_show', ['id' => $user->getId()]), 'showUrl' => $this->generateUrl('user_show', ['id' => $user->getId()]),
@ -829,10 +834,9 @@ class UserController extends AbstractController
// Map to array (keep isConnected) // Map to array (keep isConnected)
$data = array_map(function (UsersOrganizations $uo) { $data = array_map(function (UsersOrganizations $uo) {
$user = $uo->getUsers(); $user = $uo->getUsers();
$picture = $this->awsService->getPublicUrl($_ENV['S3_PORTAL_BUCKET']) . $user->getPictureUrl();
$initials = $user->getName()[0] . $user->getSurname()[0]; $initials = $user->getName()[0] . $user->getSurname()[0];
return [ return [
'pictureUrl' => $picture, 'pictureUrl' => $user->getPictureUrl(),
'email' => $user->getEmail(), 'email' => $user->getEmail(),
'isConnected' => $this->userService->isUserConnected($user->getUserIdentifier()), 'isConnected' => $this->userService->isUserConnected($user->getUserIdentifier()),
'showUrl' => $this->generateUrl('user_show', ['id' => $user->getId()]), 'showUrl' => $this->generateUrl('user_show', ['id' => $user->getId()]),

View File

@ -30,25 +30,86 @@ class OrganizationsService
public function handleLogo(Organizations $organization, $logoFile): void public function handleLogo(Organizations $organization, $logoFile): void
{ {
// 1. Define the destination directory (adjust path as needed, e.g., 'public/uploads/profile_pictures')
$destinationDir = 'uploads/organization_logos';
// 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));
}
}
$extension = $logoFile->guessExtension(); $extension = $logoFile->guessExtension();
$customFilename = $organization->getName() . '_' . date('dmyHis') . "." . $extension; // Sanitize the filename to remove special characters/spaces to prevent filesystem errors
$safeName = preg_replace('/[^a-zA-Z0-9]/', '', $organization->getName());
$customFilename = $safeName . '_' . date('dmyHis') . '.' . $extension;
try { try {
$this->awsService->PutDocObj($_ENV['S3_PORTAL_BUCKET'], $logoFile, $customFilename, $extension, 'logo/'); // 4. Move the file to the destination directory
$this->loggerService->logAWSAction('Upload organization logo', [ // The move() method is standard in Symfony/Laravel UploadedFile objects
'organization_id' => $organization->getId(), $logoFile->move($destinationDir, $customFilename);
'filename' => $customFilename,
'bucket' => $_ENV['S3_PORTAL_BUCKET'], // 5. Update the user entity with the relative path
// Ensure you store the path relative to your public folder usually
$organization->setLogoUrl($destinationDir . '/' . $customFilename);
} catch (\Exception $e) {
// 6. Log the critical error as requested
$this->loggerService->logError('File upload failed',[
'target_organization_id' => $organization->getId(),
'message' => $e->getMessage(),
'file_name' => $customFilename,
]); ]);
$organization->setLogoUrl('logo/' . $customFilename);
} catch (FileException $e) { // Optional: Re-throw the exception if you want the controller/user to know the upload failed
$this->loggerService->logError('Failed to upload organization logo to S3', [ throw new FileException('File upload failed.');
'organization_id' => $organization->getId(), }
'error' => $e->getMessage(), }
'bucket' => $_ENV['S3_PORTAL_BUCKET'],
public function deleteLogo(Organizations $organization): void
{
// 1. Get the current picture path from the user entity
$currentPicturePath = $organization->getLogoUrl();
// 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_organization_id' => $organization->getId(),
'missing_path' => $currentPicturePath
]);
}
// 4. Update the user entity to remove the reference
$organization->setLogoUrl("");
} 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_organization_id' => $organization->getId(),
'message' => $e->getMessage(),
'file_path' => $currentPicturePath,
]); ]);
throw new FileException('Failed to upload logo to S3: ' . $e->getMessage());
// Re-throw if you want to stop execution/show error to user
throw new \RuntimeException('Unable to remove profile picture.');
} }
} }

View File

@ -19,6 +19,7 @@ use RuntimeException;
use Symfony\Bundle\SecurityBundle\Security; use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\HttpFoundation\File\Exception\FileException; use Symfony\Component\HttpFoundation\File\Exception\FileException;
use App\Event\UserCreatedEvent; use App\Event\UserCreatedEvent;
use Symfony\Component\HttpFoundation\File\UploadedFile;
use Symfony\Contracts\EventDispatcher\EventDispatcherInterface; use Symfony\Contracts\EventDispatcher\EventDispatcherInterface;
class UserService class UserService
@ -173,28 +174,91 @@ class UserService
return ['none' => $group]; return ['none' => $group];
} }
public function handleProfilePicture(User $user, $picture): void public function handleProfilePicture(User $user, UploadedFile $picture): void
{ {
// Get file extension // 1. Define the destination directory (adjust path as needed, e.g., 'public/uploads/profile_pictures')
$extension = $picture->guessExtension(); $destinationDir = 'uploads/profile_pictures';
// Create custom filename: userNameUserSurname_dmyHis // 2. Create the directory if it doesn't exist
$customFilename = $user->getName() . $user->getSurname() . '_' . date('dmyHis') . '.' . $extension; if (!file_exists($destinationDir)) {
try { // 0755 is the standard permission (Owner: read/write/exec, Others: read/exec)
$this->awsService->PutDocObj($_ENV['S3_PORTAL_BUCKET'], $picture, $customFilename, $extension, 'profile/'); if (!mkdir($destinationDir, 0755, true) && !is_dir($destinationDir)) {
$this->loggerService->logAWSAction( throw new \RuntimeException(sprintf('Directory "%s" was not created', $destinationDir));
'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());
} }
// // 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.');
}
} }
/** /**
@ -297,7 +361,7 @@ class UserService
{ {
$formatted = array_map(function (UsersOrganizations $uo) { $formatted = array_map(function (UsersOrganizations $uo) {
$user = $uo->getUsers(); $user = $uo->getUsers();
$picture = $this->awsService->getPublicUrl($_ENV['S3_PORTAL_BUCKET']) . $user->getPictureUrl();
if ($uo->getStatut() === "INVITED") { if ($uo->getStatut() === "INVITED") {
$statut = "INVITED"; $statut = "INVITED";
// if user invited but not accepted in 1 hour, set statut to EXPIRED // if user invited but not accepted in 1 hour, set statut to EXPIRED
@ -311,7 +375,7 @@ class UserService
$statut = $uo->isActive() ? "ACTIVE" : "INACTIVE"; $statut = $uo->isActive() ? "ACTIVE" : "INACTIVE";
} }
return [ return [
'pictureUrl' => $picture, 'pictureUrl' => $user->getPictureUrl(),
'name' => $user->getSurname(), 'name' => $user->getSurname(),
'prenom' => $user->getName(), 'prenom' => $user->getName(),
'email' => $user->getEmail(), 'email' => $user->getEmail(),

View File

@ -12,7 +12,7 @@
<div class="col d-flex justify-content-between align-items-center"> <div class="col d-flex justify-content-between align-items-center">
<div class="d-flex "> <div class="d-flex ">
{% if organization.logoUrl %} {% if organization.logoUrl %}
<img src="{{ aws_url ~ organization.logoUrl }}" alt="Organization logo" <img src="{{ asset(organization.logoUrl) }}" alt="Organization logo"
class="rounded-circle" style="width:40px; height:40px;"> class="rounded-circle" style="width:40px; height:40px;">
{% endif %} {% endif %}
<h1 class="mb-4 ms-3">{{ organization.name|title }} - Dashboard</h1> <h1 class="mb-4 ms-3">{{ organization.name|title }} - Dashboard</h1>

View File

@ -14,7 +14,7 @@
<div class="card-body"> <div class="card-body">
{{ form_start(form, {'action': path('user_edit', {'id': user.id}), 'method': 'PUT'}) }} {{ form_start(form, {'action': path('user_edit', {'id': user.id}), 'method': 'POST'}) }}
{{ form_widget(form) }} {{ form_widget(form) }}
<input hidden type="text" value="{{ organizationId }}" name="organizationId"> <input hidden type="text" value="{{ organizationId }}" name="organizationId">
<button type="submit" class="btn btn-primary">Enregistrer</button> <button type="submit" class="btn btn-primary">Enregistrer</button>

View File

@ -3,7 +3,7 @@
<div class="card-header d-flex justify-content-between align-items-center"> <div class="card-header d-flex justify-content-between align-items-center">
<div class="d-flex gap-2"> <div class="d-flex gap-2">
{% if user.pictureUrl is not empty %} {% if user.pictureUrl is not empty %}
<img src="{{ aws_url ~ user.pictureUrl }}" alt="user" class="rounded-circle" <img src="{{asset(user.pictureUrl)}}" alt="user" class="rounded-circle"
style="width:40px; height:40px;"> style="width:40px; height:40px;">
{% endif %} {% endif %}
<div class="card-title "> <div class="card-title ">