Compare commits

..

202 Commits

Author SHA1 Message Date
Charles c673fcd83b Fused activate and deactived user for organization route 2025-11-26 09:20:05 +01:00
Charles f0ae5a8c8a Fused activate and deactived user route 2025-11-25 16:57:02 +01:00
Charles f6a2159177 Refactor new user function 2025-11-25 16:02:10 +01:00
Charles 9513067ac5 Removed unused variables 2025-11-25 15:48:19 +01:00
Charles 52b8ba0a10 Removed unused functions 2025-11-25 15:46:15 +01:00
Charles 70842d6fe9 corrected redirection logic 2025-11-25 15:26:22 +01:00
Charles 7da97b0d02 Corrected UO link deactivation logic 2025-11-25 15:11:36 +01:00
Charles f09dd20d2b Removed debugging console log 2025-11-25 15:04:13 +01:00
Charles 94c0fd7c42 Added remote filtering 2025-11-25 15:03:57 +01:00
Charles 9d541410c4 Added remote filtering to users in organization dashboard 2025-11-25 14:43:59 +01:00
Charles a24849abe3 uniformisation des cartes 2025-11-24 16:10:08 +01:00
Charles 83c4c221af uniformisation des cartes 2025-11-24 16:01:19 +01:00
Charles 9d394c34f4 Refactor user view 2025-11-24 15:53:19 +01:00
Charles 04b8b26d65 Correct bug due to refactor 2025-11-24 12:05:38 +01:00
Charles 82c7c15068 Correct Role names display 2025-11-24 08:56:59 +01:00
Charles 9d37a7c549 Correct mercure token route 2025-11-24 08:53:48 +01:00
Charles d884ff4155 correction visuel 2025-11-19 17:27:35 +01:00
Charles 7e08998005 gestion des roles et applications 2025-11-19 16:58:49 +01:00
Charles 8c9a5da604 remove useless file 2025-11-18 10:54:58 +01:00
Charles f2123e911d Updated notification style 2025-11-17 16:43:40 +01:00
Charles 9dc97c5843 add dynamic notification with mercure 2025-11-17 16:11:35 +01:00
Charles 3744d81035 update project to allow sending of email 2025-11-17 11:50:34 +01:00
Charles a6fdb59521 refactor 2025-11-05 15:02:21 +01:00
Charles f6ce0e6229 en -> fr 2025-11-04 11:16:42 +01:00
Charles 7db986468c Added logo to bucket 2025-11-04 10:34:56 +01:00
Charles 53c3180d33 Added logo to bucket 2025-11-04 09:39:18 +01:00
Charles 8193e339b0 Added log to create user 2025-10-29 11:18:15 +01:00
Charles 346a05e51d start notification/mailing process 2025-10-29 10:34:05 +01:00
Charles 75e5921be1 added statut to user organization link 2025-10-29 10:09:28 +01:00
Charles 2d84ee8ec4 Refactor tabulator fr 2025-10-29 09:37:43 +01:00
Charles cb7afab382 update index page 2025-10-29 09:29:51 +01:00
Charles 00ed7ef491 Added new user in organization 2025-10-29 09:25:02 +01:00
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
Charles 307e615fb3 redirect on login 2025-09-05 11:08:27 +02:00
Charles 20bc6e92bc Display roles for user only 2025-09-05 10:34:09 +02:00
Charles 1788ec9062 Manage roles in application 2025-09-05 10:01:59 +02:00
Charles 3ef774d7e0 Refactor 2025-09-04 15:15:37 +02:00
Charles dc5eb702a3 Index des applications 2025-09-04 14:59:17 +02:00
Charles 346d89f42e Refactor 2025-09-04 12:24:01 +02:00
Charles ec29f42f90 Redirect to Organization Dashboard 2025-09-04 11:43:42 +02:00
Charles c75eda74a3 Display logic updated 2025-09-04 11:38:25 +02:00
Charles 633c255598 Add organization status 2025-09-04 10:18:40 +02:00
Charles 5e52386233 activate/deactivate organizations 2025-09-04 10:17:24 +02:00
Charles 1008d636a6 Delete organization 2025-09-04 09:52:56 +02:00
Charles 3ca5eea877 Log action 2025-09-04 09:11:37 +02:00
Charles 0bcab27a1d Security correction 2025-09-03 16:00:42 +02:00
Charles eaff14acc6 add user to organization 2025-09-03 15:51:51 +02:00
Charles 889010b5ad Refactor 2025-09-03 14:39:06 +02:00
Charles a540bb5d9e Show an organization informations 2025-09-03 14:38:16 +02:00
Charles 9257709605 Adjust Admin Logic 2025-09-03 10:30:24 +02:00
Charles 1516e8c890 Edit organization 2025-09-03 10:13:53 +02:00
Charles 6964bc0214 Creation organizations 2025-09-03 10:03:55 +02:00
Charles febd2ad6b2 bug correction user creation 2025-09-03 09:27:15 +02:00
Charles e33b0b8248 Display all organization for Admin 2025-08-29 12:09:21 +02:00
Charles 8d095a368f Display all organization for super Admin 2025-08-29 11:02:19 +02:00
Charles 5a7be977ba Activate users 2025-08-29 09:22:15 +02:00
Charles 1e33782f75 display inactive users 2025-08-29 08:53:00 +02:00
Charles 245f044a40 Delete User 2025-08-28 16:08:06 +02:00
Charles 446f585cc9 activate a user from organization 2025-08-28 15:44:51 +02:00
Charles 218923dfb7 deactivate a user from organization 2025-08-28 14:40:08 +02:00
Charles 84e5d7c87a Correction 2025-08-28 09:21:38 +02:00
Charles f52ad375b4 profile picture 2025-08-28 09:21:15 +02:00
Charles 7b7f58363a Deactivate user 2025-08-27 16:49:16 +02:00
Charles 52f3d2a3de create user 2025-08-27 15:30:07 +02:00
Charles 3d832b4280 Edit user data 2025-08-27 15:23:15 +02:00
Charles 9dd820d47f Edit user data 2025-08-27 14:52:46 +02:00
Charles 3b1a3dee9a Added log action 2025-08-27 14:06:10 +02:00
Charles 71c6f82b77 Display indivodual user informations 2025-08-27 13:56:08 +02:00
Charles 26637e497a display users for admin 2025-08-22 12:12:50 +02:00
Charles 3ca1446b91 display users for super admin 2025-08-22 11:45:29 +02:00
Charles 8a19b01893 Visualisation Utilisateurs 2025-08-12 13:39:37 +02:00
Charles 2dc5710e06 Visualisation Utilisateurs 2025-08-12 13:39:32 +02:00
Charles 92dd3a3d23 refonte base v2 2025-08-12 11:06:40 +02:00
Charles 2d9b44ddb6 refonte base v2 2025-08-12 11:05:39 +02:00
Charles 9dc79eaa7d refonte role 2025-08-12 10:01:00 +02:00
Charles 716bfb8ce1 app info(don't work) 2025-08-08 10:04:35 +02:00
Charles cc93387154 update action logic 2025-08-07 15:48:16 +02:00
Charles 61e43dcd98 Log action 2025-08-07 15:01:59 +02:00
Charles 0d498d4570 Log action 2025-08-07 14:17:49 +02:00
Charles 328a89f11f Redirect 2025-08-07 12:22:36 +02:00
Charles ccd44e3560 Review of access logic 2025-08-07 12:06:31 +02:00
Charles 9da1edaa92 Review of access logic 2025-08-07 12:04:06 +02:00
Charles 3f55eefddc Review of access logic 2025-08-07 12:03:02 +02:00
Charles b81b168ec3 Log actions 2025-08-07 10:06:15 +02:00
Charles 3894d72439 Log actions 2025-08-07 09:23:37 +02:00
Charles 2f3e28757e typo 2025-08-07 09:23:03 +02:00
Charles 1f7d844d6f Create Action function 2025-08-07 09:06:58 +02:00
Charles c757a841c5 update Role logic 2025-08-07 09:06:15 +02:00
Charles 790f77c430 update Role logic 2025-08-06 16:40:20 +02:00
Charles f9c63d6753 update Action 2025-08-06 16:40:11 +02:00
Charles 95f806efce change display logic 2025-08-06 15:52:48 +02:00
Charles 37ba0a5e6a activate organization 2025-08-06 15:44:00 +02:00
Charles 371c511ecf deactivate organization 2025-08-06 14:48:41 +02:00
Charles-Edouard 993188ac4f Delete public/uploads/logos/6893102127c028.11727111.jpg 2025-08-06 12:22:00 +02:00
Charles-Edouard 18dc5f8492 Delete public/uploads/logos/2025-08-06_fsadf_logofile.jpg 2025-08-06 12:21:53 +02:00
Charles f2166b604e ignore uploads 2025-08-06 12:20:55 +02:00
Charles 8d92d3f9fc update organisation 2025-08-06 12:20:05 +02:00
Charles 5ceed1f2f2 Visual 2025-08-06 12:06:50 +02:00
Charles 450543fab7 logo 2025-08-06 11:54:24 +02:00
Charles d543e69863 Add role protection 2025-08-06 11:52:55 +02:00
Charles 7021b28163 Création d'organisation 2025-08-06 11:15:53 +02:00
Charles c55e9fa039 Correction 2025-08-06 08:52:58 +02:00
Charles bdf9f0478e update userOrg update info 2025-08-05 15:09:51 +02:00
Charles 1053a2ab22 Add user to organization 2025-08-05 11:16:45 +02:00
Charles 6efbeb0fa2 Display applications 2025-08-05 10:11:05 +02:00
Charles 1ee9a0110b AWS 2025-08-05 09:13:04 +02:00
Charles cbdb47fb17 set action log on user entity 2025-08-04 13:57:13 +02:00
Charles e6c8d5a462 update reamdme 2025-08-04 12:16:02 +02:00
Charles 7e272b2b2f Add actions display 2025-08-04 12:01:40 +02:00
Charles 6670fbc8b8 Ajout organistion Id au Actions 2025-08-04 10:57:05 +02:00
Charles 1e8d5e1eaf Ajout organistion Id au Actions 2025-08-04 10:56:53 +02:00
Charles 2e99457e16 get applications access per organization 2025-07-29 16:33:16 +02:00
Charles cde6c529a9 update access logic 2025-07-29 16:07:51 +02:00
Charles a3f993b858 organizations user information 2025-07-29 15:55:55 +02:00
Charles d2c20b9423 access token on log in 2025-07-29 14:38:19 +02:00
Charles 89ed7049b9 correction 2025-07-29 14:37:55 +02:00
Charles 16dd919a5d activity template 2025-07-28 16:23:47 +02:00
Charles 301f7bb445 statut template 2025-07-28 16:05:20 +02:00
Charles 05d8ca0499 organization dashboard User management 2025-07-28 15:19:59 +02:00
Charles e17e8e0eb2 refactor 2025-07-28 12:13:04 +02:00
Charles e6391279fe refactor 2025-07-28 11:45:59 +02:00
Charles cf16ec09a1 refactor 2025-07-28 11:40:45 +02:00
Charles 943752a002 Display all organizations 2025-07-28 11:38:16 +02:00
Charles a7e7298310 refactor 2025-07-28 11:27:18 +02:00
Charles 3337b8c001 refactor 2025-07-28 11:22:57 +02:00
Charles 6446eb2ce1 Handle permission 2025-07-28 11:20:31 +02:00
Charles a10b499522 refactor 2025-07-28 11:02:26 +02:00
Charles e77e92d39f refactor 2025-07-28 11:02:19 +02:00
Charles 00e3003257 changed user index logic 2025-07-28 10:26:13 +02:00
Charles f1b953d005 helper function 2025-07-28 10:23:07 +02:00
Charles 4aadaa351a Comments 2025-07-28 09:53:26 +02:00
Charles fcb69f987f refactor 2025-07-25 14:32:02 +02:00
Charles d7677db885 refactor 2025-07-25 12:03:54 +02:00
Charles 8eb5cf433d remove app from user organization 2025-07-25 11:50:06 +02:00
Charles fed351b433 remove user from organization 2025-07-25 11:03:21 +02:00
Charles 0a602fb52e Deactivate roles 2025-07-25 10:54:01 +02:00
Charles e87fdd32e4 refactor + default value 2025-07-25 10:38:03 +02:00
Charles cfe89f58db edit user roles and app per organization 2025-07-25 10:30:00 +02:00
Charles 1d2debf364 Roles adjustment 2025-07-17 15:55:09 +02:00
Charles 3271da59fa delete and set delete user 2025-07-17 14:10:55 +02:00
Charles d8df0bc1f4 front modifications 2025-07-17 14:10:20 +02:00
Charles c3d3218bff update userForm 2025-07-17 11:45:02 +02:00
Charles f24fb0180d Edit user 2025-07-17 11:33:34 +02:00
Charles e360019e58 Type refacto 2025-07-17 11:32:28 +02:00
Charles 8e38fe47db Type refacto 2025-07-17 11:26:05 +02:00
Charles b54fe41795 const execption 2025-07-17 10:39:32 +02:00
Charles 798ca4ba07 unique email address on creation 2025-07-17 10:39:17 +02:00
Charles d43b516826 Create user 2025-07-17 09:16:09 +02:00
Charles c99b575814 Deactivate user 2025-07-16 15:59:56 +02:00
Charles 65ff838dd9 handle error 2025-07-16 15:50:45 +02:00
Charles 4a2f9d9547 Read User information 2025-07-16 15:12:45 +02:00
Charles 10a8eb2255 Correction 2025-07-16 15:04:35 +02:00
Charles 8f232498b8 Add phone number 2025-07-16 15:03:41 +02:00
Charles bbe50dbfd9 Last connection on user entity 2025-07-10 09:26:54 +02:00
Charles 436284c9c7 add name to Organizations entity 2025-07-09 14:11:31 +02:00
Charles 228ef8cbe9 refactor + update DB 2025-07-09 09:30:34 +02:00
Charles c81142e4a5 refactor 2025-07-09 09:21:47 +02:00
Charles 6a9b7568af refactor 2025-07-09 09:16:05 +02:00
Charles 57e115a6a8 update to Template 2025-07-09 09:14:29 +02:00
Charles 79c5596766 refactor 2025-07-04 15:55:35 +02:00
158 changed files with 10663 additions and 881 deletions

5
.env
View File

@ -62,3 +62,8 @@ MERCURE_PUBLIC_URL=https://example.com/.well-known/mercure
# The secret used to sign the JWTs
MERCURE_JWT_SECRET="!ChangeThisMercureHubJWTSecretKey!"
###< symfony/mercure-bundle ###
###> aws/aws-sdk-php-symfony ###
AWS_KEY=not-a-real-key
AWS_SECRET=@@not-a-real-secret
###< aws/aws-sdk-php-symfony ###

5
.gitignore vendored
View File

@ -5,6 +5,7 @@
/.env.*.local
/config/secrets/prod/prod.decrypt.private.php
/public/bundles/
/public/uploads/
/var/
/vendor/
###< symfony/framework-bundle ###
@ -23,3 +24,7 @@
/public/assets/
/assets/vendor/
###< symfony/asset-mapper ###
###> IntelliJ IDEA ###
.idea/
*.iml

View File

@ -8,8 +8,20 @@
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/mercure" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/mercure-bundle" />
<excludeFolder url="file://$MODULE_DIR$/vendor/firebase/php-jwt" />
<excludeFolder url="file://$MODULE_DIR$/vendor/aws/aws-crt-php" />
<excludeFolder url="file://$MODULE_DIR$/vendor/aws/aws-sdk-php" />
<excludeFolder url="file://$MODULE_DIR$/vendor/aws/aws-sdk-php-symfony" />
<excludeFolder url="file://$MODULE_DIR$/vendor/guzzlehttp/guzzle" />
<excludeFolder url="file://$MODULE_DIR$/vendor/guzzlehttp/promises" />
<excludeFolder url="file://$MODULE_DIR$/vendor/guzzlehttp/psr7" />
<excludeFolder url="file://$MODULE_DIR$/vendor/knplabs/knp-time-bundle" />
<excludeFolder url="file://$MODULE_DIR$/vendor/mtdowling/jmespath.php" />
<excludeFolder url="file://$MODULE_DIR$/vendor/psr/http-client" />
<excludeFolder url="file://$MODULE_DIR$/vendor/ralouphie/getallheaders" />
</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" />
@ -83,7 +88,6 @@
<path value="$PROJECT_DIR$/vendor/lcobucci/jwt" />
<path value="$PROJECT_DIR$/vendor/lcobucci/clock" />
<path value="$PROJECT_DIR$/vendor/twig/twig" />
<path value="$PROJECT_DIR$/vendor/twig/extra-bundle" />
<path value="$PROJECT_DIR$/vendor/composer" />
<path value="$PROJECT_DIR$/vendor/theseer/tokenizer" />
<path value="$PROJECT_DIR$/vendor/symfony/deprecation-contracts" />
@ -141,7 +145,6 @@
<path value="$PROJECT_DIR$/vendor/defuse/php-encryption" />
<path value="$PROJECT_DIR$/vendor/symfony/ux-turbo" />
<path value="$PROJECT_DIR$/vendor/symfony/stimulus-bundle" />
<path value="$PROJECT_DIR$/vendor/doctrine/cache" />
<path value="$PROJECT_DIR$/vendor/symfony/type-info" />
<path value="$PROJECT_DIR$/vendor/doctrine/event-manager" />
<path value="$PROJECT_DIR$/vendor/symfony/console" />
@ -164,9 +167,26 @@
<path value="$PROJECT_DIR$/vendor/symfony/mercure" />
<path value="$PROJECT_DIR$/vendor/symfony/mercure-bundle" />
<path value="$PROJECT_DIR$/vendor/firebase/php-jwt" />
<path value="$PROJECT_DIR$/vendor/doctrine/cache" />
<path value="$PROJECT_DIR$/vendor/knplabs/knp-time-bundle" />
<path value="$PROJECT_DIR$/vendor/aws/aws-sdk-php" />
<path value="$PROJECT_DIR$/vendor/aws/aws-crt-php" />
<path value="$PROJECT_DIR$/vendor/ralouphie/getallheaders" />
<path value="$PROJECT_DIR$/vendor/aws/aws-sdk-php-symfony" />
<path value="$PROJECT_DIR$/vendor/guzzlehttp/psr7" />
<path value="$PROJECT_DIR$/vendor/guzzlehttp/promises" />
<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>
@ -175,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">
```

190
NOTIFICATION.MD Normal file
View File

@ -0,0 +1,190 @@
# Système de notification de l'application
## Vue d'ensemble
Le système de notification de l'application permet d'informer les utilisateurs de diverse action.
## Architecture
### Composants principaux
1. **Service de Notification** : Gère la création, l'envoi et le suivi des notifications.
2. **Interface Utilisateur** : Affiche les notifications aux utilisateurs via des pops-ups, des bannières ou des emails.
3. **Template Email** : Modèles prédéfinis pour les notifications par email.
4. **Type D'action** : Enum d'action qui déclenche des notifications (ex: nouvel utilisateur, utilisateur actif, ...).
### Service de Notification
Le service de notification est responsable de la gestion des notifications. Il inclut les fonctionnalités suivantes :
- Création de notifications basées sur des événements spécifiques.
- Envoi de notifications via différents canaux (email, in-app).
- Suivi de l'état des notifications (envoyé, lu, etc.).
### Interface Utilisateur
L'interface utilisateur affiche les notifications de manière conviviale. Les notifications peuvent apparaître sous forme
de pops-ups, de bannières ou d'emails. (Possibilité d'intéragir avec les notifications)
### Template Email
Les templates email sont utilisés pour formater les notifications envoyées par email. Chaque type de notification
a son propre template pour assurer une communication claire et cohérente.
### Type d'action
```
enum ActionType: String {
case NewUser = "NEW_USER";
case ActiveUser = "ACTIVE_USER";
case PasswordReset = "PASSWORD_RESET";
case SubscriptionExpired = "SUBSCRIPTION_EXPIRED";
case OrganizationInvited = "ORGANIZATION_INVITED";
case OrganizationInactive = "ORGANIZATION_INACTIVE";
case OrganizationDeleted = "ORGANIZATION_DELETED";
case OrginizationUserInvited = "ORGANIZATION_USER_INVITED";
case UserDeleted = "USER_DELETED";
}
```
## Flux de travail
1. Ladministrateur crée un utilisateur depuis linterface (formulaire “Créer un utilisateur”).
2. Le contrôleur valide la requête et appelle le cas dusage UserAdministrationService->handle(ActionType::NewUser, $admin, $payload).
3. Le service crée lutilisateur en base avec le statut INVITED, associe lorganisation de ladmin, et génère un lien signé/jeton de setup de mot de passe (TTL).
4. Le service publie un événement de domaine UserInvitedEvent { userId, adminId, organizationId } sur Messenger (transport async).
5. Handler async A — SendUserInvitationEmailHandler:
6. Construit lemail via Symfony Mailer + Twig (emails/user_invitation.html.twig) avec le lien de définition de mot de passe.
7. Envoie le mail à lutilisateur invité.
8. Handler async B — NotifyAdminInvitationSentHandler:
9. Crée une notification interne (Notifier, canal “inapp”).
10. Pousse un événement temps réel via Mercure sur le topic admin/{adminId}/events avec le type INVITATION_EMAIL_SENT.
11. LUI admin affiche un toast/bannière confirmant “Email dinvitation envoyé”.
12. Lutilisateur ouvre lemail et clique le lien de définition de mot de passe.
13. Le PasswordSetupController vérifie la signature/le jeton et la validité (TTL), affiche le formulaire, puis enregistre le nouveau mot de passe.
14. À la réussite, lutilisateur passe au statut ACTIVE et laction publie UserActivatedEvent { userId, adminId, organizationId } sur Messenger (async).
15. Handler async C — NotifyAdminUserActivatedHandler:
16. Crée une notification interne (Notifier, canal “inapp”) “Compte activé”.
17. Pousse un événement Mercure sur admin/{adminId}/events avec le type USER_ACTIVATED.
18. LUI admin met à jour la liste des membres (badge “Actif”) et affiche un toast confirmant lactivation.
19. Journalisation/Audit:
20. Chaque handler écrit une trace (succès/échec) en base ou dans un EmailLog/NotificationLog.
21. En cas déchec denvoi, Messenger applique la stratégie de retry puis bascule en file failed si nécessaire (tableau de bord de supervision).
22. Cas “utilisateur existant ajouté à une autre organisation”:
23. Si lemail existe déjà, on rattache lutilisateur à la nouvelle organisation et on publie OrganizationUserInvitedEvent.
24. Handler dédié envoie un email dinformation (“Vous avez été ajouté à une nouvelle organisation”) et notifie ladmin via Notifier + Mercure.
25. Cas dactions dérivées par enum:
26. ActionType::NewUser → déclenche UserInvitedEvent (steps 36).
27. ActionType::ActiveUser (si activé par un flux admin) → déclenche directement UserActivatedEvent (steps 910).
28. ActionType::OrganizationUserInvited → flux similaire au point 12 pour la multiorganisation.
29. Autres actions (PasswordReset, UserDeleted, etc.) suivent le même patron: contrôleur → service (match enum) → événement Messenger → handlers (Mailer/Notifier/Mercure) → UI temps réel.
## Stack technologique
- Symfony Messenger: asynchrone, retries, découplage des I/O lents.
- Symfony Mailer + Twig: emails dinvitation et dinformation.
- Symfony Notifier (canal inapp) + Mercure: notifications persistées + push temps réel vers lUI admin.
- Enum ActionType: routage clair dans lapplication, évite la logique stringbased.
```mermaid
flowchart LR
%% Couche 1: Action initiale
A[User action event - Admin cree un utilisateur] --> B[HTTP controller API - Symfony]
B --> C[Domain service - UserAdministrationService]
C -->|Inspecte enum ActionType::NewUser| C1[Create user - status INVITED - liaison organisation - genere lien jeton mot de passe TTL]
C1 --> D[Dispatch UserInvitedEvent - userId adminId organizationId - vers Symfony Messenger bus]
%% Couche 2: Messaging / Infra
D --> E[Transport async - AMQP / Redis / Doctrine]
E --> RQ[Retry queue]
E --> FQ[Failed queue - dead letter]
E --> W[Workers Messenger]
F[Supervisor / systemd] --> W
%% Monolog transversal (logs a chaque etape)
A --> LOG_GLOBAL[Monolog - log event initial]
B --> LOG_GLOBAL
C --> LOG_GLOBAL
C1 --> LOG_GLOBAL
D --> LOG_GLOBAL
E --> LOG_GLOBAL
RQ --> LOG_GLOBAL
FQ --> LOG_GLOBAL
W --> LOG_GLOBAL
%% Handlers pour l'invitation
W --> H1[Handler A - Symfony Mailer + Twig]
H1 --> H1o[Email d'invitation avec lien setup mot de passe]
H1 --> LOG_GLOBAL
W --> H2[Handler B - Symfony Notifier in-app]
H2 --> UI1[Notification UI admin - Email d'invitation envoye]
H2 --> LOG_GLOBAL
W -. optionnel .-> WH1[Webhook HTTP sortant - invitation envoyee]
WH1 --> LOG_GLOBAL
W -. optionnel .-> SMS1[SMS gateway - SMS invitation]
SMS1 --> LOG_GLOBAL
W -. optionnel .-> PUSH1[Mobile push service - notification mobile]
PUSH1 --> LOG_GLOBAL
RQ --> METRICS[Metrics et dashboard]
FQ --> METRICS
LOG_GLOBAL --> METRICS
%% Flux activation utilisateur
subgraph Activation du compte
UA[User action event - Invite clique le lien] --> PS[HTTP controller API - PasswordSetupController]
PS -->|Verifie signature et TTL| PSOK[Set password - user status ACTIVE]
PS --> LOG_GLOBAL
PSOK --> LOG_GLOBAL
PSOK --> D2[Dispatch UserActivatedEvent - userId adminId organizationId - vers Messenger bus]
D2 --> E2[Transport async]
E2 --> RQ2[Retry queue]
E2 --> FQ2[Failed queue]
E2 --> W2[Workers Messenger]
F --> W2
D2 --> LOG_GLOBAL
E2 --> LOG_GLOBAL
RQ2 --> LOG_GLOBAL
FQ2 --> LOG_GLOBAL
W2 --> LOG_GLOBAL
W2 --> H3[Handler C - Notifier in-app]
H3 --> UI2[Notification UI admin - Compte active]
H3 --> LOG_GLOBAL
W2 -. optionnel .-> WH2[Webhook HTTP sortant - user active]
WH2 --> LOG_GLOBAL
W2 -. optionnel .-> MAIL2[Mailer ou SMS ou Push - confirmation utilisateur]
MAIL2 --> LOG_GLOBAL
RQ2 --> METRICS
FQ2 --> METRICS
end
%% Cas particulier : utilisateur existant ajoute a une nouvelle organisation
C -->|Email deja existant| SP1[Rattache a nouvelle organisation]
SP1 --> LOG_GLOBAL
SP1 --> D3[Dispatch OrganizationUserInvitedEvent]
D3 --> E3[Transport async] --> W3[Workers]
F --> W3
D3 --> LOG_GLOBAL
E3 --> LOG_GLOBAL
W3 --> LOG_GLOBAL
W3 --> M3[Mailer - ajoute a une nouvelle organisation]
M3 --> LOG_GLOBAL
W3 --> N3[Notifier in-app - toast admin Utilisateur ajoute]
N3 --> LOG_GLOBAL
W3 -. optionnel .-> WH3[Webhook ou SMS ou Mobile]
WH3 --> LOG_GLOBAL
M3 --> METRICS
N3 --> METRICS
WH3 --> METRICS
%% Styles
classDef infra fill:#e8f0fe,stroke:#5b8def,stroke-width:1px;
classDef handler fill:#dcf7e9,stroke:#2ea66a,stroke-width:1px;
classDef ui fill:#f0d9ff,stroke:#9c27b0,stroke-width:1px;
classDef audit fill:#eeeeee,stroke:#9e9e9e,stroke-width:1px;
class E,E2,E3,RQ,FQ,RQ2,FQ2,METRICS infra;
class W,W2,W3,H1,H2,H3,M3,N3 handler;
class H1o,UI1,UI2 ui;
class LOG_GLOBAL audit;
```

View File

@ -6,18 +6,21 @@
- Stimulus
- Turbo
- Bootstrap 5.3
- Symfony UX toogle password (https://ux.symfony.com/toggle-password)
- Les icones sont gérées via symfony UX (https://ux.symfony.com/icons)
- Les icones sont prises en prioritées dans la bibliothèque bootstrap
- Les icones n'éxistants pas dans cette bibliothèques seront prises en priorité dans fontawesome regular (pour une cohérence visuelle)
- Sinon privilégier la bibliothèque ayant le visuel le plus proche
### Version 0.1 : (17/03/2025)
- Contient la logique de login mot de passe avec une entité user (email et password seuelement)
- Une base de template twig public est gérée pour les page n'ayant pas besoin de menu
- La page de login est designé
- Une base de template est gérée pour toutes les pages de l'application aya,t besoin de l'entête et du menu général
- Une ébauche de page d'accueil est en cours
### Installation
#### Database
```bash
php bin/console doctrine:database:create
php bin/console doctrine:schema:update --force
```
#### Roles
```bash
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
```

View File

@ -8,7 +8,20 @@ import './bootstrap.js';
import 'bootstrap/dist/css/bootstrap.min.css';
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 './styles/notifications.css';
import 'bootstrap';
console.log('This log comes from assets/app.js - welcome to AssetMapper! 🎉');
import './js/template.js';
import './js/off_canvas.js';
import './js/hoverable-collapse.js';
import './js/cookies.js';
import 'choices.js';
import 'quill'
import 'tabulator-tables'
import './js/global.js'

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,323 @@
import { Controller } from '@hotwired/stimulus';
export default class extends Controller {
static targets = ['badge', 'list'];
static values = {
userId: Number,
mercureUrl: String
};
connect() {
this.loadNotifications();
this.connectToMercure();
this.toastContainer = this.createToastContainer();
}
disconnect() {
if (this.eventSource) {
this.eventSource.close();
}
}
async loadNotifications() {
try {
const response = await fetch('/notifications/unread');
const data = await response.json();
this.updateBadge(data.unreadCount);
this.renderNotifications(data.notifications);
} catch (error) {
console.error('Failed to load notifications:', error);
}
}
async connectToMercure() {
try {
// Fetch the JWT token and topic from the server
const response = await fetch('/mercure-token');
const data = await response.json();
// Use server-provided topic if available, otherwise fallback to default per-user topic
const topic = data.topic || `http://portail.solutions-easy.moi/notifications/user/${this.userIdValue}`;
const url = new URL(this.mercureUrlValue);
url.searchParams.append('topic', topic);
// Add authorization token as URL param if provided (Mercure can accept it this way)
if (data.token) {
url.searchParams.append('authorization', data.token);
}
try {
this.eventSource = new EventSource(url.toString());
} catch (e) {
console.error('❌ Failed to create EventSource:', e);
return;
}
this.eventSource.onopen = () => {
};
this.eventSource.onmessage = (event) => {
try {
const notification = JSON.parse(event.data);
this.handleNewNotification(notification);
} catch (parseError) {
console.error('Failed to parse Mercure message data:', parseError, 'raw data:', event.data);
}
};
this.eventSource.onerror = (error) => {
try {
console.error('EventSource readyState:', this.eventSource.readyState);
} catch (e) {
console.error('Could not read EventSource.readyState:', e);
}
// EventSource will automatically try to reconnect.
// If closed, log it for debugging.
try {
if (this.eventSource.readyState === EventSource.CLOSED) {
console.log();
} else if (this.eventSource.readyState === EventSource.CONNECTING) {
console.log();
} else if (this.eventSource.readyState === EventSource.OPEN) {
console.log();
}
} catch (e) {
console.error('Error while checking EventSource state:', e);
}
};
} catch (error) {
console.error('Failed to connect to Mercure:', error);
}
}
handleNewNotification(notification) {
this.showToast(notification);
this.loadNotifications();
}
updateBadge(count) {
const badge = this.badgeTarget;
if (count > 0) {
badge.textContent = count > 99 ? '99+' : count;
badge.style.display = 'block';
} else {
badge.style.display = 'none';
}
}
renderNotifications(notifications) {
const list = this.listTarget;
if (notifications.length === 0) {
list.innerHTML = `
<div class="text-center py-4 text-muted">
<i class="mx-0 mb-2">
<svg xmlns="http://www.w3.org/2000/svg" width="30" height="30" fill="currentColor" viewBox="0 0 16 16">
<path d="M8.865 2.5a.75.75 0 0 0-1.73 0L6.5 5.5H4.75a.75.75 0 0 0 0 1.5h1.5l-.5 3H3.5a.75.75 0 0 0 0 1.5h1.5l-.635 3.135a.75.75 0 0 0 1.47.28L6.5 11.5h3l-.635 3.135a.75.75 0 0 0 1.47.28L11 11.5h1.75a.75.75 0 0 0 0-1.5h-1.5l.5-3h2.25a.75.75 0 0 0 0-1.5h-2l.635-3.135zM9.5 10l.5-3h-3l-.5 3h3z"/>
</svg>
</i>
<p class="mb-0">Aucune notification</p>
</div>
`;
return;
}
list.innerHTML = notifications.map(notif => this.renderNotificationItem(notif)).join('');
}
renderNotificationItem(notification) {
const iconHtml = this.getIcon(notification.type);
const timeAgo = this.getTimeAgo(notification.createdAt);
const readClass = notification.isRead ? 'opacity-75' : '';
return `
<a class="dropdown-item preview-item ${readClass}"
href="#"
data-notification-id="${notification.id}"
data-action="click->notification#markAsRead">
<div class="preview-thumbnail">
<div class="preview-icon ${this.getIconBgClass(notification.type)}">
${iconHtml}
</div>
</div>
<div class="preview-item-content">
<h6 class="preview-subject font-weight-normal mb-1">${this.escapeHtml(notification.title)}</h6>
<p class="font-weight-light small-text mb-0 text-muted">${this.escapeHtml(notification.message)}</p>
<p class="font-weight-light small-text mb-0 text-muted mt-1">
<small>${timeAgo}</small>
</p>
</div>
<button class="btn btn-sm btn-link text-danger ms-2"
data-action="click->notification#deleteNotification"
data-notification-id="${notification.id}"
style="padding: 0.25rem;">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" viewBox="0 0 16 16">
<path d="M5.5 5.5A.5.5 0 0 1 6 6v6a.5.5 0 0 1-1 0V6a.5.5 0 0 1 .5-.5zm2.5 0a.5.5 0 0 1 .5.5v6a.5.5 0 0 1-1 0V6a.5.5 0 0 1 .5-.5zm3 .5a.5.5 0 0 0-1 0v6a.5.5 0 0 0 1 0V6z"/>
<path fill-rule="evenodd" d="M14.5 3a1 1 0 0 1-1 1H13v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V4h-.5a1 1 0 0 1-1-1V2a1 1 0 0 1 1-1H6a1 1 0 0 1 1-1h2a1 1 0 0 1 1 1h3.5a1 1 0 0 1 1 1v1zM4.118 4 4 4.059V13a1 1 0 0 0 1 1h6a1 1 0 0 0 1-1V4.059L11.882 4H4.118zM2.5 3V2h11v1h-11z"/>
</svg>
</button>
</a>
`;
}
getIcon(type) {
const icons = {
user_joined: '<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" class="mx-0 mb-1" viewBox="0 0 16 16"><path d="M8 8a3 3 0 1 0 0-6 3 3 0 0 0 0 6zm2-3a2 2 0 1 1-4 0 2 2 0 0 1 4 0zm4 8c0 1-1 1-1 1H3s-1 0-1-1 1-4 6-4 6 3 6 4zm-1-.004c-.001-.246-.154-.986-.832-1.664C11.516 10.68 10.289 10 8 10c-2.29 0-3.516.68-4.168 1.332-.678.678-.83 1.418-.832 1.664h10z"/></svg>',
user_invited: '<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" class="mx-0 mb-1" viewBox="0 0 640 512"><path fill="currentColor" d="M224 0a128 128 0 1 1 0 256a128 128 0 1 1 0-256m-45.7 304h91.4c20.6 0 40.4 3.5 58.8 9.9C323 331 320 349.1 320 368c0 59.5 29.5 112.1 74.8 144H29.7C13.3 512 0 498.7 0 482.3C0 383.8 79.8 304 178.3 304M352 368a144 144 0 1 1 288 0a144 144 0 1 1-288 0m144-80c-8.8 0-16 7.2-16 16v64c0 8.8 7.2 16 16 16h48c8.8 0 16-7.2 16-16s-7.2-16-16-16h-32v-48c0-8.8-7.2-16-16-16"/></svg>',
user_accepted: '<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" class="mx-0 mb-1" viewBox="0 0 640 512"><path fill="currentColor" d="M96 128a128 128 0 1 1 256 0a128 128 0 1 1-256 0M0 482.3C0 383.8 79.8 304 178.3 304h91.4c98.5 0 178.3 79.8 178.3 178.3c0 16.4-13.3 29.7-29.7 29.7H29.7C13.3 512 0 498.7 0 482.3M625 177L497 305c-9.4 9.4-24.6 9.4-33.9 0l-64-64c-9.4-9.4-9.4-24.6 0-33.9s24.6-9.4 33.9 0l47 47L591 143c9.4-9.4 24.6-9.4 33.9 0s9.4 24.6 0 33.9z"/></svg>',
user_removed: '<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" class="mx-0 mb-1" viewBox="0 0 640 512"><path fill="currentColor" d="M38.8 5.1C28.4-3.1 13.3-1.2 5.1 9.2s-6.3 25.5 4.1 33.7l592 464c10.4 8.2 25.5 6.3 33.7-4.1s6.3-25.5-4.1-33.7L353.3 251.6C407.9 237 448 187.2 448 128C448 57.3 390.7 0 320 0c-69.8 0-126.5 55.8-128 125.2zm225.5 299.2C170.5 309.4 96 387.2 96 482.3c0 16.4 13.3 29.7 29.7 29.7h388.6c3.9 0 7.6-.7 11-2.1z"/></svg>',
user_deactivated: '<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" class="mx-0 mb-1" viewBox="0 0 640 512"><path fill="currentColor" d="M38.8 5.1C28.4-3.1 13.3-1.2 5.1 9.2s-6.3 25.5 4.1 33.7l592 464c10.4 8.2 25.5 6.3 33.7-4.1s6.3-25.5-4.1-33.7L353.3 251.6C407.9 237 448 187.2 448 128C448 57.3 390.7 0 320 0c-69.8 0-126.5 55.8-128 125.2zm225.5 299.2C170.5 309.4 96 387.2 96 482.3c0 16.4 13.3 29.7 29.7 29.7h388.6c3.9 0 7.6-.7 11-2.1z"/></svg>',
org_update: '<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" class="mx-0 mb-1" viewBox="0 0 16 16"><path d="M8 4.754a3.246 3.246 0 1 0 0 6.492 3.246 3.246 0 0 0 0-6.492zM5.754 8a2.246 2.246 0 1 1 4.492 0 2.246 2.246 0 0 1-4.492 0z"/><path d="M9.796 1.343c-.527-1.79-3.065-1.79-3.592 0l-.094.319a.873.873 0 0 1-1.255.52l-.292-.16c-1.64-.892-3.433.902-2.54 2.541l.159.292a.873.873 0 0 1-.52 1.255l-.319.094c-1.79.527-1.79 3.065 0 3.592l.319.094a.873.873 0 0 1 .52 1.255l-.16.292c-.892 1.64.901 3.434 2.541 2.54l.292-.159a.873.873 0 0 1 1.255.52l.094.319c.527 1.79 3.065 1.79 3.592 0l.094-.319a.873.873 0 0 1 1.255-.52l.292.16c1.64.893 3.434-.902 2.54-2.541l-.159-.292a.873.873 0 0 1 .52-1.255l.319-.094c1.79-.527 1.79-3.065 0-3.592l-.319-.094a.873.873 0 0 1-.52-1.255l.16-.292c.893-1.64-.902-3.433-2.541-2.54l-.292.159a.873.873 0 0 1-1.255-.52l-.094-.319z"/></svg>',
app_access: '<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" class="mx-0 mb-1" viewBox="0 0 16 16"><path d="M8 1a2 2 0 0 1 2 2v4H6V3a2 2 0 0 1 2-2zm3 6V3a3 3 0 0 0-6 0v4a2 2 0 0 0-2 2v5a2 2 0 0 0 2 2h6a2 2 0 0 0 2-2V9a2 2 0 0 0-2-2z"/></svg>',
role_changed: '<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" class="mx-0 mb-1" viewBox="0 0 16 16"><path d="M9.405 1.05c-.413-1.4-2.397-1.4-2.81 0l-.1.34a1.464 1.464 0 0 1-2.105.872l-.31-.17c-1.283-.698-2.686.705-1.987 1.987l.169.311c.446.82.023 1.841-.872 2.105l-.34.1c-1.4.413-1.4 2.397 0 2.81l.34.1a1.464 1.464 0 0 1 .872 2.105l-.17.31c-.698 1.283.705 2.686 1.987 1.987l.311-.169a1.464 1.464 0 0 1 2.105.872l.1.34c.413 1.4 2.397 1.4 2.81 0l.1-.34a1.464 1.464 0 0 1 2.105-.872l.31.17c1.283.698 2.686-.705 1.987-1.987l-.169-.311a1.464 1.464 0 0 1 .872-2.105l.34-.1c1.4-.413 1.4-2.397 0-2.81l-.34-.1a1.464 1.464 0 0 1-.872-2.105l.17-.31c.698-1.283-.705-2.686-1.987-1.987l-.311.169a1.464 1.464 0 0 1-2.105-.872l-.1-.34zM8 10.93a2.929 2.929 0 1 1 0-5.86 2.929 2.929 0 0 1 0 5.858z"/></svg>',
};
return icons[type] || icons.user_joined;
}
getIconBgClass(type) {
const classes = {
user_joined: 'bg-primary',
user_invited: 'bg-info',
user_accepted: 'bg-primary',
user_removed: 'bg-danger',
user_deactivated: 'bg-warning',
org_update: 'bg-warning',
app_access: 'bg-primary',
role_changed: 'bg-info',
};
return classes[type] || 'bg-primary';
}
getTimeAgo(dateString) {
if (!dateString) return '';
const date = new Date(dateString);
if (isNaN(date)) return '';
const now = new Date();
const seconds = Math.floor((now - date) / 1000);
if (seconds < 60) return 'À l\'instant';
if (seconds < 3600) {
const mins = Math.floor(seconds / 60);
return `Il y a ${mins} ${mins > 1 ? 'mins' : 'min'}`;
}
if (seconds < 86400) {
const hours = Math.floor(seconds / 3600);
return `Il y a ${hours} ${hours > 1 ? 'h' : 'h'}`;
}
if (seconds < 604800) {
const days = Math.floor(seconds / 86400);
return `Il y a ${days} ${days > 1 ? 'j' : 'j'}`;
}
// For older dates, show a localized date string
try {
return date.toLocaleDateString('fr-FR', {year: 'numeric', month: 'short', day: 'numeric'});
} catch (e) {
return date.toLocaleDateString();
}
}
escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
async markAsRead(event) {
event.preventDefault();
event.stopPropagation();
const notificationId = event.currentTarget.dataset.notificationId;
try {
await fetch(`/notifications/${notificationId}/read`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
}
});
this.loadNotifications();
} catch (error) {
console.error('Failed to mark notification as read:', error);
}
}
async markAllAsRead(event) {
event.preventDefault();
event.stopPropagation();
try {
await fetch('/notifications/mark-all-read', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
}
});
this.loadNotifications();
} catch (error) {
console.error('Failed to mark all as read:', error);
}
}
async deleteNotification(event) {
event.preventDefault();
event.stopPropagation();
const notificationId = event.currentTarget.dataset.notificationId;
try {
await fetch(`/notifications/${notificationId}`, {
method: 'DELETE',
headers: {
'Content-Type': 'application/json',
}
});
this.loadNotifications();
} catch (error) {
console.error('Failed to delete notification:', error);
}
}
markDropdownAsRead(event) {
}
createToastContainer() {
let container = document.getElementById('notification-toast-container');
if (!container) {
container = document.createElement('div');
container.id = 'notification-toast-container';
container.className = 'notification-toast-container';
document.body.appendChild(container);
}
return container;
}
showToast(notification) {
const toast = document.createElement('div');
toast.className = 'notification-toast';
toast.innerHTML = `
<div class="notification-toast-icon ${this.getIconBgClass(notification.type)}">
${this.getIcon(notification.type)}
</div>
<div class="notification-toast-content">
<div class="notification-toast-title">${this.escapeHtml(notification.title)}</div>
<div class="notification-toast-message">${this.escapeHtml(notification.message)}</div>
</div>
<button class="notification-toast-close" onclick="this.parentElement.remove()">×</button>
`;
this.toastContainer.appendChild(toast);
setTimeout(() => toast.classList.add('show'), 10);
setTimeout(() => {
toast.classList.remove('show');
setTimeout(() => toast.remove(), 300);
}, 5000);
}
}

