diff --git a/assets/app.js b/assets/app.js
index 0ca5038..901e245 100644
--- a/assets/app.js
+++ b/assets/app.js
@@ -14,6 +14,7 @@ import 'choices.js/public/assets/styles/choices.min.css';
import 'tabulator-tables/dist/css/tabulator.min.css';
import './styles/tabulator.css';
import './styles/card.css';
+import './styles/notifications.css';
import 'bootstrap';
import './js/template.js';
diff --git a/assets/controllers/notification_controller.js b/assets/controllers/notification_controller.js
new file mode 100644
index 0000000..35f6a26
--- /dev/null
+++ b/assets/controllers/notification_controller.js
@@ -0,0 +1,332 @@
+import { Controller } from '@hotwired/stimulus';
+
+export default class extends Controller {
+ static targets = ['badge', 'list'];
+ static values = {
+ userId: Number,
+ mercureUrl: String
+ };
+
+ connect() {
+ this.loadNotifications();
+ this.connectToMercure();
+ this.toastContainer = this.createToastContainer();
+ }
+
+ disconnect() {
+ if (this.eventSource) {
+ this.eventSource.close();
+ }
+ }
+
+ async loadNotifications() {
+ try {
+ const response = await fetch('/notifications/unread');
+ const data = await response.json();
+
+ this.updateBadge(data.unreadCount);
+ this.renderNotifications(data.notifications);
+ } catch (error) {
+ console.error('Failed to load notifications:', error);
+ }
+ }
+
+ async connectToMercure() {
+ try {
+ // Fetch the JWT token and topic from the server
+ const response = await fetch('/notifications/mercure-token');
+ const data = await response.json();
+
+ console.log('Mercure token data:', data);
+
+ // Use server-provided topic if available, otherwise fallback to default per-user topic
+ const topic = data.topic || `http://portail.solutions-easy.moi/notifications/user/${this.userIdValue}`;
+ const url = new URL(this.mercureUrlValue);
+ url.searchParams.append('topic', topic);
+
+ // Add authorization token as URL param if provided (Mercure can accept it this way)
+ if (data.token) {
+ url.searchParams.append('authorization', data.token);
+ }
+
+ console.log('Connecting to Mercure...');
+ console.log('Mercure URL:', this.mercureUrlValue);
+ console.log('Topic:', topic);
+ console.log('Full URL:', url.toString());
+
+ try {
+ this.eventSource = new EventSource(url.toString());
+ } catch (e) {
+ console.error('❌ Failed to create EventSource:', e);
+ return;
+ }
+
+ this.eventSource.onopen = () => {
+ console.log('✅ Mercure connection established successfully!');
+ };
+
+ this.eventSource.onmessage = (event) => {
+ console.log('📨 New notification received:', event.data);
+ try {
+ const notification = JSON.parse(event.data);
+ this.handleNewNotification(notification);
+ } catch (parseError) {
+ console.error('Failed to parse Mercure message data:', parseError, 'raw data:', event.data);
+ }
+ };
+
+ this.eventSource.onerror = (error) => {
+ console.error('❌ Mercure connection error:', error);
+ try {
+ console.error('EventSource readyState:', this.eventSource.readyState);
+ } catch (e) {
+ console.error('Could not read EventSource.readyState:', e);
+ }
+
+ // EventSource will automatically try to reconnect.
+ // If closed, log it for debugging.
+ try {
+ if (this.eventSource.readyState === EventSource.CLOSED) {
+ console.log('Connection closed. Will retry...');
+ } else if (this.eventSource.readyState === EventSource.CONNECTING) {
+ console.log('Connection is reconnecting (CONNECTING).');
+ } else if (this.eventSource.readyState === EventSource.OPEN) {
+ console.log('Connection is open (OPEN).');
+ }
+ } catch (e) {
+ console.error('Error while checking EventSource state:', e);
+ }
+ };
+
+ console.log('EventSource connected to:', url.toString());
+ } catch (error) {
+ console.error('Failed to connect to Mercure:', error);
+ }
+ }
+
+ handleNewNotification(notification) {
+ this.showToast(notification);
+ this.loadNotifications();
+ }
+
+ updateBadge(count) {
+ const badge = this.badgeTarget;
+ if (count > 0) {
+ badge.textContent = count > 99 ? '99+' : count;
+ badge.style.display = 'block';
+ } else {
+ badge.style.display = 'none';
+ }
+ }
+
+ renderNotifications(notifications) {
+ const list = this.listTarget;
+
+ if (notifications.length === 0) {
+ list.innerHTML = `
+
+
+
+
+
Aucune notification
+
+ `;
+ return;
+ }
+
+ list.innerHTML = notifications.map(notif => this.renderNotificationItem(notif)).join('');
+ }
+
+ renderNotificationItem(notification) {
+ const iconHtml = this.getIcon(notification.type);
+ const timeAgo = this.getTimeAgo(notification.createdAt);
+ const readClass = notification.isRead ? 'opacity-75' : '';
+
+ return `
+
+
+
+
${this.escapeHtml(notification.title)}
+
${this.escapeHtml(notification.message)}
+
+ ${timeAgo}
+
+
+
+
+ `;
+ }
+
+ getIcon(type) {
+ const icons = {
+ user_joined: '',
+ user_invited: '',
+ user_accepted: '',
+ user_removed: '',
+ org_update: '',
+ app_access: '',
+ role_changed: '',
+ };
+ return icons[type] || icons.user_joined;
+ }
+
+ getIconBgClass(type) {
+ const classes = {
+ user_joined: 'bg-success',
+ user_invited: 'bg-info',
+ user_accepted: 'bg-success',
+ user_removed: 'bg-danger',
+ org_update: 'bg-warning',
+ app_access: 'bg-primary',
+ role_changed: 'bg-info',
+ };
+ return classes[type] || 'bg-primary';
+ }
+
+ getTimeAgo(dateString) {
+ if (!dateString) return '';
+ const date = new Date(dateString);
+ if (isNaN(date)) return '';
+ const now = new Date();
+ const seconds = Math.floor((now - date) / 1000);
+
+ if (seconds < 60) return 'À l\'instant';
+ if (seconds < 3600) {
+ const mins = Math.floor(seconds / 60);
+ return `Il y a ${mins} ${mins > 1 ? 'mins' : 'min'}`;
+ }
+ if (seconds < 86400) {
+ const hours = Math.floor(seconds / 3600);
+ return `Il y a ${hours} ${hours > 1 ? 'h' : 'h'}`;
+ }
+ if (seconds < 604800) {
+ const days = Math.floor(seconds / 86400);
+ return `Il y a ${days} ${days > 1 ? 'j' : 'j'}`;
+ }
+ // For older dates, show a localized date string
+ try {
+ return date.toLocaleDateString('fr-FR', {year: 'numeric', month: 'short', day: 'numeric'});
+ } catch (e) {
+ return date.toLocaleDateString();
+ }
+ }
+
+ escapeHtml(text) {
+ const div = document.createElement('div');
+ div.textContent = text;
+ return div.innerHTML;
+ }
+
+ async markAsRead(event) {
+ event.preventDefault();
+ event.stopPropagation();
+
+ const notificationId = event.currentTarget.dataset.notificationId;
+
+ try {
+ await fetch(`/notifications/${notificationId}/read`, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ }
+ });
+
+ this.loadNotifications();
+ } catch (error) {
+ console.error('Failed to mark notification as read:', error);
+ }
+ }
+
+ async markAllAsRead(event) {
+ event.preventDefault();
+ event.stopPropagation();
+
+ try {
+ await fetch('/notifications/mark-all-read', {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ }
+ });
+
+ this.loadNotifications();
+ } catch (error) {
+ console.error('Failed to mark all as read:', error);
+ }
+ }
+
+ async deleteNotification(event) {
+ event.preventDefault();
+ event.stopPropagation();
+
+ const notificationId = event.currentTarget.dataset.notificationId;
+
+ try {
+ await fetch(`/notifications/${notificationId}`, {
+ method: 'DELETE',
+ headers: {
+ 'Content-Type': 'application/json',
+ }
+ });
+
+ this.loadNotifications();
+ } catch (error) {
+ console.error('Failed to delete notification:', error);
+ }
+ }
+
+ markDropdownAsRead(event) {
+ }
+
+ createToastContainer() {
+ let container = document.getElementById('notification-toast-container');
+ if (!container) {
+ container = document.createElement('div');
+ container.id = 'notification-toast-container';
+ container.className = 'notification-toast-container';
+ document.body.appendChild(container);
+ }
+ return container;
+ }
+
+ showToast(notification) {
+ const toast = document.createElement('div');
+ toast.className = 'notification-toast';
+ toast.innerHTML = `
+
+ ${this.getIcon(notification.type)}
+
+
+
${this.escapeHtml(notification.title)}
+
${this.escapeHtml(notification.message)}
+
+
+ `;
+
+ this.toastContainer.appendChild(toast);
+
+ setTimeout(() => toast.classList.add('show'), 10);
+
+ setTimeout(() => {
+ toast.classList.remove('show');
+ setTimeout(() => toast.remove(), 300);
+ }, 5000);
+ }
+}
diff --git a/assets/styles/notifications.css b/assets/styles/notifications.css
new file mode 100644
index 0000000..c8e2aa4
--- /dev/null
+++ b/assets/styles/notifications.css
@@ -0,0 +1,156 @@
+.notification-toast-container {
+ position: fixed;
+ top: 80px;
+ right: 20px;
+ z-index: 9999;
+ display: flex;
+ flex-direction: column;
+ gap: 10px;
+ pointer-events: none;
+}
+
+.notification-toast {
+ background: white;
+ border-radius: 8px;
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
+ padding: 16px;
+ min-width: 320px;
+ max-width: 400px;
+ display: flex;
+ align-items: flex-start;
+ gap: 12px;
+ opacity: 0;
+ transform: translateX(400px);
+ transition: all 0.3s ease;
+ pointer-events: all;
+}
+
+.notification-toast.show {
+ opacity: 1;
+ transform: translateX(0);
+}
+
+.notification-toast-icon {
+ width: 40px;
+ height: 40px;
+ border-radius: 50%;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ flex-shrink: 0;
+ color: white;
+}
+
+.notification-toast-icon svg {
+ width: 20px;
+ height: 20px;
+}
+
+.notification-toast-content {
+ flex: 1;
+ min-width: 0;
+}
+
+.notification-toast-title {
+ font-weight: 600;
+ font-size: 14px;
+ margin-bottom: 4px;
+ color: #333;
+}
+
+.notification-toast-message {
+ font-size: 13px;
+ color: #666;
+ line-height: 1.4;
+}
+
+.notification-toast-close {
+ background: none;
+ border: none;
+ font-size: 24px;
+ line-height: 1;
+ color: #999;
+ cursor: pointer;
+ padding: 0;
+ width: 24px;
+ height: 24px;
+ flex-shrink: 0;
+ transition: color 0.2s;
+}
+
+.notification-toast-close:hover {
+ color: #333;
+}
+
+.nav-notif .count-notification {
+ position: absolute;
+ top: 15px;
+ right: -5px;
+ background: var(--primary-blue-light);
+ color: white;
+ border-radius: 12px;
+ padding: 3px 7px;
+ font-size: 8px;
+ font-weight: bold;
+ min-width: 5px;
+ height: 10px;
+ line-height:0.5;
+ box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
+}
+
+.dropdown-item.preview-item {
+ display: flex;
+ align-items: flex-start;
+ padding: 12px 16px;
+ border-bottom: 1px solid #f0f0f0;
+ transition: background-color 0.2s;
+ text-decoration: none;
+ color: inherit;
+}
+
+.dropdown-item.preview-item:hover {
+ background-color: #f8f9fa;
+}
+
+.dropdown-item.preview-item:last-child {
+ border-bottom: none;
+}
+
+.preview-thumbnail {
+ margin-right: 12px;
+}
+
+.preview-icon {
+ width: 40px;
+ height: 40px;
+ border-radius: 50%;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ color: white;
+}
+
+.preview-icon svg,
+.preview-icon i {
+ width: 20px;
+ height: 20px;
+}
+
+.preview-item-content {
+ flex: 1;
+ min-width: 0;
+}
+
+.preview-subject {
+ font-size: 14px;
+ font-weight: 600;
+ margin-bottom: 4px;
+ color: #333;
+}
+
+.preview-item-content p {
+ font-size: 12px;
+ margin-bottom: 0;
+ line-height: 1.4;
+}
+
diff --git a/config/packages/mercure.yaml b/config/packages/mercure.yaml
index f2a7395..0dd804a 100644
--- a/config/packages/mercure.yaml
+++ b/config/packages/mercure.yaml
@@ -6,3 +6,4 @@ mercure:
jwt:
secret: '%env(MERCURE_JWT_SECRET)%'
publish: '*'
+ subscribe: '*'
diff --git a/migrations/Version20251029104354.php b/migrations/Version20251029104354.php
new file mode 100644
index 0000000..5dbb66a
--- /dev/null
+++ b/migrations/Version20251029104354.php
@@ -0,0 +1,35 @@
+addSql('ALTER TABLE "user" ADD password_token VARCHAR(255) DEFAULT NULL');
+ $this->addSql('ALTER TABLE "user" ADD token_expiry TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL');
+ $this->addSql('COMMENT ON COLUMN "user".token_expiry IS \'(DC2Type:datetime_immutable)\'');
+ }
+
+ public function down(Schema $schema): void
+ {
+ // this down() migration is auto-generated, please modify it to your needs
+ $this->addSql('CREATE SCHEMA public');
+ $this->addSql('ALTER TABLE "user" DROP password_token');
+ $this->addSql('ALTER TABLE "user" DROP token_expiry');
+ }
+}
diff --git a/migrations/Version20251029104801.php b/migrations/Version20251029104801.php
new file mode 100644
index 0000000..62cdbb9
--- /dev/null
+++ b/migrations/Version20251029104801.php
@@ -0,0 +1,32 @@
+addSql('ALTER TABLE "user" ALTER password DROP NOT NULL');
+ }
+
+ public function down(Schema $schema): void
+ {
+ // this down() migration is auto-generated, please modify it to your needs
+ $this->addSql('CREATE SCHEMA public');
+ $this->addSql('ALTER TABLE "user" ALTER password SET NOT NULL');
+ }
+}
diff --git a/migrations/Version20251104081124.php b/migrations/Version20251104081124.php
new file mode 100644
index 0000000..a6214aa
--- /dev/null
+++ b/migrations/Version20251104081124.php
@@ -0,0 +1,32 @@
+addSql('ALTER TABLE apps ADD logo_mini_url VARCHAR(255) DEFAULT NULL');
+ }
+
+ public function down(Schema $schema): void
+ {
+ // this down() migration is auto-generated, please modify it to your needs
+ $this->addSql('CREATE SCHEMA public');
+ $this->addSql('ALTER TABLE apps DROP logo_mini_url');
+ }
+}
diff --git a/migrations/Version20251105083809.php b/migrations/Version20251105083809.php
new file mode 100644
index 0000000..b97f459
--- /dev/null
+++ b/migrations/Version20251105083809.php
@@ -0,0 +1,33 @@
+addSql('ALTER TABLE users_organizations ADD modified_at TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL');
+ $this->addSql('COMMENT ON COLUMN users_organizations.modified_at IS \'(DC2Type:datetime_immutable)\'');
+ }
+
+ public function down(Schema $schema): void
+ {
+ // this down() migration is auto-generated, please modify it to your needs
+ $this->addSql('CREATE SCHEMA public');
+ $this->addSql('ALTER TABLE users_organizations DROP modified_at');
+ }
+}
diff --git a/migrations/Version20251117104819.php b/migrations/Version20251117104819.php
new file mode 100644
index 0000000..679f81e
--- /dev/null
+++ b/migrations/Version20251117104819.php
@@ -0,0 +1,41 @@
+addSql('CREATE TABLE notifications (id SERIAL NOT NULL, user_id INT NOT NULL, organization_id INT DEFAULT NULL, type VARCHAR(50) NOT NULL, title VARCHAR(255) NOT NULL, message TEXT NOT NULL, data JSON DEFAULT NULL, is_read BOOLEAN DEFAULT false NOT NULL, created_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, read_at TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL, PRIMARY KEY(id))');
+ $this->addSql('CREATE INDEX IDX_6000B0D3A76ED395 ON notifications (user_id)');
+ $this->addSql('CREATE INDEX IDX_6000B0D332C8A3DE ON notifications (organization_id)');
+ $this->addSql('CREATE INDEX idx_user_read_created ON notifications (user_id, is_read, created_at)');
+ $this->addSql('COMMENT ON COLUMN notifications.created_at IS \'(DC2Type:datetime_immutable)\'');
+ $this->addSql('COMMENT ON COLUMN notifications.read_at IS \'(DC2Type:datetime_immutable)\'');
+ $this->addSql('ALTER TABLE notifications ADD CONSTRAINT FK_6000B0D3A76ED395 FOREIGN KEY (user_id) REFERENCES "user" (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE');
+ $this->addSql('ALTER TABLE notifications ADD CONSTRAINT FK_6000B0D332C8A3DE FOREIGN KEY (organization_id) REFERENCES organizations (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE');
+ }
+
+ public function down(Schema $schema): void
+ {
+ // this down() migration is auto-generated, please modify it to your needs
+ $this->addSql('CREATE SCHEMA public');
+ $this->addSql('ALTER TABLE notifications DROP CONSTRAINT FK_6000B0D3A76ED395');
+ $this->addSql('ALTER TABLE notifications DROP CONSTRAINT FK_6000B0D332C8A3DE');
+ $this->addSql('DROP TABLE notifications');
+ }
+}
diff --git a/migrations/Version20251117125146.php b/migrations/Version20251117125146.php
new file mode 100644
index 0000000..3316a82
--- /dev/null
+++ b/migrations/Version20251117125146.php
@@ -0,0 +1,31 @@
+addSql('CREATE SCHEMA public');
+ }
+}
diff --git a/src/Controller/NotificationController.php b/src/Controller/NotificationController.php
new file mode 100644
index 0000000..f0826d6
--- /dev/null
+++ b/src/Controller/NotificationController.php
@@ -0,0 +1,144 @@
+denyAccessUnlessGranted('IS_AUTHENTICATED_FULLY');
+ $user = $this->userService->getUserByIdentifier($this->getUser()->getUserIdentifier());
+
+ $notifications = $this->notificationRepository->findRecentByUser($user, 50);
+ $unreadCount = $this->notificationRepository->countUnreadByUser($user);
+
+ return new JsonResponse([
+ 'notifications' => array_map(fn($n) => $n->toArray(), $notifications),
+ 'unreadCount' => $unreadCount,
+ ]);
+ }
+
+ #[Route(path: '/unread', name: 'unread', methods: ['GET'])]
+ public function unread(): JsonResponse
+ {
+ $this->denyAccessUnlessGranted('IS_AUTHENTICATED_FULLY');
+ $user = $this->userService->getUserByIdentifier($this->getUser()->getUserIdentifier());
+
+ $notifications = $this->notificationRepository->findUnreadByUser($user);
+ $unreadCount = count($notifications);
+
+ return new JsonResponse([
+ 'notifications' => array_map(fn($n) => $n->toArray(), $notifications),
+ 'unreadCount' => $unreadCount,
+ ]);
+ }
+
+ #[Route(path: '/count', name: 'count', methods: ['GET'])]
+ public function count(): JsonResponse
+ {
+ $this->denyAccessUnlessGranted('IS_AUTHENTICATED_FULLY');
+ $user = $this->userService->getUserByIdentifier($this->getUser()->getUserIdentifier());
+
+ $unreadCount = $this->notificationRepository->countUnreadByUser($user);
+
+ return new JsonResponse(['count' => $unreadCount]);
+ }
+
+ #[Route(path: '/{id}/read', name: 'mark_read', methods: ['POST'])]
+ public function markAsRead(int $id): JsonResponse
+ {
+ $this->denyAccessUnlessGranted('IS_AUTHENTICATED_FULLY');
+ $user = $this->userService->getUserByIdentifier($this->getUser()->getUserIdentifier());
+
+ $notification = $this->notificationRepository->find($id);
+
+ if (!$notification || $notification->getUser()->getId() !== $user->getId()) {
+ return new JsonResponse(['error' => 'Notification not found'], Response::HTTP_NOT_FOUND);
+ }
+
+ $notification->setIsRead(true);
+ $this->entityManager->flush();
+
+ return new JsonResponse(['success' => true]);
+ }
+
+ #[Route(path: '/mark-all-read', name: 'mark_all_read', methods: ['POST'])]
+ public function markAllAsRead(): JsonResponse
+ {
+ $this->denyAccessUnlessGranted('IS_AUTHENTICATED_FULLY');
+ $user = $this->userService->getUserByIdentifier($this->getUser()->getUserIdentifier());
+
+ $count = $this->notificationRepository->markAllAsReadForUser($user);
+
+ return new JsonResponse(['success' => true, 'count' => $count]);
+ }
+
+ #[Route(path: '/{id}', name: 'delete', methods: ['DELETE'])]
+ public function delete(int $id): JsonResponse
+ {
+ $this->denyAccessUnlessGranted('IS_AUTHENTICATED_FULLY');
+ $user = $this->userService->getUserByIdentifier($this->getUser()->getUserIdentifier());
+
+ $notification = $this->notificationRepository->find($id);
+
+ if (!$notification || $notification->getUser()->getId() !== $user->getId()) {
+ return new JsonResponse(['error' => 'Notification not found'], Response::HTTP_NOT_FOUND);
+ }
+
+ $this->entityManager->remove($notification);
+ $this->entityManager->flush();
+
+ return new JsonResponse(['success' => true]);
+ }
+
+ #[Route(path: '/mercure-token', name: 'mercure_token', methods: ['GET'])]
+ public function getMercureToken(): JsonResponse
+ {
+ $this->denyAccessUnlessGranted('IS_AUTHENTICATED_FULLY');
+ $user = $this->userService->getUserByIdentifier($this->getUser()->getUserIdentifier());
+
+ $topic = sprintf('http://portail.solutions-easy.moi/notifications/user/%d', $user->getId());
+
+ // Generate JWT token for Mercure subscription
+ $secret = $_ENV['MERCURE_JWT_SECRET'];
+
+ $config = Configuration::forSymmetricSigner(
+ new Sha256(),
+ InMemory::plainText($secret)
+ );
+
+ $token = $config->builder()
+ ->withClaim('mercure', [
+ 'subscribe' => [$topic]
+ ])
+ ->getToken($config->signer(), $config->signingKey());
+
+ return new JsonResponse([
+ 'token' => $token->toString(),
+ 'topic' => $topic,
+ 'userId' => $user->getId(),
+ ]);
+ }
+}
diff --git a/src/Controller/SecurityController.php b/src/Controller/SecurityController.php
index f38032a..f51f040 100644
--- a/src/Controller/SecurityController.php
+++ b/src/Controller/SecurityController.php
@@ -5,6 +5,7 @@ namespace App\Controller;
use App\Repository\UserRepository;
use App\Repository\UsersOrganizationsRepository;
use App\Service\AccessTokenService;
+use App\Service\OrganizationsService;
use App\Service\UserService;
use Doctrine\ORM\EntityManagerInterface;
use Psr\Log\LoggerInterface;
@@ -27,7 +28,9 @@ class SecurityController extends AbstractController
private readonly UserRepository $userRepository,
private readonly UserService $userService,
private readonly UsersOrganizationsRepository $uoRepository,
- private readonly LoggerInterface $logger, private readonly EntityManagerInterface $entityManager)
+ private readonly LoggerInterface $logger,
+ private readonly EntityManagerInterface $entityManager,
+ private readonly OrganizationsService $organizationsService)
{
$this->cguUserService = $cguUserService;
}
@@ -35,14 +38,9 @@ class SecurityController extends AbstractController
#[Route(path: '/login', name: 'app_login')]
public function login(AuthenticationUtils $authenticationUtils): Response
{
- // get the login error if there is one
$error = $authenticationUtils->getLastAuthenticationError();
-
- // last username entered by the user
$lastUsername = $authenticationUtils->getLastUsername();
-
-
return $this->render('security/login.html.twig', [
'last_username' => $lastUsername,
'error' => $error,
@@ -52,38 +50,30 @@ class SecurityController extends AbstractController
#[Route(path: '/sso_logout', name: 'sso_logout')]
public function ssoLogout(RequestStack $stack, LoggerInterface $logger, AccessTokenService $accessTokenService, Security $security): Response
{
- // Invalidate the session and revoke tokens
try{
if( $stack->getSession()->invalidate()){
$accessTokenService->revokeTokens($security->getUser()->getUserIdentifier());
$security->logout(false);
$logger->info("Logout successfully");
- // Redirect back to the client (or to a “you are logged out” page)
return $this->redirect('/');
}
}catch (\Exception $e){
$logger->log(LogLevel::ERROR, 'Error invalidating session: ' . $e->getMessage());
}
- // If something goes wrong, redirect to the index page
return $this->redirectToRoute('app_index');
}
#[Route(path: '/consent', name: 'app_consent')]
public function consent(Request $request): Response
{
- // Handle form submission
if ($request->isMethod('POST')) {
- // Check if user declined consent
if (!$request->request->has('decline')) {
- // User accepted the CGU, save this in the database
$this->cguUserService->acceptLatestCgu($this->getUser());
}
-
- // Redirect back to the OAuth authorization endpoint with all the query parameters
+
return $this->redirectToRoute('oauth2_authorize', $request->query->all());
}
-
- // For GET requests, just show the consent form
+
return $this->render('security/consent.html.twig');
}
@@ -137,6 +127,9 @@ class SecurityController extends AbstractController
$uo->setIsActive(true);
$this->entityManager->persist($uo);
$this->entityManager->flush();
+ $data = ['user' => $user, 'organization' => $uo->getOrganization()];
+
+ $this->organizationsService->notifyOrganizationAdmins($data, "USER_ACCEPTED");
}
return $this->redirectToRoute('app_index');
}
diff --git a/src/Controller/UserController.php b/src/Controller/UserController.php
index 0fb17e5..9afdc09 100644
--- a/src/Controller/UserController.php
+++ b/src/Controller/UserController.php
@@ -14,6 +14,7 @@ use App\Repository\UsersOrganizationsRepository;
use App\Service\ActionService;
use App\Service\AwsService;
use App\Service\EmailService;
+use App\Service\OrganizationsService;
use App\Service\UserOrganizationAppService;
use App\Service\UserOrganizationService;
use App\Service\UserService;
@@ -43,7 +44,7 @@ class UserController extends AbstractController
private readonly UserOrganizationService $userOrganizationService,
private readonly UserRepository $userRepository,
private readonly UsersOrganizationsRepository $uoRepository,
- private readonly OrganizationsRepository $organizationRepository, private readonly LoggerInterface $logger, private readonly EmailService $emailService, private readonly AwsService $awsService,
+ private readonly OrganizationsRepository $organizationRepository, private readonly LoggerInterface $logger, private readonly EmailService $emailService, private readonly AwsService $awsService, private readonly OrganizationsService $organizationsService,
)
{
}
@@ -200,7 +201,7 @@ class UserController extends AbstractController
$this->entityManager->flush();
if( $orgId) {
- return $this->redirectToRoute('user_show', ['id' => $user->getId(), 'organizationId' => $orgId]);
+ return $this->redirectToRoute('organization_show', ['organizationId' => $orgId]);
}
return $this->redirectToRoute('user_index');
}
@@ -217,8 +218,6 @@ class UserController extends AbstractController
}
return $this->redirectToRoute('user_index');
}
-
- throw $this->createAccessDeniedException(self::ACCESS_DENIED);
}
//TODO : MONOLOG
@@ -295,6 +294,9 @@ class UserController extends AbstractController
}
$uo->setIsActive(false);
$this->userOrganizationAppService->deactivateAllUserOrganizationsAppLinks($uo);
+ $data = ['user' => $user,
+ 'organization' => $org];
+ $this->organizationsService->notifyOrganizationAdmins($data,"USER_DEACTIVATED");
$this->entityManager->persist($uo);
$this->entityManager->flush();
$this->actionService->createAction("Deactivate user in organization", $actingUser, $org, $org->getName() . " for user " . $user->getUserIdentifier());
@@ -331,6 +333,9 @@ class UserController extends AbstractController
$this->entityManager->persist($uo);
$this->entityManager->flush();
$this->actionService->createAction("Activate user in organization", $actingUser, $org, $org->getName() . " for user " . $user->getUserIdentifier());
+ $data = ['user' => $user,
+ 'organization' => $org];
+ $this->organizationsService->notifyOrganizationAdmins($data,"USER_ACTIVATED");
return $this->redirectToRoute('user_index');
}
@@ -357,6 +362,9 @@ class UserController extends AbstractController
$this->entityManager->persist($user);
$this->entityManager->flush();
$this->actionService->createAction("Delete user", $actingUser, null, $user->getUserIdentifier());
+ $data = ['user' => $user,
+ 'organization' => null];
+ $this->organizationsService->notifyOrganizationAdmins($data,"USER_DELETED");
return new Response('', Response::HTTP_NO_CONTENT); //204
}
@@ -633,8 +641,10 @@ class UserController extends AbstractController
}
$uo->setModifiedAt(new \DateTimeImmutable());
try {
+ $data = ['user'=>$uo->getUsers(), 'organization'=>$uo->getOrganization()];
$this->emailService->sendPasswordSetupEmail($user, $orgId);
$this->logger->info("Invitation email resent to user " . $user->getUserIdentifier() . " for organization " . $org->getName());
+ $this->organizationsService->notifyOrganizationAdmins($data,'USER_INVITED');
return $this->json(['message' => 'Invitation envoyée avec success.'], Response::HTTP_OK);
} catch (\Exception $e) {
$this->logger->error("Error resending invitation email to user " . $user->getUserIdentifier() . " for organization " . $org->getName() . ": " . $e->getMessage());
diff --git a/src/Entity/Notification.php b/src/Entity/Notification.php
new file mode 100644
index 0000000..ba77228
--- /dev/null
+++ b/src/Entity/Notification.php
@@ -0,0 +1,177 @@
+ false])]
+ private bool $isRead = false;
+
+ #[ORM\Column(type: Types::DATETIME_IMMUTABLE)]
+ private ?\DateTimeImmutable $createdAt = null;
+
+ #[ORM\Column(type: Types::DATETIME_IMMUTABLE, nullable: true)]
+ private ?\DateTimeImmutable $readAt = null;
+
+ #[ORM\ManyToOne(targetEntity: Organizations::class)]
+ #[ORM\JoinColumn(nullable: true, onDelete: 'CASCADE')]
+ private ?Organizations $organization = null;
+
+ public function __construct()
+ {
+ $this->createdAt = new \DateTimeImmutable();
+ }
+
+ public function getId(): ?int
+ {
+ return $this->id;
+ }
+
+ public function getUser(): ?User
+ {
+ return $this->user;
+ }
+
+ public function setUser(?User $user): static
+ {
+ $this->user = $user;
+ return $this;
+ }
+
+ public function getType(): ?string
+ {
+ return $this->type;
+ }
+
+ public function setType(string $type): static
+ {
+ $this->type = $type;
+ return $this;
+ }
+
+ public function getTitle(): ?string
+ {
+ return $this->title;
+ }
+
+ public function setTitle(string $title): static
+ {
+ $this->title = $title;
+ return $this;
+ }
+
+ public function getMessage(): ?string
+ {
+ return $this->message;
+ }
+
+ public function setMessage(string $message): static
+ {
+ $this->message = $message;
+ return $this;
+ }
+
+ public function getData(): ?array
+ {
+ return $this->data;
+ }
+
+ public function setData(?array $data): static
+ {
+ $this->data = $data;
+ return $this;
+ }
+
+ public function isRead(): bool
+ {
+ return $this->isRead;
+ }
+
+ public function setIsRead(bool $isRead): static
+ {
+ $this->isRead = $isRead;
+ if ($isRead && !$this->readAt) {
+ $this->readAt = new \DateTimeImmutable();
+ }
+ return $this;
+ }
+
+ public function getCreatedAt(): ?\DateTimeImmutable
+ {
+ return $this->createdAt;
+ }
+
+ public function setCreatedAt(\DateTimeImmutable $createdAt): static
+ {
+ $this->createdAt = $createdAt;
+ return $this;
+ }
+
+ public function getReadAt(): ?\DateTimeImmutable
+ {
+ return $this->readAt;
+ }
+
+ public function setReadAt(?\DateTimeImmutable $readAt): static
+ {
+ $this->readAt = $readAt;
+ return $this;
+ }
+
+ public function getOrganization(): ?Organizations
+ {
+ return $this->organization;
+ }
+
+ public function setOrganization(?Organizations $organization): static
+ {
+ $this->organization = $organization;
+ return $this;
+ }
+
+ public function toArray(): array
+ {
+ return [
+ 'id' => $this->id,
+ 'type' => $this->type,
+ 'title' => $this->title,
+ 'message' => $this->message,
+ 'data' => $this->data,
+ 'isRead' => $this->isRead,
+ 'createdAt' => $this->createdAt?->format('c'),
+ 'readAt' => $this->readAt?->format('c'),
+ 'organization' => $this->organization ? [
+ 'id' => $this->organization->getId(),
+ 'name' => $this->organization->getName(),
+ ] : null,
+ ];
+ }
+}
diff --git a/src/Message/NotificationMessage.php b/src/Message/NotificationMessage.php
new file mode 100644
index 0000000..c97ff15
--- /dev/null
+++ b/src/Message/NotificationMessage.php
@@ -0,0 +1,46 @@
+userId;
+ }
+
+ public function getType(): string
+ {
+ return $this->type;
+ }
+
+ public function getTitle(): string
+ {
+ return $this->title;
+ }
+
+ public function getMessage(): string
+ {
+ return $this->message;
+ }
+
+ public function getData(): ?array
+ {
+ return $this->data;
+ }
+
+ public function getOrganizationId(): ?int
+ {
+ return $this->organizationId;
+ }
+}
diff --git a/src/MessageHandler/NotificationMessageHandler.php b/src/MessageHandler/NotificationMessageHandler.php
new file mode 100644
index 0000000..3647ed6
--- /dev/null
+++ b/src/MessageHandler/NotificationMessageHandler.php
@@ -0,0 +1,61 @@
+entityManager->getRepository(User::class)->find($message->getUserId());
+
+ if (!$user) {
+ return;
+ }
+
+ $notification = new Notification();
+ $notification->setUser($user);
+ $notification->setType($message->getType());
+ $notification->setTitle($message->getTitle());
+ $notification->setMessage($message->getMessage());
+ $notification->setData($message->getData());
+
+ if ($message->getOrganizationId()) {
+ $organization = $this->entityManager->getRepository(Organizations::class)->find($message->getOrganizationId());
+ $notification->setOrganization($organization);
+ }
+
+ $this->entityManager->persist($notification);
+ $this->entityManager->flush();
+
+ $this->publishToMercure($notification);
+ }
+
+ private function publishToMercure(Notification $notification): void
+ {
+ $topic = sprintf('http://portail.solutions-easy.moi/notifications/user/%d', $notification->getUser()->getId());
+
+ $update = new Update(
+ $topic,
+ json_encode($notification->toArray()),
+ true
+ );
+
+ $this->hub->publish($update);
+ }
+}
diff --git a/src/Repository/NotificationRepository.php b/src/Repository/NotificationRepository.php
new file mode 100644
index 0000000..8cfbb38
--- /dev/null
+++ b/src/Repository/NotificationRepository.php
@@ -0,0 +1,77 @@
+createQueryBuilder('n')
+ ->where('n.user = :user')
+ ->andWhere('n.isRead = false')
+ ->setParameter('user', $user)
+ ->orderBy('n.createdAt', 'DESC')
+ ->setMaxResults($limit)
+ ->getQuery()
+ ->getResult();
+ }
+
+ public function findRecentByUser(User $user, int $limit = 50): array
+ {
+ return $this->createQueryBuilder('n')
+ ->where('n.user = :user')
+ ->setParameter('user', $user)
+ ->orderBy('n.createdAt', 'DESC')
+ ->setMaxResults($limit)
+ ->getQuery()
+ ->getResult();
+ }
+
+ public function countUnreadByUser(User $user): int
+ {
+ return $this->createQueryBuilder('n')
+ ->select('COUNT(n.id)')
+ ->where('n.user = :user')
+ ->andWhere('n.isRead = false')
+ ->setParameter('user', $user)
+ ->getQuery()
+ ->getSingleScalarResult();
+ }
+
+ public function markAllAsReadForUser(User $user): int
+ {
+ return $this->createQueryBuilder('n')
+ ->update()
+ ->set('n.isRead', 'true')
+ ->set('n.readAt', ':now')
+ ->where('n.user = :user')
+ ->andWhere('n.isRead = false')
+ ->setParameter('user', $user)
+ ->setParameter('now', new \DateTimeImmutable())
+ ->getQuery()
+ ->execute();
+ }
+
+ public function deleteOldReadNotifications(int $daysOld = 30): int
+ {
+ $date = new \DateTimeImmutable("-{$daysOld} days");
+
+ return $this->createQueryBuilder('n')
+ ->delete()
+ ->where('n.isRead = true')
+ ->andWhere('n.readAt < :date')
+ ->setParameter('date', $date)
+ ->getQuery()
+ ->execute();
+ }
+}
diff --git a/src/Service/NotificationService.php b/src/Service/NotificationService.php
new file mode 100644
index 0000000..9f44b2f
--- /dev/null
+++ b/src/Service/NotificationService.php
@@ -0,0 +1,191 @@
+send(
+ recipient: $recipient,
+ type: self::TYPE_USER_INVITED,
+ title: 'Invitation envoyée',
+ message: sprintf('%s %s a été invité à rejoindre %s', $invitedUser->getName(), $invitedUser->getSurname(), $organization->getName()),
+ data: [
+ 'userId' => $invitedUser->getId(),
+ 'userName' => $invitedUser->getName() . ' ' . $invitedUser->getSurname(),
+ 'userEmail' => $invitedUser->getEmail(),
+ 'organizationId' => $organization->getId(),
+ 'organizationName' => $organization->getName(),
+ ],
+ organization: $organization
+ );
+ }
+
+ public function notifyUserAcceptedInvite(User $recipient, User $acceptedUser, Organizations $organization): void
+ {
+ $this->send(
+ recipient: $recipient,
+ type: self::TYPE_USER_ACCEPTED,
+ title: 'Invitation acceptée',
+ message: sprintf('%s %s a accepté l\'invitation à %s', $acceptedUser->getName(), $acceptedUser->getSurname(), $organization->getName()),
+ data: [
+ 'userId' => $acceptedUser->getId(),
+ 'userName' => $acceptedUser->getName() . ' ' . $acceptedUser->getSurname(),
+ 'userEmail' => $acceptedUser->getEmail(),
+ 'organizationId' => $organization->getId(),
+ 'organizationName' => $organization->getName(),
+ ],
+ organization: $organization
+ );
+ }
+
+ public function notifyUserDeactivated(User $recipient, User $removedUser, Organizations $organization): void
+ {
+ $this->send(
+ recipient: $recipient,
+ type: self::TYPE_USER_REMOVED,
+ title: 'Membre retiré',
+ message: sprintf('%s %s a été désactivé de %s', $removedUser->getName(), $removedUser->getSurname(), $organization->getName()),
+ data: [
+ 'userId' => $removedUser->getId(),
+ 'userName' => $removedUser->getName() . ' ' . $removedUser->getSurname(),
+ 'organizationId' => $organization->getId(),
+ 'organizationName' => $organization->getName(),
+ ],
+ organization: $organization
+ );
+ }
+
+ public function notifyUserActivated(User $recipient, User $activatedUser, Organizations $organization): void
+ {
+ $this->send(
+ recipient: $recipient,
+ type: 'user_activated',
+ title: 'Membre réactivé',
+ message: sprintf('%s %s a été réactivé dans %s', $activatedUser->getName(), $activatedUser->getSurname(), $organization->getName()),
+ data: [
+ 'userId' => $activatedUser->getId(),
+ 'userName' => $activatedUser->getName() . ' ' . $activatedUser->getSurname(),
+ 'organizationId' => $organization->getId(),
+ 'organizationName' => $organization->getName(),
+ ],
+ organization: $organization
+ );
+ }
+
+ public function notifyOrganizationUpdate(User $recipient, Organizations $organization, string $action): void
+ {
+ $this->send(
+ recipient: $recipient,
+ type: self::TYPE_ORG_UPDATE,
+ title: 'Organisation mise à jour',
+ message: sprintf('L\'organisation %s a été %s', $organization->getName(), $action),
+ data: [
+ 'organizationId' => $organization->getId(),
+ 'organizationName' => $organization->getName(),
+ 'action' => $action,
+ ],
+ organization: $organization
+ );
+ }
+
+ public function notifyAppAccessChanged(User $recipient, Organizations $organization, string $appName, bool $granted): void
+ {
+ $action = $granted ? 'autorisé' : 'retiré';
+ $this->send(
+ recipient: $recipient,
+ type: self::TYPE_APP_ACCESS,
+ title: 'Accès application modifié',
+ message: sprintf('L\'accès à %s a été %s pour %s', $appName, $action, $organization->getName()),
+ data: [
+ 'organizationId' => $organization->getId(),
+ 'organizationName' => $organization->getName(),
+ 'appName' => $appName,
+ 'granted' => $granted,
+ ],
+ organization: $organization
+ );
+ }
+
+ public function notifyRoleChanged(User $recipient, User $targetUser, Organizations $organization, string $newRole): void
+ {
+ $this->send(
+ recipient: $recipient,
+ type: self::TYPE_ROLE_CHANGED,
+ title: 'Rôle modifié',
+ message: sprintf('Le rôle de %s %s a été changé en %s dans %s',
+ $targetUser->getName(),
+ $targetUser->getSurname(),
+ $newRole,
+ $organization->getName()
+ ),
+ data: [
+ 'userId' => $targetUser->getId(),
+ 'userName' => $targetUser->getName() . ' ' . $targetUser->getSurname(),
+ 'organizationId' => $organization->getId(),
+ 'organizationName' => $organization->getName(),
+ 'newRole' => $newRole,
+ ],
+ organization: $organization
+ );
+ }
+
+ public function notifyUserDeleted(User $recipient, User $deletedUser, ?Organizations $organization = null): void
+ {
+ $this->send(
+ recipient: $recipient,
+ type: 'user_deleted',
+ title: 'Utilisateur supprimé',
+ message: sprintf('L\'utilisateur %s %s a été supprimé du système',
+ $deletedUser->getName(),
+ $deletedUser->getSurname()
+ ),
+ data: [
+ 'userId' => $deletedUser->getId(),
+ 'userName' => $deletedUser->getName() . ' ' . $deletedUser->getSurname(),
+ 'userEmail' => $deletedUser->getEmail(),
+ ],
+ organization: $organization
+ );
+ }
+
+ private function send(
+ User $recipient,
+ string $type,
+ string $title,
+ string $message,
+ ?array $data = null,
+ ?Organizations $organization = null
+ ): void {
+ $notificationMessage = new NotificationMessage(
+ userId: $recipient->getId(),
+ type: $type,
+ title: $title,
+ message: $message,
+ data: $data,
+ organizationId: $organization?->getId()
+ );
+
+ $this->messageBus->dispatch($notificationMessage);
+ }
+}
diff --git a/src/Service/OrganizationsService.php b/src/Service/OrganizationsService.php
index a0b1d90..ece09b3 100644
--- a/src/Service/OrganizationsService.php
+++ b/src/Service/OrganizationsService.php
@@ -4,7 +4,10 @@ namespace App\Service;
use App\Entity\Apps;
use App\Entity\Organizations;
-use App\Service\AwsService;
+use App\Entity\Roles;
+use App\Entity\UserOrganizatonApp;
+use App\Repository\UsersOrganizationsRepository;
+use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\HttpFoundation\File\Exception\FileException;
class OrganizationsService
@@ -12,7 +15,10 @@ class OrganizationsService
private string $logoDirectory;
public function __construct(
- string $logoDirectory, private readonly AwsService $awsService
+ string $logoDirectory, private readonly AwsService $awsService,
+ private readonly EntityManagerInterface $entityManager,
+ private readonly UsersOrganizationsRepository $uoRepository,
+ private readonly NotificationService $notificationService
)
{
$this->logoDirectory = $logoDirectory;
@@ -55,4 +61,75 @@ class OrganizationsService
return $result;
}
+
+ public function notifyOrganizationAdmins(array $data, string $type): void
+ {
+
+ $roleAdmin = $this->entityManager->getRepository(Roles::class)->findOneBy(['name' => 'ADMIN']);
+
+ $adminUOs = $this->uoRepository->findBy(['organization' => $data['organization'], 'isActive' => true]);
+
+ foreach ($adminUOs as $adminUO) {
+ $uoa = $this->entityManager->getRepository(UserOrganizatonApp::class)
+ ->findOneBy([
+ 'userOrganization' => $adminUO,
+ 'role' => $roleAdmin,
+ 'isActive' => true
+ ]);
+ switch ($type) {
+ case 'USER_ACCEPTED':
+ if ($uoa && $adminUO->getUsers()->getId() !== $data['user']->getId() ) {
+ $newUser = $data['user'];
+ $this->notificationService->notifyUserAcceptedInvite(
+ $adminUO->getUsers(),
+ $newUser,
+ $data['organization']
+ );
+ }
+ break;
+ case 'USER_INVITED':
+ if ($uoa) {
+ $invitedUser = $data['user'];
+ $this->notificationService->notifyUserInvited(
+ $adminUO->getUsers(),
+ $invitedUser,
+ $data['organization']
+ );
+ }
+ break;
+ case 'USER_DEACTIVATED':
+ if ($uoa && $adminUO->getUsers()->getId() !== $data['user']->getId() ) {
+ $removedUser = $data['user'];
+ $this->notificationService->notifyUserDeactivated(
+ $adminUO->getUsers(),
+ $removedUser,
+ $data['organization']
+ );
+ }
+ break;
+ case 'USER_DELETED':
+ if ($uoa && $adminUO->getUsers()->getId() !== $data['user']->getId() ) {
+ $removedUser = $data['user'];
+ $this->notificationService->notifyUserDeleted(
+ $adminUO->getUsers(),
+ $removedUser,
+ $data['organization']
+ );
+ }
+ break;
+ case 'USER_ACTIVATED':
+ if ($uoa && $adminUO->getUsers()->getId() !== $data['user']->getId() ) {
+ $activatedUser = $data['user'];
+ $this->notificationService->notifyUserActivated(
+ $adminUO->getUsers(),
+ $activatedUser,
+ $data['organization']
+ );
+ }
+ break;
+ }
+
+ }
+ }
+
}
diff --git a/templates/elements/navbar.html.twig b/templates/elements/navbar.html.twig
index 30f2894..9b52742 100644
--- a/templates/elements/navbar.html.twig
+++ b/templates/elements/navbar.html.twig
@@ -40,24 +40,36 @@
-
-
+
+
{{ ux_icon('bi:bell', {height: '20px', width: '20px'}) }}
-
+
-