Compare commits

...

43 Commits

Author SHA1 Message Date
Charles b974b56a17 remove "project" select 2025-10-28 10:29:37 +01:00
Charles 5f50584f0d correction bug remove all roles 2025-10-27 16:16:22 +01:00
Charles 6aacf0cefc remove "gerer l'application" 2025-10-27 16:07:53 +01:00
Charles e6068fd538 bug correction 2025-10-27 16:04:52 +01:00
Charles a219f0f067 Added dynamic filtering 2025-10-27 15:57:14 +01:00
Charles ec3fc7f5ca refactor 2025-10-27 15:34:45 +01:00
Charles aee352924e activate/deactivate user from index table 2025-10-27 15:30:50 +01:00
Charles 83da6d0be4 refresh row on activate/deactivate user 2025-10-27 14:56:45 +01:00
Charles afac1467fa Added Active/Inactive badge 2025-10-27 14:35:38 +01:00
Charles 519556d35e Only display active new user 2025-10-27 14:35:08 +01:00
Charles e4f63c9b85 removed deactivateAllUserOrganizationLinks 2025-10-27 14:06:50 +01:00
Charles 772b920a44 added check on login 2025-10-27 14:01:50 +01:00
Charles c54df8a327 Typed controller route 2025-10-27 13:54:26 +01:00
Charles 2418e43703 Typed controller route 2025-10-27 12:56:31 +01:00
Charles 36fe5f5588 change redirect to http 204 2025-10-27 12:53:13 +01:00
Charles 003ee40992 refactor 2025-10-27 12:20:03 +01:00
Charles b430e13e3b Deny access to app if user is deleted 2025-10-27 11:25:09 +01:00
Charles 2d7adf20ec revoke user token if he is deleted 2025-10-27 11:10:32 +01:00
Charles 5a39804dd4 remove todo 2025-10-27 11:04:51 +01:00
Charles 9a51c2d86f Remove repeating text 2025-10-27 11:04:23 +01:00
Charles 3680621fcc added deactivate button in organization dashboard 2025-10-27 10:50:45 +01:00
Charles e1659accab added deleted button in tables 2025-10-27 08:37:08 +01:00
Charles 016c415c11 gestion droit d'access au application pour les compagnies 2025-10-22 11:27:44 +02:00
Charles 2b9b030d9a gestion droit d'access 2025-10-21 16:45:02 +02:00
Charles bb959a1ac1 petite refonte graphique 2025-10-21 16:38:19 +02:00
Charles 143277455a remove user role from select options on user/show/{id} 2025-10-21 15:30:52 +02:00
Charles 0fc507d4c7 remove user role from select options on user/show/{id} 2025-10-21 15:05:18 +02:00
Charles 8c7336b821 Added tabulator for all user table 2025-10-21 11:48:54 +02:00
Charles 9270849e12 Correct image circle 2025-10-14 16:40:01 +02:00
Charles abbaf016cc Refactor 2025-10-14 15:18:44 +02:00
Charles 00c58b55d1 Added tabulator to organization index 2025-10-14 14:12:12 +02:00
Charles e818a17371 Refactor 2025-10-14 09:34:09 +02:00
Charles 6e6d02e658 Added tabulator to organization index 2025-10-14 09:29:14 +02:00
Charles 0222274a17 Added S3 bucket for users 2025-10-13 15:41:41 +02:00
Charles ead3666a4f Added S3 bucket for organizations 2025-10-13 15:12:53 +02:00
Charles 25bad81f03 Added quill to Apps editor 2025-10-13 15:09:57 +02:00
Charles fd02fc26f1 refactor 2025-10-13 15:08:20 +02:00
Charles 6dc6d3bfa9 edit application 2025-10-08 11:37:18 +02:00
Charles 20509385f6 bug correction 2025-09-10 12:57:16 +02:00
Charles 3485bcc48f Ajout commande delete role 2025-09-09 16:44:03 +02:00
Charles 1a49265658 Ajout commande creation role 2025-09-09 16:39:02 +02:00
Charles a01df6345a add Admin when Super Admin is added 2025-09-09 12:04:15 +02:00
Charles 41c6e82a13 roles logic updated 2025-09-08 08:57:50 +02:00
51 changed files with 2671 additions and 591 deletions

View File

@ -21,5 +21,7 @@
</content>
<orderEntry type="inheritedJdk" />
<orderEntry type="sourceFolder" forTests="false" />
<orderEntry type="library" name="quill.snow" level="application" />
<orderEntry type="library" name="quill" level="application" />
</component>
</module>

View File

@ -10,6 +10,11 @@
<option name="highlightLevel" value="WARNING" />
<option name="transferred" value="true" />
</component>
<component name="PhpCodeSniffer">
<phpcs_settings>
<phpcs_by_interpreter asDefaultInterpreter="true" interpreter_id="e61c7a46-7290-4f1b-ace7-5462f6da9ae0" timeout="30000" />
</phpcs_settings>
</component>
<component name="PhpIncludePathManager">
<include_path>
<path value="$PROJECT_DIR$/vendor/symfony/twig-bridge" />
@ -173,9 +178,15 @@
<path value="$PROJECT_DIR$/vendor/guzzlehttp/guzzle" />
<path value="$PROJECT_DIR$/vendor/mtdowling/jmespath.php" />
<path value="$PROJECT_DIR$/vendor/psr/http-client" />
<path value="$PROJECT_DIR$/vendor/twig/extra-bundle" />
</include_path>
</component>
<component name="PhpProjectSharedConfiguration" php_language_level="8.2" />
<component name="PhpStan">
<PhpStan_settings>
<phpstan_by_interpreter asDefaultInterpreter="true" interpreter_id="e61c7a46-7290-4f1b-ace7-5462f6da9ae0" timeout="60000" />
</PhpStan_settings>
</component>
<component name="PhpStanOptionsConfiguration">
<option name="transferred" value="true" />
</component>
@ -184,6 +195,11 @@
<PhpUnitSettings configuration_file_path="$PROJECT_DIR$/phpunit.xml.dist" custom_loader_path="$PROJECT_DIR$/vendor/autoload.php" use_configuration_file="true" />
</phpunit_settings>
</component>
<component name="Psalm">
<Psalm_settings>
<psalm_fixer_by_interpreter asDefaultInterpreter="true" interpreter_id="e61c7a46-7290-4f1b-ace7-5462f6da9ae0" timeout="60000" />
</Psalm_settings>
</component>
<component name="PsalmOptionsConfiguration">
<option name="transferred" value="true" />
</component>

38
HELPER.MD Normal file
View File

@ -0,0 +1,38 @@
## Notes
- Certaines abbreviations sont utilisées afin de simplifier le code et d'éviter les répétitions ou noms trop longs :
- `uo` pour `User Organization`
- `uoId` pour `User Organization Id`
- `oa` pour `Organization Application`
- `at` pour `Access Token`
- A delete command is available to delete roles
### ROLES
```bash
php bin/console app:delete-role ROLE_NAME
```
### Tabulator
- Certaines fonctions sont déjà disponibles (snippet) mais commentées, car on ne les utilise pas
- Exemples de sorting et filtering sont disponibles dans 'src/controller/organization.php' L.268
### Frontend:
- Le Body/content de chaque page sont dans des div avec le style suivant :
``` html
<div class="w-100 h-100 p-5 m-auto">
```
- L'espace entre les éléments cartes est avec l'un des styles suivants :
``` html
<div class="mb-3"> <!-- margin bottom -->
<div class="mt-3"> <!-- margin top -->
<div class="me-3"> <!-- margin end/right -->
<div class="ms-3"> <!-- margin start/left -->
<div class="mx-3"> <!-- margin left and right -->
<div class="my-3"> <!-- margin top and bottom -->
<div class="m-3"> <!-- margin -->
<div class="d-flex gap-2"> <!-- gap entre les boutons -->
```
- Chaque élément est une carte afin de donner un style uniforme :
``` html
<div class="card p-3">
```

View File

@ -13,22 +13,14 @@
php bin/console doctrine:database:create
php bin/console doctrine:schema:update --force
```
#### SQL
#### Roles
```bash
insert into public.roles (id, name, created_at)
values (3, 'USER', '2025-05-21 13:22:52'),
(2, 'ADMIN', '2025-05-21 13:22:52'),
(1, 'SUPER ADMIN', '2025-05-21 13:22:52');
php bin/console app:create-role USER
php bin/console app:create-role ADMIN
php bin/console app:create-role "SUPER ADMIN"
```
#### Choices.js
```bash
php bin/console importmap:require choices.js
php bin/console importmap:require choices.js/public/assets/styles/choices.min.css
```
### Notes
- certaines abbreviations sont utilisées afin de simplifier le code et d'éviter les répétitions ou noms trop longs :
- `uo` pour `User Organization`
- `uoId` pour `User Organization Id`
- `oa` pour `Organization Application`
- `at` pour `Access Token`

View File

@ -10,6 +10,10 @@ import './styles/app.css';
import './styles/navbar.css';
import './styles/sidebar.css';
import './styles/choices.css'
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 'bootstrap';
import './js/template.js';
@ -17,4 +21,5 @@ import './js/off_canvas.js';
import './js/hoverable-collapse.js';
import './js/cookies.js';
import 'choices.js';
import 'choices.js/public/assets/styles/choices.min.css';
import 'quill'
import 'tabulator-tables'

View File

@ -0,0 +1,110 @@
import {Controller} from '@hotwired/stimulus'
import Quill from 'quill'
export default class extends Controller {
static values = {
application: String,
organization: String,
}
static targets = ['hidden', 'submitBtn']
connect() {
// Map each editor to its toolbar and hidden field
if (document.querySelector('#editor-description')) {
this.editors = [
{
editorSelector: '#editor-description',
toolbarSelector: '#toolbar-description',
hiddenTarget: this.hiddenTargets[0],
},
{
editorSelector: '#editor-descriptionSmall',
toolbarSelector: '#toolbar-descriptionSmall',
hiddenTarget: this.hiddenTargets[1],
},
]
this.editors.forEach(({editorSelector, toolbarSelector, hiddenTarget}) => {
const quill = new Quill(editorSelector, {
modules: {
toolbar: toolbarSelector,
},
theme: 'snow',
placeholder: 'Écrivez votre texte...',
})
quill.on('text-change', () => {
hiddenTarget.value = quill.root.innerHTML
})
hiddenTarget.value = quill.root.innerHTML
})
}
}
handleAuthorizeSubmit(event) {
event.preventDefault();
const originalText = this.submitBtnTarget.textContent;
if (!confirm(`Vous vous apprêtez à donner l'accès à ${this.organizationValue} pour ${this.applicationValue}. Êtesvous sûr(e) ?`)) {
return;
}
this.submitBtnTarget.textContent = 'En cours...';
this.submitBtnTarget.disabled = true;
fetch(event.target.action, {
method: 'POST',
body: new FormData(event.target)
})
.then(response => {
if (response.ok) {
this.submitBtnTarget.textContent = 'Autorisé ✓';
this.submitBtnTarget.classList.replace('btn-secondary', 'btn-success');
} else {
this.submitBtnTarget.textContent = originalText;
this.submitBtnTarget.disabled = false;
alert('Erreur lors de l\'action');
}
})
.catch(error => {
this.submitBtnTarget.textContent = originalText;
this.submitBtnTarget.disabled = false;
alert('Erreur lors de l\'action');
});
}
handleRemoveSubmit(event) {
event.preventDefault();
const originalText = this.submitBtnTarget.textContent;
if (!confirm(`Vous vous apprêtez à retirer l'accès à ${this.applicationValue} pour ${this.organizationValue}. Êtesvous sûr(e) ?`)) {
return;
}
this.submitBtnTarget.textContent = 'En cours...';
this.submitBtnTarget.disabled = true;
fetch(event.target.action, {
method: 'POST',
body: new FormData(event.target)
})
.then(response => {
if (response.ok) {
this.submitBtnTarget.textContent = 'Retiré ✓';
this.submitBtnTarget.classList.replace('btn-secondary', 'btn-danger');
} else {
this.submitBtnTarget.textContent = originalText;
this.submitBtnTarget.disabled = false;
alert('Erreur lors de l\'action');
}
})
.catch(error => {
this.submitBtnTarget.textContent = originalText;
this.submitBtnTarget.disabled = false;
alert('Erreur lors de l\'action');
});
}
}

View File

@ -0,0 +1,112 @@
import {Controller} from '@hotwired/stimulus'
// Important: include a build with Ajax + pagination (TabulatorFull is simplest)
import {TabulatorFull as Tabulator} from 'tabulator-tables';
export default class extends Controller {
static values = {aws: String};
connect() {
this.table();
}
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" },
},
},
locale: "fr", //'en' for English, 'fr' for French (en is default, no need to include it)
ajaxURL: "/organization/data",
ajaxConfig: "GET",
pagination: true,
paginationMode: "remote",
paginationSize: 10,
//paginationSizeSelector: [5, 10, 20, 50], // Désactivé pour l'instant car jpp faire de jolie style
ajaxResponse: (url, params, response) => response,
paginationDataSent: { page: "page", size: "size" },
paginationDataReceived: { last_page: "last_page" },
ajaxSorting: true,
ajaxFiltering: true,
rowHeight: 60,
layout: "fitColumns", // activate French
columns: [
{
title: "Logo",
field: "logoUrl",
formatter: "image",
formatterParams: {
height: "50px",
width: "50px",
urlPrefix: this.awsValue,
urlSuffix: "",
},
width: 100,
},
// TODO: regarder quel style est mieux entre les "hozAlign"
// TODO: regarder quel style est mieux avec/sans headerFilter
{title: "Nom", field: "name", headerFilter: "input", widthGrow: 2, vertAlign: "middle", headerHozAlign: "left"},
{title: "Email", field: "email", headerFilter: "input", widthGrow: 2, vertAlign: "middle", hozAlign: "center"},
{
title: "Actions",
field: "showUrl",
hozAlign: "center",
width: 100,
vertAlign: "middle",
headerSort: false,
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 '';
}
}],
});
}
}

View File