View File

@ -0,0 +1,85 @@
import {Controller} from '@hotwired/stimulus'
// Important: include a build with Ajax + pagination (TabulatorFull is simplest)
import {TabulatorFull as Tabulator} from 'tabulator-tables';
import {eyeIconLink, TABULATOR_FR_LANG} from "../js/global.js";
export default class extends Controller {
static values = {aws: String};
connect() {
this.table();
}
table(){
const table = new Tabulator("#tabulator-org", {
// Register locales here
langs: TABULATOR_FR_LANG,
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" },
filterMode: "remote",
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()}`;
},
ajaxSorting: true,
ajaxFiltering: true,
rowHeight: 60,
layout: "fitColumns", // activate French
columns: [
{
title: "Logo",
field: "logoUrl",
formatter: "image",
formatterParams: {
height: "50px",
width: "50px",
urlPrefix: "",
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 eyeIconLink(url);
}
return '';
}
}],
});
}
}

View File

@ -0,0 +1,840 @@
import {Controller} from '@hotwired/stimulus';
import Choices from 'choices.js';
import {TabulatorFull as Tabulator} from 'tabulator-tables';
import {activateUserIcon, deactivateUserIcon, eyeIconLink, sendEmailIcon, TABULATOR_FR_LANG} from "../js/global.js";
export default class extends Controller {
static values = {
rolesArray: Array,
selectedRoleIds: Array,
id: Number,
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.tableNew();
}
if (this.adminValue) {
this.tableSmallAdmin();
}
if (this.listOrganizationValue) {
this.tableOrganization()
}
}
roleSelect() {
if (this.hasSelectTarget) {
const choicesData = this.rolesArrayValue.map(role => ({
value: role.id,
label: role.name,
selected: this.selectedRoleIdsValue.includes(role.id)
}));
new Choices(this.selectTarget, {
choices: choicesData,
removeItemButton: true,
placeholder: true,
placeholderValue: 'Ajouter un ou plusieurs rôles',
});
}
}
// 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 = 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 = deactivateUserIcon();
const activateSvg = activateUserIcon();
const actionSvg = isActive ? deactivateSvg : activateSvg;
return `
<div class="d-flex gap-2 align-content-center">
${eyeIconLink(url)}
<a href="#"
class="${actionColorClass} ${actionClass} pt-3"
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 ?')) {
const formData = new FormData();
formData.append('status', 'deactivate');
fetch(`/user/activeStatus/${userId}`, {
method: 'POST',
body: formData,
headers: {'X-Requested-With': 'XMLHttpRequest'}
})
.then(async (response) => {
if (response.ok) {
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('status','activate');
fetch(`/user/activeStatus/${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'));
}
}
}
}];
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
});
};
tableNew() {
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 = 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 eyeIconLink(url);
}
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 = 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) {
eyeIconLink(url);
}
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 = 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 === "INVITED") {
return `<span class="badge bg-primary">Invité</span>`
} else if (statut === "ACTIVE") {
return `<span class="badge bg-success">Actif</span>`
}else if( statut === "EXPIRED"){
return `<span class="badge bg-warning text-dark">Expiré</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;
// Check if user is expired
if (statut === "EXPIRED") {
return `
<div class="d-flex gap-2 align-content-center">
${eyeIconLink(url)}
${sendEmailIcon(userId, orgId)}
</div>
`;
}if (statut === "INVITED") {
return `
<div class="d-flex gap-2 align-content-center">
${eyeIconLink(url)}
</div>`;
}
// Decide which action (deactivate vs activate) for non-expired users
const isActive = (statut === "ACTIVE");
const actionClass = isActive ? 'deactivate-user' : 'activate-user';
const actionTitle = isActive ? 'Désactiver' : 'Réactiver';
const actionColorClass = isActive ? 'color-secondary' : 'color-primary';
// SVGs
const deactivateSvg = deactivateUserIcon();
const activateSvg = activateUserIcon();
const actionSvg = isActive ? deactivateSvg : activateSvg;
return `
<div class="d-flex gap-2 align-content-center">
${eyeIconLink(url)}
<a href="#"
class="${actionColorClass} ${actionClass} pt-3"
data-id="${userId}"
data-org-id="${orgId}"
title="${actionTitle}">
${actionSvg}
</a>
</div>
`;
},
cellClick: function (e, cell) {
const target = e.target.closest('a');
if (!target) return;
// Handle resend invitation for expired users
if (target.classList.contains('resend-invitation')) {
e.preventDefault();
const userId = target.getAttribute('data-id');
if (confirm('Voulez-vous renvoyer l\'invitation à cet utilisateur ?')) {
const formData = new FormData();
formData.append('organizationId', target.getAttribute('data-org-id'));
fetch(`/user/organization/resend-invitation/${userId}`, {
method: 'POST',
body: formData,
headers: {'X-Requested-With': 'XMLHttpRequest'}
})
.then(async (response) => {
if (response.ok) {
const data = cell.getRow().getData();
data.statut = "INVITED";
cell.getRow().reformat();
alert('Invitation renvoyée avec succès');
} else {
const text = await response.text();
alert('Erreur lors de l\'envoi : ' + text);
}
})
.catch(() => alert('Erreur lors de l\'envoi'));
}
}
// Deactivate
if (target.classList.contains('deactivate-user')) {
e.preventDefault();
const userId = target.getAttribute('data-id');
if (confirm('Voulez-vous vraiment désactiver cet utilisateur ?')) {
const formData = new FormData();
formData.append('status', 'deactivate');
formData.append('organizationId', target.getAttribute('data-org-id'));
fetch(`/user/organization/activateStatus/${userId}`, {
method: 'POST',
body: formData,
headers: {'X-Requested-With': 'XMLHttpRequest'}
})
.then(async (response) => {
if (response.ok) {
const data = cell.getRow().getData();
data.statut = "INACTIVE";
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('status', 'activate');
formData.append('organizationId', target.getAttribute('data-org-id'));
fetch(`/user/organization/activateStatus/${userId}`, {
method: 'POST',
body: formData,
headers: {'X-Requested-With': 'XMLHttpRequest'}
})
.then(async (response) => {
if (response.ok) {
const data = cell.getRow().getData();
data.statut = "ACTIVE";
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,x
// 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,
filterMode: "remote",
ajaxURLGenerator: function(url, config, params) {
let queryParams = new URLSearchParams();
// console.log("orgId:", params.orgId);
queryParams.append('orgId', params.orgId);
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", // activate French
columns
});
};
}

View File

@ -0,0 +1 @@
<svg xmlns="https://www.w3.org/2000/svg" viewBox="0 0 16 16"><g fill="currentColor"><path d="M4 2.5a.5.5 0 0 1 .5-.5h1a.5.5 0 0 1 .5.5v1a.5.5 0 0 1-.5.5h-1a.5.5 0 0 1-.5-.5zm3 0a.5.5 0 0 1 .5-.5h1a.5.5 0 0 1 .5.5v1a.5.5 0 0 1-.5.5h-1a.5.5 0 0 1-.5-.5zm3.5-.5a.5.5 0 0 0-.5.5v1a.5.5 0 0 0 .5.5h1a.5.5 0 0 0 .5-.5v-1a.5.5 0 0 0-.5-.5zM4 5.5a.5.5 0 0 1 .5-.5h1a.5.5 0 0 1 .5.5v1a.5.5 0 0 1-.5.5h-1a.5.5 0 0 1-.5-.5zM7.5 5a.5.5 0 0 0-.5.5v1a.5.5 0 0 0 .5.5h1a.5.5 0 0 0 .5-.5v-1a.5.5 0 0 0-.5-.5zm2.5.5a.5.5 0 0 1 .5-.5h1a.5.5 0 0 1 .5.5v1a.5.5 0 0 1-.5.5h-1a.5.5 0 0 1-.5-.5zM4.5 8a.5.5 0 0 0-.5.5v1a.5.5 0 0 0 .5.5h1a.5.5 0 0 0 .5-.5v-1a.5.5 0 0 0-.5-.5zm2.5.5a.5.5 0 0 1 .5-.5h1a.5.5 0 0 1 .5.5v1a.5.5 0 0 1-.5.5h-1a.5.5 0 0 1-.5-.5zm3.5-.5a.5.5 0 0 0-.5.5v1a.5.5 0 0 0 .5.5h1a.5.5 0 0 0 .5-.5v-1a.5.5 0 0 0-.5-.5z"/><path d="M2 1a1 1 0 0 1 1-1h10a1 1 0 0 1 1 1v14a1 1 0 0 1-1 1H3a1 1 0 0 1-1-1zm11 0H3v14h3v-2.5a.5.5 0 0 1 .5-.5h3a.5.5 0 0 1 .5.5V15h3z"/></g></svg>

After

Width:  |  Height:  |  Size: 965 B

View File

@ -0,0 +1 @@
<svg xmlns="https://www.w3.org/2000/svg" viewBox="0 0 16 16"><g fill="currentColor"><path d="M14.763.075A.5.5 0 0 1 15 .5v15a.5.5 0 0 1-.5.5h-3a.5.5 0 0 1-.5-.5V14h-1v1.5a.5.5 0 0 1-.5.5h-9a.5.5 0 0 1-.5-.5V10a.5.5 0 0 1 .342-.474L6 7.64V4.5a.5.5 0 0 1 .276-.447l8-4a.5.5 0 0 1 .487.022M6 8.694L1 10.36V15h5zM7 15h2v-1.5a.5.5 0 0 1 .5-.5h2a.5.5 0 0 1 .5.5V15h2V1.309l-7 3.5z"/><path d="M2 11h1v1H2zm2 0h1v1H4zm-2 2h1v1H2zm2 0h1v1H4zm4-4h1v1H8zm2 0h1v1h-1zm-2 2h1v1H8zm2 0h1v1h-1zm2-2h1v1h-1zm0 2h1v1h-1zM8 7h1v1H8zm2 0h1v1h-1zm2 0h1v1h-1zM8 5h1v1H8zm2 0h1v1h-1zm2 0h1v1h-1zm0-2h1v1h-1z"/></g></svg>

After

Width:  |  Height:  |  Size: 598 B

View File

@ -0,0 +1 @@
<svg xmlns="https://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill="currentColor" fill-rule="evenodd" d="M1.646 4.646a.5.5 0 0 1 .708 0L8 10.293l5.646-5.647a.5.5 0 0 1 .708.708l-6 6a.5.5 0 0 1-.708 0l-6-6a.5.5 0 0 1 0-.708"/></svg>

After

Width:  |  Height:  |  Size: 236 B

View File

@ -0,0 +1 @@
<svg xmlns="https://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill="currentColor" fill-rule="evenodd" d="M4.646 1.646a.5.5 0 0 1 .708 0l6 6a.5.5 0 0 1 0 .708l-6 6a.5.5 0 0 1-.708-.708L10.293 8L4.646 2.354a.5.5 0 0 1 0-.708"/></svg>

After

Width:  |  Height:  |  Size: 236 B

View File

@ -0,0 +1 @@
<svg xmlns="https://www.w3.org/2000/svg" viewBox="0 0 512 512"><path fill="currentColor" d="M256 464a208 208 0 1 1 0-416a208 208 0 1 1 0 416m0-464a256 256 0 1 0 0 512a256 256 0 1 0 0-512m120.9 294.6c4.5-4.2 7.1-10.1 7.1-16.3c0-12.3-10-22.3-22.3-22.3H304v-96c0-17.7-14.3-32-32-32h-32c-17.7 0-32 14.3-32 32v96h-57.7c-12.3 0-22.3 10-22.3 22.3c0 6.2 2.6 12.1 7.1 16.3l107.1 99.9c3.8 3.5 8.7 5.5 13.8 5.5s10.1-2 13.8-5.5z"/></svg>

After

Width:  |  Height:  |  Size: 425 B

View File

@ -0,0 +1 @@
<svg xmlns="https://www.w3.org/2000/svg" viewBox="0 0 512 512"><path fill="currentColor" d="M256 48a208 208 0 1 1 0 416a208 208 0 1 1 0-416m0 464a256 256 0 1 0 0-512a256 256 0 1 0 0 512M151.2 217.4c-4.6 4.2-7.2 10.1-7.2 16.4c0 12.3 10 22.3 22.3 22.3H208v96c0 17.7 14.3 32 32 32h32c17.7 0 32-14.3 32-32v-96h41.7c12.3 0 22.3-10 22.3-22.3c0-6.2-2.6-12.1-7.2-16.4l-91-84c-3.8-3.5-8.7-5.4-13.9-5.4s-10.1 1.9-13.9 5.4l-91 84z"/></svg>

After

Width:  |  Height:  |  Size: 428 B

View File

@ -0,0 +1 @@
<svg xmlns="https://www.w3.org/2000/svg" viewBox="0 0 512 512"><path fill="currentColor" d="M406.5 399.6c-19.1-46.7-65-79.6-118.5-79.6h-64c-53.5 0-99.4 32.9-118.5 79.6C69.9 362.2 48 311.7 48 256c0-114.9 93.1-208 208-208s208 93.1 208 208c0 55.7-21.9 106.2-57.5 143.6m-40.1 32.7c-32 20.1-69.8 31.7-110.4 31.7s-78.4-11.6-110.5-31.7c7.3-36.7 39.7-64.3 78.5-64.3h64c38.8 0 71.2 27.6 78.5 64.3zM256 512a256 256 0 1 0 0-512a256 256 0 1 0 0 512m0-272a40 40 0 1 1 0-80a40 40 0 1 1 0 80m-88-40a88 88 0 1 0 176 0a88 88 0 1 0-176 0"/></svg>

After

Width:  |  Height:  |  Size: 528 B

View File

@ -0,0 +1 @@
<svg xmlns="https://www.w3.org/2000/svg" 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>

After

Width:  |  Height:  |  Size: 787 B

View File

@ -0,0 +1 @@
<svg xmlns="https://www.w3.org/2000/svg" viewBox="0 0 512 512"><path fill="currentColor" fill-rule="evenodd" d="m243.07 65.728l34.263 14.684v46.42l-41.306-17.703l-107.695 61.54l116.919 66.811L256 243.623v146.285l106.667-60.952v-94.288h42.666v119.048l-10.749 6.143l-149.333 85.333l-10.584 6.048l-10.585-6.048l-149.333-85.333L64 353.716V158.289l10.749-6.142l149.333-85.333l9.224-5.271zm-29.737 324.18V268.383l-106.666-60.952v121.525zm106.666-283.24h55.163l-91.581 91.582l30.17 30.17l91.581-91.582v55.163h42.667v-128h-128z" clip-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 548 B

View File

@ -0,0 +1 @@
<svg xmlns="https://www.w3.org/2000/svg" viewBox="0 0 24 24"><path fill="currentColor" d="M9 17.192V6.808L17.154 12zm1-1.842L15.289 12L10 8.65z"/></svg>

After

Width:  |  Height:  |  Size: 152 B

View File

@ -0,0 +1 @@
<svg xmlns="https://www.w3.org/2000/svg" viewBox="0 0 24 24"><path fill="currentColor" d="M10 16h4q.425 0 .713-.288T15 15v-2h-2v1h-2v-4h2v1h2V9q0-.425-.288-.712T14 8h-4q-.425 0-.712.288T9 9v6q0 .425.288.713T10 16m2 6q-2.075 0-3.9-.788t-3.175-2.137T2.788 15.9T2 12t.788-3.9t2.137-3.175T8.1 2.788T12 2t3.9.788t3.175 2.137T21.213 8.1T22 12t-.788 3.9t-2.137 3.175t-3.175 2.138T12 22m0-2q3.35 0 5.675-2.325T20 12t-2.325-5.675T12 4T6.325 6.325T4 12t2.325 5.675T12 20m0-8"/></svg>

After

Width:  |  Height:  |  Size: 473 B

View File

@ -0,0 +1 @@
<svg xmlns="https://www.w3.org/2000/svg" viewBox="0 0 24 24"><path fill="currentColor" d="M13 8V4q0-.425.288-.712T14 3h6q.425 0 .713.288T21 4v4q0 .425-.288.713T20 9h-6q-.425 0-.712-.288T13 8M3 12V4q0-.425.288-.712T4 3h6q.425 0 .713.288T11 4v8q0 .425-.288.713T10 13H4q-.425 0-.712-.288T3 12m10 8v-8q0-.425.288-.712T14 11h6q.425 0 .713.288T21 12v8q0 .425-.288.713T20 21h-6q-.425 0-.712-.288T13 20M3 20v-4q0-.425.288-.712T4 15h6q.425 0 .713.288T11 16v4q0 .425-.288.713T10 21H4q-.425 0-.712-.288T3 20m2-9h4V5H5zm10 8h4v-6h-4zm0-12h4V5h-4zM5 19h4v-2H5zm4-2"/></svg>

After

Width:  |  Height:  |  Size: 560 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path fill="currentColor" d="M11 17h2l.3-1.5q.3-.125.563-.262t.537-.338l1.45.45l1-1.7l-1.15-1q.05-.35.05-.65t-.05-.65l1.15-1l-1-1.7l-1.45.45q-.275-.2-.537-.338T13.3 8.5L13 7h-2l-.3 1.5q-.3.125-.562.263T9.6 9.1l-1.45-.45l-1 1.7l1.15 1q-.05.35-.05.65t.05.65l-1.15 1l1 1.7l1.45-.45q.275.2.538.338t.562.262zm1-3q-.825 0-1.412-.587T10 12t.588-1.412T12 10t1.413.588T14 12t-.587 1.413T12 14m-7 7q-.825 0-1.412-.587T3 19V5q0-.825.588-1.412T5 3h14q.825 0 1.413.588T21 5v14q0 .825-.587 1.413T19 21zm0-2h14V5H5zM5 5v14z"/></svg>

After

Width:  |  Height:  |  Size: 577 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.0 KiB

BIN
assets/img/logo-access.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.7 KiB

BIN
assets/img/logo-check.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.5 KiB

BIN
assets/img/logo-exploit.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

BIN
assets/img/sudalys_icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 888 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 888 B

39
assets/js/cookies.js Normal file
View File

@ -0,0 +1,39 @@
var application
function getApplication(){
const body = document.getElementsByTagName('body')[0];
application = body.getAttribute('data-application');
}
getApplication();
// Support pour différents systèmes de navigation
if (typeof Turbo !== 'undefined') {
document.addEventListener('turbo:load', getApplication);
document.addEventListener('turbo:render', getApplication);
}
// Support pour les applications SPA qui utilisent l'historique de navigation
window.addEventListener('popstate', getApplication);
window.setCookie = function (cname, cvalue, exdays) {
const d = new Date();
d.setTime(d.getTime() + (exdays * 24 * 60 * 60 * 1000));
let expires = "expires=" + d.toUTCString();
document.cookie = application + "-" + cname + "=" + cvalue + ";" + expires + ";path=/;SameSite=Strict";
}
window.getCookie = function (cname) {
let name = application + "-" + cname + "=";
let decodedCookie = decodeURIComponent(document.cookie);
let ca = decodedCookie.split(';');
for (let i = 0; i < ca.length; i++) {
let c = ca[i];
while (c.charAt(0) === ' ') {
c = c.substring(1);
}
if (c.indexOf(name) === 0) {
return c.substring(name.length, c.length);
}
}
return "";
}

51
assets/js/global.js Normal file
View File

@ -0,0 +1,51 @@
export const TABULATOR_FR_LANG = {
fr: {
ajax: {loading: "Chargement...", error: "Erreur"},
pagination: {
page_size: "Taille de page",
page_title: "Afficher la page",
first: "Premier",
first_title: "Première page",
last: "Dernier",
last_title: "Dernière page",
prev: "Précédent",
prev_title: "Page précédente",
next: "Suivant",
next_title: "Page suivante",
all: "Tout",
counter: {showing: "Affiche", of: "de", rows: "lignes", pages: "pages"},
},
headerFilters: {default: "Filtrer la colonne...", columns: {}},
data: {loading: "Chargement des données...", error: "Erreur de chargement des données"},
groups: {item: "élément", items: "éléments"},
},
};
export function eyeIconLink(url) {
return `<a href="${url}" class="p-3 align-middle color-primary" title="Voir">
<svg xmlns="http://www.w3.org/2000/svg"
width="35px"
height="35px"
viewBox="0 0 576 512">
<path fill="currentColor"
d="M288 80c-65.2 0-118.8 29.6-159.9 67.7C89.6 183.5 63 226 49.4 256C63 286 89.6 328.5 128 364.3c41.2 38.1 94.8 67.7 160 67.7s118.8-29.6 159.9-67.7C486.4 328.5 513 286 526.6 256c-13.6-30-40.2-72.5-78.6-108.3C406.8 109.6 353.2 80 288 80M95.4 112.6C142.5 68.8 207.2 32 288 32s145.5 36.8 192.6 80.6c46.8 43.5 78.1 95.4 93 131.1c3.3 7.9 3.3 16.7 0 24.6c-14.9 35.7-46.2 87.7-93 131.1C433.5 443.2 368.8 480 288 480s-145.5-36.8-192.6-80.6C48.6 356 17.3 304 2.5 268.3c-3.3-7.9-3.3-16.7 0-24.6C17.3 208 48.6 156 95.4 112.6M288 336c44.2 0 80-35.8 80-80s-35.8-80-80-80h-2c1.3 5.1 2 10.5 2 16c0 35.3-28.7 64-64 64c-5.5 0-10.9-.7-16-2v2c0 44.2 35.8 80 80 80m0-208a128 128 0 1 1 0 256a128 128 0 1 1 0-256"/></svg>
</a>`;
}
export function deactivateUserIcon() {
return `<svg xmlns="http://www.w3.org/2000/svg" width="35" height="35" viewBox="0 0 640 512">
<path fill="currentColor" d="M38.8 5.1C28.4-3.1 13.3-1.2 5.1 9.2s-6.3 25.5 4.1 33.7l592 464c10.4 8.2 25.5 6.3 33.7-4.1s6.3-25.5-4.1-33.7L353.3 251.6C407.9 237 448 187.2 448 128C448 57.3 390.7 0 320 0c-69.8 0-126.5 55.8-128 125.2zm225.5 299.2C170.5 309.4 96 387.2 96 482.3c0 16.4 13.3 29.7 29.7 29.7h388.6c3.9 0 7.6-.7 11-2.1z"/>
</svg>`
}
export function activateUserIcon() {
return `<svg xmlns="http://www.w3.org/2000/svg" width="35" height="35" viewBox="0 0 640 512">
<path fill="currentColor" d="M96 128a128 128 0 1 1 256 0a128 128 0 1 1-256 0M0 482.3C0 383.8 79.8 304 178.3 304h91.4c98.5 0 178.3 79.8 178.3 178.3c0 16.4-13.3 29.7-29.7 29.7H29.7C13.3 512 0 498.7 0 482.3M625 177L497 305c-9.4 9.4-24.6 9.4-33.9 0l-64-64c-9.4-9.4-9.4-24.6 0-33.9s24.6-9.4 33.9 0l47 47L591 143c9.4-9.4 24.6-9.4 33.9 0s9.4 24.6 0 33.9z"/>
</svg>`
}
export function sendEmailIcon(userId, orgId) {
return `<a href="#" class="color-primary resend-invitation pt-3" data-id="${userId}" data-org-id="${orgId}" title="Renvoyer l'invitation" >
<svg xmlns="http://www.w3.org/2000/svg" width="35px" height="35px" viewBox="0 0 24 24"><path fill="currentColor" d="M18.175 17H15q-.425 0-.712-.288T14 16t.288-.712T15 15h3.175l-.9-.9Q17 13.825 17 13.413t.3-.713q.275-.275.7-.275t.7.275l2.6 2.6q.125.125.2.312t.075.388t-.075.387t-.2.313l-2.6 2.6q-.275.275-.687.288T17.3 19.3q-.275-.275-.275-.7t.275-.7zM4 17q-.825 0-1.412-.587T2 15V5q0-.825.588-1.412T4 3h13q.825 0 1.413.588T19 5v4.075q0 .4-.3.7t-.7.3q-.425 0-.712-.288T17 9.076V6.4L10.4 11L4 6.425V15h7.075q.425 0 .713.288t.287.712t-.287.713t-.713.287zM5.45 5l4.95 3.55L15.5 5zM4 15V5z"/></svg>
</a>`
}

View File

@ -0,0 +1,39 @@
//Open submenu on hover in compact sidebar mode and horizontal menu mode
function initSubMenu(){
var sidebar = document.querySelectorAll('.sidebar .nav-item')
sidebar.forEach(element => {
element.addEventListener('mouseenter', eventMenu)
element.addEventListener('mouseleave', eventMenu)
})
}
function eventMenu(e){
var body = document.querySelector('body');
var sidebarIconOnly = body.classList.contains("sidebar-icon-only");
var sidebarFixed = body.classList.contains("sidebar-fixed");
if (!('ontouchstart' in document.documentElement)) {
if (sidebarIconOnly) {
var menuItem = this;
if (e.type === 'mouseenter') {
menuItem.classList.add('hover-open')
} else {
menuItem.classList.remove('hover-open')
}
}
}
}
// Exécuter l'initialisation au chargement de la page
document.addEventListener('DOMContentLoaded', initSubMenu);
// Support pour différents systèmes de navigation
if (typeof Turbo !== 'undefined') {
document.addEventListener('turbo:load', initSubMenu);
document.addEventListener('turbo:render', initSubMenu);
}
// Support pour les applications SPA qui utilisent l'historique de navigation
window.addEventListener('popstate', initSubMenu);

0
assets/js/off_canvas.js Normal file
View File

65
assets/js/template.js Normal file
View File

@ -0,0 +1,65 @@
// Utilisation des fonctions de cookies globales définies dans app.js
// Fonction d'initialisation du template
function initTemplate() {
// Appliquer l'état du menu depuis le cookie
applyMenuState();
// Initialiser le bouton de minimisation du menu
initMinimizeButton();
}
// Fonction pour appliquer l'état du menu depuis le cookie
function applyMenuState() {
var body = document.querySelector('body');
var menuState = getCookie('sidebar_state');
// Si le cookie existe, appliquer l'état enregistré
if (menuState === 'collapsed') {
if (!body.classList.contains('sidebar-icon-only')) {
body.classList.add('sidebar-icon-only');
}
} else if (menuState === 'expanded') {
if (body.classList.contains('sidebar-icon-only')) {
body.classList.remove('sidebar-icon-only');
}
}
}
// Fonction pour initialiser le bouton de minimisation du menu
function initMinimizeButton() {
// Supprimer l'ancien gestionnaire d'événements s'il existe
document.querySelectorAll('[data-toggle="minimize"]').forEach(function(button) {
// Créer une copie du bouton pour supprimer tous les écouteurs d'événements
var newButton = button.cloneNode(true);
button.parentNode.replaceChild(newButton, button);
// Ajouter le nouvel écouteur d'événements
newButton.addEventListener("click", function() {
var body = document.querySelector('body');
if ((body.classList.contains('sidebar-toggle-display')) || (body.classList.contains('sidebar-absolute'))) {
body.classList.toggle('sidebar-hidden');
// Enregistrer l'état dans un cookie
var newState = body.classList.contains('sidebar-hidden') ? 'collapsed' : 'expanded';
setCookie('sidebar_state', newState, 365); // Valable 1 an
} else {
body.classList.toggle('sidebar-icon-only');
// Enregistrer l'état dans un cookie
var newState = body.classList.contains('sidebar-icon-only') ? 'collapsed' : 'expanded';
setCookie('sidebar_state', newState, 365); // Valable 1 an
}
});
});
}
// Exécuter l'initialisation au chargement de la page
document.addEventListener('DOMContentLoaded', initTemplate);
// Support pour différents systèmes de navigation
if (typeof Turbo !== 'undefined') {
document.addEventListener('turbo:load', initTemplate);
document.addEventListener('turbo:render', initTemplate);
}
// Support pour les applications SPA qui utilisent l'historique de navigation
window.addEventListener('popstate', initTemplate);

View File

@ -1,3 +1,15 @@
/*variable*/
:root{
--primary-blue-light : #086572;
--primary-blue-dark : #094754;
--black-font: #1D1E1C;
--delete : #E42E31;
--disable : #A3A3A3;
--check : #80F20E;
--secondary : #cc664c;
--secondary-dark : #a5543d;
}
html {
margin: 0;
padding: 0;
@ -31,3 +43,105 @@ body {
overflow: hidden;
}
.page-body-wrapper {
min-height: calc(100vh - 60px);
display: -webkit-flex;
display: flex;
-webkit-flex-direction: row;
flex-direction: row;
padding-left: 0;
padding-right: 0;
padding-top: 60px;
&.full-page-wrapper {
width: 100%;
min-height: 100vh;
padding-top: 0;
}
}
.main-panel {
transition: width 0.25s ease, margin 0.25s ease;
width: calc(100% - 235px);
min-height: calc(100vh - 60px);
display: -webkit-flex;
display: flex;
-webkit-flex-direction: column;
flex-direction: column;
}
.content-wrapper {
background: #EEF0FD;
width: 100%;
-webkit-flex-grow: 1;
flex-grow: 1;
display: flex;
}
.footer {
background: #fff;
padding: 10px 2.45rem;
transition: all 0.25s ease;
-moz-transition: all 0.25s ease;
-webkit-transition: all 0.25s ease;
-ms-transition: all 0.25s ease;
font-size: calc(0.875rem - 0.05rem);
font-family: "Nunito", sans-serif;
font-weight: 400;
border-top: 1px solid rgba(0, 0, 0, 0.06);
}
.btn-primary{
background: var(--primary-blue-light);
color : #FFFFFF;
border: var(--primary-blue-dark);
border-radius: 1rem;
}
.btn-primary:hover{
background: var(--primary-blue-dark);
color : #FFFFFF;
border: var(--primary-blue-light);
}
.btn-danger{
background: var(--delete);
color : #FFFFFF;
border: var(--delete);
border-radius: 1rem;
}
.color-primary{
color: var(--primary-blue-light) !important;
}
.color-primary-dark{
color: var(--primary-blue-dark);
}
.btn-secondary{
background: var(--secondary);
color : #FFFFFF;
border: var(--secondary);
border-radius: 1rem;
}
.btn-secondary:hover{
background: var(--secondary-dark);
color : #FFFFFF;
border: var(--secondary);
}
.btn-warning{
border-radius: 1rem;
}
.color-secondary{
color: var(--secondary);
}
.bg-primary{
background-color: var(--primary-blue-light) !important;
}
.bg-warning{
background-color: var(--secondary) !important;
}

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;
}

63
assets/styles/choices.css Normal file
View File

@ -0,0 +1,63 @@
.choices {
font-size: 0.9rem;
width: 100%;
}
/* Input style */
.choices__inner {
background: #fff;
border: 1px solid var(--primary-blue-light);
border-radius: 0.375rem; /* same as Bootstrap `.form-control` */
padding: 0.5rem;
min-height: 2.5rem;
box-shadow: none;
cursor: text;
}
/* Placeholder */
.choices__placeholder {
color: #6c757d; /* Bootstrap muted */
opacity: 0.9;
}
/* Selected items (tags) */
.choices__list--multiple .choices__item {
background-color: var(--primary-blue-light) !important;
color: #fff;
border: none;
border-radius: 0.25rem;
padding: 0.25rem 0.5rem;
margin: 0.15rem;
font-size: 0.85rem;
}
/* Remove "x" button */
.choices__list--multiple .choices__item .choices__button {
border-left: 1px solid rgba(255,255,255,0.3);
margin-left: 0.25rem;
color: #fff;
opacity: 0.9;
}
.choices__list--multiple .choices__item .choices__button:hover {
opacity: 1;
}
/* Dropdown list */
.choices__list--dropdown {
border: 1px solid var(--primary-blue-light);
border-radius: 0.25rem;
box-shadow: 0 3px 6px rgba(0,0,0,0.1);
margin-top: 0.2rem;
}
/* Dropdown options */
.choices__list--dropdown .choices__item {
padding: 0.5rem;
font-size: 0.9rem;
}
/* Hover/active in dropdown */
.choices__list--dropdown .choices__item--highlighted {
background-color: var(--primary-blue-light);
color: #fff;
}

View File

@ -69,8 +69,11 @@
border-radius: 0;
}
.navbar-nav-right{
flex-direction: row;
.navbar .navbar-menu-wrapper .navbar-toggler:active,
.navbar .navbar-menu-wrapper .navbar-toggler:focus {
outline: none !important;
box-shadow: none !important;
outline: 0;
}
.navbar .navbar-menu-wrapper .navbar-toggler:not(.navbar-toggler-right) {
@ -82,12 +85,24 @@
transition: transform 0.3s linear;
}
.sidebar-icon-only .navbar .navbar-menu-wrapper .navbar-toggler:not(.navbar-toggler-right) {
transform: rotate(180deg);
}
.navbar-nav-right{
flex-direction: row;
}
.navbar .navbar-menu-wrapper .navbar-nav .nav-item.nav-search .input-group{
align-items: center;
}
.navbar .navbar-menu-wrapper .navbar-nav .nav-item.nav-search .input-group .form-control{
margin-left: 0.7rem;
}
#navbar-search-icon > #search{
vertical-align: middle;
vertical-align: baseline;
}
.navbar .navbar-menu-wrapper .navbar-nav.navbar-nav-right {
@ -244,4 +259,16 @@
#logo_orga{
width:auto;
max-height:40px;
}
.navbar .navbar-menu-wrapper .navbar-nav .nav-item.nav-search .input-group .form-control{
background: transparent;
border: 0;
color: #000;
padding: 0;
}
#change-project{
padding: 2px 5px;
font-size: 16px;
}

View File

@ -0,0 +1,156 @@
.notification-toast-container {
position: fixed;
top: 80px;
right: 20px;
z-index: 9999;
display: flex;
flex-direction: column;
gap: 10px;
pointer-events: none;
}
.notification-toast {
background: white;
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
padding: 16px;
min-width: 320px;
max-width: 400px;
display: flex;
align-items: flex-start;
gap: 12px;
opacity: 0;
transform: translateX(400px);
transition: all 0.3s ease;
pointer-events: all;
}
.notification-toast.show {
opacity: 1;
transform: translateX(0);
}
.notification-toast-icon {
width: 40px;
height: 40px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
color: white;
}
.notification-toast-icon svg {
width: 20px;
height: 20px;
}
.notification-toast-content {
flex: 1;
min-width: 0;
}
.notification-toast-title {
font-weight: 600;
font-size: 14px;
margin-bottom: 4px;
color: #333;
}
.notification-toast-message {
font-size: 13px;
color: #666;
line-height: 1.4;
}
.notification-toast-close {
background: none;
border: none;
font-size: 24px;
line-height: 1;
color: #999;
cursor: pointer;
padding: 0;
width: 24px;
height: 24px;
flex-shrink: 0;
transition: color 0.2s;
}
.notification-toast-close:hover {
color: #333;
}
.nav-notif .count-notification {
position: absolute;
top: 15px;
right: -5px;
background: var(--primary-blue-light);
color: white;
border-radius: 12px;
padding: 3px 7px;
font-size: 8px;
font-weight: bold;
min-width: 5px;
height: 10px;
line-height:0.5;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
}
.dropdown-item.preview-item {
display: flex;
align-items: flex-start;
padding: 12px 16px;
border-bottom: 1px solid #f0f0f0;
transition: background-color 0.2s;
text-decoration: none;
color: inherit;
}
.dropdown-item.preview-item:hover {
background-color: #f8f9fa;
}
.dropdown-item.preview-item:last-child {
border-bottom: none;
}
.preview-thumbnail {
margin-right: 12px;
}
.preview-icon {
width: 40px;
height: 40px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
color: white;
}
.preview-icon svg,
.preview-icon i {
width: 20px;
height: 20px;
}
.preview-item-content {
flex: 1;
min-width: 0;
}
.preview-subject {
font-size: 14px;
font-weight: 600;
margin-bottom: 4px;
color: #333;
}
.preview-item-content p {
font-size: 12px;
margin-bottom: 0;
line-height: 1.4;
}

290
assets/styles/sidebar.css Normal file
View File

@ -0,0 +1,290 @@
.sidebar {
min-height: calc(100vh - 60px);
background: #fff;
font-weight: 500;
padding: 0;
width: 235px;
z-index: 11;
transition: width 0.25s ease, background 0.25s ease;
-webkit-transition: width 0.25s ease, background 0.25s ease;
-moz-transition: width 0.25s ease, background 0.25s ease;
-ms-transition: width 0.25s ease, background 0.25s ease;
}
.sidebar .nav {
overflow: hidden;
flex-wrap: nowrap;
flex-direction: column;
margin-bottom: 60px;
}
.sidebar .nav:not(.sub-menu) {
padding-top: 1.45rem;
padding-left: 1rem;
padding-right: 1rem;
padding-bottom: 0.5rem;
}
.sidebar .nav .nav-item {
-webkit-transition-duration: 0.25s;
-moz-transition-duration: 0.25s;
-o-transition-duration: 0.25s;
transition-duration: 0.25s;
transition-property: background;
-webkit-transition-property: background;
}
.sidebar .nav .nav-item .collapse {
z-index: 999;
}
.sidebar .nav .nav-item.active {
border-radius: 8px;
box-shadow: 0px 0px 5px 0px rgba(197, 197, 197, 0.75);
}
.sidebar .nav:not(.sub-menu) > .nav-item {
border-radius: 8px;
margin-top: 0.2rem;
}
.sidebar > .nav:not(.sub-menu) > .nav-item:hover {
border-radius: 8px;
color: #494949;
}
.sidebar .nav .nav-item .nav-link {
display: -webkit-flex;
display: flex;
-webkit-align-items: center;
align-items: center;
white-space: nowrap;
padding: 0.8125rem 1.937rem 0.8125rem 1rem;
color: #848484;
border-radius: 8px;
-webkit-transition-duration: 0.45s;
-moz-transition-duration: 0.45s;
-o-transition-duration: 0.45s;
transition-duration: 0.45s;
transition-property: color;
-webkit-transition-property: color;
height: 50px;
}
.sidebar .nav .nav-item.active > .nav-link {
color:lightgrey;
position: relative;
}
.sidebar .nav:not(.sub-menu) > .nav-item > .nav-link {
margin: 0;
}
.sidebar .nav .nav-item .nav-link i.menu-icon {
font-size: 1rem;
line-height: 1;
margin-right: 1rem;
color: #838383;
}
.sidebar .nav .nav-item .nav-link i.menu-arrow {
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
margin-left: auto;
margin-right: 0;
color: #686868;
transform: rotate(0deg);
-webkit-transition: transform 0.3s linear;
-moz-transition: transform 0.3s linear;
-ms-transition: transform 0.3s linear;
-o-transition: transform 0.3s linear;
transition: transform 0.3s linear;
}
.sidebar .nav .nav-item .nav-link .menu-title {
display: inline-block;
font-size: 0.875rem;
line-height: 1;
vertical-align: middle;
}
.sidebar .nav .nav-item.active > .nav-link i, .sidebar .nav .nav-item.active > .nav-link .menu-title, .sidebar .nav .nav-item.active > .nav-link .menu-arrow {
color: #494949;
}
.sidebar .nav:not(.sub-menu) > .nav-item > .nav-link[aria-expanded=true] {
border-radius: 8px 8px 0 0;
color: #494949;
}
.sidebar .nav:not(.sub-menu) > .nav-item > .nav-link[aria-expanded=true] i.menu-arrow {
transform: rotate(90deg);
}
.sidebar .nav.sub-menu {
margin-bottom: 0;
margin-top: 0;
list-style: none;
padding: 0.25rem 1.5rem 0 2rem;
padding-bottom: 12px;
}
.sidebar .nav.sub-menu .nav-item {
position: relative;
padding: 0;
display: flex;
align-items: center;
color: #969696;
justify-content: space-between;
list-style: none;
}
.sidebar .nav.sub-menu .nav-item svg {
position: absolute;
color: #b2b2b2;
}
.sidebar .nav.sub-menu .nav-item .nav-link {
padding: 0.7rem 1rem;
position: relative;
font-size: 0.875rem;
line-height: 1;
height: auto;
border-top: 0;
font-weight: 400;
}
.sidebar .nav:not(.sub-menu) .nav-link:hover{
color: #494949;
}
@media (min-width: 992px) {
.sidebar-icon-only .sidebar {
width: 70px;
}
.sidebar-icon-only .sidebar .nav {
overflow: visible;
padding-left: 0;
padding-right: 0;
}
.sidebar-icon-only .navbar .navbar-brand-wrapper {
width: 70px;
}
.sidebar-icon-only .navbar .navbar-brand-wrapper .brand-logo {
display: none;
}
.sidebar-icon-only .navbar .navbar-brand-wrapper .brand-logo-mini {
display: inline-block;
}
.sidebar-icon-only .sidebar .nav .nav-item .nav-link {
display: block;
padding-left: 0.5rem;
padding-right: 0.5rem;
text-align: center;
position: static;
}
.sidebar-icon-only .sidebar .nav .nav-item {
border-radius: 0px;
position: relative;
}
.sidebar-icon-only .sidebar .nav .nav-item .collapse {
display: none;
}
.sidebar-icon-only .sidebar .nav:not(.sub-menu) .nav-item.active {
border-radius: 0;
box-shadow: 4px 0px 7px 0px rgba(182, 185, 189, 0.25);
}
.sidebar-icon-only .sidebar .nav .nav-item .nav-link i.menu-icon {
margin-right: 0;
margin-left: 0;
margin-bottom: 0;
}
.sidebar-icon-only .sidebar .nav .nav-item .nav-link i.menu-arrow {
display: none;
}
.sidebar-icon-only .sidebar .nav.sub-menu {
padding: 0 0 0 1.5rem;
}
.sidebar-icon-only .sidebar .nav.sub-menu .nav-item .nav-link {
text-align: left;
padding-left: 20px;
}
.sidebar-icon-only .main-panel {
width: calc(100% - 70px);
}
.sidebar-icon-only .navbar .navbar-menu-wrapper {
width: calc(100% - 70px);
}
.sidebar-icon-only .sidebar .nav .nav-item .nav-link .menu-title, .sidebar-icon-only .sidebar .nav .nav-item .nav-link .badge, .sidebar-icon-only .sidebar .nav .nav-item .nav-link .menu-sub-title {
display: none;
}
.sidebar-icon-only .sidebar .nav .nav-item .nav-link .menu-title {
border-radius: 0 5px 5px 0px;
}
.sidebar .nav:not(.sub-menu) > .nav-item:hover {
border-radius: 8px;
box-shadow: 0px 0px 5px 0px rgba(197, 197, 197, 0.75);
}
.sidebar-icon-only .nav:not(.sub-menu) > .nav-item:hover,
.sidebar-icon-only .nav:not(.sub-menu) > .nav-item:hover .nav-link{
border-radius: 0;
}
.sidebar-icon-only .sidebar .nav .nav-item:hover .nav-link[aria-expanded] .menu-title {
border-radius: 0 5px 0 0;
}
}
.sidebar-icon-only .sidebar .nav .nav-item.hover-open .nav-link .menu-title {
display: -webkit-flex;
display: flex;
-webkit-align-items: center;
align-items: center;
background: #ffffff;
padding: 0.5rem 1.4rem;
left: 70px;
position: absolute;
text-align: left;
top: 0;
bottom: 0;
width: 190px;
z-index: 10000;
line-height: 1.8;
-webkit-box-shadow: 4px 0px 7px 0px rgba(182, 185, 189, 0.25);
box-shadow: 4px 0px 7px 0px rgba(182, 185, 189, 0.25);
}
.sidebar-icon-only .sidebar .nav .nav-item.hover-open .nav-link .menu-title:after {
display: none;
}
.sidebar-icon-only .sidebar .nav .nav-item.hover-open .collapse,
.sidebar-icon-only .sidebar .nav .nav-item.hover-open .collapsing {
display: block;
background: #fff;
border-radius: 0 0 5px 0;
position: absolute;
left: 70px;
width: 190px;
-webkit-box-shadow: 4px 4px 7px 0px rgba(182, 185, 189, 0.25);
box-shadow: 4px 4px 7px 0px rgba(182, 185, 189, 0.25);
}

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

@ -8,11 +8,13 @@
"ext-ctype": "*",
"ext-iconv": "*",
"ext-openssl": "*",
"aws/aws-sdk-php-symfony": "^2.8",
"doctrine/dbal": "^3",
"doctrine/doctrine-bundle": "^2.14",
"doctrine/doctrine-migrations-bundle": "^3.4",
"doctrine/orm": "^3.3",
"firebase/php-jwt": "^6.11",
"knplabs/knp-time-bundle": "^2.4",
"league/oauth2-server-bundle": "^0.11.0",
"nelmio/cors-bundle": "^2.5",
"phpdocumentor/reflection-docblock": "^5.6",
@ -30,8 +32,9 @@
"symfony/intl": "7.2.*",
"symfony/mailer": "7.2.*",
"symfony/mercure-bundle": "^0.3.9",
"symfony/messenger": "7.2.*",
"symfony/mime": "7.2.*",
"symfony/monolog-bundle": "^3.0",
"symfony/monolog-bundle": "^3.10",
"symfony/notifier": "7.2.*",
"symfony/process": "7.2.*",
"symfony/property-access": "7.2.*",
@ -49,7 +52,6 @@
"symfony/validator": "7.2.*",
"symfony/web-link": "7.2.*",
"symfony/yaml": "7.2.*",
"twig/extra-bundle": "^2.12|^3.0",
"twig/twig": "^2.12|^3.0"
},
"config": {

1976
composer.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -9,7 +9,6 @@ return [
Symfony\Bundle\WebProfilerBundle\WebProfilerBundle::class => ['dev' => true, 'test' => true],
Symfony\UX\StimulusBundle\StimulusBundle::class => ['all' => true],
Symfony\UX\Turbo\TurboBundle::class => ['all' => true],
Twig\Extra\TwigExtraBundle\TwigExtraBundle::class => ['all' => true],
Symfony\Bundle\SecurityBundle\SecurityBundle::class => ['all' => true],
Symfony\Bundle\MonologBundle\MonologBundle::class => ['all' => true],
Symfony\Bundle\MakerBundle\MakerBundle::class => ['dev' => true],
@ -18,4 +17,6 @@ return [
League\Bundle\OAuth2ServerBundle\LeagueOAuth2ServerBundle::class => ['all' => true],
Nelmio\CorsBundle\NelmioCorsBundle::class => ['all' => true],
Symfony\Bundle\MercureBundle\MercureBundle::class => ['all' => true],
Knp\Bundle\TimeBundle\KnpTimeBundle::class => ['all' => true],
Aws\Symfony\AwsBundle::class => ['all' => true],
];

11
config/packages/aws.yaml Normal file
View File

@ -0,0 +1,11 @@
aws:
version: latest
region: "%env(AWS_REGION)%"
credentials:
key: "%env(AWS_KEY)%"
secret: "%env(AWS_SECRET)%"
S3:
region: "%env(AWS_REGION)%"
endpoint: "%env(AWS_ENDPOINT)%"
use_path_style_endpoint: true
signature_version: 'v4'

View File

@ -6,3 +6,4 @@ mercure:
jwt:
secret: '%env(MERCURE_JWT_SECRET)%'
publish: '*'
subscribe: '*'

View File

@ -11,8 +11,9 @@ security:
property: email
role_hierarchy:
ROLE_ADMIN: ROLE_USER
ROLE_SUPER_ADMIN: [ROLE_ADMIN, ROLE_ALLOWED_TO_SWITCH]
ROLE_ADMIN: ROLE_USER
ROLE_SUPER_ADMIN: [ROLE_ALLOWED_TO_SWITCH, ROLE_ADMIN]
firewalls:
dev:
@ -34,7 +35,11 @@ security:
security: true
stateless: true
oauth2: true
password_setup:
pattern: ^/password_setup
stateless: true
main:
user_checker: App\Security\UserChecker
lazy: true
provider: app_user_provider
form_login:
@ -57,6 +62,8 @@ security:
# Note: Only the *first* access control that matches will be used
access_control:
- { path: ^/login, roles: PUBLIC_ACCESS }
- { path: ^/password_setup, roles: PUBLIC_ACCESS }
- { path: ^/password_reset, roles: PUBLIC_ACCESS }
- { path: ^/sso_logout, roles: IS_AUTHENTICATED_FULLY }
- { path: ^/token, roles: PUBLIC_ACCESS }
- { path: ^/oauth2/revoke_tokens, roles: PUBLIC_ACCESS }

View File

@ -2,6 +2,16 @@ twig:
file_name_pattern: '*.twig'
form_themes: ['bootstrap_5_layout.html.twig']
globals:
application: '%env(APPLICATION)%'
aws_url: '%env(AWS_S3_PORTAL_URL)%'
version: '0.4'
paths:
'%kernel.project_dir%/assets/img': images
when@test:
twig:
strict_variables: true

View File

@ -4,6 +4,9 @@
# Put parameters here that don't need to change on each machine where the app is deployed
# https://symfony.com/doc/current/best_practices.html#use-parameters-for-application-configuration
parameters:
aws_url: '%env(AWS_ENDPOINT)%'
aws_public_url: '%env(AWS_ENDPOINT)%'
logos_directory: '%kernel.project_dir%/public/uploads/logos'
services:
# default configuration for services in *this* file
@ -22,6 +25,12 @@ services:
App\EventSubscriber\:
resource: '../src/EventSubscriber/'
tags: ['kernel.event_subscriber']
App\Service\AwsService:
arguments:
$awsPublicUrl: '%aws_public_url%'
App\Service\OrganizationsService:
arguments:
$logoDirectory: '%logos_directory%'
App\EventSubscriber\ScopeResolveListener:
tags:
- { name: kernel.event_listener, event: league.oauth2_server.event.scope_resolve, method: onScopeResolve }

View File

@ -35,4 +35,42 @@ return [
'version' => '5.3.5',
'type' => 'css',
],
'choices.js' => [
'version' => '11.1.0',
],
'choices.js/public/assets/styles/choices.min.css' => [
'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,37 @@
<?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 Version20250709072959 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('DROP SEQUENCE subscriptions_id_seq CASCADE');
$this->addSql('ALTER TABLE subscriptions DROP CONSTRAINT fk_4778a0167b3b43d');
$this->addSql('DROP TABLE subscriptions');
}
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('CREATE SEQUENCE subscriptions_id_seq INCREMENT BY 1 MINVALUE 1 START 1');
$this->addSql('CREATE TABLE subscriptions (id SERIAL NOT NULL, users_id INT NOT NULL, PRIMARY KEY(id))');
$this->addSql('CREATE INDEX idx_4778a0167b3b43d ON subscriptions (users_id)');
$this->addSql('ALTER TABLE subscriptions ADD CONSTRAINT fk_4778a0167b3b43d FOREIGN KEY (users_id) REFERENCES "user" (id) NOT DEFERRABLE INITIALLY IMMEDIATE');
}
}

View File

@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20250709073312 extends AbstractMigration
{
public function getDescription(): string
{
return '';
}
public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
$this->addSql('CREATE TABLE subscriptions (id SERIAL NOT NULL, users_id INT NOT NULL, PRIMARY KEY(id))');
$this->addSql('CREATE INDEX IDX_4778A0167B3B43D ON subscriptions (users_id)');
$this->addSql('ALTER TABLE subscriptions ADD CONSTRAINT FK_4778A0167B3B43D FOREIGN KEY (users_id) REFERENCES "user" (id) NOT DEFERRABLE INITIALLY IMMEDIATE');
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql('CREATE SCHEMA public');
$this->addSql('ALTER TABLE subscriptions DROP CONSTRAINT FK_4778A0167B3B43D');
$this->addSql('DROP TABLE subscriptions');
}
}

View File

@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20250709073752 extends AbstractMigration
{
public function getDescription(): string
{
return '';
}
public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
$this->addSql('CREATE TABLE user_tab (id SERIAL NOT NULL, users_id INT NOT NULL, ip_address VARCHAR(255) NOT NULL, tab_id VARCHAR(255) DEFAULT NULL, PRIMARY KEY(id))');
$this->addSql('CREATE INDEX IDX_98F5228767B3B43D ON user_tab (users_id)');
$this->addSql('ALTER TABLE user_tab ADD CONSTRAINT FK_98F5228767B3B43D FOREIGN KEY (users_id) REFERENCES "user" (id) NOT DEFERRABLE INITIALLY IMMEDIATE');
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql('CREATE SCHEMA public');
$this->addSql('ALTER TABLE user_tab DROP CONSTRAINT FK_98F5228767B3B43D');
$this->addSql('DROP TABLE user_tab');
}
}

View File

@ -0,0 +1,37 @@
<?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 Version20250709115309 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('DROP SEQUENCE subscriptions_id_seq CASCADE');
$this->addSql('ALTER TABLE subscriptions DROP CONSTRAINT fk_4778a0167b3b43d');
$this->addSql('DROP TABLE subscriptions');
}
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('CREATE SEQUENCE subscriptions_id_seq INCREMENT BY 1 MINVALUE 1 START 1');
$this->addSql('CREATE TABLE subscriptions (id SERIAL NOT NULL, users_id INT NOT NULL, PRIMARY KEY(id))');
$this->addSql('CREATE INDEX idx_4778a0167b3b43d ON subscriptions (users_id)');
$this->addSql('ALTER TABLE subscriptions ADD CONSTRAINT fk_4778a0167b3b43d FOREIGN KEY (users_id) REFERENCES "user" (id) NOT DEFERRABLE INITIALLY IMMEDIATE');
}
}

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 Version20250709120951 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 organizations ADD name VARCHAR(255) 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 organizations DROP name');
}
}

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 Version20250709121023 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 organizations ADD name VARCHAR(255) DEFAULT NULL');
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql('CREATE SCHEMA public');
$this->addSql('ALTER TABLE organizations DROP name');
}
}

