Compare commits
14 Commits
b974b56a17
...
8c9a5da604
| Author | SHA1 | Date |
|---|---|---|
|
|
8c9a5da604 | |
|
|
f2123e911d | |
|
|
9dc97c5843 | |
|
|
3744d81035 | |
|
|
a6fdb59521 | |
|
|
f6ce0e6229 | |
|
|
7db986468c | |
|
|
53c3180d33 | |
|
|
8193e339b0 | |
|
|
346a05e51d | |
|
|
75e5921be1 | |
|
|
2d84ee8ec4 | |
|
|
cb7afab382 | |
|
|
00ed7ef491 |
|
|
@ -0,0 +1,190 @@
|
|||
# Système de notification de l'application
|
||||
## Vue d'ensemble
|
||||
Le système de notification de l'application permet d'informer les utilisateurs de diverse action.
|
||||
|
||||
## Architecture
|
||||
### Composants principaux
|
||||
1. **Service de Notification** : Gère la création, l'envoi et le suivi des notifications.
|
||||
2. **Interface Utilisateur** : Affiche les notifications aux utilisateurs via des pops-ups, des bannières ou des emails.
|
||||
3. **Template Email** : Modèles prédéfinis pour les notifications par email.
|
||||
4. **Type D'action** : Enum d'action qui déclenche des notifications (ex: nouvel utilisateur, utilisateur actif, ...).
|
||||
|
||||
### Service de Notification
|
||||
Le service de notification est responsable de la gestion des notifications. Il inclut les fonctionnalités suivantes :
|
||||
- Création de notifications basées sur des événements spécifiques.
|
||||
- Envoi de notifications via différents canaux (email, in-app).
|
||||
- Suivi de l'état des notifications (envoyé, lu, etc.).
|
||||
|
||||
### Interface Utilisateur
|
||||
L'interface utilisateur affiche les notifications de manière conviviale. Les notifications peuvent apparaître sous forme
|
||||
de pops-ups, de bannières ou d'emails. (Possibilité d'intéragir avec les notifications)
|
||||
|
||||
### Template Email
|
||||
Les templates email sont utilisés pour formater les notifications envoyées par email. Chaque type de notification
|
||||
a son propre template pour assurer une communication claire et cohérente.
|
||||
|
||||
|
||||
### Type d'action
|
||||
```
|
||||
enum ActionType: String {
|
||||
case NewUser = "NEW_USER";
|
||||
case ActiveUser = "ACTIVE_USER";
|
||||
case PasswordReset = "PASSWORD_RESET";
|
||||
case SubscriptionExpired = "SUBSCRIPTION_EXPIRED";
|
||||
case OrganizationInvited = "ORGANIZATION_INVITED";
|
||||
case OrganizationInactive = "ORGANIZATION_INACTIVE";
|
||||
case OrganizationDeleted = "ORGANIZATION_DELETED";
|
||||
case OrginizationUserInvited = "ORGANIZATION_USER_INVITED";
|
||||
case UserDeleted = "USER_DELETED";
|
||||
}
|
||||
```
|
||||
|
||||
## Flux de travail
|
||||
1. L’administrateur crée un utilisateur depuis l’interface (formulaire “Créer un utilisateur”).
|
||||
2. Le contrôleur valide la requête et appelle le cas d’usage UserAdministrationService->handle(ActionType::NewUser, $admin, $payload).
|
||||
3. Le service crée l’utilisateur en base avec le statut INVITED, associe l’organisation de l’admin, et génère un lien signé/jeton de setup de mot de passe (TTL).
|
||||
4. Le service publie un événement de domaine UserInvitedEvent { userId, adminId, organizationId } sur Messenger (transport async).
|
||||
5. Handler async A — SendUserInvitationEmailHandler:
|
||||
6. Construit l’email via Symfony Mailer + Twig (emails/user_invitation.html.twig) avec le lien de définition de mot de passe.
|
||||
7. Envoie le mail à l’utilisateur invité.
|
||||
8. Handler async B — NotifyAdminInvitationSentHandler:
|
||||
9. Crée une notification interne (Notifier, canal “in‑app”).
|
||||
10. Pousse un événement temps réel via Mercure sur le topic admin/{adminId}/events avec le type INVITATION_EMAIL_SENT.
|
||||
11. L’UI admin affiche un toast/bannière confirmant “Email d’invitation envoyé”.
|
||||
12. L’utilisateur ouvre l’email et clique le lien de définition de mot de passe.
|
||||
13. Le PasswordSetupController vérifie la signature/le jeton et la validité (TTL), affiche le formulaire, puis enregistre le nouveau mot de passe.
|
||||
14. À la réussite, l’utilisateur passe au statut ACTIVE et l’action publie UserActivatedEvent { userId, adminId, organizationId } sur Messenger (async).
|
||||
15. Handler async C — NotifyAdminUserActivatedHandler:
|
||||
16. Crée une notification interne (Notifier, canal “in‑app”) “Compte activé”.
|
||||
17. Pousse un événement Mercure sur admin/{adminId}/events avec le type USER_ACTIVATED.
|
||||
18. L’UI admin met à jour la liste des membres (badge “Actif”) et affiche un toast confirmant l’activation.
|
||||
19. Journalisation/Audit:
|
||||
20. Chaque handler écrit une trace (succès/échec) en base ou dans un EmailLog/NotificationLog.
|
||||
21. En cas d’échec d’envoi, Messenger applique la stratégie de retry puis bascule en file failed si nécessaire (tableau de bord de supervision).
|
||||
22. Cas “utilisateur existant ajouté à une autre organisation”:
|
||||
23. Si l’email existe déjà, on rattache l’utilisateur à la nouvelle organisation et on publie OrganizationUserInvitedEvent.
|
||||
24. Handler dédié envoie un email d’information (“Vous avez été ajouté à une nouvelle organisation”) et notifie l’admin via Notifier + Mercure.
|
||||
25. Cas d’actions dérivées par enum:
|
||||
26. ActionType::NewUser → déclenche UserInvitedEvent (steps 3–6).
|
||||
27. ActionType::ActiveUser (si activé par un flux admin) → déclenche directement UserActivatedEvent (steps 9–10).
|
||||
28. ActionType::OrganizationUserInvited → flux similaire au point 12 pour la multi‑organisation.
|
||||
29. Autres actions (PasswordReset, UserDeleted, etc.) suivent le même patron: contrôleur → service (match enum) → événement Messenger → handlers (Mailer/Notifier/Mercure) → UI temps réel.
|
||||
|
||||
## Stack technologique
|
||||
- Symfony Messenger: asynchrone, retries, découplage des I/O lents.
|
||||
- Symfony Mailer + Twig: emails d’invitation et d’information.
|
||||
- Symfony Notifier (canal in‑app) + Mercure: notifications persistées + push temps réel vers l’UI admin.
|
||||
- Enum ActionType: routage clair dans l’application, évite la logique string‑based.
|
||||
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
%% Couche 1: Action initiale
|
||||
A[User action event - Admin cree un utilisateur] --> B[HTTP controller API - Symfony]
|
||||
B --> C[Domain service - UserAdministrationService]
|
||||
C -->|Inspecte enum ActionType::NewUser| C1[Create user - status INVITED - liaison organisation - genere lien jeton mot de passe TTL]
|
||||
C1 --> D[Dispatch UserInvitedEvent - userId adminId organizationId - vers Symfony Messenger bus]
|
||||
|
||||
%% Couche 2: Messaging / Infra
|
||||
D --> E[Transport async - AMQP / Redis / Doctrine]
|
||||
E --> RQ[Retry queue]
|
||||
E --> FQ[Failed queue - dead letter]
|
||||
E --> W[Workers Messenger]
|
||||
F[Supervisor / systemd] --> W
|
||||
|
||||
%% Monolog transversal (logs a chaque etape)
|
||||
A --> LOG_GLOBAL[Monolog - log event initial]
|
||||
B --> LOG_GLOBAL
|
||||
C --> LOG_GLOBAL
|
||||
C1 --> LOG_GLOBAL
|
||||
D --> LOG_GLOBAL
|
||||
E --> LOG_GLOBAL
|
||||
RQ --> LOG_GLOBAL
|
||||
FQ --> LOG_GLOBAL
|
||||
W --> LOG_GLOBAL
|
||||
|
||||
%% Handlers pour l'invitation
|
||||
W --> H1[Handler A - Symfony Mailer + Twig]
|
||||
H1 --> H1o[Email d'invitation avec lien setup mot de passe]
|
||||
H1 --> LOG_GLOBAL
|
||||
|
||||
W --> H2[Handler B - Symfony Notifier in-app]
|
||||
H2 --> UI1[Notification UI admin - Email d'invitation envoye]
|
||||
H2 --> LOG_GLOBAL
|
||||
|
||||
W -. optionnel .-> WH1[Webhook HTTP sortant - invitation envoyee]
|
||||
WH1 --> LOG_GLOBAL
|
||||
W -. optionnel .-> SMS1[SMS gateway - SMS invitation]
|
||||
SMS1 --> LOG_GLOBAL
|
||||
W -. optionnel .-> PUSH1[Mobile push service - notification mobile]
|
||||
PUSH1 --> LOG_GLOBAL
|
||||
|
||||
RQ --> METRICS[Metrics et dashboard]
|
||||
FQ --> METRICS
|
||||
LOG_GLOBAL --> METRICS
|
||||
|
||||
%% Flux activation utilisateur
|
||||
subgraph Activation du compte
|
||||
UA[User action event - Invite clique le lien] --> PS[HTTP controller API - PasswordSetupController]
|
||||
PS -->|Verifie signature et TTL| PSOK[Set password - user status ACTIVE]
|
||||
PS --> LOG_GLOBAL
|
||||
PSOK --> LOG_GLOBAL
|
||||
|
||||
PSOK --> D2[Dispatch UserActivatedEvent - userId adminId organizationId - vers Messenger bus]
|
||||
D2 --> E2[Transport async]
|
||||
E2 --> RQ2[Retry queue]
|
||||
E2 --> FQ2[Failed queue]
|
||||
E2 --> W2[Workers Messenger]
|
||||
F --> W2
|
||||
|
||||
D2 --> LOG_GLOBAL
|
||||
E2 --> LOG_GLOBAL
|
||||
RQ2 --> LOG_GLOBAL
|
||||
FQ2 --> LOG_GLOBAL
|
||||
W2 --> LOG_GLOBAL
|
||||
|
||||
W2 --> H3[Handler C - Notifier in-app]
|
||||
H3 --> UI2[Notification UI admin - Compte active]
|
||||
H3 --> LOG_GLOBAL
|
||||
|
||||
W2 -. optionnel .-> WH2[Webhook HTTP sortant - user active]
|
||||
WH2 --> LOG_GLOBAL
|
||||
W2 -. optionnel .-> MAIL2[Mailer ou SMS ou Push - confirmation utilisateur]
|
||||
MAIL2 --> LOG_GLOBAL
|
||||
|
||||
RQ2 --> METRICS
|
||||
FQ2 --> METRICS
|
||||
end
|
||||
|
||||
%% Cas particulier : utilisateur existant ajoute a une nouvelle organisation
|
||||
C -->|Email deja existant| SP1[Rattache a nouvelle organisation]
|
||||
SP1 --> LOG_GLOBAL
|
||||
SP1 --> D3[Dispatch OrganizationUserInvitedEvent]
|
||||
D3 --> E3[Transport async] --> W3[Workers]
|
||||
F --> W3
|
||||
D3 --> LOG_GLOBAL
|
||||
E3 --> LOG_GLOBAL
|
||||
W3 --> LOG_GLOBAL
|
||||
|
||||
W3 --> M3[Mailer - ajoute a une nouvelle organisation]
|
||||
M3 --> LOG_GLOBAL
|
||||
W3 --> N3[Notifier in-app - toast admin Utilisateur ajoute]
|
||||
N3 --> LOG_GLOBAL
|
||||
W3 -. optionnel .-> WH3[Webhook ou SMS ou Mobile]
|
||||
WH3 --> LOG_GLOBAL
|
||||
|
||||
M3 --> METRICS
|
||||
N3 --> METRICS
|
||||
WH3 --> METRICS
|
||||
|
||||
%% Styles
|
||||
classDef infra fill:#e8f0fe,stroke:#5b8def,stroke-width:1px;
|
||||
classDef handler fill:#dcf7e9,stroke:#2ea66a,stroke-width:1px;
|
||||
classDef ui fill:#f0d9ff,stroke:#9c27b0,stroke-width:1px;
|
||||
classDef audit fill:#eeeeee,stroke:#9e9e9e,stroke-width:1px;
|
||||
|
||||
class E,E2,E3,RQ,FQ,RQ2,FQ2,METRICS infra;
|
||||
class W,W2,W3,H1,H2,H3,M3,N3 handler;
|
||||
class H1o,UI1,UI2 ui;
|
||||
class LOG_GLOBAL audit;
|
||||
```
|
||||
|
|
@ -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';
|
||||
|
|
@ -23,3 +24,4 @@ import './js/cookies.js';
|
|||
import 'choices.js';
|
||||
import 'quill'
|
||||
import 'tabulator-tables'
|
||||
import './js/global.js'
|
||||
|
|
@ -0,0 +1,334 @@
|
|||
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 = `
|
||||
<div class="text-center py-4 text-muted">
|
||||
<i class="mx-0 mb-2">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="30" height="30" fill="currentColor" viewBox="0 0 16 16">
|
||||
<path d="M8.865 2.5a.75.75 0 0 0-1.73 0L6.5 5.5H4.75a.75.75 0 0 0 0 1.5h1.5l-.5 3H3.5a.75.75 0 0 0 0 1.5h1.5l-.635 3.135a.75.75 0 0 0 1.47.28L6.5 11.5h3l-.635 3.135a.75.75 0 0 0 1.47.28L11 11.5h1.75a.75.75 0 0 0 0-1.5h-1.5l.5-3h2.25a.75.75 0 0 0 0-1.5h-2l.635-3.135zM9.5 10l.5-3h-3l-.5 3h3z"/>
|
||||
</svg>
|
||||
</i>
|
||||
<p class="mb-0">Aucune notification</p>
|
||||
</div>
|
||||
`;
|
||||
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 `
|
||||
<a class="dropdown-item preview-item ${readClass}"
|
||||
href="#"
|
||||
data-notification-id="${notification.id}"
|
||||
data-action="click->notification#markAsRead">
|
||||
<div class="preview-thumbnail">
|
||||
<div class="preview-icon ${this.getIconBgClass(notification.type)}">
|
||||
${iconHtml}
|
||||
</div>
|
||||
</div>
|
||||
<div class="preview-item-content">
|
||||
<h6 class="preview-subject font-weight-normal mb-1">${this.escapeHtml(notification.title)}</h6>
|
||||
<p class="font-weight-light small-text mb-0 text-muted">${this.escapeHtml(notification.message)}</p>
|
||||
<p class="font-weight-light small-text mb-0 text-muted mt-1">
|
||||
<small>${timeAgo}</small>
|
||||
</p>
|
||||
</div>
|
||||
<button class="btn btn-sm btn-link text-danger ms-2"
|
||||
data-action="click->notification#deleteNotification"
|
||||
data-notification-id="${notification.id}"
|
||||
style="padding: 0.25rem;">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" viewBox="0 0 16 16">
|
||||
<path d="M5.5 5.5A.5.5 0 0 1 6 6v6a.5.5 0 0 1-1 0V6a.5.5 0 0 1 .5-.5zm2.5 0a.5.5 0 0 1 .5.5v6a.5.5 0 0 1-1 0V6a.5.5 0 0 1 .5-.5zm3 .5a.5.5 0 0 0-1 0v6a.5.5 0 0 0 1 0V6z"/>
|
||||
<path fill-rule="evenodd" d="M14.5 3a1 1 0 0 1-1 1H13v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V4h-.5a1 1 0 0 1-1-1V2a1 1 0 0 1 1-1H6a1 1 0 0 1 1-1h2a1 1 0 0 1 1 1h3.5a1 1 0 0 1 1 1v1zM4.118 4 4 4.059V13a1 1 0 0 0 1 1h6a1 1 0 0 0 1-1V4.059L11.882 4H4.118zM2.5 3V2h11v1h-11z"/>
|
||||
</svg>
|
||||
</button>
|
||||
</a>
|
||||
`;
|
||||
}
|
||||
|
||||
getIcon(type) {
|
||||
const icons = {
|
||||
user_joined: '<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" class="mx-0 mb-1" viewBox="0 0 16 16"><path d="M8 8a3 3 0 1 0 0-6 3 3 0 0 0 0 6zm2-3a2 2 0 1 1-4 0 2 2 0 0 1 4 0zm4 8c0 1-1 1-1 1H3s-1 0-1-1 1-4 6-4 6 3 6 4zm-1-.004c-.001-.246-.154-.986-.832-1.664C11.516 10.68 10.289 10 8 10c-2.29 0-3.516.68-4.168 1.332-.678.678-.83 1.418-.832 1.664h10z"/></svg>',
|
||||
user_invited: '<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" class="mx-0 mb-1" viewBox="0 0 640 512"><path fill="currentColor" d="M224 0a128 128 0 1 1 0 256a128 128 0 1 1 0-256m-45.7 304h91.4c20.6 0 40.4 3.5 58.8 9.9C323 331 320 349.1 320 368c0 59.5 29.5 112.1 74.8 144H29.7C13.3 512 0 498.7 0 482.3C0 383.8 79.8 304 178.3 304M352 368a144 144 0 1 1 288 0a144 144 0 1 1-288 0m144-80c-8.8 0-16 7.2-16 16v64c0 8.8 7.2 16 16 16h48c8.8 0 16-7.2 16-16s-7.2-16-16-16h-32v-48c0-8.8-7.2-16-16-16"/></svg>',
|
||||
user_accepted: '<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" class="mx-0 mb-1" viewBox="0 0 640 512"><path fill="currentColor" d="M96 128a128 128 0 1 1 256 0a128 128 0 1 1-256 0M0 482.3C0 383.8 79.8 304 178.3 304h91.4c98.5 0 178.3 79.8 178.3 178.3c0 16.4-13.3 29.7-29.7 29.7H29.7C13.3 512 0 498.7 0 482.3M625 177L497 305c-9.4 9.4-24.6 9.4-33.9 0l-64-64c-9.4-9.4-9.4-24.6 0-33.9s24.6-9.4 33.9 0l47 47L591 143c9.4-9.4 24.6-9.4 33.9 0s9.4 24.6 0 33.9z"/></svg>',
|
||||
user_removed: '<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" class="mx-0 mb-1" viewBox="0 0 640 512"><path fill="currentColor" d="M38.8 5.1C28.4-3.1 13.3-1.2 5.1 9.2s-6.3 25.5 4.1 33.7l592 464c10.4 8.2 25.5 6.3 33.7-4.1s6.3-25.5-4.1-33.7L353.3 251.6C407.9 237 448 187.2 448 128C448 57.3 390.7 0 320 0c-69.8 0-126.5 55.8-128 125.2zm225.5 299.2C170.5 309.4 96 387.2 96 482.3c0 16.4 13.3 29.7 29.7 29.7h388.6c3.9 0 7.6-.7 11-2.1z"/></svg>',
|
||||
user_deactivated: '<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" class="mx-0 mb-1" viewBox="0 0 640 512"><path fill="currentColor" d="M38.8 5.1C28.4-3.1 13.3-1.2 5.1 9.2s-6.3 25.5 4.1 33.7l592 464c10.4 8.2 25.5 6.3 33.7-4.1s6.3-25.5-4.1-33.7L353.3 251.6C407.9 237 448 187.2 448 128C448 57.3 390.7 0 320 0c-69.8 0-126.5 55.8-128 125.2zm225.5 299.2C170.5 309.4 96 387.2 96 482.3c0 16.4 13.3 29.7 29.7 29.7h388.6c3.9 0 7.6-.7 11-2.1z"/></svg>',
|
||||
org_update: '<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" class="mx-0 mb-1" viewBox="0 0 16 16"><path d="M8 4.754a3.246 3.246 0 1 0 0 6.492 3.246 3.246 0 0 0 0-6.492zM5.754 8a2.246 2.246 0 1 1 4.492 0 2.246 2.246 0 0 1-4.492 0z"/><path d="M9.796 1.343c-.527-1.79-3.065-1.79-3.592 0l-.094.319a.873.873 0 0 1-1.255.52l-.292-.16c-1.64-.892-3.433.902-2.54 2.541l.159.292a.873.873 0 0 1-.52 1.255l-.319.094c-1.79.527-1.79 3.065 0 3.592l.319.094a.873.873 0 0 1 .52 1.255l-.16.292c-.892 1.64.901 3.434 2.541 2.54l.292-.159a.873.873 0 0 1 1.255.52l.094.319c.527 1.79 3.065 1.79 3.592 0l.094-.319a.873.873 0 0 1 1.255-.52l.292.16c1.64.893 3.434-.902 2.54-2.541l-.159-.292a.873.873 0 0 1 .52-1.255l.319-.094c1.79-.527 1.79-3.065 0-3.592l-.319-.094a.873.873 0 0 1-.52-1.255l.16-.292c.893-1.64-.902-3.433-2.541-2.54l-.292.159a.873.873 0 0 1-1.255-.52l-.094-.319z"/></svg>',
|
||||
app_access: '<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" class="mx-0 mb-1" viewBox="0 0 16 16"><path d="M8 1a2 2 0 0 1 2 2v4H6V3a2 2 0 0 1 2-2zm3 6V3a3 3 0 0 0-6 0v4a2 2 0 0 0-2 2v5a2 2 0 0 0 2 2h6a2 2 0 0 0 2-2V9a2 2 0 0 0-2-2z"/></svg>',
|
||||
role_changed: '<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" class="mx-0 mb-1" viewBox="0 0 16 16"><path d="M9.405 1.05c-.413-1.4-2.397-1.4-2.81 0l-.1.34a1.464 1.464 0 0 1-2.105.872l-.31-.17c-1.283-.698-2.686.705-1.987 1.987l.169.311c.446.82.023 1.841-.872 2.105l-.34.1c-1.4.413-1.4 2.397 0 2.81l.34.1a1.464 1.464 0 0 1 .872 2.105l-.17.31c-.698 1.283.705 2.686 1.987 1.987l.311-.169a1.464 1.464 0 0 1 2.105.872l.1.34c.413 1.4 2.397 1.4 2.81 0l.1-.34a1.464 1.464 0 0 1 2.105-.872l.31.17c1.283.698 2.686-.705 1.987-1.987l-.169-.311a1.464 1.464 0 0 1 .872-2.105l.34-.1c1.4-.413 1.4-2.397 0-2.81l-.34-.1a1.464 1.464 0 0 1-.872-2.105l.17-.31c.698-1.283-.705-2.686-1.987-1.987l-.311.169a1.464 1.464 0 0 1-2.105-.872l-.1-.34zM8 10.93a2.929 2.929 0 1 1 0-5.86 2.929 2.929 0 0 1 0 5.858z"/></svg>',
|
||||
};
|
||||
return icons[type] || icons.user_joined;
|
||||
}
|
||||
|
||||
getIconBgClass(type) {
|
||||
const classes = {
|
||||
user_joined: 'bg-primary',
|
||||
user_invited: 'bg-info',
|
||||
user_accepted: 'bg-primary',
|
||||
user_removed: 'bg-danger',
|
||||
user_deactivated: 'bg-warning',
|
||||
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 = `
|
||||
<div class="notification-toast-icon ${this.getIconBgClass(notification.type)}">
|
||||
${this.getIcon(notification.type)}
|
||||
</div>
|
||||
<div class="notification-toast-content">
|
||||
<div class="notification-toast-title">${this.escapeHtml(notification.title)}</div>
|
||||
<div class="notification-toast-message">${this.escapeHtml(notification.message)}</div>
|
||||
</div>
|
||||
<button class="notification-toast-close" onclick="this.parentElement.remove()">×</button>
|
||||
`;
|
||||
|
||||
this.toastContainer.appendChild(toast);
|
||||
|
||||
setTimeout(() => toast.classList.add('show'), 10);
|
||||
|
||||
setTimeout(() => {
|
||||
toast.classList.remove('show');
|
||||
setTimeout(() => toast.remove(), 300);
|
||||
}, 5000);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
import {Controller} from '@hotwired/stimulus'
|
||||
// Important: include a build with Ajax + pagination (TabulatorFull is simplest)
|
||||
import {TabulatorFull as Tabulator} from 'tabulator-tables';
|
||||
import {eyeIconLink, TABULATOR_FR_LANG} from "../js/global.js";
|
||||
|
||||
export default class extends Controller {
|
||||
static values = {aws: String};
|
||||
|
|
@ -12,42 +13,7 @@ export default class extends Controller {
|
|||
table(){
|
||||
const table = new Tabulator("#tabulator-org", {
|
||||
// Register locales here
|
||||
langs: {
|
||||
fr: {
|
||||
ajax: {
|
||||
loading: "Chargement...",
|
||||
error: "Erreur",
|
||||
},
|
||||
pagination: {
|
||||
page_size: "Taille de page",
|
||||
page_title: "Afficher la page",
|
||||
first: "Premier",
|
||||
first_title: "Première page",
|
||||
last: "Dernier",
|
||||
last_title: "Dernière page",
|
||||
prev: "Précédent",
|
||||
prev_title: "Page précédente",
|
||||
next: "Suivant",
|
||||
next_title: "Page suivante",
|
||||
all: "Tout",
|
||||
counter: {
|
||||
showing: "Affiche",
|
||||
of: "de",
|
||||
rows: "lignes",
|
||||
pages: "pages",
|
||||
},
|
||||
},
|
||||
headerFilters: {
|
||||
default: "Filtrer la colonne...",
|
||||
columns: {},
|
||||
},
|
||||
data: {
|
||||
loading: "Chargement des données...",
|
||||
error: "Erreur de chargement des données",
|
||||
},
|
||||
groups: { item: "élément", items: "éléments" },
|
||||
},
|
||||
},
|
||||
langs: TABULATOR_FR_LANG,
|
||||
|
||||
locale: "fr", //'en' for English, 'fr' for French (en is default, no need to include it)
|
||||
|
||||
|
|
@ -74,7 +40,7 @@ export default class extends Controller {
|
|||
formatterParams: {
|
||||
height: "50px",
|
||||
width: "50px",
|
||||
urlPrefix: this.awsValue,
|
||||
urlPrefix: "",
|
||||
urlSuffix: "",
|
||||
},
|
||||
width: 100,
|
||||
|
|
@ -93,16 +59,7 @@ export default class extends Controller {
|
|||
formatter: (cell) => {
|
||||
const url = cell.getValue();
|
||||
if (url) {
|
||||
return `
|
||||
<a href="${url}" class="p-3 align-middle color-primary" title="Voir">
|
||||
<svg xmlns="http://www.w3.org/2000/svg"
|
||||
width="35px"
|
||||
height="35px"
|
||||
viewBox="0 0 576 512">
|
||||
<path fill="currentColor"
|
||||
d="M288 80c-65.2 0-118.8 29.6-159.9 67.7C89.6 183.5 63 226 49.4 256C63 286 89.6 328.5 128 364.3c41.2 38.1 94.8 67.7 160 67.7s118.8-29.6 159.9-67.7C486.4 328.5 513 286 526.6 256c-13.6-30-40.2-72.5-78.6-108.3C406.8 109.6 353.2 80 288 80M95.4 112.6C142.5 68.8 207.2 32 288 32s145.5 36.8 192.6 80.6c46.8 43.5 78.1 95.4 93 131.1c3.3 7.9 3.3 16.7 0 24.6c-14.9 35.7-46.2 87.7-93 131.1C433.5 443.2 368.8 480 288 480s-145.5-36.8-192.6-80.6C48.6 356 17.3 304 2.5 268.3c-3.3-7.9-3.3-16.7 0-24.6C17.3 208 48.6 156 95.4 112.6M288 336c44.2 0 80-35.8 80-80s-35.8-80-80-80h-2c1.3 5.1 2 10.5 2 16c0 35.3-28.7 64-64 64c-5.5 0-10.9-.7-16-2v2c0 44.2 35.8 80 80 80m0-208a128 128 0 1 1 0 256a128 128 0 1 1 0-256"/></svg>
|
||||
</a>
|
||||
`;
|
||||
return eyeIconLink(url);
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,36 +1,14 @@
|
|||
import {Controller} from '@hotwired/stimulus';
|
||||
import Choices from 'choices.js';
|
||||
import {TabulatorFull as Tabulator} from 'tabulator-tables';
|
||||
import {activateUserIcon, deactivateUserIcon, eyeIconLink, sendEmailIcon, TABULATOR_FR_LANG} from "../js/global.js";
|
||||
|
||||
const TABULATOR_FR_LANG = {
|
||||
fr: {
|
||||
ajax: {loading: "Chargement...", error: "Erreur"},
|
||||
pagination: {
|
||||
page_size: "Taille de page",
|
||||
page_title: "Afficher la page",
|
||||
first: "Premier",
|
||||
first_title: "Première page",
|
||||
last: "Dernier",
|
||||
last_title: "Dernière page",
|
||||
prev: "Précédent",
|
||||
prev_title: "Page précédente",
|
||||
next: "Suivant",
|
||||
next_title: "Page suivante",
|
||||
all: "Tout",
|
||||
counter: {showing: "Affiche", of: "de", rows: "lignes", pages: "pages"},
|
||||
},
|
||||
headerFilters: {default: "Filtrer la colonne...", columns: {}},
|
||||
data: {loading: "Chargement des données...", error: "Erreur de chargement des données"},
|
||||
groups: {item: "élément", items: "éléments"},
|
||||
},
|
||||
};
|
||||
|
||||
export default class extends Controller {
|
||||
static values = {
|
||||
rolesArray: Array,
|
||||
selectedRoleIds: Array,
|
||||
id: Number,
|
||||
aws: String,
|
||||
list: Boolean,
|
||||
listOrganization: Boolean,
|
||||
new: Boolean,
|
||||
|
|
@ -48,7 +26,7 @@ export default class extends Controller {
|
|||
this.table();
|
||||
}
|
||||
if (this.newValue) {
|
||||
this.tableSmall();
|
||||
this.tableNew();
|
||||
}
|
||||
if (this.adminValue) {
|
||||
this.tableSmallAdmin();
|
||||
|
|
@ -139,7 +117,7 @@ export default class extends Controller {
|
|||
|
||||
// Image case: make it fill the same wrapper
|
||||
const img = document.createElement("img");
|
||||
img.src = `${this.awsValue || ""}${url}`;
|
||||
img.src = url;
|
||||
img.alt = initials || "avatar";
|
||||
img.style.width = "100%";
|
||||
img.style.height = "100%";
|
||||
|
|
@ -197,27 +175,17 @@ export default class extends Controller {
|
|||
const actionColorClass = isActive ? 'color-secondary' : 'color-primary';
|
||||
|
||||
// SVGs
|
||||
const deactivateSvg = `
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="35" height="35" viewBox="0 0 640 512">
|
||||
<path fill="currentColor" d="M38.8 5.1C28.4-3.1 13.3-1.2 5.1 9.2s-6.3 25.5 4.1 33.7l592 464c10.4 8.2 25.5 6.3 33.7-4.1s6.3-25.5-4.1-33.7L353.3 251.6C407.9 237 448 187.2 448 128C448 57.3 390.7 0 320 0c-69.8 0-126.5 55.8-128 125.2zm225.5 299.2C170.5 309.4 96 387.2 96 482.3c0 16.4 13.3 29.7 29.7 29.7h388.6c3.9 0 7.6-.7 11-2.1z"/>
|
||||
</svg>`;
|
||||
|
||||
const activateSvg = `
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="35" height="35" viewBox="0 0 640 512">
|
||||
<path fill="currentColor" d="M96 128a128 128 0 1 1 256 0a128 128 0 1 1-256 0M0 482.3C0 383.8 79.8 304 178.3 304h91.4c98.5 0 178.3 79.8 178.3 178.3c0 16.4-13.3 29.7-29.7 29.7H29.7C13.3 512 0 498.7 0 482.3M625 177L497 305c-9.4 9.4-24.6 9.4-33.9 0l-64-64c-9.4-9.4-9.4-24.6 0-33.9s24.6-9.4 33.9 0l47 47L591 143c9.4-9.4 24.6-9.4 33.9 0s9.4 24.6 0 33.9z"/></svg>`;
|
||||
const deactivateSvg = deactivateUserIcon();
|
||||
|
||||
const activateSvg = activateUserIcon();
|
||||
const actionSvg = isActive ? deactivateSvg : activateSvg;
|
||||
|
||||
return `
|
||||
<div class="d-flex gap-2 align-content-center">
|
||||
<a href="${url}" class="color-primary" title="Voir">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="35" height="35" viewBox="0 0 640 512">
|
||||
<path fill="currentColor"
|
||||
d="M288 80c-65.2 0-118.8 29.6-159.9 67.7C89.6 183.5 63 226 49.4 256C63 286 89.6 328.5 128 364.3c41.2 38.1 94.8 67.7 160 67.7s118.8-29.6 159.9-67.7C486.4 328.5 513 286 526.6 256c-13.6-30-40.2-72.5-78.6-108.3C406.8 109.6 353.2 80 288 80M95.4 112.6C142.5 68.8 207.2 32 288 32s145.5 36.8 192.6 80.6c46.8 43.5 78.1 95.4 93 131.1c3.3 7.9 3.3 16.7 0 24.6c-14.9 35.7-46.2 87.7-93 131.1C433.5 443.2 368.8 480 288 480s-145.5-36.8-192.6-80.6C48.6 356 17.3 304 2.5 268.3c-3.3-7.9-3.3-16.7 0-24.6C17.3 208 48.6 156 95.4 112.6M288 336c44.2 0 80-35.8 80-80s-35.8-80-80-80h-2c1.3 5.1 2 10.5 2 16c0 35.3-28.7 64-64 64c-5.5 0-10.9-.7-16-2v2c0 44.2 35.8 80 80 80m0-208a128 128 0 1 1 0 256a128 128 0 1 1 0-256"/></svg>
|
||||
</a>
|
||||
${eyeIconLink(url)}
|
||||
|
||||
<a href="#"
|
||||
class="${actionColorClass} ${actionClass}"
|
||||
class="${actionColorClass} ${actionClass} pt-3"
|
||||
data-id="${userId}"
|
||||
title="${actionTitle}">
|
||||
${actionSvg}
|
||||
|
|
@ -321,14 +289,7 @@ export default class extends Controller {
|
|||
};
|
||||
|
||||
|
||||
onSelectChange(row, newValue) {
|
||||
const data = row.getData();
|
||||
console.log("Change select" + data);
|
||||
|
||||
};
|
||||
|
||||
|
||||
tableSmall() {
|
||||
tableNew() {
|
||||
const columns = [
|
||||
{
|
||||
title: "Profil",
|
||||
|
|
@ -367,7 +328,7 @@ export default class extends Controller {
|
|||
|
||||
// Image case: make it fill the same wrapper
|
||||
const img = document.createElement("img");
|
||||
img.src = `${this.awsValue || ""}${url}`;
|
||||
img.src = url;
|
||||
img.alt = initials || "avatar";
|
||||
img.style.width = "100%";
|
||||
img.style.height = "100%";
|
||||
|
|
@ -401,16 +362,7 @@ export default class extends Controller {
|
|||
formatter: (cell) => {
|
||||
const url = cell.getValue();
|
||||
if (url) {
|
||||
return `
|
||||
<a href="${url}" class="p-3 align-middle color-primary" title="Voir">
|
||||
<svg xmlns="http://www.w3.org/2000/svg"
|
||||
width="35px"
|
||||
height="35px"
|
||||
viewBox="0 0 576 512">
|
||||
<path fill="currentColor"
|
||||
d="M288 80c-65.2 0-118.8 29.6-159.9 67.7C89.6 183.5 63 226 49.4 256C63 286 89.6 328.5 128 364.3c41.2 38.1 94.8 67.7 160 67.7s118.8-29.6 159.9-67.7C486.4 328.5 513 286 526.6 256c-13.6-30-40.2-72.5-78.6-108.3C406.8 109.6 353.2 80 288 80M95.4 112.6C142.5 68.8 207.2 32 288 32s145.5 36.8 192.6 80.6c46.8 43.5 78.1 95.4 93 131.1c3.3 7.9 3.3 16.7 0 24.6c-14.9 35.7-46.2 87.7-93 131.1C433.5 443.2 368.8 480 288 480s-145.5-36.8-192.6-80.6C48.6 356 17.3 304 2.5 268.3c-3.3-7.9-3.3-16.7 0-24.6C17.3 208 48.6 156 95.4 112.6M288 336c44.2 0 80-35.8 80-80s-35.8-80-80-80h-2c1.3 5.1 2 10.5 2 16c0 35.3-28.7 64-64 64c-5.5 0-10.9-.7-16-2v2c0 44.2 35.8 80 80 80m0-208a128 128 0 1 1 0 256a128 128 0 1 1 0-256"/></svg>
|
||||
</a>
|
||||
`;
|
||||
return eyeIconLink(url);
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
|
@ -481,7 +433,7 @@ export default class extends Controller {
|
|||
|
||||
// Image case: make it fill the same wrapper
|
||||
const img = document.createElement("img");
|
||||
img.src = `${this.awsValue || ""}${url}`;
|
||||
img.src = url;
|
||||
img.alt = initials || "avatar";
|
||||
img.style.width = "100%";
|
||||
img.style.height = "100%";
|
||||
|
|
@ -515,16 +467,7 @@ export default class extends Controller {
|
|||
formatter: (cell) => {
|
||||
const url = cell.getValue();
|
||||
if (url) {
|
||||
return `
|
||||
<a href="${url}" class="p-3 align-middle color-primary" title="Voir">
|
||||
<svg xmlns="http://www.w3.org/2000/svg"
|
||||
width="35px"
|
||||
height="35px"
|
||||
viewBox="0 0 576 512">
|
||||
<path fill="currentColor"
|
||||
d="M288 80c-65.2 0-118.8 29.6-159.9 67.7C89.6 183.5 63 226 49.4 256C63 286 89.6 328.5 128 364.3c41.2 38.1 94.8 67.7 160 67.7s118.8-29.6 159.9-67.7C486.4 328.5 513 286 526.6 256c-13.6-30-40.2-72.5-78.6-108.3C406.8 109.6 353.2 80 288 80M95.4 112.6C142.5 68.8 207.2 32 288 32s145.5 36.8 192.6 80.6c46.8 43.5 78.1 95.4 93 131.1c3.3 7.9 3.3 16.7 0 24.6c-14.9 35.7-46.2 87.7-93 131.1C433.5 443.2 368.8 480 288 480s-145.5-36.8-192.6-80.6C48.6 356 17.3 304 2.5 268.3c-3.3-7.9-3.3-16.7 0-24.6C17.3 208 48.6 156 95.4 112.6M288 336c44.2 0 80-35.8 80-80s-35.8-80-80-80h-2c1.3 5.1 2 10.5 2 16c0 35.3-28.7 64-64 64c-5.5 0-10.9-.7-16-2v2c0 44.2 35.8 80 80 80m0-208a128 128 0 1 1 0 256a128 128 0 1 1 0-256"/></svg>
|
||||
</a>
|
||||
`;
|
||||
eyeIconLink(url);
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
|
@ -618,7 +561,7 @@ export default class extends Controller {
|
|||
|
||||
// Image case: make it fill the same wrapper
|
||||
const img = document.createElement("img");
|
||||
img.src = `${this.awsValue || ""}${url}`;
|
||||
img.src = url;
|
||||
img.alt = initials || "avatar";
|
||||
img.style.width = "100%";
|
||||
img.style.height = "100%";
|
||||
|
|
@ -648,9 +591,13 @@ export default class extends Controller {
|
|||
title: "<b>Statut</b>", field: "statut", vertAlign: "middle",
|
||||
formatter: (cell) => {
|
||||
const statut = cell.getValue();
|
||||
if (statut) {
|
||||
if (statut === "INVITED") {
|
||||
return `<span class="badge bg-primary">Invité</span>`
|
||||
} else if (statut === "ACTIVE") {
|
||||
return `<span class="badge bg-success">Actif</span>`
|
||||
} else {
|
||||
}else if( statut === "EXPIRED"){
|
||||
return `<span class="badge bg-warning text-dark">Expiré</span>`
|
||||
} else{
|
||||
return `<span class="badge bg-secondary">Inactif</span>`
|
||||
}
|
||||
}
|
||||
|
|
@ -669,48 +616,83 @@ export default class extends Controller {
|
|||
const statut = rowData.statut;
|
||||
const orgId = this.orgIdValue;
|
||||
|
||||
// Decide which action (deactivate vs activate)
|
||||
const isActive = Boolean(statut);
|
||||
// Check if user is expired
|
||||
if (statut === "EXPIRED") {
|
||||
return `
|
||||
<div class="d-flex gap-2 align-content-center">
|
||||
${eyeIconLink(url)}
|
||||
|
||||
${sendEmailIcon(userId, orgId)}
|
||||
</div>
|
||||
`;
|
||||
}if (statut === "INVITED") {
|
||||
return `
|
||||
<div class="d-flex gap-2 align-content-center">
|
||||
${eyeIconLink(url)}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// Decide which action (deactivate vs activate) for non-expired users
|
||||
const isActive = (statut === "ACTIVE");
|
||||
|
||||
const actionClass = isActive ? 'deactivate-user' : 'activate-user';
|
||||
const actionTitle = isActive ? 'Désactiver' : 'Réactiver';
|
||||
const actionColorClass = isActive ? 'color-secondary' : 'color-primary';
|
||||
|
||||
// SVGs
|
||||
const deactivateSvg = `
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="35" height="35" viewBox="0 0 640 512">
|
||||
<path fill="currentColor" d="M38.8 5.1C28.4-3.1 13.3-1.2 5.1 9.2s-6.3 25.5 4.1 33.7l592 464c10.4 8.2 25.5 6.3 33.7-4.1s6.3-25.5-4.1-33.7L353.3 251.6C407.9 237 448 187.2 448 128C448 57.3 390.7 0 320 0c-69.8 0-126.5 55.8-128 125.2zm225.5 299.2C170.5 309.4 96 387.2 96 482.3c0 16.4 13.3 29.7 29.7 29.7h388.6c3.9 0 7.6-.7 11-2.1z"/>
|
||||
</svg>`;
|
||||
const deactivateSvg = deactivateUserIcon();
|
||||
|
||||
const activateSvg = `
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="35" height="35" viewBox="0 0 640 512">
|
||||
<path fill="currentColor" d="M96 128a128 128 0 1 1 256 0a128 128 0 1 1-256 0M0 482.3C0 383.8 79.8 304 178.3 304h91.4c98.5 0 178.3 79.8 178.3 178.3c0 16.4-13.3 29.7-29.7 29.7H29.7C13.3 512 0 498.7 0 482.3M625 177L497 305c-9.4 9.4-24.6 9.4-33.9 0l-64-64c-9.4-9.4-9.4-24.6 0-33.9s24.6-9.4 33.9 0l47 47L591 143c9.4-9.4 24.6-9.4 33.9 0s9.4 24.6 0 33.9z"/></svg>`;
|
||||
const activateSvg = activateUserIcon();
|
||||
|
||||
const actionSvg = isActive ? deactivateSvg : activateSvg;
|
||||
|
||||
return `
|
||||
<div class="d-flex gap-2 align-content-center">
|
||||
<a href="${url}" class="color-primary" title="Voir">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="35" height="35" viewBox="0 0 640 512">
|
||||
<path fill="currentColor"
|
||||
d="M288 80c-65.2 0-118.8 29.6-159.9 67.7C89.6 183.5 63 226 49.4 256C63 286 89.6 328.5 128 364.3c41.2 38.1 94.8 67.7 160 67.7s118.8-29.6 159.9-67.7C486.4 328.5 513 286 526.6 256c-13.6-30-40.2-72.5-78.6-108.3C406.8 109.6 353.2 80 288 80M95.4 112.6C142.5 68.8 207.2 32 288 32s145.5 36.8 192.6 80.6c46.8 43.5 78.1 95.4 93 131.1c3.3 7.9 3.3 16.7 0 24.6c-14.9 35.7-46.2 87.7-93 131.1C433.5 443.2 368.8 480 288 480s-145.5-36.8-192.6-80.6C48.6 356 17.3 304 2.5 268.3c-3.3-7.9-3.3-16.7 0-24.6C17.3 208 48.6 156 95.4 112.6M288 336c44.2 0 80-35.8 80-80s-35.8-80-80-80h-2c1.3 5.1 2 10.5 2 16c0 35.3-28.7 64-64 64c-5.5 0-10.9-.7-16-2v2c0 44.2 35.8 80 80 80m0-208a128 128 0 1 1 0 256a128 128 0 1 1 0-256"/></svg>
|
||||
</a>
|
||||
<div class="d-flex gap-2 align-content-center">
|
||||
${eyeIconLink(url)}
|
||||
|
||||
<a href="#"
|
||||
class="${actionColorClass} ${actionClass}"
|
||||
data-id="${userId}"
|
||||
data-org-id="${orgId}"
|
||||
title="${actionTitle}">
|
||||
${actionSvg}
|
||||
</a>
|
||||
</div>
|
||||
`;
|
||||
<a href="#"
|
||||
class="${actionColorClass} ${actionClass} pt-3"
|
||||
data-id="${userId}"
|
||||
data-org-id="${orgId}"
|
||||
title="${actionTitle}">
|
||||
${actionSvg}
|
||||
</a>
|
||||
</div>
|
||||
`;
|
||||
},
|
||||
|
||||
cellClick: function (e, cell) {
|
||||
const target = e.target.closest('a');
|
||||
if (!target) return;
|
||||
|
||||
// Handle resend invitation for expired users
|
||||
if (target.classList.contains('resend-invitation')) {
|
||||
e.preventDefault();
|
||||
const userId = target.getAttribute('data-id');
|
||||
if (confirm('Voulez-vous renvoyer l\'invitation à cet utilisateur ?')) {
|
||||
const formData = new FormData();
|
||||
formData.append('organizationId', target.getAttribute('data-org-id'));
|
||||
|
||||
fetch(`/user/organization/resend-invitation/${userId}`, {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
headers: {'X-Requested-With': 'XMLHttpRequest'}
|
||||
})
|
||||
.then(async (response) => {
|
||||
if (response.ok) {
|
||||
const data = cell.getRow().getData();
|
||||
data.statut = "INVITED";
|
||||
cell.getRow().reformat();
|
||||
alert('Invitation renvoyée avec succès');
|
||||
} else {
|
||||
const text = await response.text();
|
||||
alert('Erreur lors de l\'envoi : ' + text);
|
||||
}
|
||||
})
|
||||
.catch(() => alert('Erreur lors de l\'envoi'));
|
||||
}
|
||||
}
|
||||
|
||||
// Deactivate
|
||||
if (target.classList.contains('deactivate-user')) {
|
||||
e.preventDefault();
|
||||
|
|
@ -726,9 +708,8 @@ export default class extends Controller {
|
|||
})
|
||||
.then(async (response) => {
|
||||
if (response.ok) {
|
||||
// Option 1: update row status and re-render to switch icon
|
||||
const data = cell.getRow().getData();
|
||||
data.statut = false;
|
||||
data.statut = "INACTIVE";
|
||||
cell.getRow().reformat();
|
||||
} else {
|
||||
const text = await response.text();
|
||||
|
|
@ -754,9 +735,8 @@ export default class extends Controller {
|
|||
})
|
||||
.then(async (response) => {
|
||||
if (response.ok) {
|
||||
// Switch status back to active and re-render row
|
||||
const data = cell.getRow().getData();
|
||||
data.statut = true;
|
||||
data.statut = "ACTIVE";
|
||||
cell.getRow().reformat();
|
||||
} else {
|
||||
const text = await response.text();
|
||||
|
|
|
|||
|
|
@ -0,0 +1,51 @@
|
|||
export const TABULATOR_FR_LANG = {
|
||||
fr: {
|
||||
ajax: {loading: "Chargement...", error: "Erreur"},
|
||||
pagination: {
|
||||
page_size: "Taille de page",
|
||||
page_title: "Afficher la page",
|
||||
first: "Premier",
|
||||
first_title: "Première page",
|
||||
last: "Dernier",
|
||||
last_title: "Dernière page",
|
||||
prev: "Précédent",
|
||||
prev_title: "Page précédente",
|
||||
next: "Suivant",
|
||||
next_title: "Page suivante",
|
||||
all: "Tout",
|
||||
counter: {showing: "Affiche", of: "de", rows: "lignes", pages: "pages"},
|
||||
},
|
||||
headerFilters: {default: "Filtrer la colonne...", columns: {}},
|
||||
data: {loading: "Chargement des données...", error: "Erreur de chargement des données"},
|
||||
groups: {item: "élément", items: "éléments"},
|
||||
},
|
||||
};
|
||||
|
||||
export function eyeIconLink(url) {
|
||||
return `<a href="${url}" class="p-3 align-middle color-primary" title="Voir">
|
||||
<svg xmlns="http://www.w3.org/2000/svg"
|
||||
width="35px"
|
||||
height="35px"
|
||||
viewBox="0 0 576 512">
|
||||
<path fill="currentColor"
|
||||
d="M288 80c-65.2 0-118.8 29.6-159.9 67.7C89.6 183.5 63 226 49.4 256C63 286 89.6 328.5 128 364.3c41.2 38.1 94.8 67.7 160 67.7s118.8-29.6 159.9-67.7C486.4 328.5 513 286 526.6 256c-13.6-30-40.2-72.5-78.6-108.3C406.8 109.6 353.2 80 288 80M95.4 112.6C142.5 68.8 207.2 32 288 32s145.5 36.8 192.6 80.6c46.8 43.5 78.1 95.4 93 131.1c3.3 7.9 3.3 16.7 0 24.6c-14.9 35.7-46.2 87.7-93 131.1C433.5 443.2 368.8 480 288 480s-145.5-36.8-192.6-80.6C48.6 356 17.3 304 2.5 268.3c-3.3-7.9-3.3-16.7 0-24.6C17.3 208 48.6 156 95.4 112.6M288 336c44.2 0 80-35.8 80-80s-35.8-80-80-80h-2c1.3 5.1 2 10.5 2 16c0 35.3-28.7 64-64 64c-5.5 0-10.9-.7-16-2v2c0 44.2 35.8 80 80 80m0-208a128 128 0 1 1 0 256a128 128 0 1 1 0-256"/></svg>
|
||||
</a>`;
|
||||
}
|
||||
|
||||
export function deactivateUserIcon() {
|
||||
return `<svg xmlns="http://www.w3.org/2000/svg" width="35" height="35" viewBox="0 0 640 512">
|
||||
<path fill="currentColor" d="M38.8 5.1C28.4-3.1 13.3-1.2 5.1 9.2s-6.3 25.5 4.1 33.7l592 464c10.4 8.2 25.5 6.3 33.7-4.1s6.3-25.5-4.1-33.7L353.3 251.6C407.9 237 448 187.2 448 128C448 57.3 390.7 0 320 0c-69.8 0-126.5 55.8-128 125.2zm225.5 299.2C170.5 309.4 96 387.2 96 482.3c0 16.4 13.3 29.7 29.7 29.7h388.6c3.9 0 7.6-.7 11-2.1z"/>
|
||||
</svg>`
|
||||
}
|
||||
|
||||
export function activateUserIcon() {
|
||||
return `<svg xmlns="http://www.w3.org/2000/svg" width="35" height="35" viewBox="0 0 640 512">
|
||||
<path fill="currentColor" d="M96 128a128 128 0 1 1 256 0a128 128 0 1 1-256 0M0 482.3C0 383.8 79.8 304 178.3 304h91.4c98.5 0 178.3 79.8 178.3 178.3c0 16.4-13.3 29.7-29.7 29.7H29.7C13.3 512 0 498.7 0 482.3M625 177L497 305c-9.4 9.4-24.6 9.4-33.9 0l-64-64c-9.4-9.4-9.4-24.6 0-33.9s24.6-9.4 33.9 0l47 47L591 143c9.4-9.4 24.6-9.4 33.9 0s9.4 24.6 0 33.9z"/>
|
||||
</svg>`
|
||||
}
|
||||
|
||||
export function sendEmailIcon(userId, orgId) {
|
||||
return `<a href="#" class="color-primary resend-invitation pt-3" data-id="${userId}" data-org-id="${orgId}" title="Renvoyer l'invitation" >
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="35px" height="35px" viewBox="0 0 24 24"><path fill="currentColor" d="M18.175 17H15q-.425 0-.712-.288T14 16t.288-.712T15 15h3.175l-.9-.9Q17 13.825 17 13.413t.3-.713q.275-.275.7-.275t.7.275l2.6 2.6q.125.125.2.312t.075.388t-.075.387t-.2.313l-2.6 2.6q-.275.275-.687.288T17.3 19.3q-.275-.275-.275-.7t.275-.7zM4 17q-.825 0-1.412-.587T2 15V5q0-.825.588-1.412T4 3h13q.825 0 1.413.588T19 5v4.075q0 .4-.3.7t-.7.3q-.425 0-.712-.288T17 9.076V6.4L10.4 11L4 6.425V15h7.075q.425 0 .713.288t.287.712t-.287.713t-.713.287zM5.45 5l4.95 3.55L15.5 5zM4 15V5z"/></svg>
|
||||
</a>`
|
||||
}
|
||||
|
|
@ -111,7 +111,7 @@ body {
|
|||
}
|
||||
|
||||
.color-primary{
|
||||
color: var(--primary-blue-light);
|
||||
color: var(--primary-blue-light) !important;
|
||||
}
|
||||
.color-primary-dark{
|
||||
color: var(--primary-blue-dark);
|
||||
|
|
@ -136,4 +136,12 @@ body {
|
|||
|
||||
.color-secondary{
|
||||
color: var(--secondary);
|
||||
}
|
||||
|
||||
.bg-primary{
|
||||
background-color: var(--primary-blue-light) !important;
|
||||
}
|
||||
|
||||
.bg-warning{
|
||||
background-color: var(--secondary) !important;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
@ -32,8 +32,9 @@
|
|||
"symfony/intl": "7.2.*",
|
||||
"symfony/mailer": "7.2.*",
|
||||
"symfony/mercure-bundle": "^0.3.9",
|
||||
"symfony/messenger": "7.2.*",
|
||||
"symfony/mime": "7.2.*",
|
||||
"symfony/monolog-bundle": "^3.0",
|
||||
"symfony/monolog-bundle": "^3.10",
|
||||
"symfony/notifier": "7.2.*",
|
||||
"symfony/process": "7.2.*",
|
||||
"symfony/property-access": "7.2.*",
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
||||
"This file is @generated automatically"
|
||||
],
|
||||
"content-hash": "2e4c0342c6040486970857205e1e8d75",
|
||||
"content-hash": "6694f361e2e9943b29abdd66ef40904a",
|
||||
"packages": [
|
||||
{
|
||||
"name": "aws/aws-crt-php",
|
||||
|
|
|
|||
|
|
@ -6,3 +6,4 @@ mercure:
|
|||
jwt:
|
||||
secret: '%env(MERCURE_JWT_SECRET)%'
|
||||
publish: '*'
|
||||
subscribe: '*'
|
||||
|
|
|
|||
|
|
@ -35,6 +35,9 @@ security:
|
|||
security: true
|
||||
stateless: true
|
||||
oauth2: true
|
||||
password_setup:
|
||||
pattern: ^/password_setup
|
||||
stateless: true
|
||||
main:
|
||||
user_checker: App\Security\UserChecker
|
||||
lazy: true
|
||||
|
|
@ -59,6 +62,8 @@ security:
|
|||
# Note: Only the *first* access control that matches will be used
|
||||
access_control:
|
||||
- { path: ^/login, roles: PUBLIC_ACCESS }
|
||||
- { path: ^/password_setup, roles: PUBLIC_ACCESS }
|
||||
- { path: ^/password_reset, roles: PUBLIC_ACCESS }
|
||||
- { path: ^/sso_logout, roles: IS_AUTHENTICATED_FULLY }
|
||||
- { path: ^/token, roles: PUBLIC_ACCESS }
|
||||
- { path: ^/oauth2/revoke_tokens, roles: PUBLIC_ACCESS }
|
||||
|
|
|
|||
|
|
@ -7,6 +7,9 @@ twig:
|
|||
aws_url: '%env(AWS_S3_PORTAL_URL)%'
|
||||
version: '0.4'
|
||||
|
||||
paths:
|
||||
'%kernel.project_dir%/assets/img': images
|
||||
|
||||
|
||||
|
||||
when@test:
|
||||
|
|
|
|||
|
|
@ -0,0 +1,32 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
/**
|
||||
* Auto-generated Migration: Please modify to your needs!
|
||||
*/
|
||||
final class Version20251028154635 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return '';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
// this up() migration is auto-generated, please modify it to your needs
|
||||
$this->addSql('ALTER TABLE users_organizations ADD statut 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 users_organizations DROP statut');
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,35 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
/**
|
||||
* Auto-generated Migration: Please modify to your needs!
|
||||
*/
|
||||
final class Version20251029104354 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return '';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
// this up() migration is auto-generated, please modify it to your needs
|
||||
$this->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');
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,32 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
/**
|
||||
* Auto-generated Migration: Please modify to your needs!
|
||||
*/
|
||||
final class Version20251029104801 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return '';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
// this up() migration is auto-generated, please modify it to your needs
|
||||
$this->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');
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,32 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
/**
|
||||
* Auto-generated Migration: Please modify to your needs!
|
||||
*/
|
||||
final class Version20251104081124 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return '';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
// this up() migration is auto-generated, please modify it to your needs
|
||||
$this->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');
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,33 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
/**
|
||||
* Auto-generated Migration: Please modify to your needs!
|
||||
*/
|
||||
final class Version20251105083809 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return '';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
// this up() migration is auto-generated, please modify it to your needs
|
||||
$this->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');
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,41 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
/**
|
||||
* Auto-generated Migration: Please modify to your needs!
|
||||
*/
|
||||
final class Version20251117104819 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return '';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
// this up() migration is auto-generated, please modify it to your needs
|
||||
$this->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');
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,31 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
/**
|
||||
* Auto-generated Migration: Please modify to your needs!
|
||||
*/
|
||||
final class Version20251117125146 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return '';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
// this up() migration is auto-generated, please modify it to your needs
|
||||
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
// this down() migration is auto-generated, please modify it to your needs
|
||||
$this->addSql('CREATE SCHEMA public');
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,144 @@
|
|||
<?php
|
||||
|
||||
namespace App\Controller;
|
||||
|
||||
use App\Entity\Notification;
|
||||
use App\Repository\NotificationRepository;
|
||||
use App\Service\UserService;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Lcobucci\JWT\Configuration;
|
||||
use Lcobucci\JWT\Signer\Hmac\Sha256;
|
||||
use Lcobucci\JWT\Signer\Key\InMemory;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
|
||||
#[Route(path: '/notifications', name: 'notification_')]
|
||||
class NotificationController extends AbstractController
|
||||
{
|
||||
public function __construct(
|
||||
private readonly NotificationRepository $notificationRepository,
|
||||
private readonly EntityManagerInterface $entityManager,
|
||||
private readonly UserService $userService
|
||||
) {
|
||||
}
|
||||
|
||||
#[Route(path: '/', name: 'index', methods: ['GET'])]
|
||||
public function index(): JsonResponse
|
||||
{
|
||||
$this->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(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -11,6 +11,7 @@ use App\Entity\UsersOrganizations;
|
|||
use App\Form\OrganizationForm;
|
||||
use App\Repository\OrganizationsRepository;
|
||||
use App\Service\ActionService;
|
||||
use App\Service\AwsService;
|
||||
use App\Service\OrganizationsService;
|
||||
use App\Service\UserOrganizationService;
|
||||
use App\Service\UserService;
|
||||
|
|
@ -35,7 +36,8 @@ class OrganizationController extends AbstractController
|
|||
private readonly OrganizationsService $organizationsService,
|
||||
private readonly ActionService $actionService,
|
||||
private readonly UserOrganizationService $userOrganizationService,
|
||||
private readonly OrganizationsRepository $organizationsRepository,)
|
||||
private readonly OrganizationsRepository $organizationsRepository,
|
||||
private readonly AwsService $awsService)
|
||||
{
|
||||
}
|
||||
|
||||
|
|
@ -310,11 +312,12 @@ class OrganizationController extends AbstractController
|
|||
|
||||
// Map to array
|
||||
$data = array_map(function (Organizations $org) {
|
||||
$picture = $this->awsService->getPublicUrl($_ENV['S3_PORTAL_BUCKET']) . $org->getLogoUrl();
|
||||
return [
|
||||
'id' => $org->getId(),
|
||||
'name' => $org->getName(),
|
||||
'email' => $org->getEmail(),
|
||||
'logoUrl' => $org->getLogoUrl() ?: null,
|
||||
'logoUrl' => $picture ?: null,
|
||||
'active' => $org->isActive(),
|
||||
'showUrl' => $this->generateUrl('organization_show', ['id' => $org->getId()]),
|
||||
];
|
||||
|
|
|
|||
|
|
@ -2,7 +2,12 @@
|
|||
|
||||
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;
|
||||
use Psr\Log\LogLevel;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
|
|
@ -16,9 +21,16 @@ use App\Service\CguUserService;
|
|||
|
||||
class SecurityController extends AbstractController
|
||||
{
|
||||
const NOT_FOUND = "NOT FOUND";
|
||||
private CguUserService $cguUserService;
|
||||
|
||||
public function __construct(CguUserService $cguUserService)
|
||||
public function __construct(CguUserService $cguUserService,
|
||||
private readonly UserRepository $userRepository,
|
||||
private readonly UserService $userService,
|
||||
private readonly UsersOrganizationsRepository $uoRepository,
|
||||
private readonly LoggerInterface $logger,
|
||||
private readonly EntityManagerInterface $entityManager,
|
||||
private readonly OrganizationsService $organizationsService)
|
||||
{
|
||||
$this->cguUserService = $cguUserService;
|
||||
}
|
||||
|
|
@ -26,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,
|
||||
|
|
@ -43,39 +50,88 @@ 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');
|
||||
}
|
||||
|
||||
#[Route('/password_setup/{id}', name: 'password_setup', methods: ['GET'])]
|
||||
public function password_setup(int $id, Request $request): Response
|
||||
{
|
||||
$error = $request->get('error');
|
||||
$user = $this->userRepository->find($id);
|
||||
if (!$user) {
|
||||
throw $this->createNotFoundException(self::NOT_FOUND);
|
||||
}
|
||||
$token = $request->get('token');
|
||||
if (empty($token) || !$this->userService->isPasswordTokenValid($user, $token)) {
|
||||
$error = 'Le lien de définition du mot de passe est invalide ou a expiré. Veuillez en demander un nouveau.';
|
||||
$this->logger->warning($user->getUserIdentifier(). " tried to use an invalid or expired password setup token.");
|
||||
}
|
||||
return $this->render('security/password_setup.html.twig', [
|
||||
'id' => $id,
|
||||
'token' => $token,
|
||||
'error' => $error,
|
||||
]);
|
||||
}
|
||||
|
||||
#[Route('/password_reset/{id}', name: 'password_reset', methods: ['POST'])]
|
||||
public function password_reset(int $id): Response
|
||||
{
|
||||
$user = $this->userRepository->find($id);
|
||||
if (!$user) {
|
||||
throw $this->createNotFoundException(self::NOT_FOUND);
|
||||
}
|
||||
$newPassword = $_POST['_password'] ?? null;
|
||||
$confirmPassword = $_POST['_passwordConfirm'] ?? null;
|
||||
if ($newPassword !== $confirmPassword) {
|
||||
$error = 'Les mots de passe ne correspondent pas. Veuillez réessayer.';
|
||||
$this->logger->warning($user->getUserIdentifier(). " provided non-matching passwords during password reset.");
|
||||
return $this->redirectToRoute('password_setup', [
|
||||
'id' => $id,
|
||||
'token' => $_POST['token'] ?? '',
|
||||
'error'=> $error]);
|
||||
}
|
||||
if (!$this->userService->isPasswordStrong($newPassword)) {
|
||||
$error = 'Le mot de passe ne respecte pas les critères de sécurité. Veuillez en choisir un autre.';
|
||||
$this->logger->warning($user->getUserIdentifier(). " provided a weak password during password reset.");
|
||||
return $this->redirectToRoute('password_setup', ['id' => $id, 'token' => $_POST['token'] ?? '', 'error'=> $error]);
|
||||
}
|
||||
$this->userService->updateUserPassword($user, $newPassword);
|
||||
$orgId = $this->userService->getOrgFromToken( $_POST['token']);
|
||||
$uo = $this->uoRepository->findOneBy(['users' => $user, 'organization' => $orgId]);
|
||||
if($uo){
|
||||
$uo->setStatut("ACCEPTED");
|
||||
$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');
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,14 +12,22 @@ use App\Repository\OrganizationsRepository;
|
|||
use App\Repository\UserRepository;
|
||||
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;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Symfony\Bridge\Twig\Mime\TemplatedEmail;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Mailer\Mailer;
|
||||
use Symfony\Component\Mailer\MailerInterface;
|
||||
use Symfony\Component\Mime\Email;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
|
||||
#[Route(path: '/user', name: 'user_')]
|
||||
|
|
@ -29,44 +37,19 @@ class UserController extends AbstractController
|
|||
private const ACCESS_DENIED = 'Access denied';
|
||||
|
||||
public function __construct(
|
||||
private readonly EntityManagerInterface $entityManager,
|
||||
private readonly UserService $userService,
|
||||
private readonly ActionService $actionService,
|
||||
private readonly UserOrganizationAppService $userOrganizationAppService,
|
||||
private readonly UserOrganizationService $userOrganizationService,
|
||||
private readonly UserRepository $userRepository,
|
||||
private readonly EntityManagerInterface $entityManager,
|
||||
private readonly UserService $userService,
|
||||
private readonly ActionService $actionService,
|
||||
private readonly UserOrganizationAppService $userOrganizationAppService,
|
||||
private readonly UserOrganizationService $userOrganizationService,
|
||||
private readonly UserRepository $userRepository,
|
||||
private readonly UsersOrganizationsRepository $uoRepository,
|
||||
private readonly OrganizationsRepository $organizationRepository,
|
||||
private readonly OrganizationsRepository $organizationRepository, private readonly LoggerInterface $logger, private readonly EmailService $emailService, private readonly AwsService $awsService, private readonly OrganizationsService $organizationsService,
|
||||
)
|
||||
{
|
||||
}
|
||||
|
||||
#[Route('/', name: 'index', methods: ['GET'])]
|
||||
public function index(): Response
|
||||
{
|
||||
$this->denyAccessUnlessGranted('ROLE_USER');
|
||||
|
||||
$user = $this->userService->getUserByIdentifier($this->getUser()->getUserIdentifier());
|
||||
|
||||
if ($this->isGranted('ROLE_SUPER_ADMIN')) {
|
||||
$uo = $this->uoRepository->findUsersWithOrganization();
|
||||
$noOrgUsers = $this->userService->formatNoOrgUsersAsAssoc(
|
||||
$this->userRepository->findUsersWithoutOrganization());
|
||||
$usersByOrganization = $this->userService->groupByOrganization($uo);
|
||||
$usersByOrganization += $noOrgUsers;
|
||||
|
||||
//Log action
|
||||
$this->actionService->createAction("View all users", $user, null, "All");
|
||||
|
||||
} else {
|
||||
$usersByOrganization = [];
|
||||
}
|
||||
|
||||
return $this->render('user/index.html.twig', [
|
||||
'usersByOrganization' => $usersByOrganization,
|
||||
]);
|
||||
}
|
||||
|
||||
//TODO : REMOVE DEAD CODE due to the use of tabulator in the frontend
|
||||
#[Route('/view/{id}', name: 'show', methods: ['GET'])]
|
||||
public function view(int $id, Request $request): Response
|
||||
{
|
||||
|
|
@ -106,7 +89,7 @@ class UserController extends AbstractController
|
|||
'uoActive' => $uoActive ?? null// specific for single organization context and deactivate user from said org
|
||||
]);
|
||||
}
|
||||
|
||||
//TODO : MONOLOG
|
||||
#[Route('/edit/{id}', name: 'edit', methods: ['GET', 'POST'])]
|
||||
public function edit(int $id, Request $request): Response
|
||||
{
|
||||
|
|
@ -155,54 +138,93 @@ class UserController extends AbstractController
|
|||
public function new(Request $request): Response
|
||||
{
|
||||
$this->denyAccessUnlessGranted('ROLE_ADMIN');
|
||||
$actingUser = $this->userService->getUserByIdentifier($this->getUser()->getUserIdentifier());
|
||||
if ($this->userService->hasAccessTo($actingUser)) {
|
||||
$user = new User();
|
||||
$form = $this->createForm(UserForm::class, $user);
|
||||
$form->handleRequest($request);
|
||||
$orgId = $request->get('organizationId');
|
||||
try {
|
||||
$actingUser = $this->userService->getUserByIdentifier($this->getUser()->getUserIdentifier());
|
||||
if ($this->userService->hasAccessTo($actingUser)) {
|
||||
$user = new User();
|
||||
$form = $this->createForm(UserForm::class, $user);
|
||||
$form->handleRequest($request);
|
||||
$orgId = $request->get('organizationId');
|
||||
|
||||
if ($form->isSubmitted() && $form->isValid()) {
|
||||
|
||||
// Handle file upload
|
||||
$picture = $form->get('pictureUrl')->getData();
|
||||
|
||||
if ($picture) {
|
||||
$this->userService->handleProfilePicture($user, $picture);
|
||||
}
|
||||
// else {
|
||||
// $user->setPictureUrl("");
|
||||
// }
|
||||
//FOR TEST PURPOSES, SETTING A DEFAULT RANDOM PASSWORD
|
||||
$user->setPassword($this->userService->generateRandomPassword());
|
||||
if ($orgId) {
|
||||
$org = $this->organizationRepository->find($orgId);
|
||||
if ($org) {
|
||||
if ($form->isSubmitted() && $form->isValid()) {
|
||||
$existingUser = $this->userRepository->findOneBy(['email' => $user->getEmail()]);
|
||||
if ($existingUser && $orgId) {
|
||||
$org = $this->organizationRepository->find($orgId);
|
||||
$uo = new UsersOrganizations();
|
||||
$uo->setUsers($user);
|
||||
$uo->setUsers($existingUser);
|
||||
$uo->setOrganization($org);
|
||||
$uo->setStatut("INVITED");
|
||||
$uo->setIsActive(false);
|
||||
$uo->setModifiedAt(new \DateTimeImmutable('now'));
|
||||
$this->entityManager->persist($uo);
|
||||
$this->actionService->createAction("Create new user", $user, $org, "Added user to organization" . $user->getUserIdentifier() . " for organization " . $org->getName());
|
||||
$this->entityManager->flush();
|
||||
$this->actionService->createAction("Create new user", $existingUser, $org, "Added user to organization" . $existingUser->getUserIdentifier() . " for organization " . $org->getName());
|
||||
$this->logger->notice("User added to organization " . $org->getName());
|
||||
$this->emailService->sendExistingUserNotificationEmail($existingUser, $org);
|
||||
$this->logger->notice("Existing user notification email sent to " . $existingUser->getUserIdentifier());
|
||||
$data = ['user'=>$uo->getUsers(), 'organization'=>$uo->getOrganization()];
|
||||
$this->organizationsService->notifyOrganizationAdmins($data,'USER_INVITED');
|
||||
|
||||
return $this->redirectToRoute('organization_show', ['id' => $orgId]);
|
||||
}
|
||||
|
||||
// capitalize name and surname
|
||||
$user->setName(ucfirst(strtolower($user->getName())));
|
||||
$user->setSurname(ucfirst(strtolower($user->getSurname())));
|
||||
|
||||
// Handle file upload
|
||||
$picture = $form->get('pictureUrl')->getData();
|
||||
|
||||
if ($picture) {
|
||||
$this->userService->handleProfilePicture($user, $picture);
|
||||
}
|
||||
|
||||
//FOR TEST PURPOSES, SETTING A DEFAULT RANDOM PASSWORD
|
||||
$user->setPassword($this->userService->generateRandomPassword());
|
||||
if ($orgId) {
|
||||
$org = $this->organizationRepository->find($orgId);
|
||||
if ($org) {
|
||||
$uo = new UsersOrganizations();
|
||||
$uo->setUsers($user);
|
||||
$uo->setOrganization($org);
|
||||
$uo->setStatut("INVITED");
|
||||
$uo->setIsActive(false);
|
||||
$uo->setModifiedAt(new \DateTimeImmutable('now'));
|
||||
$this->entityManager->persist($uo);
|
||||
$this->actionService->createAction("Create new user", $user, $org, "Added user to organization" . $user->getUserIdentifier() . " for organization " . $org->getName());
|
||||
$this->logger->notice("User added to organization " . $org->getName());
|
||||
$this->emailService->sendPasswordSetupEmail($user, $orgId);
|
||||
$this->logger->notice("Password setup email sent to " . $user->getUserIdentifier());
|
||||
$data = ['user'=>$uo->getUsers(), 'organization'=>$uo->getOrganization()];
|
||||
$this->organizationsService->notifyOrganizationAdmins($data,'USER_INVITED');
|
||||
}
|
||||
}
|
||||
$this->actionService->createAction("Create new user", $actingUser, null, $user->getUserIdentifier());
|
||||
$this->logger->notice("User created " . $user->getUserIdentifier());
|
||||
$this->entityManager->persist($user);
|
||||
$this->entityManager->flush();
|
||||
|
||||
if( $orgId) {
|
||||
return $this->redirectToRoute('organization_show', ['organizationId' => $orgId]);
|
||||
}
|
||||
return $this->redirectToRoute('user_index');
|
||||
}
|
||||
|
||||
$this->actionService->createAction("Create new user", $actingUser, null, $user->getUserIdentifier());
|
||||
|
||||
$this->entityManager->persist($user);
|
||||
$this->entityManager->flush();
|
||||
|
||||
return $this->redirectToRoute('user_index');
|
||||
}
|
||||
|
||||
return $this->render('user/new.html.twig', [
|
||||
'user' => $user,
|
||||
'form' => $form->createView(),
|
||||
'organizationId' => $orgId
|
||||
]);
|
||||
} catch (\Exception $e) {
|
||||
$this->logger->error($e->getMessage());
|
||||
if( $orgId) {
|
||||
return $this->redirectToRoute('organization_show', ['id' => $orgId]);
|
||||
}
|
||||
return $this->redirectToRoute('user_index');
|
||||
}
|
||||
throw $this->createAccessDeniedException(self::ACCESS_DENIED);
|
||||
}
|
||||
|
||||
//TODO : MONOLOG
|
||||
#[Route('/deactivate/{id}', name: 'deactivate', methods: ['GET', 'POST'])]
|
||||
public function deactivate(int $id): Response
|
||||
{
|
||||
|
|
@ -216,7 +238,7 @@ class UserController extends AbstractController
|
|||
$user->setIsActive(false);
|
||||
$user->setModifiedAt(new \DateTimeImmutable('now'));
|
||||
$this->userOrganizationService->deactivateAllUserOrganizationLinks($user, $actingUser);
|
||||
if($this->userService->isUserConnected($user->getUserIdentifier())){
|
||||
if ($this->userService->isUserConnected($user->getUserIdentifier())) {
|
||||
$this->userService->revokeUserTokens($user->getUserIdentifier());
|
||||
}
|
||||
$this->entityManager->persist($user);
|
||||
|
|
@ -229,6 +251,7 @@ class UserController extends AbstractController
|
|||
throw $this->createAccessDeniedException(self::ACCESS_DENIED);
|
||||
}
|
||||
|
||||
//TODO : MONOLOG
|
||||
#[Route('/activate/{id}', name: 'activate', methods: ['GET', 'POST'])]
|
||||
public function activate(int $id): Response
|
||||
{
|
||||
|
|
@ -251,6 +274,7 @@ class UserController extends AbstractController
|
|||
throw $this->createAccessDeniedException(self::ACCESS_DENIED);
|
||||
}
|
||||
|
||||
//TODO : MONOLOG
|
||||
#[Route('/organization/deactivate/{id}', name: 'deactivate_organization', methods: ['GET', 'POST'])]
|
||||
public function deactivateUserInOrganization(int $id, Request $request): Response
|
||||
{
|
||||
|
|
@ -274,6 +298,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());
|
||||
|
|
@ -284,6 +311,7 @@ class UserController extends AbstractController
|
|||
throw $this->createAccessDeniedException(self::ACCESS_DENIED);
|
||||
}
|
||||
|
||||
//TODO : MONOLOG
|
||||
#[Route('/organization/activate/{id}', name: 'activate_organization', methods: ['GET', 'POST'])]
|
||||
public function activateUserInOrganization(int $id, Request $request): Response
|
||||
{
|
||||
|
|
@ -309,13 +337,16 @@ 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');
|
||||
}
|
||||
|
||||
throw $this->createAccessDeniedException(self::ACCESS_DENIED);
|
||||
}
|
||||
|
||||
//TODO : MONOLOG + remove picture from bucket
|
||||
#[Route('/delete/{id}', name: 'delete', methods: ['GET', 'POST'])]
|
||||
public function delete(int $id, Request $request): Response
|
||||
{
|
||||
|
|
@ -329,16 +360,20 @@ class UserController extends AbstractController
|
|||
$user->setModifiedAt(new \DateTimeImmutable('now'));
|
||||
$this->userOrganizationService->deactivateAllUserOrganizationLinks($user, $actingUser);
|
||||
$user->setIsDeleted(true);
|
||||
if($this->userService->isUserConnected($user)){
|
||||
if ($this->userService->isUserConnected($user)) {
|
||||
$this->userService->revokeUserTokens($user->getUserIdentifier());
|
||||
}
|
||||
$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
|
||||
}
|
||||
|
||||
//TODO : MONOLOG
|
||||
#[Route(path: '/application/roles/{id}', name: 'application_role', methods: ['GET', 'POST'])]
|
||||
public function applicationRole(int $id, Request $request): Response
|
||||
{
|
||||
|
|
@ -425,9 +460,10 @@ class UserController extends AbstractController
|
|||
|
||||
// Map to array
|
||||
$data = array_map(function (User $user) {
|
||||
$picture = $this->awsService->getPublicUrl($_ENV['S3_PORTAL_BUCKET']) . $user->getPictureUrl();
|
||||
return [
|
||||
'id' => $user->getId(),
|
||||
'pictureUrl' => $user->getPictureUrl(),
|
||||
'pictureUrl' => $picture,
|
||||
'name' => $user->getSurname(),
|
||||
'prenom' => $user->getName(),
|
||||
'email' => $user->getEmail(),
|
||||
|
|
@ -446,13 +482,13 @@ class UserController extends AbstractController
|
|||
]);
|
||||
}
|
||||
|
||||
#[Route(path: '/indexTest', name: 'indexTest', methods: ['GET'])]
|
||||
public function indexTest(): Response
|
||||
#[Route(path: '/', name: 'index', methods: ['GET'])]
|
||||
public function index(): Response
|
||||
{
|
||||
$actingUser = $this->userService->getUserByIdentifier($this->getUser()->getUserIdentifier());
|
||||
if ($this->userService->hasAccessTo($actingUser, true) && $this->isGranted("ROLE_ADMIN")) {
|
||||
$totalUsers = $this->userRepository->count(['isDeleted' => false, 'isActive' => true]);
|
||||
return $this->render('user/indexTest.html.twig', [
|
||||
return $this->render('user/index.html.twig', [
|
||||
'users' => $totalUsers
|
||||
]);
|
||||
}
|
||||
|
|
@ -469,15 +505,17 @@ class UserController extends AbstractController
|
|||
$actingUser = $this->userService->getUserByIdentifier($this->getUser()->getUserIdentifier());
|
||||
if ($this->userService->hasAccessTo($actingUser, true) && $this->isGranted("ROLE_ADMIN")) {
|
||||
$orgId = $request->query->get('orgId');
|
||||
$uos = $this->uoRepository->findBy(['organization' => $orgId, 'isActive' =>true], limit: 5, orderBy: ['createdAt' => 'DESC']);
|
||||
$uos = $this->uoRepository->findBy(['organization' => $orgId, 'statut' => ["ACCEPTED", "INVITED"]],
|
||||
orderBy: ['createdAt' => 'DESC'], limit: 5);
|
||||
|
||||
|
||||
// Map to array (keep isConnected)
|
||||
$data = array_map(function (UsersOrganizations $uo) {
|
||||
$user = $uo->getUsers();
|
||||
$picture = $this->awsService->getPublicUrl($_ENV['S3_PORTAL_BUCKET']) . $user->getPictureUrl();
|
||||
$initials = $user->getName()[0] . $user->getSurname()[0];
|
||||
return [
|
||||
'pictureUrl' => $user->getPictureUrl(),
|
||||
'pictureUrl' => $picture,
|
||||
'email' => $user->getEmail(),
|
||||
'isConnected' => $this->userService->isUserConnected($user->getUserIdentifier()),
|
||||
'showUrl' => $this->generateUrl('user_show', ['id' => $user->getId()]),
|
||||
|
|
@ -517,9 +555,10 @@ class UserController extends AbstractController
|
|||
// Map to array (keep isConnected)
|
||||
$data = array_map(function (UsersOrganizations $uo) {
|
||||
$user = $uo->getUsers();
|
||||
$picture = $this->awsService->getPublicUrl($_ENV['S3_PORTAL_BUCKET']) . $user->getPictureUrl();
|
||||
$initials = $user->getName()[0] . $user->getSurname()[0];
|
||||
return [
|
||||
'pictureUrl' => $user->getPictureUrl(),
|
||||
'pictureUrl' =>$picture,
|
||||
'email' => $user->getEmail(),
|
||||
'isConnected' => $this->userService->isUserConnected($user->getUserIdentifier()),
|
||||
'showUrl' => $this->generateUrl('user_show', ['id' => $user->getId()]),
|
||||
|
|
@ -527,7 +566,6 @@ class UserController extends AbstractController
|
|||
];
|
||||
}, $users);
|
||||
|
||||
|
||||
return $this->json([
|
||||
'data' => $data,
|
||||
]);
|
||||
|
|
@ -563,29 +601,14 @@ class UserController extends AbstractController
|
|||
$countQb = clone $qb;
|
||||
$total = (int)$countQb->select('COUNT(uo.id)')->getQuery()->getSingleScalarResult();
|
||||
|
||||
// Pagination
|
||||
$qb->orderBy('uo.isActive', 'DESC')
|
||||
->addOrderBy('CASE WHEN uo.statut = :invited THEN 0 ELSE 1 END', 'ASC')
|
||||
->setParameter('invited', 'INVITED');
|
||||
|
||||
$offset = ($page - 1) * $size;
|
||||
$rows = $qb->setFirstResult($offset)->setMaxResults($size)->getQuery()->getResult();
|
||||
$data = $this->userService->formatStatutForOrganizations($rows);
|
||||
|
||||
// Map to array
|
||||
$data = array_map(function (UsersOrganizations $uo) {
|
||||
$user = $uo->getUsers();
|
||||
return [
|
||||
'pictureUrl' => $user->getPictureUrl(),
|
||||
'name' => $user->getSurname(),
|
||||
'prenom' => $user->getName(),
|
||||
'email' => $user->getEmail(),
|
||||
'isConnected' => $this->userService->isUserConnected($user->getUserIdentifier()),
|
||||
'statut' => $uo->isActive(),
|
||||
'showUrl' => $this->generateUrl('user_show', [
|
||||
'id' => $user->getId(),
|
||||
'organizationId' => $uo->getOrganization()->getId(),
|
||||
]),
|
||||
'id' => $user->getId(),
|
||||
];
|
||||
}, $rows);
|
||||
|
||||
// Return Tabulator-compatible response
|
||||
$lastPage = (int)ceil($total / $size);
|
||||
|
||||
return $this->json([
|
||||
|
|
@ -598,4 +621,73 @@ class UserController extends AbstractController
|
|||
throw $this->createAccessDeniedException(self::ACCESS_DENIED);
|
||||
|
||||
}
|
||||
|
||||
#[Route(path: '/organization/resend-invitation/{userId}', name: 'resend_invitation', methods: ['POST'])]
|
||||
public function resendInvitation(int $userId, Request $request): JsonResponse
|
||||
{
|
||||
$this->denyAccessUnlessGranted("ROLE_ADMIN");
|
||||
$actingUser = $this->userService->getUserByIdentifier($this->getUser()->getUserIdentifier());
|
||||
if ($this->userService->hasAccessTo($actingUser, true)) {
|
||||
$orgId = $request->get('organizationId');
|
||||
$org = $this->organizationRepository->find($orgId);
|
||||
if (!$org) {
|
||||
throw $this->createNotFoundException(self::NOT_FOUND);
|
||||
}
|
||||
$user = $this->userRepository->find($userId);
|
||||
if (!$user) {
|
||||
throw $this->createNotFoundException(self::NOT_FOUND);
|
||||
}
|
||||
$uo = $this->uoRepository->findOneBy(['users' => $user,
|
||||
'organization' => $org,
|
||||
'statut' => "INVITED"]);
|
||||
if (!$uo) {
|
||||
throw $this->createNotFoundException(self::NOT_FOUND);
|
||||
}
|
||||
$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());
|
||||
return $this->json(['message' => 'Erreur lors de l\'envoie du mail.'], Response::HTTP_INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
}
|
||||
throw $this->createAccessDeniedException(self::ACCESS_DENIED);
|
||||
}
|
||||
|
||||
#[Route(path: '/accept-invitation', name: 'accept', methods: ['GET'])]
|
||||
public function acceptInvitation(Request $request): Response
|
||||
{
|
||||
$token = $request->get('token');
|
||||
$userId = $request->get('id');
|
||||
|
||||
if (!$token || !$userId) {
|
||||
throw $this->createNotFoundException('Invalid invitation link.');
|
||||
}
|
||||
$user = $this->userRepository->find($userId);
|
||||
if (!$user) {
|
||||
throw $this->createNotFoundException(self::NOT_FOUND);
|
||||
}
|
||||
if (!$this->userService->isPasswordTokenValid($user, $token)) {
|
||||
throw $this->createNotFoundException('Invalid or expired invitation token.');
|
||||
}
|
||||
$orgId = $this->userService->getOrgFromToken($token);
|
||||
$uo = $this->uoRepository->findOneBy(['users' => $user, 'organization' => $orgId]);
|
||||
if (!$uo || $uo->getStatut() !== 'INVITED') {
|
||||
$this->logger->warning("User " . $user->getUserIdentifier() . " tried to accept an invitation but no pending invitation was found for organization ID " . $orgId);
|
||||
throw $this->createNotFoundException('No pending invitation found for this user and organization.');
|
||||
}
|
||||
$uo->setModifiedAt(new \DateTimeImmutable());
|
||||
$uo->setStatut("ACCEPTED");
|
||||
$uo->setIsActive(true);
|
||||
$this->entityManager->persist($uo);
|
||||
$this->entityManager->flush();
|
||||
$this->logger->info("User " . $user->getUserIdentifier() . " accepted invitation for organization ID " . $orgId);
|
||||
|
||||
return $this->render('user/show.html.twig', ['user' => $user, 'orgId' => $orgId]);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -52,6 +52,9 @@ class Apps
|
|||
#[ORM\ManyToMany(targetEntity: Organizations::class, inversedBy: 'apps')]
|
||||
private Collection $organization;
|
||||
|
||||
#[ORM\Column(length: 255, nullable: true)]
|
||||
private ?string $logoMiniUrl = null;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->userOrganizatonApps = new ArrayCollection();
|
||||
|
|
@ -199,4 +202,16 @@ class Apps
|
|||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getLogoMiniUrl(): ?string
|
||||
{
|
||||
return $this->logoMiniUrl;
|
||||
}
|
||||
|
||||
public function setLogoMiniUrl(?string $logoMiniUrl): static
|
||||
{
|
||||
$this->logoMiniUrl = $logoMiniUrl;
|
||||
|
||||
return $this;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,177 @@
|
|||
<?php
|
||||
|
||||
namespace App\Entity;
|
||||
|
||||
use App\Repository\NotificationRepository;
|
||||
use Doctrine\DBAL\Types\Types;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
|
||||
#[ORM\Entity(repositoryClass: NotificationRepository::class)]
|
||||
#[ORM\Table(name: 'notifications')]
|
||||
#[ORM\Index(columns: ['user_id', 'is_read', 'created_at'], name: 'idx_user_read_created')]
|
||||
class Notification
|
||||
{
|
||||
#[ORM\Id]
|
||||
#[ORM\GeneratedValue]
|
||||
#[ORM\Column]
|
||||
private ?int $id = null;
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: User::class)]
|
||||
#[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')]
|
||||
private ?User $user = null;
|
||||
|
||||
#[ORM\Column(length: 50)]
|
||||
private ?string $type = null;
|
||||
|
||||
#[ORM\Column(length: 255)]
|
||||
private ?string $title = null;
|
||||
|
||||
#[ORM\Column(type: Types::TEXT)]
|
||||
private ?string $message = null;
|
||||
|
||||
#[ORM\Column(type: Types::JSON, nullable: true)]
|
||||
private ?array $data = null;
|
||||
|
||||
#[ORM\Column(type: Types::BOOLEAN, options: ['default' => 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,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -11,7 +11,6 @@ use Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface;
|
|||
use Symfony\Component\Security\Core\User\UserInterface;
|
||||
use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity;
|
||||
|
||||
#[UniqueEntity(fields: ['email'], message: 'This email address is already in use.')]
|
||||
#[ORM\Entity(repositoryClass: UserRepository::class)]
|
||||
#[ORM\Table(name: '`user`')]
|
||||
#[ORM\UniqueConstraint(name: 'UNIQ_IDENTIFIER_EMAIL', fields: ['email'])]
|
||||
|
|
@ -34,7 +33,7 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
|
|||
/**
|
||||
* @var string The hashed password
|
||||
*/
|
||||
#[ORM\Column]
|
||||
#[ORM\Column(nullable: true)]
|
||||
private ?string $password = null;
|
||||
|
||||
#[ORM\Column(length: 255)]
|
||||
|
|
@ -64,6 +63,12 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
|
|||
#[ORM\Column(length: 20, nullable: true)]
|
||||
private ?string $phoneNumber = null;
|
||||
|
||||
#[ORM\Column(length: 255, nullable: true)]
|
||||
private ?string $passwordToken = null;
|
||||
|
||||
#[ORM\Column(nullable: true)]
|
||||
private ?\DateTimeImmutable $tokenExpiry = null;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->createdAt = new \DateTimeImmutable();
|
||||
|
|
@ -263,4 +268,28 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
|
|||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getPasswordToken(): ?string
|
||||
{
|
||||
return $this->passwordToken;
|
||||
}
|
||||
|
||||
public function setPasswordToken(?string $passwordToken): static
|
||||
{
|
||||
$this->passwordToken = $passwordToken;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getTokenExpiry(): ?\DateTimeImmutable
|
||||
{
|
||||
return $this->tokenExpiry;
|
||||
}
|
||||
|
||||
public function setTokenExpiry(?\DateTimeImmutable $tokenExpiry): static
|
||||
{
|
||||
$this->tokenExpiry = $tokenExpiry;
|
||||
|
||||
return $this;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -35,6 +35,12 @@ class UsersOrganizations
|
|||
#[ORM\OneToMany(targetEntity: UserOrganizatonApp::class, mappedBy: 'userOrganization')]
|
||||
private Collection $userOrganizatonApps;
|
||||
|
||||
#[ORM\Column(length: 255, nullable: true)]
|
||||
private ?string $statut = null;
|
||||
|
||||
#[ORM\Column(nullable: true)]
|
||||
private ?\DateTimeImmutable $modifiedAt = null;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->isActive = true; // Default value for isActive
|
||||
|
|
@ -117,4 +123,28 @@ class UsersOrganizations
|
|||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getStatut(): ?string
|
||||
{
|
||||
return $this->statut;
|
||||
}
|
||||
|
||||
public function setStatut(?string $statut): static
|
||||
{
|
||||
$this->statut = $statut;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getModifiedAt(): ?\DateTimeImmutable
|
||||
{
|
||||
return $this->modifiedAt;
|
||||
}
|
||||
|
||||
public function setModifiedAt(?\DateTimeImmutable $modifiedAt): static
|
||||
{
|
||||
$this->modifiedAt = $modifiedAt;
|
||||
|
||||
return $this;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,46 @@
|
|||
<?php
|
||||
|
||||
namespace App\Message;
|
||||
|
||||
class NotificationMessage
|
||||
{
|
||||
public function __construct(
|
||||
private readonly int $userId,
|
||||
private readonly string $type,
|
||||
private readonly string $title,
|
||||
private readonly string $message,
|
||||
private readonly ?array $data = null,
|
||||
private readonly ?int $organizationId = null
|
||||
) {
|
||||
}
|
||||
|
||||
public function getUserId(): int
|
||||
{
|
||||
return $this->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;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,61 @@
|
|||
<?php
|
||||
|
||||
namespace App\MessageHandler;
|
||||
|
||||
use App\Entity\Notification;
|
||||
use App\Entity\Organizations;
|
||||
use App\Entity\User;
|
||||
use App\Message\NotificationMessage;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Component\Mercure\HubInterface;
|
||||
use Symfony\Component\Mercure\Update;
|
||||
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
|
||||
|
||||
#[AsMessageHandler]
|
||||
class NotificationMessageHandler
|
||||
{
|
||||
public function __construct(
|
||||
private readonly EntityManagerInterface $entityManager,
|
||||
private readonly HubInterface $hub
|
||||
) {
|
||||
}
|
||||
|
||||
public function __invoke(NotificationMessage $message): void
|
||||
{
|
||||
$user = $this->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);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,77 @@
|
|||
<?php
|
||||
|
||||
namespace App\Repository;
|
||||
|
||||
use App\Entity\Notification;
|
||||
use App\Entity\User;
|
||||
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||
use Doctrine\Persistence\ManagerRegistry;
|
||||
|
||||
class NotificationRepository extends ServiceEntityRepository
|
||||
{
|
||||
public function __construct(ManagerRegistry $registry)
|
||||
{
|
||||
parent::__construct($registry, Notification::class);
|
||||
}
|
||||
|
||||
public function findUnreadByUser(User $user, int $limit = 50): array
|
||||
{
|
||||
return $this->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();
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,82 @@
|
|||
<?php
|
||||
|
||||
namespace App\Service;
|
||||
|
||||
use App\Entity\Organizations;
|
||||
use App\Entity\User;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Symfony\Bridge\Twig\Mime\TemplatedEmail;
|
||||
use Symfony\Component\Mailer\Exception\TransportExceptionInterface;
|
||||
use Symfony\Component\Mailer\MailerInterface;
|
||||
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
|
||||
|
||||
class EmailService
|
||||
{
|
||||
public function __construct(
|
||||
private readonly MailerInterface $mailer,
|
||||
private readonly UserService $userService,
|
||||
private readonly LoggerInterface $logger,
|
||||
private UrlGeneratorInterface $urlGenerator
|
||||
) {}
|
||||
|
||||
public function sendPasswordSetupEmail(User $user, int $orgId): void
|
||||
{
|
||||
$token = $this->userService->generatePasswordToken($user, $orgId);
|
||||
|
||||
// Generate absolute URL for the password setup route
|
||||
$link = $this->urlGenerator->generate(
|
||||
'password_setup',
|
||||
[
|
||||
'id' => $user->getId(),
|
||||
'token' => $token
|
||||
],
|
||||
UrlGeneratorInterface::ABSOLUTE_URL
|
||||
);
|
||||
$this->logger->info("link generated: " . $link);
|
||||
$email = (new TemplatedEmail())
|
||||
->from('no-reply@sudalys.fr')
|
||||
->to($user->getEmail())
|
||||
->subject('Définissez votre mot de passe')
|
||||
->htmlTemplate('emails/password_setup.html.twig')
|
||||
->context([
|
||||
'user' => $user,
|
||||
'token' => $token,
|
||||
'linkUrl' => $link, // pass the absolute link to Twig
|
||||
'expirationHours'=> 1, //1 hour
|
||||
]);
|
||||
|
||||
try {
|
||||
$this->mailer->send($email);
|
||||
} catch (\Symfony\Component\Mailer\Exception\TransportExceptionInterface $e) {
|
||||
$this->logger->error('Failed to send password setup email: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
public function sendExistingUserNotificationEmail(User $existingUser, Organizations $org): void
|
||||
{
|
||||
$token = $this->userService->generatePasswordToken($existingUser, $org->getId());
|
||||
$link = $this->urlGenerator->generate('user_accept',[
|
||||
'id' => $existingUser->getId(),
|
||||
'token' => $token
|
||||
], UrlGeneratorInterface::ABSOLUTE_URL);
|
||||
$this->logger->info("link generated: " . $link);
|
||||
$email = (new TemplatedEmail())
|
||||
->from('no-reply@sudalys.fr')
|
||||
->to($existingUser->getEmail())
|
||||
->subject("Invitation à rejoindre l'organisation " . $org->getName())
|
||||
->htmlTemplate('emails/existing_user_notification.html.twig')
|
||||
->context([
|
||||
'user' => $existingUser,
|
||||
'organization' => $org,
|
||||
'linkUrl' => $link,
|
||||
'expirationDays'=> 15, //15 days
|
||||
]);
|
||||
|
||||
try{
|
||||
$this->mailer->send($email);
|
||||
} catch (TransportExceptionInterface $e) {
|
||||
$this->logger->error('Failed to send existing user notification email: ' . $e->getMessage());
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,192 @@
|
|||
<?php
|
||||
|
||||
namespace App\Service;
|
||||
|
||||
use App\Entity\Organizations;
|
||||
use App\Entity\User;
|
||||
use App\Message\NotificationMessage;
|
||||
use Symfony\Component\Messenger\MessageBusInterface;
|
||||
|
||||
class NotificationService
|
||||
{
|
||||
public const TYPE_USER_JOINED = 'user_joined';
|
||||
public const TYPE_USER_INVITED = 'user_invited';
|
||||
public const TYPE_USER_ACCEPTED = 'user_accepted';
|
||||
public const TYPE_USER_REMOVED = 'user_removed';
|
||||
public const TYPE_USER_DEACTIVATED = 'user_deactivated';
|
||||
public const TYPE_ORG_UPDATE = 'org_update';
|
||||
public const TYPE_APP_ACCESS = 'app_access';
|
||||
public const TYPE_ROLE_CHANGED = 'role_changed';
|
||||
|
||||
public function __construct(
|
||||
private readonly MessageBusInterface $messageBus
|
||||
) {
|
||||
}
|
||||
|
||||
|
||||
public function notifyUserInvited(User $recipient, User $invitedUser, Organizations $organization): void
|
||||
{
|
||||
$this->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_DEACTIVATED,
|
||||
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: self::TYPE_USER_REMOVED,
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ use App\Entity\UserOrganizatonApp;
|
|||
use App\Entity\UsersOrganizations;
|
||||
use App\Service\AwsService;
|
||||
use DateTimeImmutable;
|
||||
use DateTimeZone;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Doctrine\ORM\EntityNotFoundException;
|
||||
use Exception;
|
||||
|
|
@ -242,7 +243,7 @@ class UserService
|
|||
// Use a fixed key (e.g., 0 or 'none') to avoid collisions with real org IDs
|
||||
return ['none' => $group];
|
||||
}
|
||||
|
||||
//TODO: reset function
|
||||
public function handleProfilePicture(User $user, $picture): void
|
||||
{
|
||||
// Get file extension
|
||||
|
|
@ -250,6 +251,7 @@ class UserService
|
|||
|
||||
// Create custom filename: userNameUserSurname_ddmmyyhhmmss
|
||||
$customFilename = $user->getName() . $user->getSurname() . '_' . date('dmyHis') . '.' . $extension;
|
||||
// $customFilename = $user->getName() . $user->getSurname() . "." .$extension;
|
||||
try{
|
||||
$this->awsService->PutDocObj($_ENV['S3_PORTAL_BUCKET'], $picture, $customFilename , $extension, 'profile/');
|
||||
|
||||
|
|
@ -379,4 +381,94 @@ class UserService
|
|||
$token->revoke();
|
||||
}
|
||||
}
|
||||
|
||||
public function formatStatutForOrganizations(array $rows): array
|
||||
{
|
||||
$formatted = array_map(function (UsersOrganizations $uo) {
|
||||
$user = $uo->getUsers();
|
||||
$picture = $this->awsService->getPublicUrl($_ENV['S3_PORTAL_BUCKET']) . $user->getPictureUrl();
|
||||
if ($uo->getStatut() === "INVITED") {
|
||||
$statut = "INVITED";
|
||||
// if user invited but not accepted in 1 hour, set statut to EXPIRED
|
||||
$now = new DateTimeImmutable();
|
||||
$invitationTime = $uo->getModifiedAt();
|
||||
$expiryTime = $invitationTime->modify('+1 hour');
|
||||
if ($now > $expiryTime) {
|
||||
$statut = "EXPIRED";
|
||||
}
|
||||
} else {
|
||||
$statut = $uo->isActive() ? "ACTIVE" : "INACTIVE";
|
||||
}
|
||||
return [
|
||||
'pictureUrl' => $picture,
|
||||
'name' => $user->getSurname(),
|
||||
'prenom' => $user->getName(),
|
||||
'email' => $user->getEmail(),
|
||||
'isConnected' => $this->isUserConnected($user->getUserIdentifier()),
|
||||
'statut' => $statut,
|
||||
'showUrl' => '/user/view/' . $user->getId() . '?organizationId=' . $uo->getOrganization()->getId(),
|
||||
'id' => $user->getId(),
|
||||
];
|
||||
}, $rows);
|
||||
return $formatted;
|
||||
}
|
||||
|
||||
public function generatePasswordToken(User $user, int $orgId): 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;
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
<div class="card no-header-bg">
|
||||
|
||||
<div class="card-header d-flex gap-2 mt-2">
|
||||
<img class="rounded-circle " style="width:50px; height:50px;" src="{{ asset(application.logoUrl) }}"
|
||||
<img class="rounded-circle " style="width:50px; height:50px;" src="{{ aws_url ~ application.logoMiniUrl }}"
|
||||
alt="Logo application">
|
||||
<div class="card-title">
|
||||
<h1>{{ application.name }}</h1>
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
<div class="card ">
|
||||
<div class="card-header d-flex gap-2">
|
||||
<img class="rounded-circle " style="width:50px; height:50px;" src="{{ asset(application.entity.logoUrl) }}"
|
||||
<img class="rounded-circle " style="width:50px; height:50px;" src="{{ aws_url ~ application.entity.logoMiniUrl }}"
|
||||
alt="Logo application">
|
||||
<div class="card-title">
|
||||
<h1>{{ application.entity.name }}</h1>
|
||||
|
|
|
|||
|
|
@ -40,24 +40,36 @@
|
|||
<li class="nav-item d-flex">
|
||||
<img id="logo_orga" class="m-auto" src="{{asset('logo_org/logo-sudalys.png')}}" alt="logo organisation">
|
||||
</li>
|
||||
<li class="nav-item dropdown nav-notif">
|
||||
<a id="notificationDropdown" class="nav-link count-indicator dropdown-toggle m-auto" href="#" data-bs-toggle="dropdown" aria-expanded="false">
|
||||
<li class="nav-item dropdown nav-notif"
|
||||
data-controller="notification"
|
||||
data-notification-user-id-value="{{ app.user.id }}"
|
||||
data-notification-mercure-url-value="{{ app.request.server.get('MERCURE_PUBLIC_URL') ?: 'http://mercure.solutions-easy.moi/.well-known/mercure' }}">
|
||||
<a id="notificationDropdown"
|
||||
class="nav-link count-indicator dropdown-toggle m-auto"
|
||||
href="#"
|
||||
data-bs-toggle="dropdown"
|
||||
aria-expanded="false"
|
||||
data-action="click->notification#markDropdownAsRead">
|
||||
<i class="mx-0">{{ ux_icon('bi:bell', {height: '20px', width: '20px'}) }}</i>
|
||||
<span class="count"></span>
|
||||
<span class="count-notification" data-notification-target="badge" style="display: none;"></span>
|
||||
</a>
|
||||
<div class="dropdown-menu dropdown-menu-right navbar-dropdown preview-list" aria-labelledby="notificationDropdown">
|
||||
<p class="mb-0 font-weight-normal float-left dropdown-header">Notifications</p>
|
||||
<a class="dropdown-item preview-item" href="#" data-bs-toggle="dropdown">
|
||||
<div class="preview-thumbnail">
|
||||
<div class="preview-icon bg-primary">
|
||||
<i class="mx-0 mb-1">{{ ux_icon('bi:info-circle', {height: '20px', width: '20px'}) }}</i>
|
||||
</div>
|
||||
<div class="dropdown-menu dropdown-menu-right navbar-dropdown preview-list"
|
||||
aria-labelledby="notificationDropdown"
|
||||
style="max-height: 400px; overflow-y: auto; min-width: 350px;">
|
||||
<div class="d-flex justify-content-between align-items-center px-3 py-2 border-bottom">
|
||||
<p class="mb-0 font-weight-normal dropdown-header">Notifications</p>
|
||||
<button class="btn btn-sm btn-link text-primary p-0"
|
||||
data-action="click->notification#markAllAsRead"
|
||||
style="font-size: 0.875rem;">
|
||||
Tout marquer comme lu
|
||||
</button>
|
||||
</div>
|
||||
<div data-notification-target="list">
|
||||
<div class="text-center py-4 text-muted">
|
||||
<i class="mx-0 mb-2">{{ ux_icon('bi:bell-slash', {height: '30px', width: '30px'}) }}</i>
|
||||
<p class="mb-0">Aucune notification</p>
|
||||
</div>
|
||||
<div class="preview-item-content">
|
||||
<h6 class="preview-subject font-weight-normal">Information</h6>
|
||||
<p class="font-weight-light small-text mb-0 text-muted">A l'instant</p>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
<li class="nav-item dropdown nav-profile">
|
||||
|
|
|
|||
|
|
@ -0,0 +1,89 @@
|
|||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<style>
|
||||
:root{
|
||||
--primary-blue-light : #086572;
|
||||
--primary-blue-dark : #094754;
|
||||
}
|
||||
body {
|
||||
font-family: lato, sans-serif;
|
||||
line-height: 1.6;
|
||||
color: #333;
|
||||
background-color: #f4f4f4;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
width: 100%;
|
||||
}
|
||||
.container {
|
||||
max-width: 600px;
|
||||
margin: 2rem auto;
|
||||
background: #EEF0FD;
|
||||
border-radius: 8px;
|
||||
text-align: center;
|
||||
padding: 2rem 1.5rem;
|
||||
box-shadow: 0 2px 6px rgba(0,0,0,0.08);
|
||||
}
|
||||
.logo {
|
||||
display: block;
|
||||
margin: 0 auto 1.5rem auto;
|
||||
max-height: 80px;
|
||||
}
|
||||
h1 {
|
||||
color: var(--primary-blue-dark);
|
||||
}
|
||||
p {
|
||||
margin-bottom: 1.25rem;
|
||||
font-size: 15px;
|
||||
}
|
||||
a {
|
||||
text-decoration: none;
|
||||
color: #fff;
|
||||
}
|
||||
.btn-primary {
|
||||
display: inline-block;
|
||||
background: var(--primary-blue-light);
|
||||
color: #fff !important;
|
||||
border-radius: 6px;
|
||||
padding: 12px 28px;
|
||||
font-weight: bold;
|
||||
text-decoration: none;
|
||||
margin: 1rem 0;
|
||||
}
|
||||
.btn-primary:hover {
|
||||
background: var(--primary-blue-dark);
|
||||
}
|
||||
.footer {
|
||||
text-align: center;
|
||||
font-size: 13px;
|
||||
color: #999;
|
||||
margin-top: 1.5rem;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div class="container">
|
||||
<img src="{{ email.image('@images/logo-solutions.png') }}" alt="Logo" class="logo">
|
||||
|
||||
<h1 class="color-primary">Bonjour, {{ user.name ?? user.surname ?? user.email }} !</h1>
|
||||
|
||||
<p>L'organisme {{ organization.name }} vous a invité à la rejoindre :</p>
|
||||
|
||||
<p>
|
||||
<a href="{{ linkUrl }}" class="btn-primary">Rejoindre</a>
|
||||
</p>
|
||||
<p>
|
||||
Ce lien expirera dans {{ expirationDays }} jour(s).
|
||||
</p>
|
||||
|
||||
<p>Si vous ou votre administrateur n’êtes pas à l’origine de cette action, ignorez cet email.</p>
|
||||
|
||||
<div class="footer">
|
||||
© {{ "now"|date("Y") }} Sudalys. Tous droits réservés.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -0,0 +1,89 @@
|
|||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<style>
|
||||
:root{
|
||||
--primary-blue-light : #086572;
|
||||
--primary-blue-dark : #094754;
|
||||
}
|
||||
body {
|
||||
font-family: lato, sans-serif;
|
||||
line-height: 1.6;
|
||||
color: #333;
|
||||
background-color: #f4f4f4;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
width: 100%;
|
||||
}
|
||||
.container {
|
||||
max-width: 600px;
|
||||
margin: 2rem auto;
|
||||
background: #EEF0FD;
|
||||
border-radius: 8px;
|
||||
text-align: center;
|
||||
padding: 2rem 1.5rem;
|
||||
box-shadow: 0 2px 6px rgba(0,0,0,0.08);
|
||||
}
|
||||
.logo {
|
||||
display: block;
|
||||
margin: 0 auto 1.5rem auto;
|
||||
max-height: 80px;
|
||||
}
|
||||
h1 {
|
||||
color: var(--primary-blue-dark);
|
||||
}
|
||||
p {
|
||||
margin-bottom: 1.25rem;
|
||||
font-size: 15px;
|
||||
}
|
||||
a {
|
||||
text-decoration: none;
|
||||
color: #fff;
|
||||
}
|
||||
.btn-primary {
|
||||
display: inline-block;
|
||||
background: var(--primary-blue-light);
|
||||
color: #fff !important;
|
||||
border-radius: 6px;
|
||||
padding: 12px 28px;
|
||||
font-weight: bold;
|
||||
text-decoration: none;
|
||||
margin: 1rem 0;
|
||||
}
|
||||
.btn-primary:hover {
|
||||
background: var(--primary-blue-dark);
|
||||
}
|
||||
.footer {
|
||||
text-align: center;
|
||||
font-size: 13px;
|
||||
color: #999;
|
||||
margin-top: 1.5rem;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div class="container">
|
||||
<img src="{{ email.image('@images/logo-solutions.png') }}" alt="Logo" class="logo">
|
||||
|
||||
<h1 class="color-primary">Bienvenue, {{ user.name ?? user.surname ?? user.email }} !</h1>
|
||||
|
||||
<p>Veuillez définir votre mot de passe en cliquant sur le bouton ci-dessous :</p>
|
||||
|
||||
<p>
|
||||
<a href="{{ linkUrl }}" class="btn-primary">Définir mon mot de passe</a>
|
||||
</p>
|
||||
<p>
|
||||
Ce lien expirera dans {{ expirationHours }} heure(s).
|
||||
</p>
|
||||
|
||||
<p>Si vous ou votre administrateur n’êtes pas à l’origine de cette action, ignorez cet email.</p>
|
||||
|
||||
<div class="footer">
|
||||
© {{ "now"|date("Y") }} Sudalys. Tous droits réservés.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -45,10 +45,11 @@
|
|||
<div class="col-9">
|
||||
<div class="row mb-3 d-flex gap-2 ">
|
||||
<div class="col mb-3 card">
|
||||
<div class="card-header">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<h2>
|
||||
Nouveaux utilisateurs
|
||||
</h2>
|
||||
<a href="{{ path('user_new', {'organizationId': organization.id}) }}" class="btn btn-primary">Ajouter un utilisateur</a>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div id="tabulator-userListSmall" data-controller="user"
|
||||
|
|
|
|||
|
|
@ -13,8 +13,8 @@
|
|||
<input type="email" value="{{ last_username }}" name="_username" id="username" class="form-control mb-2" autocomplete="email" required autofocus>
|
||||
<label class="form-label" for="password">Password</label>
|
||||
<input type="password" name="_password" id="password" class="form-control" autocomplete="current-password" required {{ stimulus_controller('symfony/ux-toggle-password/toggle-password', {
|
||||
visibleLabel: 'Show',
|
||||
hiddenLabel: 'Hide',
|
||||
visibleLabel: 'Voir',
|
||||
hiddenLabel: 'Cacher',
|
||||
buttonClasses: ['toggle-password'],}) }}>
|
||||
|
||||
<input type="hidden" name="_csrf_token" value="{{ csrf_token('authenticate') }}">
|
||||
|
|
@ -29,7 +29,7 @@
|
|||
</div>
|
||||
#}
|
||||
<div class="mt-3 mx-auto ">
|
||||
<button class="btn btn-primary" type="submit">Sign in</button>
|
||||
<button class="btn btn-primary" type="submit">Connexion</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,40 @@
|
|||
{% extends 'publicBase.html.twig' %}
|
||||
|
||||
{% block title %}{{application}} - Mot de Passe{% endblock %}
|
||||
{% block body %}
|
||||
<div class="col col-sm-10 col-md-7 col-lg-4 col-xl-3 col-xxl-3 shadow rounded px-4 py-3">
|
||||
<form method="post" class='d-flex flex-column' action="{{ path('password_reset', {id: id}) }}">
|
||||
<img src="{{ aws_url }}application/LogoSolution.png" class="img-fluid rounded-top m-auto" alt="Logo-application" />
|
||||
{% if error %}
|
||||
<div class="alert alert-danger">{{ error }}</div>
|
||||
{% endif %}
|
||||
<label class="form-label" for="password">Password</label>
|
||||
<input type="password" name="_password" id="password" class="form-control" autocomplete="current-password" required {{ stimulus_controller('symfony/ux-toggle-password/toggle-password', {
|
||||
visibleLabel: 'Show',
|
||||
hiddenLabel: 'Hide',
|
||||
buttonClasses: ['toggle-password'],}) }}>
|
||||
|
||||
<label class="form-label" for="passwordConfirm">Confirm Password</label>
|
||||
<input type="password" name="_passwordConfirm" id="passwordConfirm" class="form-control" autocomplete="current-password" required {{ stimulus_controller('symfony/ux-toggle-password/toggle-password', {
|
||||
visibleLabel: 'Show',
|
||||
hiddenLabel: 'Hide',
|
||||
buttonClasses: ['toggle-password'],}) }}>
|
||||
<input hidden name="token" value="{{ token }}">
|
||||
|
||||
<input type="hidden" name="_csrf_token" value="{{ csrf_token('authenticate') }}">
|
||||
|
||||
{#
|
||||
Uncomment this section and add a remember_me option below your firewall to activate remember me functionality.
|
||||
See https://symfony.com/doc/current/security/remember_me.html
|
||||
|
||||
<div class="checkbox mb-3">
|
||||
<input type="checkbox" name="_remember_me" id="_remember_me">
|
||||
<label for="_remember_me">Remember me</label>
|
||||
</div>
|
||||
#}
|
||||
<div class="mt-3 mx-auto ">
|
||||
<button class="btn btn-primary" type="submit">Confirmer</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
|
@ -3,35 +3,40 @@
|
|||
{% block title %}User Profile{% endblock %}
|
||||
|
||||
{% block body %}
|
||||
<div class="w-100 h-100 p-5 m-auto">
|
||||
{# TODO: check for border-0#}
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<h1>Gestion Utilisateurs</h1>
|
||||
{% if is_granted('ROLE_SUPER_ADMIN') %}
|
||||
<a href="{{ path('user_new') }}" class="btn btn-primary">Ajouter un utilisateur</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
{#TODO: Remove/Adapt userList depending on design review #}
|
||||
{% if is_granted('ROLE_SUPER_ADMIN') or is_granted('ROLE_ADMIN') %}
|
||||
{% if usersByOrganization|length == 0 %}
|
||||
<div class="alert alert-info">
|
||||
<h4>Aucun utilisateur trouvé</h4>
|
||||
{% if is_granted('ROLE_SUPER_ADMIN') %}
|
||||
<div class="w-100 h-100 p-5 m-auto">
|
||||
<div class="card p-3 m-3 border-0">
|
||||
<div class="card-header border-0">
|
||||
<div class="d-flex justify-content-between align-items-center mb-3 ">
|
||||
<h1>Gestion Utilisateurs</h1>
|
||||
<a href="{{ path('user_new') }}" class="btn btn-primary">Ajouter un utilisateur</a>
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
{% for org in usersByOrganization %}
|
||||
{% include 'user/userList.html.twig' with {
|
||||
title: org.name,
|
||||
organizationId: org.id|default(null),
|
||||
logo: org.logo|default(null),
|
||||
users: org.users
|
||||
} %}
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
{% else %}
|
||||
|
||||
{% if users|length == 0 %}
|
||||
<div class="alert alert-info">
|
||||
<h4>Aucun utilisateur trouvé</h4>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="card border-0">
|
||||
<div class="card-body">
|
||||
<div id="tabulator-userList" data-controller="user"
|
||||
data-user-list-value="true">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
{% else %}
|
||||
<div class="w-100 h-100 p-5 m-auto">
|
||||
<div class="alert alert-warning">
|
||||
<h4>Accès limité</h4>
|
||||
<p>Vous n'avez pas les permissions nécessaires pour voir la liste des utilisateurs.</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
|
@ -1,43 +0,0 @@
|
|||
{% extends 'base.html.twig' %}
|
||||
|
||||
{% block title %}User Profile{% endblock %}
|
||||
|
||||
{% block body %}
|
||||
{% if is_granted('ROLE_SUPER_ADMIN') %}
|
||||
<div class="w-100 h-100 p-5 m-auto">
|
||||
<div class="card p-3 m-3 border-0">
|
||||
<div class="card-header border-0">
|
||||
<div class="d-flex justify-content-between align-items-center mb-3 ">
|
||||
<h1>Gestion Utilisateurs</h1>
|
||||
<a href="{{ path('user_new') }}" class="btn btn-primary">Ajouter un utilisateur</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if users|length == 0 %}
|
||||
<div class="alert alert-info">
|
||||
<h4>Aucun utilisateur trouvé</h4>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="card border-0">
|
||||
<div class="card-body">
|
||||
<div id="tabulator-userList" data-controller="user"
|
||||
data-user-aws-value="{{ aws_url }}"
|
||||
data-user-list-value="true">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
{% else %}
|
||||
<div class="w-100 h-100 p-5 m-auto">
|
||||
<div class="alert alert-warning">
|
||||
<h4>Accès limité</h4>
|
||||
<p>Vous n'avez pas les permissions nécessaires pour voir la liste des utilisateurs.</p>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
|
@ -1,99 +0,0 @@
|
|||
{% block body %}
|
||||
|
||||
<div class="card p-3 mb-3">
|
||||
|
||||
{% if title is defined %}
|
||||
<div class="card-header">
|
||||
<div class="card-title d-flex align-items-center ">
|
||||
{% if logo %}
|
||||
<img src="{{ aws_url ~ logo }}" alt="Organization Logo" style="height: 50px; width: 50px;" class="rounded-circle">
|
||||
|
||||
{% endif %}
|
||||
<h3 class="ms-3">{{ title|capitalize }}</h3>
|
||||
{% if organizationId %}
|
||||
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% endif %}
|
||||
<div class="card-body">
|
||||
|
||||
<table class="table align-middle ">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Profile</th>
|
||||
<th>Nom</th>
|
||||
<th>Prénom</th>
|
||||
<th>Email</th>
|
||||
<th>Statut</th>
|
||||
<th>Action</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% if users|length == 0 %}
|
||||
<tr>
|
||||
<td colspan="6" class="text-center">Aucun utilisateur trouvé dans cette organisation.</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
{% for user in users %}
|
||||
<tr>
|
||||
<td>
|
||||
{% if user.entity.pictureUrl %}
|
||||
<img src="{{ aws_url ~ user.entity.pictureUrl }}" alt="User profile pic"
|
||||
class="rounded-circle"
|
||||
style="width:40px; height:40px;">
|
||||
{% else %}
|
||||
<div class="rounded-circle bg-secondary d-flex align-items-center justify-content-center"
|
||||
style="width:40px; height:40px;">
|
||||
<span class="text-white">{{ user.entity.name|first|upper }}{{ user.entity.surname|first|upper }}</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>{{ user.entity.surname }}</td>
|
||||
<td>{{ user.entity.name }}</td>
|
||||
<td>{{ user.entity.email }}</td>
|
||||
{# Logic for status #}
|
||||
<td>
|
||||
{# check if the user is active in the organization link #}
|
||||
{% if user.isActive is defined %}
|
||||
{% if user.isActive %}
|
||||
{% if user.connected %}
|
||||
<span class="badge bg-success">Actif</span>
|
||||
{% else %}
|
||||
<span class="badge bg-secondary">Inactif</span>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<span class="badge bg-secondary">Désactivé</span>
|
||||
{% endif %}
|
||||
{# if no organization link #}
|
||||
{% else %}
|
||||
{% if user.connected %}
|
||||
<span class="badge bg-success">Actif</span>
|
||||
{% else %}
|
||||
<span class="badge bg-secondary">Inactif</span>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{% if organizationId is defined and organizationId %}
|
||||
<a href="{{ path('user_show', {'id': user.entity.id, 'organizationId': organizationId}) }}"
|
||||
class="p-3 align-middle color-primary">
|
||||
{{ ux_icon('fa6-regular:eye', {height: '30px', width: '30px'}) }}
|
||||
</a>
|
||||
{% else %}
|
||||
<a href="{{ path('user_show', {'id': user.entity.id}) }}"
|
||||
class="p-3 align-middle color-primary">
|
||||
{{ ux_icon('fa6-regular:eye', {height: '30px', width: '30px'}) }}
|
||||
</a>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
Loading…
Reference in New Issue