@ -1,16 +1,62 @@
import { Controller } from '@hotwired/stimulus';
import {Controller} from '@hotwired/stimulus';
import Choices from 'choices.js';
import {TabulatorFull as Tabulator} from 'tabulator-tables';
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,
admin: Boolean,
listSmall: Boolean,
statut: Boolean,
orgId: Number
}
static targets = ["select"];
connect() {
this.roleSelect();
if (this.listValue) {
this.table();
}
if (this.newValue) {
this.tableSmall();
}
if (this.adminValue) {
this.tableSmallAdmin();
}
if (this.listOrganizationValue) {
this.tableOrganization()
}
}
roleSelect() {
@ -29,4 +75,761 @@ export default class extends Controller {
});
}
}
// TODO: vérifier le style des header filter et vertAlign/hozalign
table() {
const columns = [
{
title: "",
field: "isConnected",
width: 40, // small column
hozAlign: "center",
vertAlign: "middle",
headerSort: false,
tooltip: false,
formatter: (cell) => {
const online = !!cell.getValue();
const color = online ? "#80F20E" : "#E42E31"; // green/red
return `<span class="status-dot" style="
display:inline-block;
width:10px;height:10px;
border-radius:50%;
background:${color};
"></span>`;
},
// Optional: for accessibility
formatterPrint: (cell) => (cell.getValue() ? "online" : "offline"),
formatterClipboard: (cell) => (cell.getValue() ? "online" : "offline"),
},
{
title: "Profil",
field: "pictureUrl",
width: 80,
hozAlign: "center",
headerSort: false,
formatter: (cell) => {
const data = cell.getRow().getData();
const url = cell.getValue();
const first = (s) => (typeof s === "string" && s.length ? s.trim()[0].toUpperCase() : "");
const initials = `${first(data.name)}${first(data.prenom)}`;
// wrapper is for centering and circle clipping
const wrapper = document.createElement("div");
wrapper.className = "avatar-wrapper";
// same size for both cases
wrapper.style.width = "40px";
wrapper.style.height = "40px";
wrapper.style.display = "flex";
wrapper.style.alignItems = "center";
wrapper.style.justifyContent = "center";
wrapper.style.borderRadius = "50%";
wrapper.style.overflow = "hidden"; // ensure image clips to circle
if (!url) {
wrapper.style.background = "#6c757d"; // gray background
const span = document.createElement("span");
span.className = "avatar-initials";
span.style.color = "#fff";
span.style.fontWeight = "600";
span.style.fontSize = "14px";
span.textContent = initials || "•";
wrapper.appendChild(span);
return wrapper;
}
// Image case: make it fill the same wrapper
const img = document.createElement("img");
img.src = `${this.awsValue || ""}${url}`;
img.alt = initials || "avatar";
img.style.width = "100%";
img.style.height = "100%";
img.style.objectFit = "cover"; // keep aspect and cover circle
wrapper.appendChild(img);
// Optional: fallback if image fails
img.addEventListener("error", () => {
wrapper.innerHTML = "";
wrapper.style.background = "#6c757d";
const span = document.createElement("span");
span.className = "avatar-initials";
span.style.color = "#fff";
span.style.fontWeight = "600";
span.style.fontSize = "12px";
span.textContent = initials || "•";
wrapper.appendChild(span);
});
return wrapper;
},
},
{title: "<b>Nom</b>", field: "name", headerFilter: "input", widthGrow: 2, vertAlign: "middle"},
{title: "<b>Prénom</b>", field: "prenom", headerFilter: "input", widthGrow: 2, vertAlign: "middle"},
{title: "<b>Email</b>", field: "email", headerFilter: "input", widthGrow: 3, vertAlign: "middle"},
{
title: "<b>Statut</b>", field: "statut", vertAlign: "middle",
formatter: (cell) => {
const statut = cell.getValue();
if (statut) {
return `<span class="badge bg-success">Actif</span>`
} else {
return `<span class="badge bg-secondary">Inactif</span>`
}
}
},
{
title: "<b>Actions</b>",
field: "showUrl",
vertAlign: "middle",
headerSort: false,
formatter: (cell) => {
const url = cell.getValue();
if (!url) return '';
const rowData = cell.getRow().getData();
const userId = rowData.id;
const statut = rowData.statut;
// Decide which action (deactivate vs activate)
const isActive = Boolean(statut);
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 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 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>
<a href="#"
class="${actionColorClass} ${actionClass}"
data-id="${userId}"
title="${actionTitle}">
${actionSvg}
</a>
</div>
`;
},
cellClick: function (e, cell) {
const target = e.target.closest('a');
if (!target) return;
// Deactivate
if (target.classList.contains('deactivate-user')) {
e.preventDefault();
const userId = target.getAttribute('data-id');
if (confirm('Voulez-vous vraiment désactiver cet utilisateur ?')) {
fetch(`/user/deactivate/${userId}`, {
method: 'POST',
headers: {'X-Requested-With': 'XMLHttpRequest'}
})
.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;
cell.getRow().reformat();
} else {
const text = await response.text();
alert('Erreur lors de la désactivation: ' + text);
}
})
.catch(() => alert('Erreur lors de la désactivation'));
}
}
// Activate
if (target.classList.contains('activate-user')) {
e.preventDefault();
const userId = target.getAttribute('data-id');
if (confirm('Voulez-vous réactiver cet utilisateur ?')) {
fetch(`/user/activate/${userId}`, {
method: 'POST',
headers: {'X-Requested-With': 'XMLHttpRequest'}
})
.then(async (response) => {
if (response.ok) {
// Switch status back to active and re-render row
const data = cell.getRow().getData();
data.statut = true;
cell.getRow().reformat();
} else {
const text = await response.text();
alert('Erreur lors de la réactivation: ' + text);
}
})
.catch(() => alert('Erreur lors de la réactivation'));
}
}
}
}];
const tabulator = new Tabulator("#tabulator-userList", {
langs: TABULATOR_FR_LANG,
locale: "fr",
ajaxURL: "/user/data",
ajaxConfig: "GET",
pagination: true,
paginationMode: "remote",
paginationSize: 10,
ajaxResponse: (url, params, response) => response,
paginationDataSent: {page: "page", size: "size"},
paginationDataReceived: {last_page: "last_page"},
ajaxSorting: true,
ajaxFiltering: true,
filterMode: "remote",
// Add this to send filter data
ajaxURLGenerator: function(url, config, params) {
let queryParams = new URLSearchParams();
queryParams.append('page', params.page || 1);
queryParams.append('size', params.size || 10);
// Add filters
if (params.filter) {
params.filter.forEach(filter => {
queryParams.append(`filter[${filter.field}]`, filter.value);
});
}
return `${url}?${queryParams.toString()}`;
},
rowHeight: 60,
layout: "fitColumns",
columns
});
};
onSelectChange(row, newValue) {
const data = row.getData();
console.log("Change select" + data);
};
tableSmall() {
const columns = [
{
title: "Profil",
field: "pictureUrl",
width: 80,
hozAlign: "center",
headerSort: false,
formatter: (cell) => {
const data = cell.getRow().getData();
const url = cell.getValue();
const first = (s) => (typeof s === "string" && s.length ? s.trim()[0].toUpperCase() : "");
const initials = `${data.initials}`;
const wrapper = document.createElement("div");
wrapper.className = "avatar-wrapper";
// same size for both cases
wrapper.style.width = "40px";
wrapper.style.height = "40px";
wrapper.style.display = "flex";
wrapper.style.alignItems = "center";
wrapper.style.justifyContent = "center";
wrapper.style.borderRadius = "50%";
wrapper.style.overflow = "hidden"; // ensure image clips to circle
if (!url) {
wrapper.style.background = "#6c757d"; // gray background
const span = document.createElement("span");
span.className = "avatar-initials";
span.style.color = "#fff";
span.style.fontWeight = "600";
span.style.fontSize = "14px";
span.textContent = initials || "•";
wrapper.appendChild(span);
return wrapper;
}
// Image case: make it fill the same wrapper
const img = document.createElement("img");
img.src = `${this.awsValue || ""}${url}`;
img.alt = initials || "avatar";
img.style.width = "100%";
img.style.height = "100%";
img.style.objectFit = "cover"; // keep aspect and cover circle
wrapper.appendChild(img);
// Optional: fallback if image fails
img.addEventListener("error", () => {
wrapper.innerHTML = "";
wrapper.style.background = "#6c757d";
const span = document.createElement("span");
span.className = "avatar-initials";
span.style.color = "#fff";
span.style.fontWeight = "600";
span.style.fontSize = "12px";
span.textContent = initials || "•";
wrapper.appendChild(span);
});
return wrapper;
},
},
{title: "<b>Email</b>", field: "email", widthGrow: 3, vertAlign: "middle"},
{
title: "<b>Actions</b>",
field: "showUrl",
hozAlign: "center",
width: 100,
vertAlign: "middle",
headerSort: false,
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 '';
}
}
];
const tabulator = new Tabulator("#tabulator-userListSmall", {
locale: "fr", //'en' for English, 'fr' for French (en is default, no need to include it)
ajaxURL: "/user/data/new",
ajaxConfig: "GET",
pagination: false,
paginationMode: "remote",
// paginationSize: 5,
ajaxParams: {orgId: this.orgIdValue},
langs: TABULATOR_FR_LANG,
ajaxResponse: (url, params, response) => response.data,
// paginationDataSent: {page: "page", size: "size"},
// paginationDataReceived: {last_page: "last_page"},
// ajaxSorting: true,
// ajaxFiltering: true,
rowHeight: 60,
layout: "fitColumns", // activate French
columns
});
}
tableSmallAdmin() {
const columns = [
{
title: "Profil",
field: "pictureUrl",
width: 80,
hozAlign: "center",
headerSort: false,
formatter: (cell) => {
const data = cell.getRow().getData();
const url = cell.getValue();
const first = (s) => (typeof s === "string" && s.length ? s.trim()[0].toUpperCase() : "");
const initials = `${data.initials}`;
const wrapper = document.createElement("div");
wrapper.className = "avatar-wrapper";
// same size for both cases
wrapper.style.width = "40px";
wrapper.style.height = "40px";
wrapper.style.display = "flex";
wrapper.style.alignItems = "center";
wrapper.style.justifyContent = "center";
wrapper.style.borderRadius = "50%";
wrapper.style.overflow = "hidden"; // ensure image clips to circle
if (!url) {
wrapper.style.background = "#6c757d"; // gray background
const span = document.createElement("span");
span.className = "avatar-initials";
span.style.color = "#fff";
span.style.fontWeight = "600";
span.style.fontSize = "14px";
span.textContent = initials || "•";
wrapper.appendChild(span);
return wrapper;
}
// Image case: make it fill the same wrapper
const img = document.createElement("img");
img.src = `${this.awsValue || ""}${url}`;
img.alt = initials || "avatar";
img.style.width = "100%";
img.style.height = "100%";
img.style.objectFit = "cover"; // keep aspect and cover circle
wrapper.appendChild(img);
// Optional: fallback if image fails
img.addEventListener("error", () => {
wrapper.innerHTML = "";
wrapper.style.background = "#6c757d";
const span = document.createElement("span");
span.className = "avatar-initials";
span.style.color = "#fff";
span.style.fontWeight = "600";
span.style.fontSize = "12px";
span.textContent = initials || "•";
wrapper.appendChild(span);
});
return wrapper;
},
},
{title: "<b>Email</b>", field: "email", widthGrow: 3, vertAlign: "middle"},
{
title: "<b>Actions</b>",
field: "showUrl",
hozAlign: "center",
width: 100,
vertAlign: "middle",
headerSort: false,
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 '';
}
}
];
const tabulator = new Tabulator("#tabulator-userListSmallAdmin", {
locale: "fr", //'en' for English, 'fr' for French (en is default, no need to include it)
ajaxURL: "/user/data/admin",
ajaxConfig: "GET",
pagination: false,
paginationMode: "remote",
// paginationSize: 5,
ajaxParams: {orgId: this.orgIdValue},
langs: TABULATOR_FR_LANG,
ajaxResponse: (url, params, response) => response.data,
// paginationDataSent: {page: "page", size: "size"},
// paginationDataReceived: {last_page: "last_page"},
// ajaxSorting: true,
// ajaxFiltering: true,
rowHeight: 60,
layout: "fitColumns", // activate French
columns
});
}
tableOrganization() {
const columns = [
{
title: "",
field: "isConnected",
width: 40, // small column
hozAlign: "center",
vertAlign: "middle",
headerSort: false,
tooltip: false,
formatter: (cell) => {
const online = !!cell.getValue();
const color = online ? "#80F20E" : "#E42E31"; // green/red
return `<span class="status-dot" style="
display:inline-block;
width:10px;height:10px;
border-radius:50%;
background:${color};
"></span>`;
},
// Optional: for accessibility
formatterPrint: (cell) => (cell.getValue() ? "online" : "offline"),
formatterClipboard: (cell) => (cell.getValue() ? "online" : "offline"),
},
{
title: "Profil",
field: "pictureUrl",
width: 80,
hozAlign: "center",
headerSort: false,
formatter: (cell) => {
const data = cell.getRow().getData();
const url = cell.getValue();
const first = (s) => (typeof s === "string" && s.length ? s.trim()[0].toUpperCase() : "");
const initials = `${first(data.name)}${first(data.prenom)}`;
const wrapper = document.createElement("div");
wrapper.className = "avatar-wrapper";
// same size for both cases
wrapper.style.width = "40px";
wrapper.style.height = "40px";
wrapper.style.display = "flex";
wrapper.style.alignItems = "center";
wrapper.style.justifyContent = "center";
wrapper.style.borderRadius = "50%";
wrapper.style.overflow = "hidden"; // ensure image clips to circle
if (!url) {
wrapper.style.background = "#6c757d"; // gray background
const span = document.createElement("span");
span.className = "avatar-initials";
span.style.color = "#fff";
span.style.fontWeight = "600";
span.style.fontSize = "14px";
span.textContent = initials || "•";
wrapper.appendChild(span);
return wrapper;
}
// Image case: make it fill the same wrapper
const img = document.createElement("img");
img.src = `${this.awsValue || ""}${url}`;
img.alt = initials || "avatar";
img.style.width = "100%";
img.style.height = "100%";
img.style.objectFit = "cover"; // keep aspect and cover circle
wrapper.appendChild(img);
// Optional: fallback if image fails
img.addEventListener("error", () => {
wrapper.innerHTML = "";
wrapper.style.background = "#6c757d";
const span = document.createElement("span");
span.className = "avatar-initials";
span.style.color = "#fff";
span.style.fontWeight = "600";
span.style.fontSize = "12px";
span.textContent = initials || "•";
wrapper.appendChild(span);
});
return wrapper;
},
},
{title: "<b>Nom</b>", field: "name", headerFilter: "input", widthGrow: 2, vertAlign: "middle"},
{title: "<b>Prénom</b>", field: "prenom", headerFilter: "input", widthGrow: 2, vertAlign: "middle"},
{title: "<b>Email</b>", field: "email", headerFilter: "input", widthGrow: 3, vertAlign: "middle"},
{
title: "<b>Statut</b>", field: "statut", vertAlign: "middle",
formatter: (cell) => {
const statut = cell.getValue();
if (statut) {
return `<span class="badge bg-success">Actif</span>`
} else {
return `<span class="badge bg-secondary">Inactif</span>`
}
}
},
{
title: "<b>Actions</b>",
field: "showUrl",
vertAlign: "middle",
headerSort: false,
formatter: (cell) => {
const url = cell.getValue();
if (!url) return '';
const rowData = cell.getRow().getData();
const userId = rowData.id;
const statut = rowData.statut;
const orgId = this.orgIdValue;
// Decide which action (deactivate vs activate)
const isActive = Boolean(statut);
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 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 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>
<a href="#"
class="${actionColorClass} ${actionClass}"
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;
// Deactivate
if (target.classList.contains('deactivate-user')) {
e.preventDefault();
const userId = target.getAttribute('data-id');
if (confirm('Voulez-vous vraiment désactiver cet utilisateur ?')) {
const formData = new FormData();
formData.append('organizationId', target.getAttribute('data-org-id'));
fetch(`/user/organization/deactivate/${userId}`, {
method: 'POST',
body: formData,
headers: {'X-Requested-With': 'XMLHttpRequest'}
})
.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;
cell.getRow().reformat();
} else {
const text = await response.text();
alert('Erreur lors de la désactivation: ' + text);
}
})
.catch(() => alert('Erreur lors de la désactivation'));
}
}
// Activate
if (target.classList.contains('activate-user')) {
e.preventDefault();
const userId = target.getAttribute('data-id');
if (confirm('Voulez-vous réactiver cet utilisateur ?')) {
const formData = new FormData();
formData.append('organizationId', target.getAttribute('data-org-id'));
fetch(`/user/organization/activate/${userId}`, {
method: 'POST',
body: formData,
headers: {'X-Requested-With': 'XMLHttpRequest'}
})
.then(async (response) => {
if (response.ok) {
// Switch status back to active and re-render row
const data = cell.getRow().getData();
data.statut = true;
cell.getRow().reformat();
} else {
const text = await response.text();
alert('Erreur lors de la réactivation: ' + text);
}
})
.catch(() => alert('Erreur lors de la réactivation'));
}
}
}
}];
// if (this.statutValue) {
// columns.push(
// {
// title: "Statut", field: "role", // or any field you want
// headerSort: false,
// hozAlign: "center",
// vertAlign: "middle",
// formatter: (cell) => {
// const row = cell.getRow();
// const current = cell.getValue() ?? "";
//
// const select = document.createElement("select");
// select.className = "table-select-action";
// // Options
// [
// {value: "", label: "Choisir..."},
// {value: "viewer", label: "Viewer"},
// {value: "editor", label: "Editor"},
// {value: "admin", label: "Admin"},
// ].forEach(opt => {
// const o = document.createElement("option");
// o.value = opt.value;
// o.textContent = opt.label;
// if (opt.value === current) o.selected = true;
// select.appendChild(o);
// });
//
// // Hook change
// select.addEventListener("change", (e) => {
// this.onSelectChange(row, e.target.value);
// });
//
// // Return a DOM node from a formatter → Tabulator will mount it
// return select;
// },
// // Optional: provide text for clipboard/print
// formatterClipboard: cell => cell.getValue(),
// formatterPrint: cell => cell.getValue(),
// },
// )
// }
const tabulator = new Tabulator("#tabulator-userListOrganization", {
langs: TABULATOR_FR_LANG,
locale: "fr",
ajaxURL: "/user/data/organization",
ajaxConfig: "GET",
ajaxParams: {orgId: this.orgIdValue},
pagination: true,
paginationMode: "remote",
paginationSize: 10,
ajaxResponse: (url, params, response) => response,
paginationDataSent: {page: "page", size: "size"},
paginationDataReceived: {last_page: "last_page"},
ajaxSorting: true,
ajaxFiltering: true,
rowHeight: 60,
layout: "fitColumns", // activate French
columns
});
};
}