View File

@ -0,0 +1,33 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20250709141934 extends AbstractMigration
{
public function getDescription(): string
{
return '';
}
public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
$this->addSql('ALTER TABLE "user" ADD last_connection DATE DEFAULT NULL');
$this->addSql('COMMENT ON COLUMN "user".last_connection IS \'(DC2Type:date_immutable)\'');
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql('CREATE SCHEMA public');
$this->addSql('ALTER TABLE "user" DROP last_connection');
}
}

View File

@ -0,0 +1,34 @@
<?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 Version20250710070735 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 last_connection TYPE DATE');
$this->addSql('COMMENT ON COLUMN "user".last_connection IS 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 last_connection TYPE DATE');
$this->addSql('COMMENT ON COLUMN "user".last_connection IS \'(DC2Type:date_immutable)\'');
}
}

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 Version20250710071344 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 last_connection TYPE TIMESTAMP(0) WITHOUT TIME ZONE');
}
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 last_connection TYPE DATE');
}
}

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 Version20250710090534 extends AbstractMigration
{
public function getDescription(): string
{
return '';
}
public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
$this->addSql('ALTER TABLE "user" ADD phone_number VARCHAR(20) DEFAULT NULL');
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql('CREATE SCHEMA public');
$this->addSql('ALTER TABLE "user" DROP phone_number');
}
}

View File

@ -0,0 +1,33 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20250716083017 extends AbstractMigration
{
public function getDescription(): string
{
return '';
}
public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
$this->addSql('ALTER TABLE users_organizations ADD created_at TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT CURRENT_TIMESTAMP');
$this->addSql('COMMENT ON COLUMN users_organizations.created_at IS \'(DC2Type:datetime_immutable)\'');
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql('CREATE SCHEMA public');
$this->addSql('ALTER TABLE users_organizations DROP created_at');
}
}

View File

@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20250716130850 extends AbstractMigration
{
public function getDescription(): string
{
return '';
}
public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql('CREATE SCHEMA public');
}
}

View File

@ -0,0 +1,52 @@
<?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 Version20250724133531 extends AbstractMigration
{
public function getDescription(): string
{
return '';
}
public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
$this->addSql('CREATE TABLE user_organization_app (id SERIAL NOT NULL, applications_id INT DEFAULT NULL, organization_id INT DEFAULT NULL, users_id INT DEFAULT NULL, PRIMARY KEY(id))');
$this->addSql('CREATE INDEX IDX_BEF66DF129A0022 ON user_organization_app (applications_id)');
$this->addSql('CREATE INDEX IDX_BEF66DF132C8A3DE ON user_organization_app (organization_id)');
$this->addSql('CREATE INDEX IDX_BEF66DF167B3B43D ON user_organization_app (users_id)');
$this->addSql('CREATE TABLE user_organization_roles (id SERIAL NOT NULL, role_id INT DEFAULT NULL, users_id INT DEFAULT NULL, organization_id INT DEFAULT NULL, PRIMARY KEY(id))');
$this->addSql('CREATE INDEX IDX_94FD2EFBD60322AC ON user_organization_roles (role_id)');
$this->addSql('CREATE INDEX IDX_94FD2EFB67B3B43D ON user_organization_roles (users_id)');
$this->addSql('CREATE INDEX IDX_94FD2EFB32C8A3DE ON user_organization_roles (organization_id)');
$this->addSql('ALTER TABLE user_organization_app ADD CONSTRAINT FK_BEF66DF129A0022 FOREIGN KEY (applications_id) REFERENCES apps (id) NOT DEFERRABLE INITIALLY IMMEDIATE');
$this->addSql('ALTER TABLE user_organization_app ADD CONSTRAINT FK_BEF66DF132C8A3DE FOREIGN KEY (organization_id) REFERENCES organizations (id) NOT DEFERRABLE INITIALLY IMMEDIATE');
$this->addSql('ALTER TABLE user_organization_app ADD CONSTRAINT FK_BEF66DF167B3B43D FOREIGN KEY (users_id) REFERENCES "user" (id) NOT DEFERRABLE INITIALLY IMMEDIATE');
$this->addSql('ALTER TABLE user_organization_roles ADD CONSTRAINT FK_94FD2EFBD60322AC FOREIGN KEY (role_id) REFERENCES roles (id) NOT DEFERRABLE INITIALLY IMMEDIATE');
$this->addSql('ALTER TABLE user_organization_roles ADD CONSTRAINT FK_94FD2EFB67B3B43D FOREIGN KEY (users_id) REFERENCES "user" (id) NOT DEFERRABLE INITIALLY IMMEDIATE');
$this->addSql('ALTER TABLE user_organization_roles ADD CONSTRAINT FK_94FD2EFB32C8A3DE FOREIGN KEY (organization_id) REFERENCES organizations (id) NOT DEFERRABLE INITIALLY IMMEDIATE');
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql('CREATE SCHEMA public');
$this->addSql('ALTER TABLE user_organization_app DROP CONSTRAINT FK_BEF66DF129A0022');
$this->addSql('ALTER TABLE user_organization_app DROP CONSTRAINT FK_BEF66DF132C8A3DE');
$this->addSql('ALTER TABLE user_organization_app DROP CONSTRAINT FK_BEF66DF167B3B43D');
$this->addSql('ALTER TABLE user_organization_roles DROP CONSTRAINT FK_94FD2EFBD60322AC');
$this->addSql('ALTER TABLE user_organization_roles DROP CONSTRAINT FK_94FD2EFB67B3B43D');
$this->addSql('ALTER TABLE user_organization_roles DROP CONSTRAINT FK_94FD2EFB32C8A3DE');
$this->addSql('DROP TABLE user_organization_app');
$this->addSql('DROP TABLE user_organization_roles');
}
}