View File

@ -132,4 +132,8 @@ body {
.btn-warning{
border-radius: 1rem;
}
.color-secondary{
color: var(--secondary);
}

4
assets/styles/card.css Normal file
View File

@ -0,0 +1,4 @@
.card.no-header-bg .card-header{
background-color: transparent !important;
border-bottom: none;
}

114
assets/styles/tabulator.css Normal file
View File

@ -0,0 +1,114 @@
/* Remove outer table border */
.tabulator {
border: none !important;
font-size: 18px !important;
}
/* Remove header and row cell borders */
.tabulator-header,
.tabulator-header .tabulator-col,
.tabulator-tableholder,
.tabulator-table,
.tabulator-row,
.tabulator-row .tabulator-cell {
border: none !important;
}
/* Remove column header bottom border and row separators */
.tabulator-header {
border-bottom: none !important;
background-color: transparent !important;
/*border-top-left-radius: 25%;*/
/*border-top-right-radius: 25%;*/
}
.tabulator-row {
border-bottom: none !important;
background-color: transparent !important;
}
/* Remove look on hover/selected without borders */
.tabulator-row:hover {
box-shadow: none !important;
}
.tabulator-row.tabulator-selected {
box-shadow: none !important;
}
.tabulator-row.tabulator-row-odd {
background-color: transparent !important;
}
/* Rounded border for images in cells */
.tabulator-cell img {
border-radius: 50%;
object-fit: cover;
}
/* Scope to this table only */
.tabulator,
.tabulator-header,
.tabulator-header .tabulator-header-contents,
.tabulator-header .tabulator-col{
background: none !important;
}
.tabulator-footer {border-top: none !important;
background-color: transparent !important;
}
.tabulator-footer .tabulator-page.active{
background-color: var(--primary-blue-light) !important;
border: 1px solid var(--primary-blue-light) !important;
color: #FFFFFF/* text color */ !important
}
.tabulator-footer .tabulator-page {
background-color: transparent !important;
border: 1px solid var(--primary-blue-light) !important;
color: var(--black-font)/* text color */ !important;
}
.tabulator-footer .tabulator-page:hover,
.tabulator-footer .tabulator-page.active:hover{
background-color: var(--primary-blue-dark) !important;
border: 1px solid var(--primary-blue-dark) !important;
color: #FFFFFF/* text color */ !important
}
.tabulator-footer select{
border: 1px solid var(--primary-blue-light) !important;
background-color: transparent !important;
color: var(--black-font)/* text color */ !important;
}
.tabulator-header input{
border: 0;
border-radius: 10px;
height: 40px;
background-color: lightgray !important;
padding-left: 15px !important;
}
.tabulator-header input::placeholder{
color: var(--black-font) !important;
font-size: 14px !important;
opacity: 1 !important; /* Firefox */
}
.tabulator-header input:focus {
border:0;
}
.tabulator .tabulator-header .tabulator-col .tabulator-col-title {
font-size: 18px !important;
}
/* Select hover Désactivé pour l'instant car jpp faire de jolie style */
/*#tabulator-org .tabulator-footer select:hover {*/
/* border: 1px solid var(--primary-blue-dark) !important;*/
/* background-color: var(--primary-blue-dark) !important;*/
/* color: #fff !important;*/
/*}*/
/*.tabulator-footer select:focus {*/
/* border: 1px solid var(--primary-blue-dark) !important;*/
/* outline: none !important;*/
/* background-color: var(--primary-blue-dark) !important;*/
/* color: #fff !important;*/
/*}*/

View File

@ -36,6 +36,7 @@ security:
stateless: true
oauth2: true
main:
user_checker: App\Security\UserChecker
lazy: true
provider: app_user_provider
form_login:

View File

@ -4,8 +4,11 @@ twig:
globals:
application: '%env(APPLICATION)%'
aws_url: '%env(AWS_S3_PORTAL_URL)%'
version: '0.4'
when@test:
twig:
strict_variables: true

View File

@ -42,4 +42,35 @@ return [
'version' => '11.1.0',
'type' => 'css',
],
'quill' => [
'version' => '2.0.3',
],
'lodash-es' => [
'version' => '4.17.21',
],
'parchment' => [
'version' => '3.0.0',
],
'quill-delta' => [
'version' => '5.1.0',
],
'eventemitter3' => [
'version' => '5.0.1',
],
'fast-diff' => [
'version' => '1.3.0',
],
'lodash.clonedeep' => [
'version' => '4.5.0',
],
'lodash.isequal' => [
'version' => '4.5.0',
],
'tabulator-tables' => [
'version' => '6.3.1',
],
'tabulator-tables/dist/css/tabulator.min.css' => [
'version' => '6.3.1',
'type' => 'css',
],
];

View File

@ -0,0 +1,36 @@
<?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 Version20251008081943 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 ALTER description TYPE TEXT');
$this->addSql('ALTER TABLE apps ALTER description DROP NOT NULL');
$this->addSql('ALTER TABLE apps ALTER description_small TYPE TEXT');
}
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 ALTER description TYPE VARCHAR(255)');
$this->addSql('ALTER TABLE apps ALTER description SET NOT NULL');
$this->addSql('ALTER TABLE apps ALTER description_small TYPE VARCHAR(255)');
}
}

View File

@ -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 Version20251013133256 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 picture_url 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 picture_url SET NOT NULL');
}
}

View File

@ -0,0 +1,64 @@
<?php
namespace App\Command;
use App\Entity\Roles; // ⚡ your Roles entity
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
#[AsCommand(
name: 'app:create-role',
description: 'Creates a new role in the database'
)]
class CreateRoleCommand extends Command
{
private EntityManagerInterface $entityManager;
public function __construct(EntityManagerInterface $entityManager)
{
parent::__construct();
$this->entityManager = $entityManager;
}
protected function configure(): void
{
$this
->addArgument('name', InputArgument::REQUIRED, 'The name of the role'); // role name required
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$roleName = trim($input->getArgument('name'));
$roleName = strtoupper($roleName); // Normalize to uppercase
// Ensure not empty
if ($roleName === '') {
$output->writeln('<error>The role name cannot be empty</error>');
return Command::FAILURE;
}
// Check if role already exists
$existing = $this->entityManager->getRepository(Roles::class)
->findOneBy(['name' => $roleName]);
if ($existing) {
$output->writeln("<comment>Role '{$roleName}' already exists.</comment>");
return Command::SUCCESS; // not failure, just redundant
}
// Create and persist new role
$role = new Roles();
$role->setName($roleName);
$this->entityManager->persist($role);
$this->entityManager->flush();
$output->writeln("<info>Role '{$roleName}' created successfully!</info>");
return Command::SUCCESS;
}
}

View File

@ -0,0 +1,83 @@
<?php
namespace App\Command;
use App\Entity\Roles;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Question\ConfirmationQuestion;
#[AsCommand(
name: 'app:delete-role',
description: 'Deletes a role from the database'
)]
class DeleteRoleCommand extends Command
{
private EntityManagerInterface $entityManager;
public function __construct(EntityManagerInterface $entityManager)
{
parent::__construct();
$this->entityManager = $entityManager;
}
protected function configure(): void
{
$this
->addArgument('name', InputArgument::REQUIRED, 'The name of the role to delete');
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$roleName = trim($input->getArgument('name'));
$roleName = strtoupper($roleName); // Normalize to uppercase
if ($roleName === '') {
$output->writeln('<error>The role name cannot be empty</error>');
return Command::FAILURE;
}
// Find the role
$role = $this->entityManager->getRepository(Roles::class)
->findOneBy(['name' => $roleName]);
if (!$role) {
$output->writeln("<error>Role '{$roleName}' not found.</error>");
return Command::FAILURE;
}
// Check if role is being used (optional safety check)
$usageCount = $this->entityManager->getRepository(\App\Entity\UserOrganizatonApp::class)
->count(['role' => $role]);
if ($usageCount > 0) {
$output->writeln("<error>Cannot delete role '{$roleName}' - it is assigned to {$usageCount} user(s).</error>");
$output->writeln('<comment>Remove all assignments first, then try again.</comment>');
return Command::FAILURE;
}
// Confirmation prompt
$helper = $this->getHelper('question');
$question = new ConfirmationQuestion(
"Are you sure you want to delete role '{$roleName}'? [y/N] ",
false
);
if (!$helper->ask($input, $output, $question)) {
$output->writeln('<comment>Operation cancelled.</comment>');
return Command::SUCCESS;
}
// Delete the role
$this->entityManager->remove($role);
$this->entityManager->flush();
$output->writeln("<info>Role '{$roleName}' deleted successfully!</info>");
return Command::SUCCESS;
}
}

View File

@ -3,8 +3,12 @@
namespace App\Controller;
use App\Entity\Apps;
use App\Entity\Organizations;
use App\Service\ActionService;
use App\Service\UserService;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
@ -12,7 +16,7 @@ use Symfony\Component\Routing\Attribute\Route;
class ApplicationController extends AbstractController
{
public function __construct(private readonly EntityManagerInterface $entityManager)
public function __construct(private readonly EntityManagerInterface $entityManager, private readonly UserService $userService, private readonly ActionService $actionService)
{
}
@ -27,4 +31,73 @@ class ApplicationController extends AbstractController
]);
}
#[Route(path: '/edit/{id}', name: 'edit', methods: ['GET', 'POST'])]
public function edit(int $id, Request $request): Response{
$this->denyAccessUnlessGranted('ROLE_SUPER_ADMIN');
$actingUser = $this->userService->getUserByIdentifier($this->getUser()->getUserIdentifier());
$application = $this->entityManager->getRepository(Apps::class)->find($id);
if (!$application) {
$this->addFlash('error', "L'application n'existe pas ou n'est pas reconnu.");
return $this->redirectToRoute('application_index');
}
$applicationData = [
'id' => $application->getId(),
'name' => $application->getName(),
'description' => $application->getDescription(),
'descriptionSmall' => $application->getDescriptionSmall(),
'isActive' => $application->isActive(),
];
if ($request->isMethod('POST')) {
$data = $request->request->all();
$application->setName($data['name']);
$application->setDescription($data['description']);
$application->setDescriptionSmall($data['descriptionSmall']);
$this->entityManager->persist($application);
$this->actionService->createAction("Modification de l'application ", $actingUser, null, $application->getId());
return $this->redirectToRoute('application_index');
}
return $this->render('application/edit.html.twig', [
'apps' => $applicationData,
]);
}
#[Route(path: '/authorize/{id}', name: 'authorize', methods: ['POST'])]
public function authorize(int $id, Request $request)
{
$this->denyAccessUnlessGranted('ROLE_SUPER_ADMIN');
$actingUser = $this->userService->getUserByIdentifier($this->getUser()->getUserIdentifier());
$application = $this->entityManager->getRepository(Apps::class)->find($id);
if (!$application) {
throw $this->createNotFoundException("L'application n'existe pas.");
}
$orgId = $request->get('organizationId');
$organization = $this->entityManager->getRepository(Organizations::Class)->find($orgId);
$application->addOrganization($organization);
$this->actionService->createAction("Authorization d'accès", $actingUser, $organization, $application->getName());
return new Response('', Response::HTTP_OK);
}
#[Route(path: '/remove/{id}', name: 'remove', methods: ['POST'])]
public function remove(int $id, Request $request)
{
$this->denyAccessUnlessGranted('ROLE_SUPER_ADMIN');
$actingUser = $this->userService->getUserByIdentifier($this->getUser()->getUserIdentifier());
$application = $this->entityManager->getRepository(Apps::class)->find($id);
if (!$application) {
throw $this->createNotFoundException("L'application n'existe pas.");
}
$orgId = $request->get('organizationId');
$organization = $this->entityManager->getRepository(Organizations::Class)->find($orgId);
$application->removeOrganization($organization);
$this->actionService->createAction("Authorization retirer", $actingUser, $organization, $application->getName());
return new Response('', Response::HTTP_OK);
}
}

View File