View File

@ -0,0 +1,56 @@
<?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 Version20250725065027 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('DROP SEQUENCE user_organization_roles_id_seq CASCADE');
$this->addSql('DROP SEQUENCE user_organization_app_id_seq CASCADE');
$this->addSql('ALTER TABLE user_organization_app DROP CONSTRAINT fk_bef66df129a0022');
$this->addSql('ALTER TABLE user_organization_app DROP CONSTRAINT fk_bef66df132c8a3de');
$this->addSql('ALTER TABLE user_organization_app DROP CONSTRAINT fk_bef66df167b3b43d');
$this->addSql('ALTER TABLE user_organization_roles DROP CONSTRAINT fk_94fd2efb32c8a3de');
$this->addSql('ALTER TABLE user_organization_roles DROP CONSTRAINT fk_94fd2efb67b3b43d');
$this->addSql('ALTER TABLE user_organization_roles DROP CONSTRAINT fk_94fd2efbd60322ac');
$this->addSql('DROP TABLE user_organization_app');
$this->addSql('DROP TABLE user_organization_roles');
}
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('CREATE SEQUENCE user_organization_roles_id_seq INCREMENT BY 1 MINVALUE 1 START 1');
$this->addSql('CREATE SEQUENCE user_organization_app_id_seq INCREMENT BY 1 MINVALUE 1 START 1');
$this->addSql('CREATE TABLE user_organization_app (id SERIAL NOT NULL, applications_id INT DEFAULT NULL, organization_id INT DEFAULT NULL, users_id INT DEFAULT NULL, PRIMARY KEY(id))');
$this->addSql('CREATE INDEX idx_bef66df129a0022 ON user_organization_app (applications_id)');
$this->addSql('CREATE INDEX idx_bef66df132c8a3de ON user_organization_app (organization_id)');
$this->addSql('CREATE INDEX idx_bef66df167b3b43d ON user_organization_app (users_id)');
$this->addSql('CREATE TABLE user_organization_roles (id SERIAL NOT NULL, role_id INT DEFAULT NULL, users_id INT DEFAULT NULL, organization_id INT DEFAULT NULL, PRIMARY KEY(id))');
$this->addSql('CREATE INDEX idx_94fd2efb32c8a3de ON user_organization_roles (organization_id)');
$this->addSql('CREATE INDEX idx_94fd2efb67b3b43d ON user_organization_roles (users_id)');
$this->addSql('CREATE INDEX idx_94fd2efbd60322ac ON user_organization_roles (role_id)');
$this->addSql('ALTER TABLE user_organization_app ADD CONSTRAINT fk_bef66df129a0022 FOREIGN KEY (applications_id) REFERENCES apps (id) NOT DEFERRABLE INITIALLY IMMEDIATE');
$this->addSql('ALTER TABLE user_organization_app ADD CONSTRAINT fk_bef66df132c8a3de FOREIGN KEY (organization_id) REFERENCES organizations (id) NOT DEFERRABLE INITIALLY IMMEDIATE');
$this->addSql('ALTER TABLE user_organization_app ADD CONSTRAINT fk_bef66df167b3b43d FOREIGN KEY (users_id) REFERENCES "user" (id) NOT DEFERRABLE INITIALLY IMMEDIATE');
$this->addSql('ALTER TABLE user_organization_roles ADD CONSTRAINT fk_94fd2efb32c8a3de FOREIGN KEY (organization_id) REFERENCES organizations (id) NOT DEFERRABLE INITIALLY IMMEDIATE');
$this->addSql('ALTER TABLE user_organization_roles ADD CONSTRAINT fk_94fd2efb67b3b43d FOREIGN KEY (users_id) REFERENCES "user" (id) NOT DEFERRABLE INITIALLY IMMEDIATE');
$this->addSql('ALTER TABLE user_organization_roles ADD CONSTRAINT fk_94fd2efbd60322ac FOREIGN KEY (role_id) REFERENCES roles (id) NOT DEFERRABLE INITIALLY IMMEDIATE');
}
}

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 Version20250804084150 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 actions ADD organization_id INT DEFAULT NULL');
$this->addSql('ALTER TABLE actions ADD CONSTRAINT FK_548F1EF32C8A3DE FOREIGN KEY (organization_id) REFERENCES organizations (id) NOT DEFERRABLE INITIALLY IMMEDIATE');
$this->addSql('CREATE INDEX IDX_548F1EF32C8A3DE ON actions (organization_id)');
}
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 actions DROP CONSTRAINT FK_548F1EF32C8A3DE');
$this->addSql('DROP INDEX IDX_548F1EF32C8A3DE');
$this->addSql('ALTER TABLE actions DROP organization_id');
}
}