@ -9,6 +9,7 @@ use App\Entity\User;
use App\Entity\UserOrganizatonApp;
use App\Entity\UsersOrganizations;
use App\Form\OrganizationForm;
use App\Repository\OrganizationsRepository;
use App\Service\ActionService;
use App\Service\OrganizationsService;
use App\Service\UserOrganizationService;
@ -16,6 +17,7 @@ use App\Service\UserService;
use Doctrine\ORM\EntityManagerInterface;
use Exception;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Routing\Attribute\Route;
use App\Entity\Organizations;
@ -28,9 +30,12 @@ class OrganizationController extends AbstractController
private const ACCESS_DENIED = 'Access denied';
public function __construct(private readonly EntityManagerInterface $entityManager,
private readonly UserService $userService,
private readonly OrganizationsService $organizationsService, private readonly ActionService $actionService, private readonly UserOrganizationService $userOrganizationService)
public function __construct(private readonly EntityManagerInterface $entityManager,
private readonly UserService $userService,
private readonly OrganizationsService $organizationsService,
private readonly ActionService $actionService,
private readonly UserOrganizationService $userOrganizationService,
private readonly OrganizationsRepository $organizationsRepository,)
{
}
@ -39,8 +44,11 @@ class OrganizationController extends AbstractController
{
$this->denyAccessUnlessGranted('ROLE_ADMIN');
$user = $this->userService->getUserByIdentifier($this->getUser()->getUserIdentifier());
if ($this->isGranted("ROLE_SUPER_ADMIN")) {
$organizations = $this->entityManager->getRepository(Organizations::class)->findBy([ 'isDeleted' => false ]);
$organizations = $this->organizationsRepository->findBy(['isDeleted' => false]);
} else {
//get all the UO of the user
$uos = $this->entityManager->getRepository(UsersOrganizations::class)->findBy(['users' => $user]);
@ -52,19 +60,29 @@ class OrganizationController extends AbstractController
$organizations[] = $uo->getOrganization();
}
}
if(count($organizations) === 1 && $organizations[0]->isActive() === true){
if (count($organizations) === 1 && $organizations[0]->isActive() === true) {
return $this->redirectToRoute('organization_show', ['id' => $organizations[0]->getId()]);
}
}
// Map the entities for tabulator
$organizationsData = array_map(function ($org) {
return [
'id' => $org->getId(),
'name' => $org->getName(),
'email' => $org->getEmail(),
'logoUrl' => $org->getLogoUrl() ? $org->getLogoUrl() : null,
'active' => $org->isActive(),
'showUrl' => $this->generateUrl('organization_show', ['id' => $org->getId()]),
];
}, $organizations);
return $this->render('organization/index.html.twig', [
'organizations' => $organizations,
'organizationsData' => $organizationsData,
]);
}
#[Route(path: '/new', name: 'new', methods: ['GET', 'POST'])]
public function new(Request $request)
public function new(Request $request): Response
{
$this->denyAccessUnlessGranted('ROLE_SUPER_ADMIN');
$actingUser = $this->userService->getUserByIdentifier($this->getUser()->getUserIdentifier());
@ -75,7 +93,7 @@ class OrganizationController extends AbstractController
if ($form->isSubmitted() && $form->isValid()) {
$logoFile = $form->get('logoUrl')->getData();
if ($logoFile) {
$this->organizationService->handleLogo($organization, $logoFile);
$this->organizationsService->handleLogo($organization, $logoFile);
}
try {
$this->entityManager->persist($organization);
@ -98,11 +116,11 @@ class OrganizationController extends AbstractController
}
#[Route(path: '/edit/{id}', name: 'edit', methods: ['GET', 'POST'])]
public function edit(Request $request, $id)
public function edit(Request $request, $id): Response
{
$this->denyAccessUnlessGranted('ROLE_ADMIN');
$actingUser = $this->userService->getUserByIdentifier($this->getUser()->getUserIdentifier());
$organization = $this->entityManager->getRepository(Organizations::class)->find($id);
$organization = $this->organizationsRepository->find($id);
if (!$organization) {
$this->addFlash('error', self::NOT_FOUND);
return $this->redirectToRoute('organization_index');
@ -130,6 +148,7 @@ class OrganizationController extends AbstractController
$this->organizationsService->handleLogo($organization, $logoFile);
}
try {
$this->entityManager->persist($organization);
$this->entityManager->flush();
$this->actionService->createAction("Edit Organization", $actingUser, $organization, $organization->getName());
return $this->redirectToRoute('organization_index');
@ -144,10 +163,10 @@ class OrganizationController extends AbstractController
}
#[Route(path: '/view/{id}', name: 'show', methods: ['GET'])]
public function view($id)
public function view($id): Response
{
$this->denyAccessUnlessGranted('ROLE_ADMIN');
$organization = $this->entityManager->getRepository(Organizations::class)->find($id);
$organization = $this->organizationsRepository->find($id);
$actingUser = $this->userService->getUserByIdentifier($this->getUser()->getUserIdentifier());
if (!$organization) {
$this->addFlash('error', self::NOT_FOUND);
@ -178,7 +197,7 @@ class OrganizationController extends AbstractController
$apps = $this->organizationsService->appsAccess($allApps, $orgApps);
$actions = $this->entityManager->getRepository(Actions::class)->findBy(['Organization' => $organization]);
$actions = $this->entityManager->getRepository(Actions::class)->findBy(['Organization' => $organization], limit: 15);
$activities = $this->actionService->formatActivities($actions);
$this->actionService->createAction("View Organization", $actingUser, $organization, $organization->getName());
@ -189,15 +208,15 @@ class OrganizationController extends AbstractController
'users' => $users,
'applications' => $apps,
'activities' => $activities,
]);
]);
}
#[Route(path: '/delete/{id}', name: 'delete', methods: ['POST'])]
public function delete($id)
public function delete($id): Response
{
$this->denyAccessUnlessGranted("ROLE_ADMIN");
$actingUser = $this->userService->getUserByIdentifier($this->getUser()->getUserIdentifier());
$organization = $this->entityManager->getRepository(Organizations::class)->find($id);
$organization = $this->organizationsRepository->find($id);
if (!$organization) {
throw $this->createNotFoundException(self::NOT_FOUND);
}
@ -212,25 +231,27 @@ class OrganizationController extends AbstractController
}
#[Route(path: '/deactivate/{id}', name: 'deactivate', methods: ['POST'])]
public function deactivate($id){
$this->denyAccessUnlessGranted("ROLE_SUPER_ADMIN");
$actingUser = $this->userService->getUserByIdentifier($this->getUser()->getUserIdentifier());
$organization = $this->entityManager->getRepository(Organizations::class)->find($id);
if (!$organization) {
throw $this->createNotFoundException(self::NOT_FOUND);
}
$organization->setIsActive(false);
$this->userOrganizationService->deactivateAllUserOrganizationLinks($actingUser, null, $organization);
$this->entityManager->persist($organization);
$this->actionService->createAction("Deactivate Organization", $actingUser, $organization, $organization->getName());
return $this->redirectToRoute('organization_index');
public function deactivate($id): Response
{
$this->denyAccessUnlessGranted("ROLE_SUPER_ADMIN");
$actingUser = $this->userService->getUserByIdentifier($this->getUser()->getUserIdentifier());
$organization = $this->organizationsRepository->find($id);
if (!$organization) {
throw $this->createNotFoundException(self::NOT_FOUND);
}
$organization->setIsActive(false);
// $this->userOrganizationService->deactivateAllUserOrganizationLinks($actingUser, null, $organization);
$this->entityManager->persist($organization);
$this->actionService->createAction("Deactivate Organization", $actingUser, $organization, $organization->getName());
return $this->redirectToRoute('organization_index');
}
#[Route(path: '/activate/{id}', name: 'activate', methods: ['POST'])]
public function activate($id){
public function activate($id): Response
{
$this->denyAccessUnlessGranted("ROLE_SUPER_ADMIN");
$actingUser = $this->userService->getUserByIdentifier($this->getUser()->getUserIdentifier());
$organization = $this->entityManager->getRepository(Organizations::class)->find($id);
$organization = $this->organizationsRepository->find($id);
if (!$organization) {
throw $this->createNotFoundException(self::NOT_FOUND);
}
@ -240,5 +261,74 @@ class OrganizationController extends AbstractController
return $this->redirectToRoute('organization_index');
}
// API endpoint to fetch organization data for Tabulator
#[Route(path: '/data', name: 'data', methods: ['GET'])]
public function data(Request $request): JsonResponse
{
$this->denyAccessUnlessGranted('ROLE_ADMIN');
$page = max(1, (int)$request->query->get('page', 1));
$size = max(1, (int)$request->query->get('size', 10));
// $sorters = $request->query->all('sorters');
// $filters = $request->query->all('filters');
$user = $this->userService->getUserByIdentifier($this->getUser()->getUserIdentifier());
$qb = $this->organizationsRepository->createQueryBuilder('o')
->where('o.isDeleted = :del')->setParameter('del', false);
// // Example: apply filters (basic equals/like)
// foreach ($filters as $f) {
// if (!isset($f['field'], $f['type'])) { continue; }
// $param = 'p_' . $f['field'];
// if ($f['type'] === 'like' || $f['type'] === 'contains') {
// $qb->andWhere("LOWER(o.{$f['field']}) LIKE :$param")
// ->setParameter($param, '%' . mb_strtolower((string)$f['value']) . '%');
// } elseif ($f['type'] === '=') {
// $qb->andWhere("o.{$f['field']} = :$param")
// ->setParameter($param, $f['value']);
// }
// }
//
// // Example: apply sorters
// foreach ($sorters as $s) {
// if (!isset($s['field'], $s['dir'])) { continue; }
// $dir = strtolower($s['dir']) === 'desc' ? 'DESC' : 'ASC';
// $qb->addOrderBy('o.' . $s['field'], $dir);
// }
// Count total
$countQb = clone $qb;
$total = (int)$countQb->select('COUNT(o.id)')->getQuery()->getSingleScalarResult();
// Pagination
$offset = ($page - 1) * $size;
$rows = $qb->setFirstResult($offset)->setMaxResults($size)->getQuery()->getResult();
// Map to array
$data = array_map(function (Organizations $org) {
return [
'id' => $org->getId(),
'name' => $org->getName(),
'email' => $org->getEmail(),
'logoUrl' => $org->getLogoUrl() ?: null,
'active' => $org->isActive(),
'showUrl' => $this->generateUrl('organization_show', ['id' => $org->getId()]),
];
}, $rows);
// Tabulator expects: data, last_page (total pages), or total row count depending on config
$lastPage = (int)ceil($total / $size);
return $this->json([
'data' => $data,
'last_page' => $lastPage,
'total' => $total, // optional, useful for debugging
]);
}
}

View File

@ -3,20 +3,21 @@
namespace App\Controller;
use App\Entity\Apps;
use App\Entity\Organizations;
use App\Entity\Roles;
use App\Entity\User;
use App\Entity\UserOrganizatonApp;
use App\Entity\UsersOrganizations;
use App\Form\UserForm;
use App\Repository\OrganizationsRepository;
use App\Repository\UserRepository;
use App\Repository\UsersOrganizationsRepository;
use App\Service\ActionService;
use App\Service\UserOrganizationAppService;
use App\Service\UserOrganizationService;
use App\Service\UserService;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\Asset\Packages;
use Symfony\Component\HttpFoundation\File\Exception\FileException;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
@ -28,9 +29,14 @@ 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 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,
)
{
}
@ -43,24 +49,15 @@ class UserController extends AbstractController
$user = $this->userService->getUserByIdentifier($this->getUser()->getUserIdentifier());
if ($this->isGranted('ROLE_SUPER_ADMIN')) {
$uo = $this->entityManager->getRepository(UsersOrganizations::class)->findUsersWithOrganization();
$uo = $this->uoRepository->findUsersWithOrganization();
$noOrgUsers = $this->userService->formatNoOrgUsersAsAssoc(
$this->entityManager->getRepository(User::class)->findUsersWithoutOrganization());
$this->userRepository->findUsersWithoutOrganization());
$usersByOrganization = $this->userService->groupByOrganization($uo);
$usersByOrganization += $noOrgUsers;
//Log action
$this->actionService->createAction("View all users", $user, null, "All");
} elseif ($this->isGranted('ROLE_ADMIN')) {
$orgIds = $this->userService->getAdminOrganizationsIds($user);
if (empty($orgIds)) {
$usersByOrganization = [];
} else {
$uo = $this->entityManager->getRepository(UsersOrganizations::class)->findUsersWithOrganization($orgIds);
$usersByOrganization = $this->userService->groupByOrganization($uo);
$this->actionService->createAction("View all users for organizations", $user, null, implode(", ", $orgIds));
}
} else {
$usersByOrganization = [];
}
@ -76,18 +73,18 @@ class UserController extends AbstractController
$this->denyAccessUnlessGranted('ROLE_USER');
$actingUser = $this->userService->getUserByIdentifier($this->getUser()->getUserIdentifier());
if ($this->userService->hasAccessTo($actingUser)) {
$user = $this->entityManager->getRepository(User::class)->find($id);
$user = $this->userRepository->find($id);
try {
$orgId = $request->query->get('organizationId');
if ($orgId) {
$orgs = $this->entityManager->getRepository(Organizations::class)->findBy(['id' => $orgId]);
$uo = $this->entityManager->getRepository(UsersOrganizations::class)->findBy(['users' => $user, 'organization' => $orgs]);
$orgs = $this->organizationRepository->findBy(['id' => $orgId]);
$uo = $this->uoRepository->findBy(['users' => $user, 'organization' => $orgs]);
if (!$uo) {
throw $this->createNotFoundException(self::NOT_FOUND);
}
$uoActive = $uo[0]->isActive();
} else {
$uo = $this->entityManager->getRepository(UsersOrganizations::class)->findBy(['users' => $user, 'isActive' => true]);
$uo = $this->uoRepository->findBy(['users' => $user, 'isActive' => true]);
foreach ($uo as $u) {
$orgs[] = $u->getOrganization();
}
@ -116,7 +113,7 @@ class UserController extends AbstractController
$this->denyAccessUnlessGranted('ROLE_USER');
$actingUser = $this->userService->getUserByIdentifier($this->getUser()->getUserIdentifier());
if ($this->userService->hasAccessTo($actingUser)) {
$user = $this->entityManager->getRepository(User::class)->find($id);
$user = $this->userRepository->find($id);
if (!$user) {
throw $this->createNotFoundException(self::NOT_FOUND);
}
@ -134,7 +131,7 @@ class UserController extends AbstractController
$this->entityManager->persist($user);
$this->entityManager->flush();
if ($request->get('organizationId')) {
$org = $this->entityManager->getRepository(Organizations::class)->find($request->get('organizationId'));
$org = $this->organizationRepository->find($request->get('organizationId'));
if ($org) {
$this->actionService->createAction("Edit user information", $actingUser, $org, $user->getUserIdentifier());
}
@ -172,13 +169,14 @@ class UserController extends AbstractController
if ($picture) {
$this->userService->handleProfilePicture($user, $picture);
} else {
$user->setPictureUrl("");
}
// else {
// $user->setPictureUrl("");
// }
//FOR TEST PURPOSES, SETTING A DEFAULT RANDOM PASSWORD
$user->setPassword($this->userService->generateRandomPassword());
if ($orgId) {
$org = $this->entityManager->getRepository(Organizations::class)->find($orgId);
$org = $this->organizationRepository->find($orgId);
if ($org) {
$uo = new UsersOrganizations();
$uo->setUsers($user);
@ -211,13 +209,16 @@ class UserController extends AbstractController
$this->denyAccessUnlessGranted('ROLE_ADMIN');
$actingUser = $this->userService->getUserByIdentifier($this->getUser()->getUserIdentifier());
if ($this->userService->hasAccessTo($actingUser, true)) {
$user = $this->entityManager->getRepository(User::class)->find($id);
$user = $this->userRepository->find($id);
if (!$user) {
throw $this->createNotFoundException(self::NOT_FOUND);
}
$user->setIsActive(false);
$user->setModifiedAt(new \DateTimeImmutable('now'));
$this->userOrganizationService->deactivateAllUserOrganizationLinks($user, $actingUser);
if($this->userService->isUserConnected($user->getUserIdentifier())){
$this->userService->revokeUserTokens($user->getUserIdentifier());
}
$this->entityManager->persist($user);
$this->entityManager->flush();
$this->actionService->createAction("Deactivate user", $actingUser, null, $user->getUserIdentifier());
@ -234,7 +235,7 @@ class UserController extends AbstractController
$this->denyAccessUnlessGranted('ROLE_ADMIN');
$actingUser = $this->userService->getUserByIdentifier($this->getUser()->getUserIdentifier());
if ($this->userService->hasAccessTo($actingUser, true)) {
$user = $this->entityManager->getRepository(User::class)->find($id);
$user = $this->userRepository->find($id);
if (!$user) {
throw $this->createNotFoundException(self::NOT_FOUND);
}
@ -257,15 +258,15 @@ class UserController extends AbstractController
$actingUser = $this->userService->getUserByIdentifier($this->getUser()->getUserIdentifier());
if ($this->userService->hasAccessTo($actingUser, true)) {
$orgId = $request->get('organizationId');
$org = $this->entityManager->getRepository(Organizations::class)->find($orgId);
$org = $this->organizationRepository->find($orgId);
if (!$org) {
throw $this->createNotFoundException(self::NOT_FOUND);
}
$user = $this->entityManager->getRepository(User::class)->find($id);
$user = $this->userRepository->find($id);
if (!$user) {
throw $this->createNotFoundException(self::NOT_FOUND);
}
$uo = $this->entityManager->getRepository(UsersOrganizations::class)->findOneBy(['users' => $user,
$uo = $this->uoRepository->findOneBy(['users' => $user,
'organization' => $org,
'isActive' => true]);
if (!$uo) {
@ -277,7 +278,7 @@ class UserController extends AbstractController
$this->entityManager->flush();
$this->actionService->createAction("Deactivate user in organization", $actingUser, $org, $org->getName() . " for user " . $user->getUserIdentifier());
return $this->redirectToRoute('user_index');
return new Response('', Response::HTTP_NO_CONTENT); //204
}
throw $this->createAccessDeniedException(self::ACCESS_DENIED);
@ -290,15 +291,15 @@ class UserController extends AbstractController
$actingUser = $this->userService->getUserByIdentifier($this->getUser()->getUserIdentifier());
if ($this->userService->hasAccessTo($actingUser, true)) {
$orgId = $request->get('organizationId');
$org = $this->entityManager->getRepository(Organizations::class)->find($orgId);
$org = $this->organizationRepository->find($orgId);
if (!$org) {
throw $this->createNotFoundException(self::NOT_FOUND);
}
$user = $this->entityManager->getRepository(User::class)->find($id);
$user = $this->userRepository->find($id);
if (!$user) {
throw $this->createNotFoundException(self::NOT_FOUND);
}
$uo = $this->entityManager->getRepository(UsersOrganizations::class)->findOneBy(['users' => $user,
$uo = $this->uoRepository->findOneBy(['users' => $user,
'organization' => $org,
'isActive' => false]);
if (!$uo) {
@ -315,12 +316,12 @@ class UserController extends AbstractController
throw $this->createAccessDeniedException(self::ACCESS_DENIED);
}
#[Route('/delete/{id}', name: 'delete', methods: ['GET'])]
#[Route('/delete/{id}', name: 'delete', methods: ['GET', 'POST'])]
public function delete(int $id, Request $request): Response
{
$this->denyAccessUnlessGranted("ROLE_SUPER_ADMIN");
$actingUser = $this->userService->getUserByIdentifier($this->getUser()->getUserIdentifier());
$user = $this->entityManager->getRepository(User::class)->find($id);
$user = $this->userRepository->find($id);
if (!$user) {
throw $this->createNotFoundException(self::NOT_FOUND);
}
@ -328,10 +329,14 @@ class UserController extends AbstractController
$user->setModifiedAt(new \DateTimeImmutable('now'));
$this->userOrganizationService->deactivateAllUserOrganizationLinks($user, $actingUser);
$user->setIsDeleted(true);
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());
return $this->redirectToRoute('user_index');
return new Response('', Response::HTTP_NO_CONTENT); //204
}
#[Route(path: '/application/roles/{id}', name: 'application_role', methods: ['GET', 'POST'])]
@ -354,7 +359,7 @@ class UserController extends AbstractController
throw $this->createNotFoundException('Default role not found');
}
if (in_array($roleUser->getId(), $selectedRolesIds)) {
if (!empty($selectedRolesIds)) {
$this->userOrganizationAppService->syncRolesForUserOrganizationApp(
$uo,
$application,
@ -367,12 +372,230 @@ class UserController extends AbstractController
$user = $uo->getUsers();
return $this->redirectToRoute('user_show', [
'user' => $user,
'id' => $user->getId(),
'organizationId'=> $uo->getOrganization()->getId()
'user' => $user,
'id' => $user->getId(),
'organizationId' => $uo->getOrganization()->getId()
]);
}
throw $this->createAccessDeniedException();
}
/*
* AJAX endpoint for user listing with pagination
* Get all the users that aren´t deleted and are active
*/
#[Route(path: '/data', name: 'data', methods: ['GET'])]
public function data(Request $request): JsonResponse
{
$this->denyAccessUnlessGranted("ROLE_ADMIN");
$page = max(1, (int)$request->query->get('page', 1));
$size = max(1, (int)$request->query->get('size', 10));
// Get filter parameters
$filters = $request->query->all('filter', []);
$repo = $this->userRepository;
// Base query
$qb = $repo->createQueryBuilder('u')
->where('u.isDeleted = :del')->setParameter('del', false);
// Apply filters
if (!empty($filters['name'])) {
$qb->andWhere('u.surname LIKE :name')
->setParameter('name', '%' . $filters['name'] . '%');
}
if (!empty($filters['prenom'])) {
$qb->andWhere('u.name LIKE :prenom')
->setParameter('prenom', '%' . $filters['prenom'] . '%');
}
if (!empty($filters['email'])) {
$qb->andWhere('u.email LIKE :email')
->setParameter('email', '%' . $filters['email'] . '%');
}
$countQb = clone $qb;
$total = (int)$countQb->select('COUNT(u.id)')->getQuery()->getSingleScalarResult();
// Pagination
$offset = ($page - 1) * $size;
$rows = $qb->setFirstResult($offset)->setMaxResults($size)->getQuery()->getResult();
// Map to array
$data = array_map(function (User $user) {
return [
'id' => $user->getId(),
'pictureUrl' => $user->getPictureUrl(),
'name' => $user->getSurname(),
'prenom' => $user->getName(),
'email' => $user->getEmail(),
'isConnected' => $this->userService->isUserConnected($user->getUserIdentifier()),
'showUrl' => $this->generateUrl('user_show', ['id' => $user->getId()]),
'statut' => $user->isActive(),
];
}, $rows);
$lastPage = (int)ceil($total / $size);
return $this->json([
'data' => $data,
'last_page' => $lastPage,
'total' => $total,
]);
}
#[Route(path: '/indexTest', name: 'indexTest', methods: ['GET'])]
public function indexTest(): 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', [
'users' => $totalUsers
]);
}
throw $this->createAccessDeniedException(self::ACCESS_DENIED);
}
/*
* AJAX endpoint for new users listing
* Get the 5 most recently created users for an organization
*/
#[Route(path: '/data/new', name: 'dataNew', methods: ['GET'])]
public function dataNew(Request $request): JsonResponse
{
$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']);
// Map to array (keep isConnected)
$data = array_map(function (UsersOrganizations $uo) {
$user = $uo->getUsers();
$initials = $user->getName()[0] . $user->getSurname()[0];
return [
'pictureUrl' => $user->getPictureUrl(),
'email' => $user->getEmail(),
'isConnected' => $this->userService->isUserConnected($user->getUserIdentifier()),
'showUrl' => $this->generateUrl('user_show', ['id' => $user->getId()]),
'initials' => strtoupper($initials),
];
}, $uos);
return $this->json([
'data' => $data,
]);
}
throw $this->createAccessDeniedException(self::ACCESS_DENIED);
}
/*
* AJAX endpoint for admin users listing
* Get all admin users for an organization
*/
#[Route(path: '/data/admin', name: 'dataAdmin', methods: ['GET'])]
public function dataAdmin(Request $request): JsonResponse
{
$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]);
$roleAdmin = $this->entityManager->getRepository(Roles::class)->findOneBy(['name' => 'ADMIN']);
$users = [];
foreach ($uos as $uo) {
if ($this->entityManager->getRepository(UserOrganizatonApp::class)->findOneBy(['userOrganization' => $uo, 'role' => $roleAdmin])) {
$users[] = $uo;
}
}
// Map to array (keep isConnected)
$data = array_map(function (UsersOrganizations $uo) {
$user = $uo->getUsers();
$initials = $user->getName()[0] . $user->getSurname()[0];
return [
'pictureUrl' => $user->getPictureUrl(),
'email' => $user->getEmail(),
'isConnected' => $this->userService->isUserConnected($user->getUserIdentifier()),
'showUrl' => $this->generateUrl('user_show', ['id' => $user->getId()]),
'initials' => strtoupper($initials),
];
}, $users);
return $this->json([
'data' => $data,
]);
}
throw $this->createAccessDeniedException(self::ACCESS_DENIED);
}
/*
* AJAX endpoint for All users in an organization
*/
#[Route(path: '/data/organization', name: 'dataUserOrganization', methods: ['GET'])]
public function dataUserOrganization(Request $request): JsonResponse
{
$actingUser = $this->userService->getUserByIdentifier($this->getUser()->getUserIdentifier());
if ($this->userService->hasAccessTo($actingUser, true) && $this->isGranted("ROLE_ADMIN")) {
$orgId = $request->query->get('orgId');
$page = max(1, (int)$request->query->get('page', 1));
$size = max(1, (int)$request->query->get('size', 10));
// Optional: read Tabulator remote sort/filter payloads
// $sorters = $request->query->all('sorters') ?? [];
// $filters = $request->query->all('filters') ?? [];
$repo = $this->uoRepository;
// Base query
$qb = $repo->createQueryBuilder('uo')
->where('uo.organization = :orgId')
->setParameter('orgId', $orgId);
$countQb = clone $qb;
$total = (int)$countQb->select('COUNT(uo.id)')->getQuery()->getSingleScalarResult();
// Pagination
$offset = ($page - 1) * $size;
$rows = $qb->setFirstResult($offset)->setMaxResults($size)->getQuery()->getResult();
// 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([
'data' => $data,
'last_page' => $lastPage,
'total' => $total,
]);
}
throw $this->createAccessDeniedException(self::ACCESS_DENIED);
}
}

View File

@ -5,6 +5,7 @@ namespace App\Entity;
use App\Repository\AppsRepository;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;
#[ORM\Entity(repositoryClass: AppsRepository::class)]
@ -24,7 +25,7 @@ class Apps
#[ORM\Column(length: 255)]
private ?string $logo_url = null;
#[ORM\Column(length: 255)]
#[ORM\Column(type: Types::TEXT, nullable: true)]
private ?string $description = null;
#[ORM\Column(length: 255)]
@ -33,7 +34,7 @@ class Apps
#[ORM\Column(options: ['default' => true])]
private ?bool $isActive = null;
#[ORM\Column(length: 255, nullable: true)]
#[ORM\Column(type: Types::TEXT, nullable: true)]
private ?string $descriptionSmall = null;
/**

View File

@ -19,6 +19,10 @@ class Roles
#[ORM\Column(options: ['default' => 'CURRENT_TIMESTAMP'])]
private ?\DateTimeImmutable $createdAt = null;
public function __construct()
{
$this->createdAt = new \DateTimeImmutable();
}
public function getId(): ?int
{
return $this->id;

View File

@ -46,7 +46,7 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
#[ORM\Column(options: ['default' => 'CURRENT_TIMESTAMP'])]
private ?\DateTimeImmutable $createdAt = null;
#[ORM\Column(length: 255)]
#[ORM\Column(length: 255, nullable: true)]
private ?string $pictureUrl = null;
#[ORM\Column(options: ['default' => 'CURRENT_TIMESTAMP'])]

View File

@ -0,0 +1,40 @@
<?php
// src/Security/UserChecker.php
namespace App\Security;
use App\Entity\UsersOrganizations;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\Security\Core\User\UserCheckerInterface;
use Symfony\Component\Security\Core\User\UserInterface;
use Symfony\Component\Security\Core\Exception\CustomUserMessageAccountStatusException;
class UserChecker implements UserCheckerInterface
{
public function __construct(private readonly EntityManagerInterface $entityManager)
{
}
public function checkPreAuth(UserInterface $user): void
{
// runs before password is checked
}
public function checkPostAuth(UserInterface $user): void
{
// runs after credentials are validated
if (method_exists($user, 'isDeleted') && $user->isDeleted()) {
throw new CustomUserMessageAccountStatusException('Votre compte a été supprimé.');
}
// check if the user account is active
if (method_exists($user, 'isActive') && !$user->isActive()) {
throw new CustomUserMessageAccountStatusException('Votre compte est désactivé.');
}
//check if the user is in an organization
$uo = $this->entityManager->getRepository(UsersOrganizations::class)->findOneBy(['users' => $user, 'isActive' => true]);
if (!$uo) {
throw new CustomUserMessageAccountStatusException('Vous n\'êtes pas relié à une organisation. veuillez contacter un administrateur.');
}
}
}

View File

@ -27,7 +27,7 @@ readonly class ActionService
if ($diffInSeconds < 60 * 60) { // less than 1 hour
return '#247208';
}
return '#C76633';
return '#cc664c';
}
/**

View File

@ -4,44 +4,31 @@ namespace App\Service;
use App\Entity\Apps;
use App\Entity\Organizations;
use App\Entity\Roles;
use App\Entity\UsersOrganizations;
use Doctrine\ORM\EntityManagerInterface;
use SebastianBergmann\CodeCoverage\Util\DirectoryCouldNotBeCreatedException;
use App\Service\AwsService;
use Symfony\Component\HttpFoundation\File\Exception\FileException;
class OrganizationsService
{
private string $logoDirectory;
public function __construct(private readonly EntityManagerInterface $entityManager,
string $logoDirectory
public function __construct(
string $logoDirectory, private readonly AwsService $awsService
)
{
$this->logoDirectory = $logoDirectory;
}
public function handleLogo(Organizations $organization, $logoFile)
public function handleLogo(Organizations $organization, $logoFile): void
{
$extension = $logoFile->guessExtension();
$customFilename = $organization->getName() . '_'. date('dmyHis') . "." . $extension;
$customFilename = $organization->getName() . '_' . date('dmyHis') . "." . $extension;
$uploadDirectory = $this->logoDirectory;
if (!is_dir($uploadDirectory) && !mkdir($uploadDirectory, 0755, true) && !is_dir($uploadDirectory)) {
throw new DirectoryCouldNotBeCreatedException(sprintf('Directory "%s" was not created', $uploadDirectory));
}
try {
// Move the file to the upload directory
$logoFile->move($uploadDirectory, $customFilename);
// Update user entity with the file path (relative to public directory)
$organization->setLogoUrl('uploads/logos/' . $customFilename);
$this->awsService->PutDocObj($_ENV['S3_PORTAL_BUCKET'], $logoFile, $customFilename, $extension, 'logo/');
$organization->setLogoUrl('logo/' . $customFilename);
} catch (FileException $e) {
// Handle upload error
throw new FileException('File upload failed: ' . $e->getMessage());
throw new FileException('Failed to upload logo to S3: ' . $e->getMessage());
}
}
@ -60,7 +47,7 @@ class OrganizationsService
$result = [];
foreach ($appsAll as $app) {
$result[] = [
'entity' => $app, // Keep the full entity for Twig
'entity' => $app, // Keep the full entity for Twig
'hasAccess' => in_array($app->getId(), $orgAppIds, true),
];
}

View File

@ -8,11 +8,13 @@ use App\Entity\User;
use App\Entity\UserOrganizatonApp;
use App\Entity\UsersOrganizations;
use App\Service\ActionService;
use App\Service\UserService;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\SecurityBundle\Security;
class UserOrganizationAppService
{
public function __construct(private readonly EntityManagerInterface $entityManager, private readonly ActionService $actionService)
public function __construct(private readonly EntityManagerInterface $entityManager, private readonly ActionService $actionService, private readonly Security $security, private readonly UserService $userService)
{
}
@ -28,6 +30,9 @@ class UserOrganizationAppService
$grouped = [];
foreach ($userOrgApps as $uoa) {
if(!$uoa->getRole()->getName() === 'USER') {
continue; // Skip USER role
}
$app = $uoa->getApplication();
$appId = $app->getId();
$roleEntity = $uoa->getRole();
@ -35,9 +40,9 @@ class UserOrganizationAppService
if (!isset($grouped[$appId])) {
$grouped[$appId] = [
'uoId' => $uoa->getUserOrganization()->getId(),
'application' => $app, // you can still pass entity here
'roles' => [], // selected roles for display
'rolesArray' => [], // all possible roles
'application' => $app,
'roles' => [],
'rolesArray' => [],
'selectedRoleIds' => [],
];
}
@ -49,11 +54,21 @@ class UserOrganizationAppService
$grouped[$appId]['selectedRoleIds'][] = $roleEntity->getId();
}
// roles are the same for all apps → load once, inject into each appGroup
// Load all possible roles once
$allRoles = $this->entityManager->getRepository(Roles::class)->findAll();
foreach ($grouped as &$appGroup) {
foreach ($allRoles as $role) {
// exclude SUPER ADMIN from assignable roles if current user is just ADMIN
if ($this->security->isGranted('ROLE_ADMIN') && !$this->security->isGranted('ROLE_SUPER_ADMIN')
&& $role->getName() === 'SUPER ADMIN') {
continue;
}
// exclude USER role from assignable roles
if ($role->getName() === 'USER') {
continue;
}
$appGroup['rolesArray'][] = [
'id' => $role->getId(),
'name' => $role->getName(),
@ -85,24 +100,45 @@ class UserOrganizationAppService
}
}
/**
* Synchronizes user roles for a specific application within an organization.
*
* This method handles the complete lifecycle of user-application role assignments:
* - Activates/deactivates existing role links based on selection
* - Creates new role assignments for newly selected roles
* - Updates the user's global Symfony security roles when ADMIN/SUPER_ADMIN roles are assigned
*
* @param UsersOrganizations $uo The user-organization relationship
* @param Apps $application The target application
* @param array $selectedRoleIds Array of role IDs that should be active for this user-app combination
* @param User $actingUser The user performing this action (for audit logging)
*
* @return void
*
* @throws \Exception If role entities cannot be found or persisted
*/
public function syncRolesForUserOrganizationApp(
UsersOrganizations $uo,
Apps $application,
array $selectedRoleIds,
User $actingUser
): void {
$repo = $this->entityManager->getRepository(UserOrganizatonApp::class);
$currentLinks = $repo->findBy([
// Fetch existing UserOrganizationApp links for this user and application
$uoas = $this->entityManager->getRepository(UserOrganizatonApp::class)->findBy([
'userOrganization' => $uo,
'application' => $application,
]);
$currentRoleIds = [];
foreach ($currentLinks as $uoa) {
// Process existing role links - activate or deactivate based on selection
foreach ($uoas as $uoa) {
$roleId = $uoa->getRole()->getId();
$currentRoleIds[] = $roleId;
$roleName = $uoa->getRole()->getName();
if (in_array($roleId, $selectedRoleIds)) {
if (in_array((string) $roleId, $selectedRoleIds, true)) {
// Role is selected - ensure it's active
if (!$uoa->isActive()) {
$uoa->setIsActive(true);
$this->entityManager->persist($uoa);
@ -110,10 +146,19 @@ class UserOrganizationAppService
"Re-activate user role for application",
$actingUser,
$uo->getOrganization(),
"App: {$application->getName()}, Role: {$uoa->getRole()->getName()} for user {$uo->getUsers()->getUserIdentifier()}"
"App: {$application->getName()}, Role: $roleName for user {$uo->getUsers()->getUserIdentifier()}"
);
// Sync Admins roles to user's global Symfony security roles
if (in_array($roleName, ['ADMIN', 'SUPER ADMIN'], true)) {
$this->userService->syncUserRoles($uo->getUsers(), $roleName, true);
}
// Ensure ADMIN role is assigned if SUPER ADMIN is activated
if ($roleName === 'SUPER ADMIN') {
$this->ensureAdminRoleForSuperAdmin($uoa);
}
}
} else {
// Role is not selected - ensure it's inactive
if ($uoa->isActive()) {
$uoa->setIsActive(false);
$this->entityManager->persist($uoa);
@ -122,23 +167,37 @@ class UserOrganizationAppService
"Deactivate user role for application",
$actingUser,
$uo->getOrganization(),
"App: {$application->getName()}, Role: {$uoa->getRole()->getName()} for user {$uo->getUsers()->getUserIdentifier()}"
"App: {$application->getName()}, Role: $roleName for user {$uo->getUsers()->getUserIdentifier()}"
);
// Sync Admins roles to user's global Symfony security roles
if (in_array($roleName, ['ADMIN', 'SUPER ADMIN'], true)) {
$this->userService->syncUserRoles($uo->getUsers(), $roleName, false);
}
}
}
}
// Add missing roles
// Create new role assignments for roles that don't exist yet
foreach ($selectedRoleIds as $roleId) {
if (!in_array($roleId, $currentRoleIds)) {
$role = $this->entityManager->getRepository(Roles::class)->find($roleId);
if ($role) {
// Create new user-organization-application role link
$newUoa = new UserOrganizatonApp();
$newUoa->setUserOrganization($uo);
$newUoa->setApplication($application);
$newUoa->setRole($role);
$newUoa->setIsActive(true);
// Sync Admins roles to user's global Symfony security roles
if (in_array($role->getName(), ['ADMIN', 'SUPER ADMIN'], true)) {
$this->userService->syncUserRoles($uo->getUsers(), $role->getName(), true);
}
// Ensure ADMIN role is assigned if SUPER ADMIN is activated
if ($role->getName() === 'SUPER ADMIN') {
$this->ensureAdminRoleForSuperAdmin($newUoa);
}
$this->entityManager->persist($newUoa);
$this->actionService->createAction("New user role for application",
$actingUser,
@ -147,8 +206,34 @@ class UserOrganizationAppService
}
}
}
$this->entityManager->flush();
}
/**
* Attribute the role Admin to the user if the user has the role Super Admin
*
* @param UserOrganizatonApp $uoa
*
* @return void
*/
public function ensureAdminRoleForSuperAdmin(UserOrganizatonApp $uoa): void
{
$uoaAdmin = $this->entityManager->getRepository(UserOrganizatonApp::class)->findOneBy([
'userOrganization' => $uoa->getUserOrganization(),
'application' => $uoa->getApplication(),
'role' => $this->entityManager->getRepository(Roles::class)->findOneBy(['name' => 'ADMIN'])
]);
if(!$uoaAdmin) {
$uoaAdmin = new UserOrganizatonApp();
$uoaAdmin->setUserOrganization($uoa->getUserOrganization());
$uoaAdmin->setApplication($uoa->getApplication());
$uoaAdmin->setRole($this->entityManager->getRepository(Roles::class)->findOneBy(['name' => 'ADMIN']));
$uoaAdmin->setIsActive(true);
$this->entityManager->persist($uoaAdmin);
}
// If the ADMIN role link exists but is inactive, activate it
if ($uoaAdmin && !$uoaAdmin->isActive()) {
$uoaAdmin->setIsActive(true);
}
}
}

View File

@ -8,6 +8,7 @@ use App\Entity\Roles;
use App\Entity\User;
use App\Entity\UserOrganizatonApp;
use App\Entity\UsersOrganizations;
use App\Service\AwsService;
use DateTimeImmutable;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\EntityNotFoundException;
@ -26,7 +27,7 @@ class UserService
public function __construct(private readonly EntityManagerInterface $entityManager,
private readonly Security $security,
string $profileDirectory
string $profileDirectory, private readonly AwsService $awsService
)
{
$this->profileDirectory = $profileDirectory;
@ -78,6 +79,7 @@ class UserService
/**
* Check if the user have the rights to access the page
* Self check can be skipped when checking access for the current user
*
* @param User $user
* @param bool $skipSelfCheck
@ -194,11 +196,13 @@ class UserService
$orgId = $org->getId();
$orgName = $org->getName();
$orgLogo = $org->getLogoUrl();
if (!isset($grouped[$orgId])) {
$grouped[$orgId] = [
'id' => $orgId,
'name' => $orgName,
'logo' => $orgLogo,
'users' => [],
];
}
@ -246,25 +250,32 @@ class UserService
// Create custom filename: userNameUserSurname_ddmmyyhhmmss
$customFilename = $user->getName() . $user->getSurname() . '_' . date('dmyHis') . '.' . $extension;
try{
$this->awsService->PutDocObj($_ENV['S3_PORTAL_BUCKET'], $picture, $customFilename , $extension, 'profile/');
// Define upload directory
$uploadDirectory = $this->profileDirectory;
// Create directory if it doesn't exist
if (!is_dir($uploadDirectory) && !mkdir($uploadDirectory, 0755, true) && !is_dir($uploadDirectory)) {
throw new DirectoryCouldNotBeCreatedException(sprintf('Directory "%s" was not created', $uploadDirectory));
}
try {
// Move the file to the upload directory
$picture->move($uploadDirectory, $customFilename);
// Update user entity with the file path (relative to public directory)
$user->setPictureUrl('uploads/profile/' . $customFilename);
$user->setPictureUrl('profile/'.$customFilename);
} catch (FileException $e) {
// Handle upload error
throw new FileException('File upload failed: ' . $e->getMessage());
}
// // Define upload directory
// $uploadDirectory = $this->profileDirectory;
// // Create directory if it doesn't exist
// if (!is_dir($uploadDirectory) && !mkdir($uploadDirectory, 0755, true) && !is_dir($uploadDirectory)) {
// throw new DirectoryCouldNotBeCreatedException(sprintf('Directory "%s" was not created', $uploadDirectory));
// }
// try {
//
// // Move the file to the upload directory
// $picture->move($uploadDirectory, $customFilename);
//
// // Update user entity with the file path (relative to public directory)
// $user->setPictureUrl('uploads/profile/' . $customFilename);
//
//
}
/**
@ -281,12 +292,91 @@ class UserService
$user = $uo->getUsers();
$users[] = [
'entity' => $user,
'entity' => $user,
'connected' => $this->isUserConnected($user->getUserIdentifier()),
'isActive' => (bool)$uo->isActive(),
'isActive' => (bool)$uo->isActive(),
];
}
return $users;
}
/**
* Handle user's role synchronization
*
* @param User $user
* @param string $role
* @param boolean $add
* @return void
*/
public function syncUserRoles(User $user, string $role, bool $add): void
{
$roleFormatted = $this->formatRoleString($role);
if ($add) {
// Add the main role if not already present
if (!in_array($roleFormatted, $user->getRoles(), true)) {
$user->setRoles(array_merge($user->getRoles(), [$roleFormatted]));
}
// If SUPER ADMIN is given → ensure ADMIN is also present
if ($roleFormatted === 'ROLE_SUPER_ADMIN' && !in_array('ROLE_ADMIN', $user->getRoles(), true)) {
$user->setRoles(array_merge($user->getRoles(), ['ROLE_ADMIN']));
}
} else {
// Remove the role if present and not used elsewhere
if (in_array($roleFormatted, $user->getRoles(), true)) {
$uos = $this->entityManager->getRepository(UsersOrganizations::class)
->findBy(['users' => $user, 'isActive' => true]);
$hasRole = false;
foreach ($uos as $uo) {
$uoa = $this->entityManager->getRepository(UserOrganizatonApp::class)
->findBy([
'userOrganization' => $uo,
'isActive' => true,
'role' => $this->entityManager->getRepository(Roles::class)
->findOneBy(['name' => $role]),
]);
if ($uoa) {
$hasRole = true;
break;
}
}
// Only remove globally if no other app gives this role
if (!$hasRole) {
$roles = $user->getRoles();
$roles = array_filter($roles, fn($r) => $r !== $roleFormatted);
$user->setRoles($roles);
}
}
}
}
/**
* Format role string to match the ROLE_ convention
*/
public function formatRoleString(string $role): string
{
$role = str_replace(' ', '_', trim($role));
$role = strtoupper($role);
if (str_starts_with($role, 'ROLE_')) {
return $role;
}
return 'ROLE_' . $role;
}
public function revokeUserTokens(String $userIdentifier)
{
$tokens = $this->entityManager->getRepository(AccessToken::class)->findBy([
'userIdentifier' => $userIdentifier,
'revoked' => false
]);
foreach ($tokens as $token) {
$token->revoke();
}
}
}

View File

@ -1,19 +1,27 @@
{% block body %}
<div class="card">
<div class="card-header">
<div class="card-title">
<h3><img width=10% src="{{ asset(application.logoUrl) }}" alt="Logo {{ application.title }}">
{{ application.name }}</h3>
<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) }}"
alt="Logo application">
<div class="card-title">
<h1>{{ application.name }}</h1>
</div>
</div>
<div class="card-body d-flex flex-column align-items-center">
<p class="card-text">{{ application.description|raw }}</p>
<div>
<a href="http://{{ application.subDomain }}.solutions-easy.moi" class="btn btn-primary me-2">Accéder à
l'application</a>
{% if is_granted("ROLE_SUPER_ADMIN") %}
<a href="{{ path('application_edit', {'id': application.id}) }}" class="btn btn-secondary">Modifier
l'application</a>
{% endif %}
</div>
</div>
</div>
<div class="card-body d-flex flex-column align-items-center">
<p class="card-text">{{ application.description }}</p>
<div>
<a href="http://{{ application.subDomain }}.solutions-easy.moi" class="btn btn-primary me-2">Accéder à l'application</a>
</div>
</div>
</div>
{% endblock %}

View File

@ -1,24 +1,54 @@
{% block body %}
<div class="card">
<div class="card-header">
<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) }}"
alt="Logo application">
<div class="card-title">
<h3><img width=10% src="{{ asset(application.entity.logoUrl) }}" alt="Logo application">
{{ application.entity.name }}</h3>
<h1>{{ application.entity.name }}</h1>
</div>
</div>
<div class="card-body d-flex flex-column align-items-center">
<p class="card-text">{{ application.entity.descriptionSmall }}</p>
{% if application.hasAccess %}
<div >
<a href="http://{{ application.entity.subDomain }}.solutions-easy.moi" class="btn btn-primary me-2">Y
accéder</a>
<a href="#" class="btn btn-secondary">Gérer l'application</a>
</div>
<p class="card-text">{{ application.entity.descriptionSmall|raw }}</p>
{% if application.hasAccess %}
{% if is_granted("ROLE_SUPER_ADMIN") %}
<form method="POST"
action="{{ path('application_remove', {'id': application.entity.id}) }}"
data-controller="application"
data-application-application-value="{{ application.entity.name }}"
data-application-organization-value="{{ organization.name|capitalize }}"
data-action="submit->application#handleRemoveSubmit"
style="display: inline-block;">
<input type="hidden" name="organizationId" value="{{ organization.id }}">
<button class="btn btn-secondary" type="submit" data-application-target="submitBtn">
Retirer l'accès
</button>
</form>
{% else %}
<div>
<a href="http://{{ application.entity.subDomain }}.solutions-easy.moi"
class="btn btn-primary me-2">Y accéder</a>
{# TODO: regarder comment gérer l'attribution de droit d'accès au utilisateur à une appli client pour pune organization #}
</div>
{% endif %}
{% else %}
<a href="#" class="btn btn-primary">Demander l'accès</a>
{#TODO: page d'accès#}
{% if is_granted("ROLE_SUPER_ADMIN") %}
<form method="POST"
action="{{ path('application_authorize', {'id': application.entity.id}) }}"
data-controller="application"
data-application-application-value="{{ application.entity.name }}"
data-application-organization-value="{{ organization.name|capitalize }}"
data-action="submit->application#handleAuthorizeSubmit"
style="display: inline-block;">
<input type="hidden" name="organizationId" value="{{ organization.id }}">
<button class="btn btn-secondary" type="submit" data-application-target="submitBtn">
Autoriser l'accès
</button>
</form>
{% else %}
<a href="#" class="btn btn-primary">Demander l'accès</a>
{% endif %}
{% endif %}
</div>
</div>

View File

@ -0,0 +1,90 @@
{% extends 'base.html.twig' %}
{% block body %}
<div class="w-100 h-100 p-5 m-auto">
<div class="card">
<div class="card-title shadow-sm p-3 d-flex justify-content-between align-items-center">
<h2>Modifier l'application</h2>
</div>
<div class="card-body">
<form method="post" action="{{ path('application_edit', {'id': apps.id}) }}" data-controller="application">
<input name="name" type="text" value="{{ apps.name }}" class="form-control mb-3" required>
{# Description (full) #}
<div class="card">
<div class="card-header">
<div id="toolbar-description" class="border-0">
<button class="ql-bold"></button>
<button class="ql-italic"></button>
<button class="ql-underline"></button>
<button class="ql-strike"></button>
<button class="ql-blockquote"></button>
<button class="ql-code-block"></button>
<select class="ql-header">
<option value="1"></option>
<option value="2"></option>
<option selected></option>
</select>
<select class="ql-size">
<option value="small"></option>
<option selected></option>
<option value="large"></option>
<option value="huge"></option>
</select>
<select class="ql-color"></select>
<select class="ql-background"></select>
<select class="ql-align"></select>
<button class="ql-link"></button>
<button class="ql-image"></button>
<button class="ql-clean"></button>
</div>
</div>
<div class="card-body">
<div id="editor-description" class="form-control border-0" style="min-height: 200px;">
{{ apps.description|raw }}
</div>
<textarea name="description" data-application-target="hidden" class="d-none">{{ apps.description|raw }}</textarea>
</div>
</div>
{# Description Small #}
<div class="card mt-3">
<div class="card-header">
<div id="toolbar-descriptionSmall" class="border-0">
<button class="ql-bold"></button>
<button class="ql-italic"></button>
<button class="ql-underline"></button>
<select class="ql-header">
<option value="2"></option>
<option selected></option>
</select>
<button class="ql-clean"></button>
</div>
</div>
<div class="card-body">
<div id="editor-descriptionSmall" class="form-control border-0" style="min-height: 120px;">
{{ apps.descriptionSmall|raw }}
</div>
<textarea name="descriptionSmall" data-application-target="hidden" class="d-none">{{ apps.descriptionSmall|raw }}</textarea>
</div>
</div>
<input type="file" name="logo" class="form-control mb-3 mt-3">
<button type="submit" class="btn btn-primary mt-3">Enregistrer</button>
</form>
</div>
</div>
</div>
{% endblock %}
{% block title %}
{% endblock %}

View File

@ -5,19 +5,21 @@
{% endblock %}
{% block body %}
<div class="row m-5">
<div class="container mt-5">
<h1 class="mb-4">Bienvenue sur la suite Easy</h1>
<p class="lead">Ici, vous pouvez trouver toutes nos applications à un seul endroit!</p>
</div>
{% for application in applications %}
<div class="col-6 mb-3">
{% include 'application/InformationCard.html.twig' with {
application: application
} %}
<div class="w-100 h-100 p-5 m-auto">
<div class="row m-5">
<div class="container mt-5">
<h1 class="mb-4">Bienvenue sur la suite Easy</h1>
<p class="lead">Ici, vous pouvez trouver toutes nos applications à un seul endroit!</p>
</div>
{% endfor %}
{% for application in applications %}
<div class="col-6 mb-3">
{% include 'application/InformationCard.html.twig' with {
application: application
} %}
</div>
{% endfor %}
</div>
</div>
{% endblock %}

View File

@ -13,10 +13,12 @@
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Lato:ital,wght@0,100;0,300;0,400;0,700;0,900;1,100;1,300;1,400;1,700;1,900&display=swap" rel="stylesheet">
<link href="https://cdn.quilljs.com/1.3.7/quill.snow.css" rel="stylesheet">
{% endblock %}
{% block javascripts %}
{% block importmap %}{{ importmap('app') }}{% endblock %}
<script src="https://cdn.quilljs.com/1.3.7/quill.min.js"></script>
{% endblock %}
</head>
<body data-application="{{application}}">

View File

@ -21,21 +21,21 @@
</div>
</li>
</ul>
<ul class="navbar-nav w-auto m-auto">
<li class="nav-item nav-search d-none d-lg-block">
<div class="input-group">
<div id="navbar-search-icon" class="input-group-prepend hover-cursor">
<span id="search">
<i style="width:22px; height:22px">{{ ux_icon('ix:project-arrow-diagonal-top-right', {height: '22px', width: '22px'}) }}</i>
</span>
</div>
<select id="change-project" class="form-control">
<option>Projet 1</option>
<option>Projet 2</option>
</select>
</div>
</li>
</ul>
{# <ul class="navbar-nav w-auto m-auto">#}
{# <li class="nav-item nav-search d-none d-lg-block">#}
{# <div class="input-group">#}
{# <div id="navbar-search-icon" class="input-group-prepend hover-cursor">#}
{# <span id="search">#}
{# <i style="width:22px; height:22px">{{ ux_icon('ix:project-arrow-diagonal-top-right', {height: '22px', width: '22px'}) }}</i>#}
{# </span>#}
{# </div>#}
{# <select id="change-project" class="form-control">#}
{# <option>Projet 1</option>#}
{# <option>Projet 2</option>#}
{# </select>#}
{# </div>#}
{# </li>#}
{# </ul>#}
<ul class="navbar-nav navbar-nav-right">
<li class="nav-item d-flex">
<img id="logo_orga" class="m-auto" src="{{asset('logo_org/logo-sudalys.png')}}" alt="logo organisation">
@ -64,7 +64,12 @@
<a id="profileDropdown" class="nav-link count-indicator dropdown-toggle m-auto" href="#" data-bs-toggle="dropdown">
<div id="profil" class="rounded-circle bg-secondary d-flex">
{% if app.user %}
<p class="text-light m-auto">{{ app.user.email|first|capitalize }}</p>
{# {% if app.user.pictureUrl is defined %}#}
{# <img src="{{ aws_url ~ app.user.pictureUrl }}" alt="User profile pic"#}
{# class="rounded-circle" style="width:40px; height:40px;">#}
{# {% else %}#}
<p class="text-light m-auto">{{ app.user.email|first|capitalize }}</p>
{# {% endif %}#}
{% endif %}
</div>
</a>
@ -88,7 +93,7 @@
</div>
</div>
<a class="dropdown-item" style="padding-left: 8px;" href="{{ path('sso_logout') }}">
<i class="me-2">{{ ux_icon('material-symbols:logout', {height: '20px', width: '20px'}) }}</i>
<i class="me-2">{{ ux_icon('material-symbols:logout', {height: '20px', width: '20px'}) }}</i>
Deconnexion
</a>
</div>

View File

@ -10,7 +10,7 @@
<p>Aucune activité récente.</p>
{% else %}
{% set sortedActivities = activities|sort((a, b) => a.date <=> b.date)|reverse %}
<ul class="list-group">
<ul class="list-group gap-2">
{% for activity in sortedActivities%}
{% include 'user/organization/userActivity.html.twig' with {
activityTime: activity.date,

View File

@ -1,9 +1,9 @@
{% extends 'base.html.twig' %}
{% block body %}
<div class=" col-md-10 m-auto p-5">
<div class="card">
<div class="card-title shadow-sm p-3 d-flex justify-content-between align-items-center">
<div class="w-100 h-100 p-5 m-auto">
<div class="card p-3 m-3">
<div class="card-header border-0">
<h2>Modifier l'organisation</h2>
{% if is_granted("ROLE_SUPER_ADMIN") %}
{# <a href="{{ path('organization_delete', {'id': organization.id}) }}" class="btn btn-danger">Supprimer</a>#}

View File

@ -3,60 +3,39 @@
{% block title %} Gestion des organisations {% endblock %}
{% block body %}
<div class="w-100 h-100 p-5 m-auto" data-controller="organization">
<div class="d-flex justify-content-between align-items-center mb-4">
<h1>Gestion des organisations</h1>
{% if is_granted("ROLE_SUPER_ADMIN") %}
<a href="{{ path('organization_new') }}" class="btn btn-primary">Ajouter une organisation</a>
{% endif %}
</div>
{% if organizations|length == 0 %}
<tr>
<td colspan="4" class="text-center">Aucune organisation trouvée.</td>
<td colspan="4" class="text-center">
<a href="{{ path('organization_new') }}" class="btn btn-primary">Créer une organisation</a>
</td>
</tr>
{% else %}
<table class="table align-middle shadow">
<thead class="table-light shadow-sm">
<tr>
<th>Logo</th>
<th>Nom</th>
<th>Email</th>
<th>Visualiser</th>
<th>Status</th>
</tr>
</thead>
<tbody>
{% for organization in organizations %}
<tr>
<td>
{% if organization.logoUrl %}
<img src="{{ asset(organization.logoUrl) }}" alt="Organization logo"
class="rounded-circle" style="width:40px; height:40px;">
{% endif %}
</td>
<td>{{ organization.name }}</td>
<td>{{ organization.email }}</td>
<td>
{% if organization.active %}
<span class="badge bg-success">Active</span>
{% else %}
<span class="badge bg-danger">Inactive</span>
{% endif %}
</td>
<td>
<a href="{{ path('organization_show', {'id': organization.id}) }}"
class="p-3 align-middle color-primary">
{{ ux_icon('fa6-regular:eye', {height: '30px', width: '30px'}) }}
</a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endif %}
<div class="w-100 h-100 p-5 m-auto">
<div class="card p-3 m-3 border-0">
<div class="card-header d-flex justify-content-between align-items-center border-0">
<div class="card-title">
<h1>Gestion des organisations</h1>
</div>
{% if is_granted("ROLE_SUPER_ADMIN") %}
<a href="{{ path('organization_new') }}" class="btn btn-primary">Ajouter une organisation</a>
{% endif %}
</div>
<div class="card-body ">
{% if organizationsData|length == 0 %}
{# style présent juste pour créer de l'espace #}
<div class="div text-center my-5 py-5">
<h1 class="my-5 ty-5"> Aucune organisation trouvée. </h1>
<a href="{{ path('organization_new') }}" class="btn btn-primary">Créer une organisation</a>
</div>
{% else %}
<div id="tabulator-org" data-controller="organization"
data-organization-data-value="{{ organizationsData|json_encode(constant("JSON_UNESCAPED_UNICODE"))|e("html_attr") }}"
data-organization-aws-value="{{ aws_url }}"></div>
{% endif %}
</div>
</div>
</div>
{% endblock %}

View File

@ -3,11 +3,14 @@
{% block title %}Ajouter une organisation{% endblock %}
{% block body %}
<div class=" col-md-10 m-auto p-5">
<div class="card">
<div class="card-title shadow-sm p-3 d-flex justify-content-between align-items-center">
<h1>Ajouter une organisation</h1>
<div class="w-100 h-100 p-5 m-auto">
<div class="card p-3 m-3">
<div class="card-header border-0">
<div class="card-title d-flex justify-content-between align-items-center">
<h1>Ajouter une organisation</h1>
</div>
</div>
<div class="card-body">
<form method="post" action="{{ path('organization_new') }}" enctype="multipart/form-data">
{{ form_start(form) }}

View File

@ -1,23 +1,23 @@
{% extends 'base.html.twig' %}
{% block body %}
<div class="col-md-12 m-auto p-5">
<div class="col d-flex justify-content-between align-items-center ">
<div class="w-100 h-100 p-5 m-auto">
<div class="col d-flex justify-content-between align-items-center">
<div class="d-flex ">
{% if organization.logoUrl %}
<img src="{{ asset(organization.logoUrl) }}" alt="Organization logo"
<img src="{{ aws_url ~ organization.logoUrl }}" alt="Organization logo"
class="rounded-circle" style="width:40px; height:40px;">
{% endif %}
<h1 class="mb-4 ms-3">{{ organization.name|title }} - Dashboard</h1>
</div>
<div>
<div class="d-flex gap-2">
{% if is_granted("ROLE_SUPER_ADMIN") %}
<a href="{{ path('organization_edit', {'id': organization.id}) }}" class="btn btn-primary">Gérer
l'organisation</a>
<form method="POST" action="{{ path('organization_delete', {'id': organization.id}) }}"
onsubmit="return confirm('Vous allez supprimer cette organisation, êtes vous sûre?');"
style="display: inline-block;">
<button class="btn btn-danger" type="submit">Supprimer l'organisation</button>
<button class="btn btn-secondary" type="submit">Supprimer l'organisation</button>
</form>
{% if organization.active %}
<form method="POST" action="{{ path('organization_deactivate', {'id': organization.id}) }}"
@ -39,35 +39,61 @@
</div>
</div>
{# USER ROW #}
{# single row so that activity and users tabs are next to each other#}
<div class="row">
{# User tables #}
<div class="col-9">
<div class="row mb-4">
<div class="col mb-3 mb-sm-0">
{% include 'user/userListSmall.html.twig' with {
title: 'Nouveaux utilisateurs',
users: newUsers,
empty_message: 'Aucun nouveaux utilisateurs trouvé.',
organizationId: organization.id
} %}
<div class="row mb-3 d-flex gap-2 ">
<div class="col mb-3 card">
<div class="card-header">
<h2>
Nouveaux utilisateurs
</h2>
</div>
<div class="card-body">
<div id="tabulator-userListSmall" data-controller="user"
data-user-aws-value="{{ aws_url }}"
data-user-new-value="true"
data-user-list-small-value="true"
data-user-org-id-value="{{ organization.id }}">
</div>
</div>
</div>
<div class="col mb-3 mb-sm-0">
{% include 'user/userListSmall.html.twig' with {
title: 'Administrateurs',
users: adminUsers,
empty_message: 'Aucun administrateur trouvé.'
} %}
<div class="col mb-3 card">
<div class="card-header">
<h2>
Administrateurs
</h2>
</div>
<div class="card-body">
<div id="tabulator-userListSmallAdmin" data-controller="user"
data-user-aws-value="{{ aws_url }}"
data-user-admin-value="true"
data-user-list-small-value="true"
data-user-org-id-value="{{ organization.id }}">
</div>
</div>
</div>
</div>
{# <div class="m-auto"> #}
{% include 'user/userList.html.twig' with {
title: 'Mes utilisateurs',
organizationId: organization.id,
empty_message: 'Aucun utilisateurs trouvé.'
} %}
{# </div> #}
<div class="row mb-3 card">
<div class="card-header">
<h2>
Mes utilisateurs
</h2>
</div>
<div class="card-body">
<div id="tabulator-userListOrganization" data-controller="user"
data-user-aws-value="{{ aws_url }}"
data-user-statut-value="true"
data-user-list-organization-value="true"
data-user-org-id-value="{{ organization.id }}">
</div>
</div>
</div>
{# APPLICATION ROW #}
<div class="row ">
{# TODO: Weird gap not going away#}
<div class="row mb-3 ">
{% for application in applications %}
<div class="col-6 mb-3">
{% include 'application/appSmall.html.twig' with {
@ -77,7 +103,7 @@
{% endfor %}
</div>
</div>
{# Activities col#}
<div class="col-3 m-auto">
{% include 'organization/activity.html.twig' with {
title: 'Activités récentes',
@ -86,9 +112,12 @@
</div>
</div>
{# Ne pas enlever le 2ème /div#}
</div>
</div>
{% endblock %}

View File

@ -1,25 +1,27 @@
{% block body %}
{% set roles = uoa.roles %}
<div class="card col-6 mb-4 me-4">
<div class="card-header">
<div class="d-flex">
{% if uoa.application.logoUrl %}
<img src="{{ asset(uoa.application.logoUrl) }}" alt="Logo {{ uoa.application.name }}"
class="rounded-circle me-2" style="width:40px; height:40px;">
{% endif %}
<h1 class="mb-0">{{ uoa.application.name|title }}</h1>
{# TODO: compare style with/without border#}
<div class="card col-6">
<div class="card-header d-flex gap-2">
{% if uoa.application.logoUrl %}
<img src="{{ asset(uoa.application.logoUrl) }}" alt="Logo {{ uoa.application.name }}"
class="rounded-circle " style="width:50px; height:50px;">
{% endif %}
<div class="card-title">
<h1>{{ uoa.application.name|title }}</h1>
</div>
</div>
<div class="card-body">
<div class="row">
<p><b> Description : </b>{{ uoa.application.description|default('Aucune description disponible.') }}</p>
{# TODO: pb avec le |raw retour à la ligne#}
{# maybe remove Description label #}
<p><b> Description : </b>{{ uoa.application.descriptionSmall|default('Aucune description disponible.')|raw }}</p>
</div>
{% if is_granted('ROLE_ADMIN') %}
{# TODO: Can be turn into an ajax function#}
<form method="POST"
action="{{ path('user_application_role', { id : uoa.uoId }) }}"
onsubmit="return confirm('Attention, si le role utilisateur ' +
'n\'est pas attribué, l\'utilisateur ne pourra plus accéder à l\'application. Êtes-vous sûr ?');"
data-controller="user"
data-user-roles-array-value="{{ uoa.rolesArray|json_encode }}"
data-user-selected-role-ids-value="{{ uoa.selectedRoleIds|json_encode }}">

View File

@ -1,12 +1,15 @@
{% extends 'base.html.twig' %}
{% block body %}
<div class=" col-md-10 m-auto p-5">
<div class="card">
<div class="card-title shadow-sm p-3 d-flex justify-content-between align-items-center">
<h2>Modifier l'utilisateur</h2>
{# <a href="{{ path('user_delete', {'id': user.id}) }}" class="btn btn-danger">Supprimer</a>#}
<div class="w-100 h-100 p-5 m-auto">
<div class="card p-3">
<div class="card-header d-flex justify-content-between align-items-center">
<div class="card-title">
<h2>Modifier l'utilisateur</h2>
</div>
<a href="{{ path('user_delete', {'id': user.id}) }}" class="btn btn-danger m-3">Supprimer</a>
</div>
<div class="card-body">
{{ form_start(form, {'action': path('user_edit', {'id': user.id}), 'method': 'PUT'}) }}

View File

@ -3,14 +3,15 @@
{% block title %}User Profile{% endblock %}
{% block body %}
<div class="w-100 h-100 p-5 m-auto" data-controller="user">
<div class="d-flex justify-content-between align-items-center mb-4">
<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">
@ -21,6 +22,7 @@
{% include 'user/userList.html.twig' with {
title: org.name,
organizationId: org.id|default(null),
logo: org.logo|default(null),
users: org.users
} %}
{% endfor %}

View File

@ -0,0 +1,43 @@
{% 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 %}

View File

@ -3,19 +3,21 @@
{% block title %}Ajouter un utilisateur{% endblock %}
{% block body %}
<div class=" col-md-10 m-auto p-5">
<div class="card">
<div class="card-title shadow-sm p-3 d-flex justify-content-between align-items-center">
<h1>Ajouter un utilisateur</h1>
<div class="w-100 h-100 p-5 m-auto">
<div class="card p-3">
<div class="card-header d-flex justify-content-between align-items-center">
<div class="card-title">
<h1>Ajouter un utilisateur</h1>
</div>
</div>
<div class="card-body">
<form method="post" action="{{ path('user_new') }}" enctype="multipart/form-data">
{{ form_start(form) }}
{{ form_widget(form) }}
{% if organizationId is defined %}
<div class="form-group">
<input hidden type="text" value="{{ organizationId }}" name="organizationId">
</div>
<div class="form-group">
<input hidden type="text" value="{{ organizationId }}" name="organizationId">
</div>
{% endif %}
<button type="submit" class="btn btn-primary">Enregistrer</button>
{{ form_end(form) }}

View File

@ -1,50 +0,0 @@
{% extends 'base.html.twig' %}
{% block body %}
<div class="col-md-10 m-auto p-5">
<div class="card">
{% for uo in userOrganization %}
<div class="card-title shadow-sm p-3 d-flex justify-content-between align-items-center">
<h3>
Modification de : <b>{{ user.name|capitalize }} {{ user.surname|capitalize }} </b> chez
<b> {{ uo.organization.name }}</b>
</h3>
<a href="{{ path('user_organization_deactivate', {'id': uo.uoId}) }}" class="btn btn-danger">Supprimer</a>
</div>
<div class="card-body">
<form method="POST" action="{{ path('user_organization_edit', {'id' : uo.uoId}) }}"
data-controller="user"
data-user-roles-array-value="{{ rolesArray|json_encode }}"
data-user-selected-role-ids-value="{{ selectedRoleIds|json_encode }}"
data-user-applications-array-value="{{ appsArray|json_encode }}"
data-user-selected-application-ids-value="{{ selectedAppIds|json_encode }}">
<div class="mb-3">
<div class="form-group mb-3">
<label for="roles">Roles</label>
<select class="choices" data-type="select-multiple" id="roles" name="roles[]" multiple>
</select>
</div>
<div class="form-group mb-3">
<label for="applications">Applications</label>
<select class="choices" data-type="select-multiple" id="applications" name="applications[]"
multiple>
</select>
</div>
</div>
{# bouton d'envoie du formulaire#}
<button type="submit" class="btn btn-primary">Modifier</button>
{# <a href="{{ path('user_organization_list') }}" class="btn btn-secondary">Annuler</a>#}
</form>
</div>
</div>
{% endfor %}
</div>
{% endblock %}

View File

@ -1,89 +0,0 @@
{% block body %}
<div class="card col-4 mt-3 me-3" >
<div class="card-title shadow-sm p-3 d-flex justify-content-between align-items-center">
{# Affichage du nom de l'organisation et de l'icône de flèche #}
<div class="d-flex user-org-card"
style="cursor:pointer;" data-bs-toggle="collapse"
data-bs-target="#org-details-{{ organization.id }}" aria-expanded="false"
aria-controls="org-details-{{ organization.id }}">
<h2 class=" pe-2">{{ organization.name|capitalize }}</h2>
<i class="pt-2" id="arrow-icon-{{ organization.id }}">
{{ ux_icon('fa6-regular:circle-down', {height: '25px', width: '25px'}) }}
</i>
</div>
{% if is_granted("ROLE_ADMIN") %}
<a href="{{ path('user_organization_edit', {'id': uoId}) }}" class="btn btn-primary" >Modifier</a>
{% endif %}
</div>
{# Information principale sur l'utilisateur dans l'organisation#}
<div class="card-body">
{# Affichage du plus haut role #}
<p><b>Role:</b>
{% if roles|length > 0 %}
{% set firstRole = roles[0] %}
{% if firstRole.name == "SUPER ADMIN" %}
<span class="badge bg-danger">Super Administrateur</span>
{% elseif firstRole.name == "ADMIN" %}
<span class="badge bg-danger">Administrateur</span>
{% else %}
<span class="badge bg-primary">{{ firstRole.name|capitalize }}</span>
{% endif %}
{% else %}
Aucun rôle
{% endif %}
</p>
{# Affichage des applications dont l'utilisateur à accès #}
<div class="d-flex">
{% if apps is not empty %}
{% for app in apps %}
<img src="{{ asset(app.logoUrl) }}" alt="Logo {{ app.name }}" class="img-fluid ms-2" style="height: 30px; width: 30px;">
{% endfor %}
{% else %}
Aucune application associée.
{% endif %}
</div>
</div>
{# Détails supplémentaires sur l'organisation #}
<div class="collapse card-body border-top" id="org-details-{{ organization.id }}">
{% if roles|length > 1 %}
<p><b>Autres rôles:</b>
{% for role in roles|slice(1) %}
{% if role.name == "SUPER ADMIN"%}
<span class="badge bg-danger">Super Administrateur</span>
{% elseif role.name == "ADMIN" %}
<span class="badge bg-danger">Administrateur</span>
{% else %}
<span class="badge bg-primary">{{ role.name|capitalize }}</span>
{% endif %}
{% if not loop.last %} - {% endif %}
{% endfor %}
</p>
{% endif %}
<p><b>Membre depuis:</b> {{ organization.createdAt|date('d/m/Y') }}</p>
</div>
</div>
{% endblock %}
{% block javascript %}
<script>
document.addEventListener('DOMContentLoaded', function() {
var collapseEl = document.getElementById('org-details-{{ organization.id }}');
var arrowEl = document.getElementById('arrow-icon-{{ organization.id }}');
if (collapseEl && arrowEl) {
collapseEl.addEventListener('show.bs.collapse', function () {
arrowEl.innerHTML = `{{ ux_icon('fa6-regular:circle-up', {height: '25px', width: '25px'})|e('js') }}`;
});
collapseEl.addEventListener('hide.bs.collapse', function () {
arrowEl.innerHTML = `{{ ux_icon('fa6-regular:circle-down', {height: '25px', width: '25px'})|e('js') }}`;
});
}
});
</script>
{% endblock %}

View File

@ -2,45 +2,61 @@
{% block body %}
<div class="col-md-10 m-auto p-5">
<div class="w-100 h-100 p-5 m-auto">
<div class="card p-3 m-3 border-0">
{% if is_granted("ROLE_ADMIN") %}
<div class="col d-flex justify-content-between align-items-center ">
<h1 class="mb-4">Gestion Utilisateur</h1>
<div>
{% if is_granted("ROLE_SUPER_ADMIN") %}
<a href="{{ path('user_delete', {'id': user.id}) }}" class="btn btn-danger">Supprimer</a>
{% if user.active %}
<a href="{{ path('user_deactivate', {'id': user.id}) }}"
class="btn btn-danger">Désactiver l'utilisateur</a>
{% else %}
<a href="{{ path('user_activate', {'id': user.id}) }}" class="btn btn-success">Activer l'utilisateur</a>
{% if is_granted("ROLE_ADMIN") %}
<div class="card-header border-0 d-flex justify-content-between align-items-center ">
<div class="card-title">
<h1>Gestion Utilisateur</h1>
</div>
<div class="d-flex gap-2">
{% if is_granted("ROLE_SUPER_ADMIN") %}
<a href="{{ path('user_delete', {'id': user.id}) }}"
class="btn btn-secondary">Supprimer</a>
{% if user.active %}
<a href="{{ path('user_deactivate', {'id': user.id}) }}"
class="btn btn-secondary">Désactiver l'utilisateur</a>
{% else %}
<a href="{{ path('user_activate', {'id': user.id}) }}" class="btn btn-primary ">Activer
l'utilisateur</a>
{% endif %}
{% endif %}
{% endif %}
</div>
</div>
{% endif %}
<div class="card-body">
{% include 'user/userInformation.html.twig' %}
<div class="card border-0 no-header-bg ">
<div class="card-header">
{% if orgs|length >0 %}
<div class="card-title">
<h1>Vos applications</h1>
</div>
{% else %}
<div class="card-title">
<h1>Aucune application</h1>
</div>
{% endif %}
</div>
<div class="d-flex gap-2 card-body">
{% for uoa in uoas %}
{% include 'user/application/information.html.twig' %}
{% endfor %}
</div>
</div>
</div>
{% endif %}
{% include 'user/userInformation.html.twig' %}
{% if orgs|length >0 %}
<h1 class="mt-5 mb-4">Vos applications</h1>
{% else %}
<h1 class="mt-5 mb-4">Aucune application</h1>
{% endif %}
<div class="d-flex ">
{% for uoa in uoas %}
{% include 'user/application/information.html.twig' %}
{% endfor %}
</div>
</div>
{% endblock %}
{% block title %}

View File

@ -1,39 +1,48 @@
{% block body %}
<div class="card border-0">
<div class="card-title shadow-sm p-3 d-flex justify-content-between align-items-center">
<div class="d-flex">
<img src="{{ asset(user.pictureUrl) }}" alt="user" class="me-3 rounded-circle"
style="width: 50px; height: 50px;">
<h2>{{ user.surname|capitalize }} {{ user.name|capitalize }}</h2>
</div>
<div class="d-flex gap-2">
{% if organizationId is not null %}
{% if uoActive %}
<form method="post" action="{{ path('user_deactivate_organization', {'id': user.id}) }}"
onsubmit="return confirm('Vous allez retirer l\'utilisateur de cette organisation, êtes vous sûre?');">
<input type="hidden" name="organizationId" value="{{ organizationId }}">
<button class="btn btn-danger" type="submit">Désactiver l'utilisateur de l'organisation</button>
</form>
{% else %}
<form method="post" action="{{ path('user_activate_organization', {'id': user.id}) }}"
onsubmit="return confirm('Vous allez activer cette utilisateur dans votre organisation, êtes vous sûre?');">
<input type="hidden" name="organizationId" value="{{ organizationId }}">
<button class="btn btn-primary" type="submit">Activer l'utilisateur de l'organisation</button>
</form>
<div class="card no-header-bg border-0 ">
<div class="card-header d-flex justify-content-between align-items-center">
<div class="d-flex gap-2">
{% if user.pictureUrl is not empty %}
<img src="{{ aws_url ~ user.pictureUrl }}" alt="user" class="rounded-circle"
style="width:40px; height:40px;">
{% endif %}
{% endif %}
<a href="{{ path('user_edit', {'id': user.id, 'organizationId': organizationId}) }}" class="btn btn-primary">Modifier</a>
<div class="card-title ">
<h2>{{ user.surname|capitalize }} {{ user.name|capitalize }}</h2>
</div>
</div>
<div class="d-flex gap-2">
{% if organizationId is not null %}
{% if uoActive %}
<form method="post" action="{{ path('user_deactivate_organization', {'id': user.id}) }}"
onsubmit="return confirm('Vous allez retirer l\'utilisateur de cette organisation, êtes vous sûre?');">
<input type="hidden" name="organizationId" value="{{ organizationId }}">
<button class="btn btn-secondary" type="submit">Désactiver l'utilisateur de l'organisation
</button>
</form>
{% else %}
<form method="post" action="{{ path('user_activate_organization', {'id': user.id}) }}"
onsubmit="return confirm('Vous allez activer cette utilisateur dans votre organisation, êtes vous sûre?');">
<input type="hidden" name="organizationId" value="{{ organizationId }}">
<button class="btn btn-primary" type="submit">Activer l'utilisateur de l'organisation
</button>
</form>
{% endif %}
{% endif %}
<a href="{{ path('user_edit', {'id': user.id, 'organizationId': organizationId}) }}"
class="btn btn-primary">Modifier</a>
</div>
</div>
<div class="card-body ">
<p><b>Email: </b>{{ user.email }}</p>
<p><b>Dernière connection: </b>{{ user.lastConnection|date('d/m/Y') }}
à {{ user.lastConnection|date('H:m:s') }} </p>
<p><b>Compte crée le: </b>{{ user.createdAt|date('d/m/Y') }}</p>
<p><b>Numéro de téléphone: </b>{{ user.phoneNumber ? user.phoneNumber : 'Non renseigné' }}</p>
</div>
</div>
<div class="card-body">
<p><b>Email: </b>{{ user.email }}</p>
<p><b>Dernière connection: </b>{{ user.lastConnection|date('d/m/Y') }}
à {{ user.lastConnection|date('H:m:s') }} </p>
<p><b>Compte crée le: </b>{{ user.createdAt|date('d/m/Y') }}</p>
<p><b>Numéro de téléphone: </b>{{ user.phoneNumber ? user.phoneNumber : 'Non renseigné' }}</p>
</div>
</div>
{% endblock %}

View File

@ -1,26 +1,33 @@
{% block body %}
<div class="card border-0 p-3 mb-4">
<div class="card p-3 mb-3">
{% if title is defined %}
<div class="card-title d-flex justify-content-between align-items-center ">
<h3>{{ title }}</h3>
{% if organizationId %}
<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 %}
{% endif %}
<h3 class="ms-3">{{ title|capitalize }}</h3>
{% if organizationId %}
{% endif %}
</div>
</div>
{% endif %}
<div class="card-body">
<table class="table align-middle ">
<thead class="table-light">
<thead>
<tr>
<th>Picture</th>
<th>Surname</th>
<th>Name</th>
<th>Profile</th>
<th>Nom</th>
<th>Prénom</th>
<th>Email</th>
<th>Statut</th>
<th>Visualiser</th>
<th>Action</th>
</tr>
</thead>
<tbody>
@ -33,7 +40,7 @@
<tr>
<td>
{% if user.entity.pictureUrl %}
<img src="{{ asset(user.entity.pictureUrl) }}" alt="User profile pic"
<img src="{{ aws_url ~ user.entity.pictureUrl }}" alt="User profile pic"
class="rounded-circle"
style="width:40px; height:40px;">
{% else %}
@ -57,7 +64,7 @@
<span class="badge bg-secondary">Inactif</span>
{% endif %}
{% else %}
<span class="badge bg-danger">Désactivé</span>
<span class="badge bg-secondary">Désactivé</span>
{% endif %}
{# if no organization link #}
{% else %}

View File

@ -1,56 +0,0 @@
{% block body %}
<div class="card border-0">
<div class="card-title p-3 d-flex justify-content-between align-items-center ">
<h3>{{ title }}</h3>
{% if organizationId is defined %}
<a href="{{ path('user_new', {'organizationId': organizationId}) }}" class="btn btn-primary">Ajouter un utilisateur</a>
{% endif %}
</div>
<div class="card-body">
<table class="table align-middle table-borderless">
<thead class="table-light">
<tr>
<th>Picture</th>
<th>Email</th>
<th>Visualiser</th>
</tr>
</thead>
<tbody>
{% if users|length == 0 %}
<tr>
<td colspan="3" class="text-center">{{ empty_message }}</td>
</tr>
{% else %}
{% for user in users %}
<tr>
<td>
{% if user.pictureUrl %}
<img src="{{ asset(user.pictureUrl) }}" alt="User profile pic"
class="rounded-circle" style="width:40px; height:40px;">
{% endif %}
</td>
<td>{{ user.email }}</td>
<td>
{% if organizationId is defined %}
<a href="{{ path('user_show', {'id': user.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.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 %}