View File

@ -0,0 +1,34 @@
<?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 Version20250804085615 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('DROP INDEX uniq_548f1ef67b3b43d');
$this->addSql('CREATE INDEX IDX_548F1EF67B3B43D ON actions (users_id)');
}
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('DROP INDEX IDX_548F1EF67B3B43D');
$this->addSql('CREATE UNIQUE INDEX uniq_548f1ef67b3b43d ON actions (users_id)');
}
}

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 Version20250804101742 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 actions ADD description VARCHAR(255) DEFAULT NULL');
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql('CREATE SCHEMA public');
$this->addSql('ALTER TABLE actions DROP description');
}
}

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 Version20250804121445 extends AbstractMigration
{
public function getDescription(): string
{
return '';
}
public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
$this->addSql('ALTER TABLE apps ADD description_small VARCHAR(255) DEFAULT NULL');
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql('CREATE SCHEMA public');
$this->addSql('ALTER TABLE apps DROP description_small');
}
}

View File

@ -0,0 +1,64 @@
<?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 Version20250808085504 extends AbstractMigration
{
public function getDescription(): string
{
return '';
}
public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
$this->addSql('CREATE TABLE user_organizaton_app (id SERIAL NOT NULL, users_id INT NOT NULL, organization_id INT DEFAULT NULL, role_id INT DEFAULT NULL, created_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, is_active BOOLEAN NOT NULL, PRIMARY KEY(id))');
$this->addSql('CREATE INDEX IDX_2C952FC767B3B43D ON user_organizaton_app (users_id)');
$this->addSql('CREATE INDEX IDX_2C952FC732C8A3DE ON user_organizaton_app (organization_id)');
$this->addSql('CREATE INDEX IDX_2C952FC7D60322AC ON user_organizaton_app (role_id)');
$this->addSql('COMMENT ON COLUMN user_organizaton_app.created_at IS \'(DC2Type:datetime_immutable)\'');
$this->addSql('ALTER TABLE user_organizaton_app ADD CONSTRAINT FK_2C952FC767B3B43D FOREIGN KEY (users_id) REFERENCES "user" (id) NOT DEFERRABLE INITIALLY IMMEDIATE');
$this->addSql('ALTER TABLE user_organizaton_app ADD CONSTRAINT FK_2C952FC732C8A3DE FOREIGN KEY (organization_id) REFERENCES organizations (id) NOT DEFERRABLE INITIALLY IMMEDIATE');
$this->addSql('ALTER TABLE user_organizaton_app ADD CONSTRAINT FK_2C952FC7D60322AC FOREIGN KEY (role_id) REFERENCES roles (id) NOT DEFERRABLE INITIALLY IMMEDIATE');
$this->addSql('ALTER TABLE users_organizations_apps DROP CONSTRAINT fk_f01f6897964985f0');
$this->addSql('ALTER TABLE users_organizations_apps DROP CONSTRAINT fk_f01f6897a2d76671');
$this->addSql('ALTER TABLE apps_organizations DROP CONSTRAINT fk_ffe659d586288a55');
$this->addSql('ALTER TABLE apps_organizations DROP CONSTRAINT fk_ffe659d5a2d76671');
$this->addSql('DROP TABLE users_organizations_apps');
$this->addSql('DROP TABLE apps_organizations');
$this->addSql('ALTER TABLE users_organizations DROP CONSTRAINT fk_4b991472d60322ac');
$this->addSql('DROP INDEX idx_4b991472d60322ac');
$this->addSql('ALTER TABLE users_organizations DROP role_id');
}
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('CREATE TABLE users_organizations_apps (users_organizations_id INT NOT NULL, apps_id INT NOT NULL, PRIMARY KEY(users_organizations_id, apps_id))');
$this->addSql('CREATE INDEX idx_f01f6897964985f0 ON users_organizations_apps (users_organizations_id)');
$this->addSql('CREATE INDEX idx_f01f6897a2d76671 ON users_organizations_apps (apps_id)');
$this->addSql('CREATE TABLE apps_organizations (apps_id INT NOT NULL, organizations_id INT NOT NULL, PRIMARY KEY(apps_id, organizations_id))');
$this->addSql('CREATE INDEX idx_ffe659d586288a55 ON apps_organizations (organizations_id)');
$this->addSql('CREATE INDEX idx_ffe659d5a2d76671 ON apps_organizations (apps_id)');
$this->addSql('ALTER TABLE users_organizations_apps ADD CONSTRAINT fk_f01f6897964985f0 FOREIGN KEY (users_organizations_id) REFERENCES users_organizations (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE');
$this->addSql('ALTER TABLE users_organizations_apps ADD CONSTRAINT fk_f01f6897a2d76671 FOREIGN KEY (apps_id) REFERENCES apps (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE');
$this->addSql('ALTER TABLE apps_organizations ADD CONSTRAINT fk_ffe659d586288a55 FOREIGN KEY (organizations_id) REFERENCES organizations (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE');
$this->addSql('ALTER TABLE apps_organizations ADD CONSTRAINT fk_ffe659d5a2d76671 FOREIGN KEY (apps_id) REFERENCES apps (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE');
$this->addSql('ALTER TABLE user_organizaton_app DROP CONSTRAINT FK_2C952FC767B3B43D');
$this->addSql('ALTER TABLE user_organizaton_app DROP CONSTRAINT FK_2C952FC732C8A3DE');
$this->addSql('ALTER TABLE user_organizaton_app DROP CONSTRAINT FK_2C952FC7D60322AC');
$this->addSql('DROP TABLE user_organizaton_app');
$this->addSql('ALTER TABLE users_organizations ADD role_id INT NOT NULL');
$this->addSql('ALTER TABLE users_organizations ADD CONSTRAINT fk_4b991472d60322ac FOREIGN KEY (role_id) REFERENCES roles (id) NOT DEFERRABLE INITIALLY IMMEDIATE');
$this->addSql('CREATE INDEX idx_4b991472d60322ac ON users_organizations (role_id)');
}
}

View File

@ -0,0 +1,46 @@
<?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 Version20250808091021 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_organizaton_app DROP CONSTRAINT fk_2c952fc732c8a3de');
$this->addSql('ALTER TABLE user_organizaton_app DROP CONSTRAINT fk_2c952fc767b3b43d');
$this->addSql('DROP INDEX idx_2c952fc732c8a3de');
$this->addSql('DROP INDEX idx_2c952fc767b3b43d');
$this->addSql('ALTER TABLE user_organizaton_app DROP organization_id');
$this->addSql('ALTER TABLE user_organizaton_app RENAME COLUMN users_id TO user_organization_id');
$this->addSql('ALTER TABLE user_organizaton_app ADD CONSTRAINT FK_2C952FC72014CF51 FOREIGN KEY (user_organization_id) REFERENCES users_organizations (id) NOT DEFERRABLE INITIALLY IMMEDIATE');
$this->addSql('CREATE INDEX IDX_2C952FC72014CF51 ON user_organizaton_app (user_organization_id)');
}
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_organizaton_app DROP CONSTRAINT FK_2C952FC72014CF51');
$this->addSql('DROP INDEX IDX_2C952FC72014CF51');
$this->addSql('ALTER TABLE user_organizaton_app ADD organization_id INT DEFAULT NULL');
$this->addSql('ALTER TABLE user_organizaton_app RENAME COLUMN user_organization_id TO users_id');
$this->addSql('ALTER TABLE user_organizaton_app ADD CONSTRAINT fk_2c952fc732c8a3de FOREIGN KEY (organization_id) REFERENCES organizations (id) NOT DEFERRABLE INITIALLY IMMEDIATE');
$this->addSql('ALTER TABLE user_organizaton_app ADD CONSTRAINT fk_2c952fc767b3b43d FOREIGN KEY (users_id) REFERENCES "user" (id) NOT DEFERRABLE INITIALLY IMMEDIATE');
$this->addSql('CREATE INDEX idx_2c952fc732c8a3de ON user_organizaton_app (organization_id)');
$this->addSql('CREATE INDEX idx_2c952fc767b3b43d ON user_organizaton_app (users_id)');
}
}

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 Version20250811121053 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_organizaton_app ADD application_id INT NOT NULL');
$this->addSql('ALTER TABLE user_organizaton_app ADD CONSTRAINT FK_2C952FC73E030ACD FOREIGN KEY (application_id) REFERENCES apps (id) NOT DEFERRABLE INITIALLY IMMEDIATE');
$this->addSql('CREATE INDEX IDX_2C952FC73E030ACD ON user_organizaton_app (application_id)');
}
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_organizaton_app DROP CONSTRAINT FK_2C952FC73E030ACD');
$this->addSql('DROP INDEX IDX_2C952FC73E030ACD');
$this->addSql('ALTER TABLE user_organizaton_app DROP application_id');
}
}

View File

@ -0,0 +1,38 @@
<?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 Version20250812113220 extends AbstractMigration
{
public function getDescription(): string
{
return '';
}
public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
$this->addSql('CREATE TABLE apps_organizations (apps_id INT NOT NULL, organizations_id INT NOT NULL, PRIMARY KEY(apps_id, organizations_id))');
$this->addSql('CREATE INDEX IDX_FFE659D5A2D76671 ON apps_organizations (apps_id)');
$this->addSql('CREATE INDEX IDX_FFE659D586288A55 ON apps_organizations (organizations_id)');
$this->addSql('ALTER TABLE apps_organizations ADD CONSTRAINT FK_FFE659D5A2D76671 FOREIGN KEY (apps_id) REFERENCES apps (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE');
$this->addSql('ALTER TABLE apps_organizations ADD CONSTRAINT FK_FFE659D586288A55 FOREIGN KEY (organizations_id) REFERENCES organizations (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE');
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql('CREATE SCHEMA public');
$this->addSql('ALTER TABLE apps_organizations DROP CONSTRAINT FK_FFE659D5A2D76671');
$this->addSql('ALTER TABLE apps_organizations DROP CONSTRAINT FK_FFE659D586288A55');
$this->addSql('DROP TABLE apps_organizations');
}
}

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,32 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20251028154635 extends AbstractMigration
{
public function getDescription(): string
{
return '';
}
public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
$this->addSql('ALTER TABLE users_organizations ADD statut VARCHAR(255) DEFAULT NULL');
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql('CREATE SCHEMA public');
$this->addSql('ALTER TABLE users_organizations DROP statut');
}
}

View File

@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20251029104354 extends AbstractMigration
{
public function getDescription(): string
{
return '';
}
public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
$this->addSql('ALTER TABLE "user" ADD password_token VARCHAR(255) DEFAULT NULL');
$this->addSql('ALTER TABLE "user" ADD token_expiry TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL');
$this->addSql('COMMENT ON COLUMN "user".token_expiry IS \'(DC2Type:datetime_immutable)\'');
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql('CREATE SCHEMA public');
$this->addSql('ALTER TABLE "user" DROP password_token');
$this->addSql('ALTER TABLE "user" DROP token_expiry');
}
}

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 Version20251029104801 extends AbstractMigration
{
public function getDescription(): string
{
return '';
}
public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
$this->addSql('ALTER TABLE "user" ALTER password DROP NOT NULL');
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql('CREATE SCHEMA public');
$this->addSql('ALTER TABLE "user" ALTER password SET NOT NULL');
}
}

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 Version20251104081124 extends AbstractMigration
{
public function getDescription(): string
{
return '';
}
public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
$this->addSql('ALTER TABLE apps ADD logo_mini_url VARCHAR(255) DEFAULT NULL');
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql('CREATE SCHEMA public');
$this->addSql('ALTER TABLE apps DROP logo_mini_url');
}
}

View File

@ -0,0 +1,33 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20251105083809 extends AbstractMigration
{
public function getDescription(): string
{
return '';
}
public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
$this->addSql('ALTER TABLE users_organizations ADD modified_at TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL');
$this->addSql('COMMENT ON COLUMN users_organizations.modified_at IS \'(DC2Type:datetime_immutable)\'');
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql('CREATE SCHEMA public');
$this->addSql('ALTER TABLE users_organizations DROP modified_at');
}
}

View File

@ -0,0 +1,41 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20251117104819 extends AbstractMigration
{
public function getDescription(): string
{
return '';
}
public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
$this->addSql('CREATE TABLE notifications (id SERIAL NOT NULL, user_id INT NOT NULL, organization_id INT DEFAULT NULL, type VARCHAR(50) NOT NULL, title VARCHAR(255) NOT NULL, message TEXT NOT NULL, data JSON DEFAULT NULL, is_read BOOLEAN DEFAULT false NOT NULL, created_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, read_at TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL, PRIMARY KEY(id))');
$this->addSql('CREATE INDEX IDX_6000B0D3A76ED395 ON notifications (user_id)');
$this->addSql('CREATE INDEX IDX_6000B0D332C8A3DE ON notifications (organization_id)');
$this->addSql('CREATE INDEX idx_user_read_created ON notifications (user_id, is_read, created_at)');
$this->addSql('COMMENT ON COLUMN notifications.created_at IS \'(DC2Type:datetime_immutable)\'');
$this->addSql('COMMENT ON COLUMN notifications.read_at IS \'(DC2Type:datetime_immutable)\'');
$this->addSql('ALTER TABLE notifications ADD CONSTRAINT FK_6000B0D3A76ED395 FOREIGN KEY (user_id) REFERENCES "user" (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE');
$this->addSql('ALTER TABLE notifications ADD CONSTRAINT FK_6000B0D332C8A3DE FOREIGN KEY (organization_id) REFERENCES organizations (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE');
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql('CREATE SCHEMA public');
$this->addSql('ALTER TABLE notifications DROP CONSTRAINT FK_6000B0D3A76ED395');
$this->addSql('ALTER TABLE notifications DROP CONSTRAINT FK_6000B0D332C8A3DE');
$this->addSql('DROP TABLE notifications');
}
}

View File

@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20251117125146 extends AbstractMigration
{
public function getDescription(): string
{
return '';
}
public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql('CREATE SCHEMA public');
}
}

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

@ -0,0 +1,103 @@
<?php
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;
#[Route(path: '/application', name: 'application_')]
class ApplicationController extends AbstractController
{
public function __construct(private readonly EntityManagerInterface $entityManager, private readonly UserService $userService, private readonly ActionService $actionService)
{
}
#[Route(path: '/', name: 'index', methods: ['GET'])]
public function index(): Response
{
$this->denyAccessUnlessGranted('IS_AUTHENTICATED_FULLY');
$applications = $this->entityManager->getRepository(Apps::class)->findAll();
return $this->render('application/index.html.twig', [
'applications' => $applications,
]);
}
#[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

@ -12,9 +12,15 @@ use Symfony\Component\Routing\Attribute\Route;
final class IndexController extends AbstractController
{
#[Route('/', name: 'app_index')]
public function index(Request $request, LoggerInterface $logger): Response
public function index(): Response
{
$logger->info("SESSION ID: " . $request->getSession()->getId());
if ($this->isGranted('ROLE_ADMIN')) {
return $this->redirectToRoute('organization_index');
}
if($this->isGranted('ROLE_USER')) {
return $this->redirectToRoute('application_index');
}
return $this->render('index/index.html.twig', [
'controller_name' => 'IndexController',
]);

View File

@ -0,0 +1,46 @@
<?php
namespace App\Controller;
use App\Service\UserService;
use Lcobucci\JWT\Configuration;
use Lcobucci\JWT\Signer\Hmac\Sha256;
use Lcobucci\JWT\Signer\Key\InMemory;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\Routing\Attribute\Route;
class MercureController extends AbstractController
{
public function __construct(private readonly UserService $userService)
{
}
#[Route(path: '/mercure-token', name: 'mercure_token', methods: ['GET'])]
public function getMercureToken(): JsonResponse
{
$this->denyAccessUnlessGranted('IS_AUTHENTICATED_FULLY');
$user = $this->userService->getUserByIdentifier($this->getUser()->getUserIdentifier());
$topic = sprintf('http://portail.solutions-easy.moi/notifications/user/%d', $user->getId());
// Generate JWT token for Mercure subscription
$secret = $_ENV['MERCURE_JWT_SECRET'];
$config = Configuration::forSymmetricSigner(
new Sha256(),
InMemory::plainText($secret)
);
$token = $config->builder()
->withClaim('mercure', [
'subscribe' => [$topic]
])
->getToken($config->signer(), $config->signingKey());
return new JsonResponse([
'token' => $token->toString(),
'topic' => $topic,
'userId' => $user->getId(),
]);
}
}

View File

@ -0,0 +1,117 @@
<?php
namespace App\Controller;
use App\Entity\Notification;
use App\Repository\NotificationRepository;
use App\Service\UserService;
use Doctrine\ORM\EntityManagerInterface;
use Lcobucci\JWT\Configuration;
use Lcobucci\JWT\Signer\Hmac\Sha256;
use Lcobucci\JWT\Signer\Key\InMemory;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
#[Route(path: '/notifications', name: 'notification_')]
class NotificationController extends AbstractController
{
public function __construct(
private readonly NotificationRepository $notificationRepository,
private readonly EntityManagerInterface $entityManager,
private readonly UserService $userService
) {
}
#[Route(path: '/', name: 'index', methods: ['GET'])]
public function index(): JsonResponse
{
$this->denyAccessUnlessGranted('IS_AUTHENTICATED_FULLY');
$user = $this->userService->getUserByIdentifier($this->getUser()->getUserIdentifier());
$notifications = $this->notificationRepository->findRecentByUser($user, 50);
$unreadCount = $this->notificationRepository->countUnreadByUser($user);
return new JsonResponse([
'notifications' => array_map(fn($n) => $n->toArray(), $notifications),
'unreadCount' => $unreadCount,
]);
}
#[Route(path: '/unread', name: 'unread', methods: ['GET'])]
public function unread(): JsonResponse
{
$this->denyAccessUnlessGranted('IS_AUTHENTICATED_FULLY');
$user = $this->userService->getUserByIdentifier($this->getUser()->getUserIdentifier());
$notifications = $this->notificationRepository->findUnreadByUser($user);
$unreadCount = count($notifications);
return new JsonResponse([
'notifications' => array_map(fn($n) => $n->toArray(), $notifications),
'unreadCount' => $unreadCount,
]);
}
#[Route(path: '/count', name: 'count', methods: ['GET'])]
public function count(): JsonResponse
{
$this->denyAccessUnlessGranted('IS_AUTHENTICATED_FULLY');
$user = $this->userService->getUserByIdentifier($this->getUser()->getUserIdentifier());
$unreadCount = $this->notificationRepository->countUnreadByUser($user);
return new JsonResponse(['count' => $unreadCount]);
}
#[Route(path: '/{id}/read', name: 'mark_read', methods: ['POST'])]
public function markAsRead(int $id): JsonResponse
{
$this->denyAccessUnlessGranted('IS_AUTHENTICATED_FULLY');
$user = $this->userService->getUserByIdentifier($this->getUser()->getUserIdentifier());
$notification = $this->notificationRepository->find($id);
if (!$notification || $notification->getUser()->getId() !== $user->getId()) {
return new JsonResponse(['error' => 'Notification not found'], Response::HTTP_NOT_FOUND);
}
$notification->setIsRead(true);
$this->entityManager->flush();
return new JsonResponse(['success' => true]);
}
#[Route(path: '/mark-all-read', name: 'mark_all_read', methods: ['POST'])]
public function markAllAsRead(): JsonResponse
{
$this->denyAccessUnlessGranted('IS_AUTHENTICATED_FULLY');
$user = $this->userService->getUserByIdentifier($this->getUser()->getUserIdentifier());
$count = $this->notificationRepository->markAllAsReadForUser($user);
return new JsonResponse(['success' => true, 'count' => $count]);
}
#[Route(path: '/{id}', name: 'delete', methods: ['DELETE'])]
public function delete(int $id): JsonResponse
{
$this->denyAccessUnlessGranted('IS_AUTHENTICATED_FULLY');
$user = $this->userService->getUserByIdentifier($this->getUser()->getUserIdentifier());
$notification = $this->notificationRepository->find($id);
if (!$notification || $notification->getUser()->getId() !== $user->getId()) {
return new JsonResponse(['error' => 'Notification not found'], Response::HTTP_NOT_FOUND);
}
$this->entityManager->remove($notification);
$this->entityManager->flush();
return new JsonResponse(['success' => true]);
}
}

View File

@ -0,0 +1,325 @@
<?php
namespace App\Controller;
use App\Entity\Actions;
use App\Entity\Apps;
use App\Entity\Roles;
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\AwsService;
use App\Service\OrganizationsService;
use App\Service\UserOrganizationService;
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;
use Symfony\Component\HttpFoundation\Response;
#[Route(path: '/organization', name: 'organization_')]
class OrganizationController extends AbstractController
{
private const NOT_FOUND = 'Entity not found';
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,
private readonly OrganizationsRepository $organizationsRepository,
private readonly AwsService $awsService)
{
}
#[Route(path: '/', name: 'index', methods: ['GET'])]
public function index(): Response
{
$this->denyAccessUnlessGranted('ROLE_ADMIN');
$user = $this->userService->getUserByIdentifier($this->getUser()->getUserIdentifier());
if ($this->isGranted("ROLE_SUPER_ADMIN")) {
$organizations = $this->organizationsRepository->findBy(['isDeleted' => false]);
} else {
//get all the UO of the user
$uos = $this->entityManager->getRepository(UsersOrganizations::class)->findBy(['users' => $user]);
$organizations = [];
$roleAdmin = $this->entityManager->getRepository(Roles::class)->findOneBy(['name' => 'ADMIN']);
foreach ($uos as $uo) {
$uoaAdmin = $this->entityManager->getRepository(UserOrganizatonApp::class)->findOneBy(['userOrganization' => $uo, 'role' => $roleAdmin]);
if ($uoaAdmin) {
$organizations[] = $uo->getOrganization();
}
}
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', [
'organizationsData' => $organizationsData,
]);
}
#[Route(path: '/new', name: 'new', methods: ['GET', 'POST'])]
public function new(Request $request): Response
{
$this->denyAccessUnlessGranted('ROLE_SUPER_ADMIN');
$actingUser = $this->userService->getUserByIdentifier($this->getUser()->getUserIdentifier());
if ($request->isMethod('POST')) {
$organization = new Organizations();
$form = $this->createForm(OrganizationForm::class, $organization);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
$logoFile = $form->get('logoUrl')->getData();
if ($logoFile) {
$this->organizationsService->handleLogo($organization, $logoFile);
}
try {
$this->entityManager->persist($organization);
$this->entityManager->flush();
$this->actionService->createAction("Create Organization", $actingUser, $organization, $organization->getName());
return $this->redirectToRoute('organization_index');
} catch (Exception $e) {
$this->addFlash('error', 'Error creating organization: ' . $e->getMessage());
}
}
return $this->render('organization/new.html.twig', [
'form' => $form->createView(),
]);
}
$form = $this->createForm(OrganizationForm::class);
return $this->render('organization/new.html.twig', [
'form' => $form->createView(),
]);
}
#[Route(path: '/edit/{id}', name: 'edit', methods: ['GET', 'POST'])]
public function edit(Request $request, $id): Response
{
$this->denyAccessUnlessGranted('ROLE_ADMIN');
$actingUser = $this->userService->getUserByIdentifier($this->getUser()->getUserIdentifier());
$organization = $this->organizationsRepository->find($id);
if (!$organization) {
$this->addFlash('error', self::NOT_FOUND);
return $this->redirectToRoute('organization_index');
}
if (!$this->isGranted("ROLE_SUPER_ADMIN")) {
//check if the user is admin of the organization
$user = $this->userService->getUserByIdentifier($this->getUser()->getUserIdentifier());
$uo = $this->entityManager->getRepository(UsersOrganizations::class)->findOneBy(['users' => $user, 'organization' => $organization]);
if (!$uo) {
$this->addFlash('error', self::ACCESS_DENIED);
return $this->redirectToRoute('organization_index');
}
$roleAdmin = $this->entityManager->getRepository(Roles::class)->findOneBy(['name' => 'ADMIN']);
$uoaAdmin = $this->entityManager->getRepository(UserOrganizatonApp::class)->findOneBy(['userOrganization' => $uo, 'role' => $roleAdmin]);
if (!$uoaAdmin) {
$this->addFlash('error', self::ACCESS_DENIED);
return $this->redirectToRoute('organization_index');
}
}
$form = $this->createForm(OrganizationForm::class, $organization);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
$logoFile = $form->get('logoUrl')->getData();
if ($logoFile) {
$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');
} catch (Exception $e) {
$this->addFlash('error', 'Error editing organization: ' . $e->getMessage());
}
}
return $this->render('organization/edit.html.twig', [
'form' => $form->createView(),
'organization' => $organization,
]);
}
#[Route(path: '/view/{id}', name: 'show', methods: ['GET'])]
public function view($id): Response
{
$this->denyAccessUnlessGranted('ROLE_ADMIN');
$organization = $this->organizationsRepository->find($id);
$actingUser = $this->userService->getUserByIdentifier($this->getUser()->getUserIdentifier());
if (!$organization) {
$this->addFlash('error', self::NOT_FOUND);
return $this->redirectToRoute('organization_index');
}
//check if the user is admin of the organization
if (!$this->isGranted("ROLE_SUPER_ADMIN") && !$this->userService->isAdminOfOrganization($organization)) {
$this->createNotFoundException(self::NOT_FOUND);
}
$newUO = $this->entityManager->getRepository(UsersOrganizations::class)->findNewestUO($organization);
$newUsers = [];
foreach ($newUO as $uo) {
$newUsers[] = $uo->getUsers();
}
$adminUO = $this->entityManager->getRepository(UsersOrganizations::class)->findAdminsInOrganization($organization);
$adminUsers = [];
foreach ($adminUO as $uo) {
$adminUsers[] = $uo->getUsers();
}
$uos = $this->entityManager
->getRepository(UsersOrganizations::class)
->findBy(['organization' => $organization]);
$users = $this->userService->formatOrgUsers($uos);
$allApps = $this->entityManager->getRepository(Apps::class)->findAll(); // appsAll
$orgApps = $organization->getApps()->toArray(); // apps
$apps = $this->organizationsService->appsAccess($allApps, $orgApps);
$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());
return $this->render('organization/show.html.twig', [
'organization' => $organization,
'newUsers' => $newUsers,
'adminUsers' => $adminUsers,
'users' => $users,
'applications' => $apps,
'activities' => $activities,
]);
}
#[Route(path: '/delete/{id}', name: 'delete', methods: ['POST'])]
public function delete($id): Response
{
$this->denyAccessUnlessGranted("ROLE_ADMIN");
$actingUser = $this->userService->getUserByIdentifier($this->getUser()->getUserIdentifier());
$organization = $this->organizationsRepository->find($id);
if (!$organization) {
throw $this->createNotFoundException(self::NOT_FOUND);
}
$organization->setIsActive(false);
$organization->setIsDeleted(true);
// Deactivate all associated UsersOrganizations
$this->userOrganizationService->deactivateAllUserOrganizationLinks($actingUser, null, $organization);
$this->entityManager->persist($organization);
$this->actionService->createAction("Delete Organization", $actingUser, $organization, $organization->getName());
return $this->redirectToRoute('organization_index');
}
#[Route(path: '/deactivate/{id}', name: 'deactivate', methods: ['POST'])]
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): 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(true);
$this->entityManager->persist($organization);
$this->actionService->createAction("Activate Organization", $actingUser, $organization, $organization->getName());
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));
$filters = $request->query->all('filter');
$user = $this->userService->getUserByIdentifier($this->getUser()->getUserIdentifier());
$qb = $this->organizationsRepository->createQueryBuilder('o')
->where('o.isDeleted = :del')->setParameter('del', false);
if (!empty($filters['name'])) {
$qb->andWhere('o.name LIKE :name')
->setParameter('name', '%' . $filters['name'] . '%');
}
if (!empty($filters['email'])) {
$qb->andWhere('o.email LIKE :email')
->setParameter('email', '%' . $filters['email'] . '%');
}
// 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) {
$picture = $this->awsService->getPublicUrl($_ENV['S3_PORTAL_BUCKET']) . $org->getLogoUrl();
return [
'id' => $org->getId(),
'name' => $org->getName(),
'email' => $org->getEmail(),
'logoUrl' => $picture ?: 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

@ -2,7 +2,12 @@
namespace App\Controller;
use App\Repository\UserRepository;
use App\Repository\UsersOrganizationsRepository;
use App\Service\AccessTokenService;
use App\Service\OrganizationsService;
use App\Service\UserService;
use Doctrine\ORM\EntityManagerInterface;
use Psr\Log\LoggerInterface;
use Psr\Log\LogLevel;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
@ -16,9 +21,16 @@ use App\Service\CguUserService;
class SecurityController extends AbstractController
{
const NOT_FOUND = "NOT FOUND";
private CguUserService $cguUserService;
public function __construct(CguUserService $cguUserService)
public function __construct(CguUserService $cguUserService,
private readonly UserRepository $userRepository,
private readonly UserService $userService,
private readonly UsersOrganizationsRepository $uoRepository,
private readonly LoggerInterface $logger,
private readonly EntityManagerInterface $entityManager,
private readonly OrganizationsService $organizationsService)
{
$this->cguUserService = $cguUserService;
}
@ -26,14 +38,9 @@ class SecurityController extends AbstractController
#[Route(path: '/login', name: 'app_login')]
public function login(AuthenticationUtils $authenticationUtils): Response
{
// get the login error if there is one
$error = $authenticationUtils->getLastAuthenticationError();
// last username entered by the user
$lastUsername = $authenticationUtils->getLastUsername();
return $this->render('security/login.html.twig', [
'last_username' => $lastUsername,
'error' => $error,
@ -43,39 +50,88 @@ class SecurityController extends AbstractController
#[Route(path: '/sso_logout', name: 'sso_logout')]
public function ssoLogout(RequestStack $stack, LoggerInterface $logger, AccessTokenService $accessTokenService, Security $security): Response
{
// Invalidate the session and revoke tokens
try{
if( $stack->getSession()->invalidate()){
$accessTokenService->revokeTokens($security->getUser()->getUserIdentifier());
$security->logout(false);
$logger->info("Logout successfully");
// Redirect back to the client (or to a “you are logged out” page)
return $this->redirect('/');
}
}catch (\Exception $e){
$logger->log(LogLevel::ERROR, 'Error invalidating session: ' . $e->getMessage());
}
// If something goes wrong, redirect to the index page
return $this->redirectToRoute('app_index');
}
#[Route(path: '/consent', name: 'app_consent')]
public function consent(Request $request): Response
{
// Handle form submission
if ($request->isMethod('POST')) {
// Check if user declined consent
if (!$request->request->has('decline')) {
// User accepted the CGU, save this in the database
$this->cguUserService->acceptLatestCgu($this->getUser());
}
// Redirect back to the OAuth authorization endpoint with all the query parameters
return $this->redirectToRoute('oauth2_authorize', $request->query->all());
}
// For GET requests, just show the consent form
return $this->render('security/consent.html.twig');
}
#[Route('/password_setup/{id}', name: 'password_setup', methods: ['GET'])]
public function password_setup(int $id, Request $request): Response
{
$error = $request->get('error');
$user = $this->userRepository->find($id);
if (!$user) {
throw $this->createNotFoundException(self::NOT_FOUND);
}
$token = $request->get('token');
if (empty($token) || !$this->userService->isPasswordTokenValid($user, $token)) {
$error = 'Le lien de définition du mot de passe est invalide ou a expiré. Veuillez en demander un nouveau.';
$this->logger->warning($user->getUserIdentifier(). " tried to use an invalid or expired password setup token.");
}
return $this->render('security/password_setup.html.twig', [
'id' => $id,
'token' => $token,
'error' => $error,
]);
}
#[Route('/password_reset/{id}', name: 'password_reset', methods: ['POST'])]
public function password_reset(int $id): Response
{
$user = $this->userRepository->find($id);
if (!$user) {
throw $this->createNotFoundException(self::NOT_FOUND);
}
$newPassword = $_POST['_password'] ?? null;
$confirmPassword = $_POST['_passwordConfirm'] ?? null;
if ($newPassword !== $confirmPassword) {
$error = 'Les mots de passe ne correspondent pas. Veuillez réessayer.';
$this->logger->warning($user->getUserIdentifier(). " provided non-matching passwords during password reset.");
return $this->redirectToRoute('password_setup', [
'id' => $id,
'token' => $_POST['token'] ?? '',
'error'=> $error]);
}
if (!$this->userService->isPasswordStrong($newPassword)) {
$error = 'Le mot de passe ne respecte pas les critères de sécurité. Veuillez en choisir un autre.';
$this->logger->warning($user->getUserIdentifier(). " provided a weak password during password reset.");
return $this->redirectToRoute('password_setup', ['id' => $id, 'token' => $_POST['token'] ?? '', 'error'=> $error]);
}
$this->userService->updateUserPassword($user, $newPassword);
$orgId = $this->userService->getOrgFromToken( $_POST['token']);
$uo = $this->uoRepository->findOneBy(['users' => $user, 'organization' => $orgId]);
if($uo){
$uo->setStatut("ACCEPTED");
$uo->setIsActive(true);
$this->entityManager->persist($uo);
$this->entityManager->flush();
$data = ['user' => $user, 'organization' => $uo->getOrganization()];
$this->organizationsService->notifyOrganizationAdmins($data, "USER_ACCEPTED");
}
return $this->redirectToRoute('app_index');
}
}

View File

@ -0,0 +1,738 @@
<?php
namespace App\Controller;
use App\Entity\Apps;
use App\Entity\Roles;
use App\Entity\User;
use App\Entity\UserOrganizatonApp;
use App\Entity\UsersOrganizations;
use App\Form\UserForm;
use App\Repository\AppsRepository;
use App\Repository\OrganizationsRepository;
use App\Repository\RolesRepository;
use App\Repository\UserRepository;
use App\Repository\UsersOrganizationsRepository;
use App\Service\ActionService;
use App\Service\AwsService;
use App\Service\EmailService;
use App\Service\OrganizationsService;
use App\Service\UserOrganizationAppService;
use App\Service\UserOrganizationService;
use App\Service\UserService;
use Doctrine\ORM\EntityManagerInterface;
use mysql_xdevapi\Exception;
use Psr\Log\LoggerInterface;
use Symfony\Bridge\Twig\Mime\TemplatedEmail;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Symfony\Component\Mailer\Mailer;
use Symfony\Component\Mailer\MailerInterface;
use Symfony\Component\Mime\Email;
use Symfony\Component\Routing\Attribute\Route;
#[Route(path: '/user', name: 'user_')]
class UserController extends AbstractController
{
private const NOT_FOUND = 'Entity not found';
private const ACCESS_DENIED = 'Access denied';
public function __construct(
private readonly EntityManagerInterface $entityManager,
private readonly UserService $userService,
private readonly ActionService $actionService,
private readonly UserOrganizationAppService $userOrganizationAppService,
private readonly UserOrganizationService $userOrganizationService,
private readonly UserRepository $userRepository,
private readonly UsersOrganizationsRepository $uoRepository,
private readonly OrganizationsRepository $organizationRepository,
private readonly LoggerInterface $logger,
private readonly EmailService $emailService,
private readonly AwsService $awsService,
private readonly OrganizationsService $organizationsService,
private readonly AppsRepository $appsRepository,
private readonly RolesRepository $rolesRepository,
)
{
}
#[Route('/view/{id}', name: 'show', methods: ['GET'])]
public function view(int $id, Request $request): Response
{
// Accès : uniquement utilisateur authentifié
$this->denyAccessUnlessGranted('ROLE_USER');
// Utilisateur courant (acting user) via UserService
$actingUser = $this->userService->getUserByIdentifier($this->getUser()->getUserIdentifier());
// Vérification des droits d'accès supplémentaires
if (!$this->userService->hasAccessTo($actingUser)) {
throw $this->createAccessDeniedException(self::ACCESS_DENIED);
}
// Chargement de l'utilisateur cible à afficher
$user = $this->userRepository->find($id);
try {
// Paramètre optionnel de contexte organisationnel
$orgId = $request->query->get('organizationId');
// Liste de toutes les applications (pour créer des groupes même si vides)
$apps = $this->appsRepository->findAll();
// Initialisations pour la résolution des UsersOrganizations (UO)
$singleUo = null;
$uoActive = null;
// get uo or uoS based on orgId
if ($orgId) {
// Contexte organisation précis : récupérer l'organisation et les liens UO
$organization = $this->organizationRepository->findBy(['id' => $orgId]);
$uoList = $this->uoRepository->findBy([
'users' => $user,
'organization' => $organization,
'isActive' => true,
]);
if (!$uoList) {
throw $this->createNotFoundException(self::NOT_FOUND);
}
// Si contexte org donné, on retient la première UO (singleUo)
$singleUo = $uoList[0];
$data["singleUo"] = $singleUo;
$uoActive = $singleUo->isActive();
} else {
// Pas de contexte org : récupérer toutes les UO actives de l'utilisateur
$uoList = $this->uoRepository->findBy([
'users' => $user,
'isActive' => true,
]);
}
// Charger les liens UserOrganizationApp (UOA) actifs pour les UO trouvées
// Load user-organization-app roles (can be empty)
$uoa = $this->entityManager
->getRepository(UserOrganizatonApp::class)
->findBy([
'userOrganization' => $uoList,
'isActive' => true,
]);
// Group UOA by app and ensure every app has a group
$data['uoas'] = $this->userOrganizationAppService
->groupUserOrganizationAppsByApplication(
$uoa,
$apps,
$singleUo ? $singleUo->getId() : null
);
//Build roles based on user permissions.
//Admin can't see or edit a super admin user
if ($this->isGranted('ROLE_SUPER_ADMIN')) {
$data['rolesArray'] = $this->rolesRepository->findAll();
} elseif (!$orgId) {
$data['rolesArray'] = $this->userService->getRolesArrayForUser($actingUser, true);
} else {
$data['rolesArray'] = $this->userService->getRolesArrayForUser($actingUser);
}
// -------------------------------------------------------------------
// Calcul du flag de modification : utilisateur admin ET exactement 1 UO
$canEdit = $this->userService->canEditRolesCheck($actingUser, $user, $organization, $this->isGranted('ROLE_ADMIN'));
} catch (\Exception $e) {
// En cas d'erreur, désactiver l'édition et logger l'exception
$canEdit = false;
$this->logger->error($e->getMessage());
}
return $this->render('user/show.html.twig', [
'user' => $user,
'organizationId' => $orgId ?? null,
'uoActive' => $uoActive ?? null,
'apps' => $apps ?? [],
'data' => $data ?? [],
'canEdit' => $canEdit ?? false,
]);
}
#[Route('/edit/{id}', name: 'edit', methods: ['GET', 'POST'])]
public function edit(int $id, Request $request): Response
{
$this->denyAccessUnlessGranted('ROLE_USER');
$actingUser = $this->userService->getUserByIdentifier($this->getUser()->getUserIdentifier());
if ($this->userService->hasAccessTo($actingUser)) {
$user = $this->userRepository->find($id);
if (!$user) {
throw $this->createNotFoundException(self::NOT_FOUND);
}
$form = $this->createForm(UserForm::class, $user);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
// Handle file upload
$picture = $form->get('pictureUrl')->getData();
if ($picture) {
$this->userService->handleProfilePicture($user, $picture);
}
$user->setModifiedAt(new \DateTimeImmutable('now'));
$this->entityManager->persist($user);
$this->entityManager->flush();
//log and action
$this->logger->notice("User information edited for " . $user->getUserIdentifier());
if ($request->get('organizationId')) {
$org = $this->organizationRepository->find($request->get('organizationId'));
if ($org) {
$this->actionService->createAction("Edit user information", $actingUser, $org, $user->getUserIdentifier());
return $this->redirectToRoute('user_show', ['id' => $user->getId(), 'organizationId' => $request->get('organizationId')]);
}
} else {
$this->actionService->createAction("Edit user information", $actingUser, null, $user->getUserIdentifier());
return $this->redirectToRoute('user_show', ['id' => $user->getId()]);
}
}
return $this->render('user/edit.html.twig', [
'user' => $user,
'form' => $form->createView(),
'organizationId' => $request->get('organizationId')
]);
}
throw $this->createAccessDeniedException(self::ACCESS_DENIED);
}
#[Route('/new', name: 'new', methods: ['GET', 'POST'])]
public function new(Request $request): Response
{
$this->denyAccessUnlessGranted('ROLE_ADMIN');
try {
$actingUser = $this->userService->getUserByIdentifier($this->getUser()->getUserIdentifier());
if ($this->userService->hasAccessTo($actingUser)) {
$user = new User();
$form = $this->createForm(UserForm::class, $user);
$form->handleRequest($request);
$orgId = $request->get('organizationId');
if ($orgId){
$org = $this->organizationRepository->find($orgId) ?? throw new NotFoundHttpException(sprintf('%s not found', $orgId));
}
if ($form->isSubmitted() && $form->isValid()) {
$existingUser = $this->userRepository->findOneBy(['email' => $user->getEmail()]);
if ($existingUser && $orgId) {
$this->userService->handleExistingUser($existingUser, $org);
$this->actionService->createAction("Create new user", $existingUser, $org, "Added user to organization" . $existingUser->getUserIdentifier() . " for organization " . $org->getName());
$this->logger->notice("User added to organization " . $org->getName());
$this->emailService->sendExistingUserNotificationEmail($existingUser, $org);
$this->logger->notice("Existing user notification email sent to " . $existingUser->getUserIdentifier());
$data = ['user' => $existingUser, 'organization' => $org];
$this->organizationsService->notifyOrganizationAdmins($data, 'USER_INVITED');
return $this->redirectToRoute('organization_show', ['id' => $orgId]);
}
// Handle file upload
$picture = $form->get('pictureUrl')->getData();
$this->userService->formatNewUserData($user, $picture);
if ($orgId) {
$uo = new UsersOrganizations();
$uo->setUsers($user);
$uo->setOrganization($org);
$uo->setStatut("INVITED");
$uo->setIsActive(false);
$uo->setModifiedAt(new \DateTimeImmutable('now'));
$this->entityManager->persist($uo);
$this->actionService->createAction("Create new user", $user, $org, "Added user to organization" . $user->getUserIdentifier() . " for organization " . $org->getName());
$this->logger->notice("User added to organization " . $org->getName());
$this->emailService->sendPasswordSetupEmail($user, $orgId);
$this->logger->notice("Password setup email sent to " . $user->getUserIdentifier());
$data = ['user' => $uo->getUsers(), 'organization' => $uo->getOrganization()];
$this->organizationsService->notifyOrganizationAdmins($data, 'USER_INVITED');
}
$this->actionService->createAction("Create new user", $actingUser, null, $user->getUserIdentifier());
$this->logger->notice("User created " . $user->getUserIdentifier());
$this->entityManager->persist($user);
$this->entityManager->flush();
if ($orgId) {
return $this->redirectToRoute('organization_show', ['organizationId' => $orgId]);
}
return $this->redirectToRoute('user_index');
}
}
return $this->render('user/new.html.twig', [
'user' => $user,
'form' => $form->createView(),
'organizationId' => $orgId
]);
} catch (\Exception $e) {
$this->logger->error($e->getMessage());
if ($orgId) {
return $this->redirectToRoute('organization_show', ['id' => $orgId]);
}
return $this->redirectToRoute('user_index');
}
}
#[Route('/activeStatus/{id}', name: 'active_status', methods: ['GET', 'POST'])]
public function activeStatus(int $id, Request $request): JsonResponse
{
$this->denyAccessUnlessGranted('ROLE_ADMIN');
$actingUser = $this->userService->getUserByIdentifier($this->getUser()->getUserIdentifier());
try{
if ($this->userService->hasAccessTo($actingUser, true)) {
$user = $this->userRepository->find($id);
if (!$user) {
throw $this->createNotFoundException(self::NOT_FOUND);
}
$status = $request->get('status');
if ($status === 'deactivate') {
$user->setIsActive(false);
$this->userOrganizationService->deactivateAllUserOrganizationLinks($actingUser, $user);
if ($this->userService->isUserConnected($user->getUserIdentifier())) {
$this->userService->revokeUserTokens($user->getUserIdentifier());
}
$user->setModifiedAt(new \DateTimeImmutable('now'));
$this->entityManager->persist($user);
$this->entityManager->flush();
$this->logger->notice("User deactivated " . $user->getUserIdentifier());
$this->actionService->createAction("Deactivate user", $actingUser, null, $user->getUserIdentifier());
return new JsonResponse(['status' => 'deactivated'], Response::HTTP_OK);
}
if ($status === 'activate') {
$user->setIsActive(true);
$user->setModifiedAt(new \DateTimeImmutable('now'));
$this->logger->notice("User activated " . $user->getUserIdentifier());
$this->actionService->createAction("Activate user", $actingUser, null, $user->getUserIdentifier());
return new JsonResponse(['status' => 'activated'], Response::HTTP_OK);
}
}
}catch (\Exception $e){
$this->logger->error($e->getMessage());
}
throw $this->createNotFoundException(self::NOT_FOUND);
}
#[Route('/organization/activateStatus/{id}', name: 'activate_organization', methods: ['GET', 'POST'])]
public function activateStatusOrganization(int $id, Request $request): JsonResponse{
$this->denyAccessUnlessGranted('ROLE_ADMIN');
$actingUser = $this->userService->getUserByIdentifier($this->getUser()->getUserIdentifier());
try {
if ($this->userService->hasAccessTo($actingUser, true)) {
$orgId = $request->get('organizationId');
$org = $this->organizationRepository->find($orgId);
if (!$org) {
throw $this->createNotFoundException(self::NOT_FOUND);
}
$user = $this->userRepository->find($id);
if (!$user) {
throw $this->createNotFoundException(self::NOT_FOUND);
}
$uo = $this->uoRepository->findOneBy(['users' => $user,
'organization' => $org]);
if (!$uo) {
throw $this->createNotFoundException(self::NOT_FOUND);
}
$status = $request->get('status');
if ($status === 'deactivate') {
$uo->setIsActive(false);
$this->userOrganizationAppService->deactivateAllUserOrganizationsAppLinks($uo);
$this->entityManager->persist($uo);
$this->entityManager->flush();
$data = ['user' => $user,
'organization' => $org];
$this->organizationsService->notifyOrganizationAdmins($data, "USER_DEACTIVATED");
$this->logger->notice("User Organizaton deactivated " . $user->getUserIdentifier());
$this->actionService->createAction("Deactivate user in organization", $actingUser, $org, $org->getName() . " for user " . $user->getUserIdentifier());
return new JsonResponse(['status' => 'deactivated'], Response::HTTP_OK);
}
if($status === "activate"){
$uo->setIsActive(true);
$this->entityManager->persist($uo);
$this->entityManager->flush();
$this->actionService->createAction("Activate user in organization", $actingUser, $org, $org->getName() . " for user " . $user->getUserIdentifier());
$data = ['user' => $user,
'organization' => $org];
$this->organizationsService->notifyOrganizationAdmins($data, "USER_ACTIVATED");
return new JsonResponse(['status' => 'activated'], Response::HTTP_OK);
}
}
}catch (\Exception $exception){
$this->logger->error($exception->getMessage());
}
throw $this->createNotFoundException(self::NOT_FOUND);
}
//TODO : MONOLOG + remove picture from bucket
#[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->userRepository->find($id);
if (!$user) {
throw $this->createNotFoundException(self::NOT_FOUND);
}
$user->setIsActive(false);
$user->setModifiedAt(new \DateTimeImmutable('now'));
$this->userOrganizationService->deactivateAllUserOrganizationLinks($actingUser, $user);
$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());
$data = ['user' => $user,
'organization' => null];
$this->organizationsService->notifyOrganizationAdmins($data, "USER_DELETED");
return new Response('', Response::HTTP_NO_CONTENT); //204
}
//TODO : MONOLOG
#[Route(path: '/application/roles/{id}', name: 'application_role', methods: ['GET', 'POST'])]
public function applicationRole(int $id, Request $request): Response
{
$this->denyAccessUnlessGranted("ROLE_ADMIN");
$actingUser = $this->userService->getUserByIdentifier($this->getUser()->getUserIdentifier());
if ($this->userService->hasAccessTo($actingUser, true)) {
$uo = $this->userOrganizationService->getByIdOrFail($id);
$application = $this->entityManager->getRepository(Apps::class)->find($request->get('appId'));
if (!$application) {
throw $this->createNotFoundException(self::NOT_FOUND);
}
$selectedRolesIds = $request->get('roles', []);
$roleUser = $this->entityManager->getRepository(Roles::class)->findOneBy(['name' => 'USER']);
if (!$roleUser) {
throw $this->createNotFoundException('Default role not found');
}
if (!empty($selectedRolesIds)) {
if (!in_array((string)$roleUser->getId(), $selectedRolesIds, true)) {
$this->userOrganizationAppService->deactivateAllUserOrganizationsAppLinks($uo, $application);
} else {
$this->userOrganizationAppService->syncRolesForUserOrganizationApp(
$uo,
$application,
$selectedRolesIds,
$actingUser
);
}
} else {
$this->userOrganizationAppService->deactivateAllUserOrganizationsAppLinks($uo, $application);
}
$user = $uo->getUsers();
return $this->redirectToRoute('user_show', [
'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) {
$picture = $this->awsService->getPublicUrl($_ENV['S3_PORTAL_BUCKET']) . $user->getPictureUrl();
return [
'id' => $user->getId(),
'pictureUrl' => $picture,
'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: '/', name: 'index', methods: ['GET'])]
public function index(): Response
{
$actingUser = $this->userService->getUserByIdentifier($this->getUser()->getUserIdentifier());
if ($this->userService->hasAccessTo($actingUser, true) && $this->isGranted("ROLE_ADMIN")) {
$totalUsers = $this->userRepository->count(['isDeleted' => false, 'isActive' => true]);
return $this->render('user/index.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, 'statut' => ["ACCEPTED", "INVITED"]],
orderBy: ['createdAt' => 'DESC'], limit: 5);
// Map to array (keep isConnected)
$data = array_map(function (UsersOrganizations $uo) {
$user = $uo->getUsers();
$picture = $this->awsService->getPublicUrl($_ENV['S3_PORTAL_BUCKET']) . $user->getPictureUrl();
$initials = $user->getName()[0] . $user->getSurname()[0];
return [
'pictureUrl' => $picture,
'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();
$picture = $this->awsService->getPublicUrl($_ENV['S3_PORTAL_BUCKET']) . $user->getPictureUrl();
$initials = $user->getName()[0] . $user->getSurname()[0];
return [
'pictureUrl' => $picture,
'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));
$filters = $request->query->all('filter') ?? [];
$repo = $this->uoRepository;
// Base query
$qb = $repo->createQueryBuilder('uo')
->join('uo.users', 'u')
->where('uo.organization = :orgId')
->setParameter('orgId', $orgId);
// 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(uo.id)')->getQuery()->getSingleScalarResult();
$qb->orderBy('uo.isActive', 'DESC')
->addOrderBy('CASE WHEN uo.statut = :invited THEN 0 ELSE 1 END', 'ASC')
->setParameter('invited', 'INVITED');
$offset = ($page - 1) * $size;
$rows = $qb->setFirstResult($offset)->setMaxResults($size)->getQuery()->getResult();
$data = $this->userService->formatStatutForOrganizations($rows);
$lastPage = (int)ceil($total / $size);
return $this->json([
'data' => $data,
'last_page' => $lastPage,
'total' => $total,
]);
}
throw $this->createAccessDeniedException(self::ACCESS_DENIED);
}
#[Route(path: '/organization/resend-invitation/{userId}', name: 'resend_invitation', methods: ['POST'])]
public function resendInvitation(int $userId, Request $request): JsonResponse
{
$this->denyAccessUnlessGranted("ROLE_ADMIN");
$actingUser = $this->userService->getUserByIdentifier($this->getUser()->getUserIdentifier());
if ($this->userService->hasAccessTo($actingUser, true)) {
$orgId = $request->get('organizationId');
$org = $this->organizationRepository->find($orgId);
if (!$org) {
throw $this->createNotFoundException(self::NOT_FOUND);
}
$user = $this->userRepository->find($userId);
if (!$user) {
throw $this->createNotFoundException(self::NOT_FOUND);
}
$uo = $this->uoRepository->findOneBy(['users' => $user,
'organization' => $org,
'statut' => "INVITED"]);
if (!$uo) {
throw $this->createNotFoundException(self::NOT_FOUND);
}
$uo->setModifiedAt(new \DateTimeImmutable());
try {
$data = ['user' => $uo->getUsers(), 'organization' => $uo->getOrganization()];
$this->emailService->sendPasswordSetupEmail($user, $orgId);
$this->logger->info("Invitation email resent to user " . $user->getUserIdentifier() . " for organization " . $org->getName());
$this->organizationsService->notifyOrganizationAdmins($data, 'USER_INVITED');
return $this->json(['message' => 'Invitation envoyée avec success.'], Response::HTTP_OK);
} catch (\Exception $e) {
$this->logger->error("Error resending invitation email to user " . $user->getUserIdentifier() . " for organization " . $org->getName() . ": " . $e->getMessage());
return $this->json(['message' => 'Erreur lors de l\'envoie du mail.'], Response::HTTP_INTERNAL_SERVER_ERROR);
}
}
throw $this->createAccessDeniedException(self::ACCESS_DENIED);
}
#[Route(path: '/accept-invitation', name: 'accept', methods: ['GET'])]
public function acceptInvitation(Request $request): Response
{
$token = $request->get('token');
$userId = $request->get('id');
if (!$token || !$userId) {
throw $this->createNotFoundException('Invalid invitation link.');
}
$user = $this->userRepository->find($userId);
if (!$user) {
throw $this->createNotFoundException(self::NOT_FOUND);
}
if (!$this->userService->isPasswordTokenValid($user, $token)) {
throw $this->createNotFoundException('Invalid or expired invitation token.');
}
$orgId = $this->userService->getOrgFromToken($token);
$uo = $this->uoRepository->findOneBy(['users' => $user, 'organization' => $orgId]);
if (!$uo || $uo->getStatut() !== 'INVITED') {
$this->logger->warning("User " . $user->getUserIdentifier() . " tried to accept an invitation but no pending invitation was found for organization ID " . $orgId);
throw $this->createNotFoundException('No pending invitation found for this user and organization.');
}
$uo->setModifiedAt(new \DateTimeImmutable());
$uo->setStatut("ACCEPTED");
$uo->setIsActive(true);
$this->entityManager->persist($uo);
$this->entityManager->flush();
$this->logger->info("User " . $user->getUserIdentifier() . " accepted invitation for organization ID " . $orgId);
return $this->render('user/show.html.twig', ['user' => $user, 'orgId' => $orgId]);
}
}

Some files were not shown because too many files have changed in this diff Show More