Compare commits
430 Commits
main
...
dev/webhoo
| Author | SHA1 | Date |
|---|---|---|
|
|
0a4cb375e9 | |
|
|
2dae055ae9 | |
|
|
91304d95dc | |
|
|
edf91ae01d | |
|
|
3200d05ed6 | |
|
|
5fea79cafa | |
|
|
32b42beb37 | |
|
|
afc1b16dea | |
|
|
6a4f1f662e | |
|
|
d603328585 | |
|
|
195f841f8c | |
|
|
683766259c | |
|
|
45e14c47ca | |
|
|
a428cf92f3 | |
|
|
e2ac177f65 | |
|
|
5dffbeaa69 | |
|
|
c7d3251545 | |
|
|
ef7684f02b | |
|
|
813c891477 | |
|
|
91f3e06dd5 | |
|
|
8892cb3a7f | |
|
|
0c758a9370 | |
|
|
a93b94ba7b | |
|
|
d5e1ad057e | |
|
|
4f7c1eb5de | |
|
|
7133e76561 | |
|
|
2d72515f0c | |
|
|
25a477a8f9 | |
|
|
cb4a262e89 | |
|
|
443c7d67b3 | |
|
|
089e644d14 | |
|
|
4b89d1a256 | |
|
|
0db330e354 | |
|
|
e545020f42 | |
|
|
2de17b12f0 | |
|
|
a0db7f64a8 | |
|
|
55606d43d5 | |
|
|
4cb447d4ad | |
|
|
1d6b9f08d3 | |
|
|
5abbd15b45 | |
|
|
d50a6bd238 | |
|
|
8f35520311 | |
|
|
2609f41a7c | |
|
|
716c7d03c1 | |
|
|
54583185f0 | |
|
|
c485740d27 | |
|
|
cf90f97f01 | |
|
|
625ecafda8 | |
|
|
b0ce17e335 | |
|
|
3f57b549a5 | |
|
|
78e770235e | |
|
|
698caebaea | |
|
|
cad6a4f370 | |
|
|
9f430a3656 | |
|
|
f89bd101fe | |
|
|
4ac179f7b6 | |
|
|
829469b1c5 | |
|
|
406ff27e27 | |
|
|
f08bf51c70 | |
|
|
6569af4720 | |
|
|
e388999ff7 | |
|
|
b2bb9fc78b | |
|
|
c0a8a9ab82 | |
|
|
782ca27b5e | |
|
|
e941363ca6 | |
|
|
4b92e83f15 | |
|
|
e50bb0402a | |
|
|
b9b0efd6c6 | |
|
|
c2ea41f0a1 | |
|
|
a893c09fcf | |
|
|
72b40e965a | |
|
|
3765b8c314 | |
|
|
3df22c2dbf | |
|
|
056325bcf3 | |
|
|
57e7ec8181 | |
|
|
2626d27288 | |
|
|
3e06a348ff | |
|
|
f73723f42b | |
|
|
c6833232a0 | |
|
|
f1d219544b | |
|
|
e536a5ebc5 | |
|
|
42bee789ba | |
|
|
d089815069 | |
|
|
4fc059b2a5 | |
|
|
252fc775bb | |
|
|
f8ba879cc9 | |
|
|
184bfa2604 | |
|
|
fe6e4b44e5 | |
|
|
35bad9eca5 | |
|
|
64317c226d | |
|
|
6a286bff0a | |
|
|
8587798619 | |
|
|
5db11384e5 | |
|
|
2fce2dd8a5 | |
|
|
e64afa87db | |
|
|
7d1e2ee4e7 | |
|
|
934720b85a | |
|
|
d434fecaa5 | |
|
|
62107aabd2 | |
|
|
5a304a8004 | |
|
|
db3a59b389 | |
|
|
709a9f44cb | |
|
|
a9493bfb0f | |
|
|
6a147cf6dd | |
|
|
20797ada9b | |
|
|
c9f7d5f317 | |
|
|
455d71693d | |
|
|
133d9b9741 | |
|
|
856e51ff09 | |
|
|
d5c93fef8c | |
|
|
2502baf265 | |
|
|
0f381af573 | |
|
|
901e83e87a | |
|
|
8317617288 | |
|
|
79cba858dd | |
|
|
e0d2457e26 | |
|
|
8aa1dfbfff | |
|
|
f88495b3f9 | |
|
|
c62f50c2f4 | |
|
|
0c7968775c | |
|
|
ca891f434c | |
|
|
393adc461d | |
|
|
0e698be3d1 | |
|
|
a20c1cd1fb | |
|
|
bcb0cbc9b9 | |
|
|
e7206d09e3 | |
|
|
0a88ad0bde | |
|
|
03d9cf5392 | |
|
|
cc98e539c7 | |
|
|
0d835573e8 | |
|
|
281e58c4a7 | |
|
|
0549494786 | |
|
|
bc730155ba | |
|
|
fef74fea64 | |
|
|
99a68dad4d | |
|
|
a41c5095b5 | |
|
|
ac532dcf89 | |
|
|
5b137b71b2 | |
|
|
f42accdbaa | |
|
|
e6b575afd8 | |
|
|
0d3da8dfdf | |
|
|
2bf438cb27 | |
|
|
7dc369ee67 | |
|
|
df4363dd37 | |
|
|
2c7402249d | |
|
|
6403048e44 | |
|
|
63396cafe6 | |
|
|
4b3a960e59 | |
|
|
ce82296fa6 | |
|
|
85e3de647c | |
|
|
3a610ba4d7 | |
|
|
59bc78fe47 | |
|
|
3d23e9cec3 | |
|
|
01a35c75a6 | |
|
|
d42dde624b | |
|
|
9f619b5293 | |
|
|
4c3c8c3043 | |
|
|
2c346c0484 | |
|
|
ce4f045c00 | |
|
|
339d83cae2 | |
|
|
8fa42c6d1b | |
|
|
89c8ae5f4d | |
|
|
a6aba4f7d5 | |
|
|
0ad22cc465 | |
|
|
940361ab4b | |
|
|
df9f102ecf | |
|
|
64ebaa4e05 | |
|
|
3113313ad3 | |
|
|
a1b92aebce | |
|
|
01f73c2ef4 | |
|
|
29c04cd843 | |
|
|
07fbb7af2c | |
|
|
d26d1cb118 | |
|
|
68864b3997 | |
|
|
08ed90a7dc | |
|
|
9cc8dc83f3 | |
|
|
86ef5fa6f6 | |
|
|
2d0eddaf51 | |
|
|
0ea4829940 | |
|
|
cb8eabef4d | |
|
|
923d36ba4e | |
|
|
12f2b39ccd | |
|
|
0b8890e3d7 | |
|
|
b5d56f1d85 | |
|
|
9af81b1d2c | |
|
|
cdd61123ea | |
|
|
271c2e31d1 | |
|
|
87ecf70d95 | |
|
|
07bd064faa | |
|
|
76b3af7f2e | |
|
|
ec561ef0a1 | |
|
|
eeb82277f7 | |
|
|
2a09564323 | |
|
|
8045ff03c8 | |
|
|
14366b5ed4 | |
|
|
70ef717506 | |
|
|
78583620fa | |
|
|
fdf52465fe | |
|
|
30901335d9 | |
|
|
0df623ba17 | |
|
|
55c42c81fa | |
|
|
0cd33e84f8 | |
|
|
4022e905a8 | |
|
|
6b4ad1d6fd | |
|
|
530c7df5e2 | |
|
|
c47d7877bb | |
|
|
88e9c6db6a | |
|
|
79ef977e1b | |
|
|
3f9d388f7f | |
|
|
5f4336d824 | |
|
|
6c8cc37313 | |
|
|
b09544eb71 | |
|
|
361fbc1ebe | |
|
|
321bfd883a | |
|
|
c528407991 | |
|
|
659eb08d6e | |
|
|
3c789dc68e | |
|
|
47724734a2 | |
|
|
cb34a18948 | |
|
|
fe0432cef1 | |
|
|
bf602143a2 | |
|
|
9c07542c1c | |
|
|
2bf48de23a | |
|
|
b0da189fa2 | |
|
|
f54f953029 | |
|
|
b4a68bcc4d | |
|
|
1d8a4a7215 | |
|
|
d8934e2cf5 | |
|
|
c673fcd83b | |
|
|
f0ae5a8c8a | |
|
|
f6a2159177 | |
|
|
9513067ac5 | |
|
|
52b8ba0a10 | |
|
|
70842d6fe9 | |
|
|
7da97b0d02 | |
|
|
f09dd20d2b | |
|
|
94c0fd7c42 | |
|
|
9d541410c4 | |
|
|
a24849abe3 | |
|
|
83c4c221af | |
|
|
9d394c34f4 | |
|
|
04b8b26d65 | |
|
|
82c7c15068 | |
|
|
9d37a7c549 | |
|
|
d884ff4155 | |
|
|
7e08998005 | |
|
|
8c9a5da604 | |
|
|
f2123e911d | |
|
|
9dc97c5843 | |
|
|
3744d81035 | |
|
|
a6fdb59521 | |
|
|
f6ce0e6229 | |
|
|
7db986468c | |
|
|
53c3180d33 | |
|
|
8193e339b0 | |
|
|
346a05e51d | |
|
|
75e5921be1 | |
|
|
2d84ee8ec4 | |
|
|
cb7afab382 | |
|
|
00ed7ef491 | |
|
|
b974b56a17 | |
|
|
5f50584f0d | |
|
|
6aacf0cefc | |
|
|
e6068fd538 | |
|
|
a219f0f067 | |
|
|
ec3fc7f5ca | |
|
|
aee352924e | |
|
|
83da6d0be4 | |
|
|
afac1467fa | |
|
|
519556d35e | |
|
|
e4f63c9b85 | |
|
|
772b920a44 | |
|
|
c54df8a327 | |
|
|
2418e43703 | |
|
|
36fe5f5588 | |
|
|
003ee40992 | |
|
|
b430e13e3b | |
|
|
2d7adf20ec | |
|
|
5a39804dd4 | |
|
|
9a51c2d86f | |
|
|
3680621fcc | |
|
|
e1659accab | |
|
|
016c415c11 | |
|
|
2b9b030d9a | |
|
|
bb959a1ac1 | |
|
|
143277455a | |
|
|
0fc507d4c7 | |
|
|
8c7336b821 | |
|
|
9270849e12 | |
|
|
abbaf016cc | |
|
|
00c58b55d1 | |
|
|
e818a17371 | |
|
|
6e6d02e658 | |
|
|
0222274a17 | |
|
|
ead3666a4f | |
|
|
25bad81f03 | |
|
|
fd02fc26f1 | |
|
|
6dc6d3bfa9 | |
|
|
20509385f6 | |
|
|
3485bcc48f | |
|
|
1a49265658 | |
|
|
a01df6345a | |
|
|
41c6e82a13 | |
|
|
307e615fb3 | |
|
|
20bc6e92bc | |
|
|
1788ec9062 | |
|
|
3ef774d7e0 | |
|
|
dc5eb702a3 | |
|
|
346d89f42e | |
|
|
ec29f42f90 | |
|
|
c75eda74a3 | |
|
|
633c255598 | |
|
|
5e52386233 | |
|
|
1008d636a6 | |
|
|
3ca5eea877 | |
|
|
0bcab27a1d | |
|
|
eaff14acc6 | |
|
|
889010b5ad | |
|
|
a540bb5d9e | |
|
|
9257709605 | |
|
|
1516e8c890 | |
|
|
6964bc0214 | |
|
|
febd2ad6b2 | |
|
|
e33b0b8248 | |
|
|
8d095a368f | |
|
|
5a7be977ba | |
|
|
1e33782f75 | |
|
|
245f044a40 | |
|
|
446f585cc9 | |
|
|
218923dfb7 | |
|
|
84e5d7c87a | |
|
|
f52ad375b4 | |
|
|
7b7f58363a | |
|
|
52f3d2a3de | |
|
|
3d832b4280 | |
|
|
9dd820d47f | |
|
|
3b1a3dee9a | |
|
|
71c6f82b77 | |
|
|
26637e497a | |
|
|
3ca1446b91 | |
|
|
8a19b01893 | |
|
|
2dc5710e06 | |
|
|
92dd3a3d23 | |
|
|
2d9b44ddb6 | |
|
|
9dc79eaa7d | |
|
|
716bfb8ce1 | |
|
|
cc93387154 | |
|
|
61e43dcd98 | |
|
|
0d498d4570 | |
|
|
328a89f11f | |
|
|
ccd44e3560 | |
|
|
9da1edaa92 | |
|
|
3f55eefddc | |
|
|
b81b168ec3 | |
|
|
3894d72439 | |
|
|
2f3e28757e | |
|
|
1f7d844d6f | |
|
|
c757a841c5 | |
|
|
790f77c430 | |
|
|
f9c63d6753 | |
|
|
95f806efce | |
|
|
37ba0a5e6a | |
|
|
371c511ecf | |
|
|
993188ac4f | |
|
|
18dc5f8492 | |
|
|
f2166b604e | |
|
|
8d92d3f9fc | |
|
|
5ceed1f2f2 | |
|
|
450543fab7 | |
|
|
d543e69863 | |
|
|
7021b28163 | |
|
|
c55e9fa039 | |
|
|
bdf9f0478e | |
|
|
1053a2ab22 | |
|
|
6efbeb0fa2 | |
|
|
1ee9a0110b | |
|
|
cbdb47fb17 | |
|
|
e6c8d5a462 | |
|
|
7e272b2b2f | |
|
|
6670fbc8b8 | |
|
|
1e8d5e1eaf | |
|
|
2e99457e16 | |
|
|
cde6c529a9 | |
|
|
a3f993b858 | |
|
|
d2c20b9423 | |
|
|
89ed7049b9 | |
|
|
16dd919a5d | |
|
|
301f7bb445 | |
|
|
05d8ca0499 | |
|
|
e17e8e0eb2 | |
|
|
e6391279fe | |
|
|
cf16ec09a1 | |
|
|
943752a002 | |
|
|
a7e7298310 | |
|
|
3337b8c001 | |
|
|
6446eb2ce1 | |
|
|
a10b499522 | |
|
|
e77e92d39f | |
|
|
00e3003257 | |
|
|
f1b953d005 | |
|
|
4aadaa351a | |
|
|
fcb69f987f | |
|
|
d7677db885 | |
|
|
8eb5cf433d | |
|
|
fed351b433 | |
|
|
0a602fb52e | |
|
|
e87fdd32e4 | |
|
|
cfe89f58db | |
|
|
1d2debf364 | |
|
|
3271da59fa | |
|
|
d8df0bc1f4 | |
|
|
c3d3218bff | |
|
|
f24fb0180d | |
|
|
e360019e58 | |
|
|
8e38fe47db | |
|
|
b54fe41795 | |
|
|
798ca4ba07 | |
|
|
d43b516826 | |
|
|
c99b575814 | |
|
|
65ff838dd9 | |
|
|
4a2f9d9547 | |
|
|
10a8eb2255 | |
|
|
8f232498b8 | |
|
|
bbe50dbfd9 | |
|
|
436284c9c7 | |
|
|
228ef8cbe9 | |
|
|
c81142e4a5 | |
|
|
6a9b7568af | |
|
|
57e115a6a8 | |
|
|
79c5596766 |
|
|
@ -0,0 +1,49 @@
|
||||||
|
# Git files
|
||||||
|
.git
|
||||||
|
.gitignore
|
||||||
|
.gitattributes
|
||||||
|
|
||||||
|
# IDE files
|
||||||
|
.idea
|
||||||
|
.vscode
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
|
||||||
|
# Documentation
|
||||||
|
README.md
|
||||||
|
*.MD
|
||||||
|
HELPER.MD
|
||||||
|
NOTIFICATION.MD
|
||||||
|
|
||||||
|
# Test files
|
||||||
|
tests
|
||||||
|
phpunit.xml.dist
|
||||||
|
|
||||||
|
# CI/CD
|
||||||
|
.github
|
||||||
|
.gitlab-ci.yml
|
||||||
|
|
||||||
|
# Platform.sh
|
||||||
|
.platform
|
||||||
|
.platform.app.yaml
|
||||||
|
|
||||||
|
# Docker files (don't copy Docker files into the image)
|
||||||
|
Dockerfile
|
||||||
|
docker-compose*.yml
|
||||||
|
compose*.yaml
|
||||||
|
.dockerignore
|
||||||
|
|
||||||
|
# Environment files (will be provided at runtime)
|
||||||
|
.env.local
|
||||||
|
.env.*.local
|
||||||
|
|
||||||
|
# Development dependencies
|
||||||
|
.php-cs-fixer.cache
|
||||||
|
|
||||||
|
# Cache and logs (will be created at runtime)
|
||||||
|
var/cache/*
|
||||||
|
var/log/*
|
||||||
|
var/sessions/*
|
||||||
|
|
||||||
|
# Vendor (will be installed during build)
|
||||||
|
# vendor is installed during the build process, so we don't ignore it
|
||||||
19
.env
|
|
@ -17,6 +17,7 @@
|
||||||
###> symfony/framework-bundle ###
|
###> symfony/framework-bundle ###
|
||||||
APP_ENV=prod
|
APP_ENV=prod
|
||||||
APP_SECRET='kjuusshgvk35434judshfgvkusd224444hvg'
|
APP_SECRET='kjuusshgvk35434judshfgvkusd224444hvg'
|
||||||
|
APPLICATION=EasyPortal
|
||||||
###< symfony/framework-bundle ###
|
###< symfony/framework-bundle ###
|
||||||
|
|
||||||
###> doctrine/doctrine-bundle ###
|
###> doctrine/doctrine-bundle ###
|
||||||
|
|
@ -43,10 +44,12 @@ MAILER_DSN=null://null
|
||||||
TRUSTED_PROXY='185.116.130.121','10.8.34.21'
|
TRUSTED_PROXY='185.116.130.121','10.8.34.21'
|
||||||
|
|
||||||
###> league/oauth2-server-bundle ###
|
###> league/oauth2-server-bundle ###
|
||||||
OAUTH_PRIVATE_KEY=%kernel.project_dir%/config/jwt/private.pem
|
OAUTH_PRIVATE_KEY=%kernel.project_dir%/config/jwt/private.key
|
||||||
OAUTH_PUBLIC_KEY=%kernel.project_dir%/config/jwt/public.pem
|
OAUTH_PUBLIC_KEY=%kernel.project_dir%/config/jwt/public.key
|
||||||
OAUTH_PASSPHRASE=8170ea18d2e3e05b5c7ae0672a754bf4
|
OAUTH_PASSPHRASE=8170ea18d2e3e05b5c7ae0672a754bf4
|
||||||
OAUTH_ENCRYPTION_KEY=f1b7c279f7992205a0df45e295d07066
|
OAUTH_ENCRYPTION_KEY=f1b7c279f7992205a0df45e295d07066
|
||||||
|
OAUTH_SSO_IDENTIFIER='sso-own-identifier'
|
||||||
|
OAUTH_SSO_IDENTIFIER_LOGIN='sso-own-identifier'
|
||||||
###< league/oauth2-server-bundle ###
|
###< league/oauth2-server-bundle ###
|
||||||
|
|
||||||
###> nelmio/cors-bundle ###
|
###> nelmio/cors-bundle ###
|
||||||
|
|
@ -62,3 +65,15 @@ MERCURE_PUBLIC_URL=https://example.com/.well-known/mercure
|
||||||
# The secret used to sign the JWTs
|
# The secret used to sign the JWTs
|
||||||
MERCURE_JWT_SECRET="!ChangeThisMercureHubJWTSecretKey!"
|
MERCURE_JWT_SECRET="!ChangeThisMercureHubJWTSecretKey!"
|
||||||
###< symfony/mercure-bundle ###
|
###< symfony/mercure-bundle ###
|
||||||
|
|
||||||
|
###> aws/aws-sdk-php-symfony ###
|
||||||
|
AWS_KEY=not-a-real-key
|
||||||
|
AWS_SECRET=@@not-a-real-secret
|
||||||
|
AWS_REGION=us-east-1
|
||||||
|
AWS_ENDPOINT=https://s3.amazonaws.com
|
||||||
|
AWS_S3_PORTAL_URL=https://s3.amazonaws.com/portal
|
||||||
|
###< aws/aws-sdk-php-symfony ###
|
||||||
|
APP_URL='https://example.com'
|
||||||
|
APP_DOMAIN='example.com'
|
||||||
|
|
||||||
|
EASYCHECK_URL='https://testcheck.solutions-easy.com'
|
||||||
4
.env.dev
|
|
@ -1,4 +0,0 @@
|
||||||
|
|
||||||
###> symfony/framework-bundle ###
|
|
||||||
APP_SECRET=bba5a2c490c3f92030618ecb97b5138e
|
|
||||||
###< symfony/framework-bundle ###
|
|
||||||
|
|
@ -1,6 +0,0 @@
|
||||||
# define your env variables for the test env here
|
|
||||||
KERNEL_CLASS='App\Kernel'
|
|
||||||
APP_SECRET='$ecretf0rt3st'
|
|
||||||
SYMFONY_DEPRECATIONS_HELPER=999999
|
|
||||||
PANTHER_APP_ENV=panther
|
|
||||||
PANTHER_ERROR_SCREENSHOT_DIR=./var/error-screenshots
|
|
||||||
|
|
@ -1,10 +1,12 @@
|
||||||
|
|
||||||
###> symfony/framework-bundle ###
|
###> symfony/framework-bundle ###
|
||||||
/.env.local
|
/.env.local
|
||||||
|
/.env.test
|
||||||
/.env.local.php
|
/.env.local.php
|
||||||
/.env.*.local
|
/.env.*.local
|
||||||
/config/secrets/prod/prod.decrypt.private.php
|
/config/secrets/prod/prod.decrypt.private.php
|
||||||
/public/bundles/
|
/public/bundles/
|
||||||
|
/public/uploads/
|
||||||
/var/
|
/var/
|
||||||
/vendor/
|
/vendor/
|
||||||
###< symfony/framework-bundle ###
|
###< symfony/framework-bundle ###
|
||||||
|
|
@ -14,12 +16,18 @@
|
||||||
.phpunit.result.cache
|
.phpunit.result.cache
|
||||||
###< phpunit/phpunit ###
|
###< phpunit/phpunit ###
|
||||||
|
|
||||||
###> symfony/phpunit-bridge ###
|
|
||||||
.phpunit.result.cache
|
|
||||||
/phpunit.xml
|
|
||||||
###< symfony/phpunit-bridge ###
|
|
||||||
|
|
||||||
###> symfony/asset-mapper ###
|
###> symfony/asset-mapper ###
|
||||||
/public/assets/
|
/public/assets/
|
||||||
/assets/vendor/
|
/assets/vendor/
|
||||||
###< symfony/asset-mapper ###
|
###< symfony/asset-mapper ###
|
||||||
|
|
||||||
|
###> IntelliJ IDEA ###
|
||||||
|
.idea/
|
||||||
|
*.iml
|
||||||
|
###< IntelliJ IDEA ###
|
||||||
|
|
||||||
|
###>jwt keys###
|
||||||
|
/config/jwt/private.key
|
||||||
|
/config/jwt/public.key
|
||||||
|
###<jwt keys###
|
||||||
|
/composer.lock
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,302 @@
|
||||||
|
stages:
|
||||||
|
- build
|
||||||
|
- deploy
|
||||||
|
- notify
|
||||||
|
|
||||||
|
variables:
|
||||||
|
# OVH Container Registry URL (without https://)
|
||||||
|
# Image name and tag (format: registry/project/image)
|
||||||
|
IMAGE_NAME: "easyportal"
|
||||||
|
IMAGE_TAG: "${OVH_REGISTRY_URL}/${IMAGE_NAME}:${CI_COMMIT_REF_SLUG}-${CI_PIPELINE_ID}"
|
||||||
|
IMAGE_LATEST: "${OVH_REGISTRY_URL}/${IMAGE_NAME}:latest"
|
||||||
|
|
||||||
|
build:
|
||||||
|
stage: build
|
||||||
|
image: docker:20.10.16
|
||||||
|
rules:
|
||||||
|
- if: '$CI_COMMIT_BRANCH == "develop"'
|
||||||
|
when: always
|
||||||
|
- when: manual
|
||||||
|
before_script:
|
||||||
|
# Use host Docker daemon via socket
|
||||||
|
- docker info
|
||||||
|
# Login to OVH Container Registry
|
||||||
|
- echo "Logging into OVH Container Registry..."
|
||||||
|
- echo "$OVH_REGISTRY_PASSWORD" | docker login -u "$OVH_REGISTRY_USERNAME" "$OVH_REGISTRY_URL" --password-stdin
|
||||||
|
script:
|
||||||
|
# Build the FrankenPHP image
|
||||||
|
- echo "Building Docker image..."
|
||||||
|
- docker build --build-arg APP_ENV=prod --target frankenphp_prod -t $IMAGE_TAG -t $IMAGE_LATEST .
|
||||||
|
|
||||||
|
# Push both tags to OVH registry
|
||||||
|
- echo "Pushing image to OVH registry..."
|
||||||
|
- docker push $IMAGE_TAG
|
||||||
|
- docker push $IMAGE_LATEST
|
||||||
|
|
||||||
|
# Display image info
|
||||||
|
- echo "Successfully pushed:"
|
||||||
|
- echo " - $IMAGE_TAG"
|
||||||
|
- echo " - $IMAGE_LATEST"
|
||||||
|
after_script:
|
||||||
|
- docker logout $OVH_REGISTRY_URL || true
|
||||||
|
|
||||||
|
deploy:
|
||||||
|
stage: deploy
|
||||||
|
image: alpine:latest
|
||||||
|
needs:
|
||||||
|
- build
|
||||||
|
rules:
|
||||||
|
- if: '$CI_COMMIT_BRANCH == "develop"'
|
||||||
|
when: on_success
|
||||||
|
- when: manual
|
||||||
|
before_script:
|
||||||
|
- apk add --no-cache openssh-client
|
||||||
|
- mkdir -p ~/.ssh
|
||||||
|
- echo "$SSH_PRIVATE_KEY" > ~/.ssh/id_rsa
|
||||||
|
- chmod 600 ~/.ssh/id_rsa
|
||||||
|
- echo "Host *\n StrictHostKeyChecking no\n UserKnownHostsFile=/dev/null" > ~/.ssh/config
|
||||||
|
- chmod 600 ~/.ssh/config
|
||||||
|
- ssh-keyscan -H $SERVER_IP >> ~/.ssh/known_hosts || true
|
||||||
|
script:
|
||||||
|
- |
|
||||||
|
ssh $SSH_USER@$SERVER_IP << ENDSSH
|
||||||
|
set -e
|
||||||
|
cd /mnt/external-disk/easy-monitor/easyportal
|
||||||
|
|
||||||
|
# ===== PRE-DEPLOYMENT DOCKER CLEANUP =====
|
||||||
|
echo '===== Starting pre-deployment Docker cleanup ====='
|
||||||
|
|
||||||
|
# Show current Docker space usage
|
||||||
|
echo 'Current Docker space usage:'
|
||||||
|
docker system df
|
||||||
|
|
||||||
|
# Clean build cache
|
||||||
|
echo 'Cleaning Docker build cache...'
|
||||||
|
docker builder prune -f || true
|
||||||
|
|
||||||
|
# Clean stopped containers
|
||||||
|
echo 'Cleaning stopped containers...'
|
||||||
|
docker container prune -f || true
|
||||||
|
|
||||||
|
# Clean unused networks
|
||||||
|
echo 'Cleaning unused networks...'
|
||||||
|
docker network prune -f || true
|
||||||
|
|
||||||
|
# Show space usage after cleanup
|
||||||
|
echo 'Docker space usage after initial cleanup:'
|
||||||
|
docker system df
|
||||||
|
|
||||||
|
# Login to OVH registry
|
||||||
|
echo "$OVH_REGISTRY_PASSWORD" | docker login -u "$OVH_REGISTRY_USERNAME" "$OVH_REGISTRY_URL" --password-stdin
|
||||||
|
|
||||||
|
# Pull latest image
|
||||||
|
echo 'Pulling latest image...'
|
||||||
|
docker pull $IMAGE_LATEST
|
||||||
|
|
||||||
|
# Clean unused images before deployment
|
||||||
|
echo 'Cleaning unused Docker images...'
|
||||||
|
docker image prune -f || true
|
||||||
|
|
||||||
|
# Update .env.compose with new image tag
|
||||||
|
echo 'Updating .env.compose with new image...'
|
||||||
|
echo "EASYPORTAL_IMAGE=$IMAGE_LATEST" > .env.compose
|
||||||
|
|
||||||
|
# Recreate php and messenger containers with new image
|
||||||
|
echo 'Restarting EasyPortal services with new image...'
|
||||||
|
docker compose --env-file .env.compose up -d --force-recreate --no-deps php messenger
|
||||||
|
|
||||||
|
# Wait for service to be ready
|
||||||
|
sleep 15
|
||||||
|
|
||||||
|
# Run migrations
|
||||||
|
echo 'Running database migrations...'
|
||||||
|
docker compose --env-file .env.compose exec -T php php bin/console doctrine:migrations:migrate --no-interaction
|
||||||
|
|
||||||
|
# Clear cache
|
||||||
|
echo 'Clearing Symfony cache...'
|
||||||
|
docker compose --env-file .env.compose exec -T php php bin/console cache:clear --env=prod --no-debug
|
||||||
|
|
||||||
|
# ===== POST-DEPLOYMENT CLEANUP =====
|
||||||
|
echo '===== Final Docker cleanup ====='
|
||||||
|
|
||||||
|
docker builder prune -f || true
|
||||||
|
docker container prune -f || true
|
||||||
|
|
||||||
|
# Keep last 3 image versions
|
||||||
|
echo 'Cleaning old image versions (keeping last 3)...'
|
||||||
|
docker images --format 'table {{.Repository}}\t{{.Tag}}\t{{.ID}}\t{{.CreatedAt}}' | grep -v REPOSITORY | sort -k1,1 -k4,4r | awk '
|
||||||
|
{
|
||||||
|
repo_tag = \$1":" \$2
|
||||||
|
if (repo_count[\$1]++ >= 3 && \$2 != "latest" && \$1 != "<none>") {
|
||||||
|
print \$3
|
||||||
|
}
|
||||||
|
}' | xargs -r docker rmi -f || true
|
||||||
|
|
||||||
|
echo 'Final Docker space usage:'
|
||||||
|
docker system df
|
||||||
|
|
||||||
|
echo '===== Deployment completed successfully! ====='
|
||||||
|
ENDSSH
|
||||||
|
after_script:
|
||||||
|
- rm -f ~/.ssh/id_rsa
|
||||||
|
environment:
|
||||||
|
name: production
|
||||||
|
url: https://testportail.solutions-easy.com
|
||||||
|
|
||||||
|
notify_success:
|
||||||
|
stage: notify
|
||||||
|
image: curlimages/curl:latest
|
||||||
|
needs:
|
||||||
|
- deploy
|
||||||
|
rules:
|
||||||
|
- when: on_success
|
||||||
|
- when: manual
|
||||||
|
allow_failure: true
|
||||||
|
script:
|
||||||
|
- |
|
||||||
|
COMMIT_SHORT_SHA=$(echo $CI_COMMIT_SHA | cut -c1-8)
|
||||||
|
DEPLOYMENT_TYPE="Déploiement Beta"
|
||||||
|
|
||||||
|
if [ "$CI_COMMIT_BRANCH" != "develop" ]; then
|
||||||
|
DEPLOYMENT_TYPE="Déploiement Beta"
|
||||||
|
fi
|
||||||
|
|
||||||
|
curl -H "Content-Type: application/json" -d "{
|
||||||
|
\"type\": \"message\",
|
||||||
|
\"attachments\": [
|
||||||
|
{
|
||||||
|
\"contentType\": \"application/vnd.microsoft.card.adaptive\",
|
||||||
|
\"content\": {
|
||||||
|
\"type\": \"AdaptiveCard\",
|
||||||
|
\"body\": [
|
||||||
|
{
|
||||||
|
\"type\": \"TextBlock\",
|
||||||
|
\"text\": \"$DEPLOYMENT_TYPE\",
|
||||||
|
\"weight\": \"Bolder\",
|
||||||
|
\"size\": \"Large\"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
\"type\": \"TextBlock\",
|
||||||
|
\"text\": \"**App:** EasyPortal\",
|
||||||
|
\"wrap\": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
\"type\": \"TextBlock\",
|
||||||
|
\"text\": \"**Version:** [$CI_COMMIT_REF_NAME - $COMMIT_SHORT_SHA]($CI_PROJECT_URL/-/commit/$CI_COMMIT_SHA)\",
|
||||||
|
\"wrap\": true,
|
||||||
|
\"markdown\": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
\"type\": \"TextBlock\",
|
||||||
|
\"text\": \"**Pipeline:** [Voir le pipeline]($CI_PIPELINE_URL)\",
|
||||||
|
\"wrap\": true,
|
||||||
|
\"markdown\": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
\"type\": \"TextBlock\",
|
||||||
|
\"text\": \"**Auteur:** $GITLAB_USER_LOGIN\",
|
||||||
|
\"wrap\": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
\"type\": \"TextBlock\",
|
||||||
|
\"text\": \"**Statut:** Succès ✓\",
|
||||||
|
\"wrap\": true,
|
||||||
|
\"color\": \"Good\"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
\"actions\": [
|
||||||
|
{
|
||||||
|
\"type\": \"Action.OpenUrl\",
|
||||||
|
\"title\": \"Voir le pipeline\",
|
||||||
|
\"url\": \"$CI_PIPELINE_URL\"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
\"type\": \"Action.OpenUrl\",
|
||||||
|
\"title\": \"Voir le commit\",
|
||||||
|
\"url\": \"$CI_PROJECT_URL/-/commit/$CI_COMMIT_SHA\"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
\"\$schema\": \"http://adaptivecards.io/schemas/adaptive-card.json\",
|
||||||
|
\"version\": \"1.0\"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}" "$TEAMS_WEBHOOK_URL"
|
||||||
|
|
||||||
|
notify_failure:
|
||||||
|
stage: notify
|
||||||
|
image: curlimages/curl:latest
|
||||||
|
needs:
|
||||||
|
- deploy
|
||||||
|
rules:
|
||||||
|
- when: on_failure
|
||||||
|
script:
|
||||||
|
- |
|
||||||
|
COMMIT_SHORT_SHA=$(echo $CI_COMMIT_SHA | cut -c1-8)
|
||||||
|
DEPLOYMENT_TYPE="Déploiement Beta"
|
||||||
|
|
||||||
|
if [ "$CI_COMMIT_BRANCH" != "develop" ]; then
|
||||||
|
DEPLOYMENT_TYPE="Déploiement Beta"
|
||||||
|
fi
|
||||||
|
|
||||||
|
curl -H "Content-Type: application/json" -d "{
|
||||||
|
\"type\": \"message\",
|
||||||
|
\"attachments\": [
|
||||||
|
{
|
||||||
|
\"contentType\": \"application/vnd.microsoft.card.adaptive\",
|
||||||
|
\"content\": {
|
||||||
|
\"type\": \"AdaptiveCard\",
|
||||||
|
\"body\": [
|
||||||
|
{
|
||||||
|
\"type\": \"TextBlock\",
|
||||||
|
\"text\": \"$DEPLOYMENT_TYPE\",
|
||||||
|
\"weight\": \"Bolder\",
|
||||||
|
\"size\": \"Large\"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
\"type\": \"TextBlock\",
|
||||||
|
\"text\": \"**App:** EasyPortal\",
|
||||||
|
\"wrap\": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
\"type\": \"TextBlock\",
|
||||||
|
\"text\": \"**Version:** [$CI_COMMIT_REF_NAME - $COMMIT_SHORT_SHA]($CI_PROJECT_URL/-/commit/$CI_COMMIT_SHA)\",
|
||||||
|
\"wrap\": true,
|
||||||
|
\"markdown\": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
\"type\": \"TextBlock\",
|
||||||
|
\"text\": \"**Pipeline:** [Voir le pipeline]($CI_PIPELINE_URL)\",
|
||||||
|
\"wrap\": true,
|
||||||
|
\"markdown\": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
\"type\": \"TextBlock\",
|
||||||
|
\"text\": \"**Auteur:** $GITLAB_USER_LOGIN\",
|
||||||
|
\"wrap\": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
\"type\": \"TextBlock\",
|
||||||
|
\"text\": \"**Statut:** Échec ✗\",
|
||||||
|
\"wrap\": true,
|
||||||
|
\"color\": \"Attention\"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
\"actions\": [
|
||||||
|
{
|
||||||
|
\"type\": \"Action.OpenUrl\",
|
||||||
|
\"title\": \"Voir le pipeline\",
|
||||||
|
\"url\": \"$CI_PIPELINE_URL\"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
\"type\": \"Action.OpenUrl\",
|
||||||
|
\"title\": \"Voir le commit\",
|
||||||
|
\"url\": \"$CI_PROJECT_URL/-/commit/$CI_COMMIT_SHA\"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
\"\$schema\": \"http://adaptivecards.io/schemas/adaptive-card.json\",
|
||||||
|
\"version\": \"1.0\"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}" "$TEAMS_WEBHOOK_URL"
|
||||||
|
|
@ -8,8 +8,23 @@
|
||||||
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/mercure" />
|
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/mercure" />
|
||||||
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/mercure-bundle" />
|
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/mercure-bundle" />
|
||||||
<excludeFolder url="file://$MODULE_DIR$/vendor/firebase/php-jwt" />
|
<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" />
|
||||||
|
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/rate-limiter" />
|
||||||
|
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/remote-event" />
|
||||||
|
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/webhook" />
|
||||||
</content>
|
</content>
|
||||||
<orderEntry type="inheritedJdk" />
|
<orderEntry type="inheritedJdk" />
|
||||||
<orderEntry type="sourceFolder" forTests="false" />
|
<orderEntry type="sourceFolder" forTests="false" />
|
||||||
|
<orderEntry type="library" name="quill.snow" level="application" />
|
||||||
|
<orderEntry type="library" name="quill" level="application" />
|
||||||
</component>
|
</component>
|
||||||
</module>
|
</module>
|
||||||
|
|
@ -10,6 +10,11 @@
|
||||||
<option name="highlightLevel" value="WARNING" />
|
<option name="highlightLevel" value="WARNING" />
|
||||||
<option name="transferred" value="true" />
|
<option name="transferred" value="true" />
|
||||||
</component>
|
</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">
|
<component name="PhpIncludePathManager">
|
||||||
<include_path>
|
<include_path>
|
||||||
<path value="$PROJECT_DIR$/vendor/symfony/twig-bridge" />
|
<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/jwt" />
|
||||||
<path value="$PROJECT_DIR$/vendor/lcobucci/clock" />
|
<path value="$PROJECT_DIR$/vendor/lcobucci/clock" />
|
||||||
<path value="$PROJECT_DIR$/vendor/twig/twig" />
|
<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/composer" />
|
||||||
<path value="$PROJECT_DIR$/vendor/theseer/tokenizer" />
|
<path value="$PROJECT_DIR$/vendor/theseer/tokenizer" />
|
||||||
<path value="$PROJECT_DIR$/vendor/symfony/deprecation-contracts" />
|
<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/defuse/php-encryption" />
|
||||||
<path value="$PROJECT_DIR$/vendor/symfony/ux-turbo" />
|
<path value="$PROJECT_DIR$/vendor/symfony/ux-turbo" />
|
||||||
<path value="$PROJECT_DIR$/vendor/symfony/stimulus-bundle" />
|
<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/symfony/type-info" />
|
||||||
<path value="$PROJECT_DIR$/vendor/doctrine/event-manager" />
|
<path value="$PROJECT_DIR$/vendor/doctrine/event-manager" />
|
||||||
<path value="$PROJECT_DIR$/vendor/symfony/console" />
|
<path value="$PROJECT_DIR$/vendor/symfony/console" />
|
||||||
|
|
@ -164,9 +167,32 @@
|
||||||
<path value="$PROJECT_DIR$/vendor/symfony/mercure" />
|
<path value="$PROJECT_DIR$/vendor/symfony/mercure" />
|
||||||
<path value="$PROJECT_DIR$/vendor/symfony/mercure-bundle" />
|
<path value="$PROJECT_DIR$/vendor/symfony/mercure-bundle" />
|
||||||
<path value="$PROJECT_DIR$/vendor/firebase/php-jwt" />
|
<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" />
|
||||||
|
<path value="$PROJECT_DIR$/vendor/staabm/side-effects-detector" />
|
||||||
|
<path value="$PROJECT_DIR$/vendor/symfony/rate-limiter" />
|
||||||
|
<path value="$PROJECT_DIR$/vendor/dama/doctrine-test-bundle" />
|
||||||
|
<path value="$PROJECT_DIR$/vendor/symfony/polyfill-php85" />
|
||||||
|
<path value="$PROJECT_DIR$/vendor/symfony/remote-event" />
|
||||||
|
<path value="$PROJECT_DIR$/vendor/symfony/webhook" />
|
||||||
</include_path>
|
</include_path>
|
||||||
</component>
|
</component>
|
||||||
<component name="PhpProjectSharedConfiguration" php_language_level="8.2" />
|
<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">
|
<component name="PhpStanOptionsConfiguration">
|
||||||
<option name="transferred" value="true" />
|
<option name="transferred" value="true" />
|
||||||
</component>
|
</component>
|
||||||
|
|
@ -175,6 +201,11 @@
|
||||||
<PhpUnitSettings configuration_file_path="$PROJECT_DIR$/phpunit.xml.dist" custom_loader_path="$PROJECT_DIR$/vendor/autoload.php" use_configuration_file="true" />
|
<PhpUnitSettings configuration_file_path="$PROJECT_DIR$/phpunit.xml.dist" custom_loader_path="$PROJECT_DIR$/vendor/autoload.php" use_configuration_file="true" />
|
||||||
</phpunit_settings>
|
</phpunit_settings>
|
||||||
</component>
|
</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">
|
<component name="PsalmOptionsConfiguration">
|
||||||
<option name="transferred" value="true" />
|
<option name="transferred" value="true" />
|
||||||
</component>
|
</component>
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,104 @@
|
||||||
|
#syntax=docker/dockerfile:1
|
||||||
|
|
||||||
|
# Versions
|
||||||
|
FROM dunglas/frankenphp:1-php8.3 AS frankenphp_upstream
|
||||||
|
|
||||||
|
# The different stages of this Dockerfile are meant to be built into separate images
|
||||||
|
# https://docs.docker.com/develop/develop-images/multistage-build/#stop-at-a-specific-build-stage
|
||||||
|
# https://docs.docker.com/compose/compose-file/#target
|
||||||
|
|
||||||
|
|
||||||
|
# Base FrankenPHP image
|
||||||
|
FROM frankenphp_upstream AS frankenphp_base
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
VOLUME /app/var/
|
||||||
|
|
||||||
|
# persistent / runtime deps
|
||||||
|
# hadolint ignore=DL3008
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
|
acl \
|
||||||
|
file \
|
||||||
|
gettext \
|
||||||
|
git \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
RUN set -eux; \
|
||||||
|
install-php-extensions \
|
||||||
|
@composer \
|
||||||
|
apcu \
|
||||||
|
intl \
|
||||||
|
opcache \
|
||||||
|
zip \
|
||||||
|
;
|
||||||
|
|
||||||
|
# https://getcomposer.org/doc/03-cli.md#composer-allow-superuser
|
||||||
|
ENV COMPOSER_ALLOW_SUPERUSER=1
|
||||||
|
|
||||||
|
# Transport to use by Mercure (default to Bolt)
|
||||||
|
ENV MERCURE_TRANSPORT_URL=bolt:///data/mercure.db
|
||||||
|
|
||||||
|
ENV PHP_INI_SCAN_DIR=":$PHP_INI_DIR/app.conf.d"
|
||||||
|
|
||||||
|
###> recipes ###
|
||||||
|
###> doctrine/doctrine-bundle ###
|
||||||
|
RUN install-php-extensions pdo_pgsql
|
||||||
|
###< doctrine/doctrine-bundle ###
|
||||||
|
###< recipes ###
|
||||||
|
|
||||||
|
COPY --link frankenphp/conf.d/10-app.ini $PHP_INI_DIR/app.conf.d/
|
||||||
|
COPY --link --chmod=755 frankenphp/docker-entrypoint.sh /usr/local/bin/docker-entrypoint
|
||||||
|
COPY --link frankenphp/Caddyfile.prod /etc/frankenphp/Caddyfile
|
||||||
|
|
||||||
|
ENTRYPOINT ["docker-entrypoint"]
|
||||||
|
|
||||||
|
HEALTHCHECK --start-period=60s CMD curl -f http://localhost:2019/metrics || exit 1
|
||||||
|
CMD [ "frankenphp", "run", "--config", "/etc/frankenphp/Caddyfile" ]
|
||||||
|
|
||||||
|
# Dev FrankenPHP image
|
||||||
|
FROM frankenphp_base AS frankenphp_dev
|
||||||
|
|
||||||
|
ENV APP_ENV=dev
|
||||||
|
ENV XDEBUG_MODE=off
|
||||||
|
ENV FRANKENPHP_WORKER_CONFIG=watch
|
||||||
|
|
||||||
|
RUN mv "$PHP_INI_DIR/php.ini-development" "$PHP_INI_DIR/php.ini"
|
||||||
|
|
||||||
|
RUN set -eux; \
|
||||||
|
install-php-extensions \
|
||||||
|
xdebug \
|
||||||
|
;
|
||||||
|
|
||||||
|
COPY --link frankenphp/conf.d/20-app.dev.ini $PHP_INI_DIR/app.conf.d/
|
||||||
|
|
||||||
|
CMD [ "frankenphp", "run", "--config", "/etc/frankenphp/Caddyfile", "--watch" ]
|
||||||
|
|
||||||
|
# Prod FrankenPHP image
|
||||||
|
FROM frankenphp_base AS frankenphp_prod
|
||||||
|
|
||||||
|
ENV APP_ENV=prod
|
||||||
|
|
||||||
|
RUN mv "$PHP_INI_DIR/php.ini-production" "$PHP_INI_DIR/php.ini"
|
||||||
|
|
||||||
|
COPY --link frankenphp/conf.d/20-app.prod.ini $PHP_INI_DIR/app.conf.d/
|
||||||
|
|
||||||
|
# prevent the reinstallation of vendors at every changes in the source code
|
||||||
|
COPY --link composer.* symfony.* ./
|
||||||
|
RUN set -eux; \
|
||||||
|
composer install --no-cache --prefer-dist --no-dev --no-autoloader --no-scripts --no-progress
|
||||||
|
|
||||||
|
# copy sources
|
||||||
|
COPY --link . ./
|
||||||
|
RUN rm -Rf frankenphp/
|
||||||
|
|
||||||
|
RUN set -eux; \
|
||||||
|
mkdir -p var/cache var/log; \
|
||||||
|
composer dump-autoload --classmap-authoritative --no-dev; \
|
||||||
|
composer dump-env prod; \
|
||||||
|
chmod +x bin/console; \
|
||||||
|
# Install JavaScript vendor assets and compile assets for production \
|
||||||
|
php bin/console importmap:install; \
|
||||||
|
php bin/console asset-map:compile; \
|
||||||
|
php bin/console assets:install public; \
|
||||||
|
sync;
|
||||||
|
|
@ -0,0 +1,41 @@
|
||||||
|
## 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">
|
||||||
|
```
|
||||||
|
|
||||||
|
|
||||||
|
php bin/console messenger:consume async -vv
|
||||||
|
|
@ -0,0 +1,190 @@
|
||||||
|
# Système de notification de l'application
|
||||||
|
## Vue d'ensemble
|
||||||
|
Le système de notification de l'application permet d'informer les utilisateurs de diverse action.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
### Composants principaux
|
||||||
|
1. **Service de Notification** : Gère la création, l'envoi et le suivi des notifications.
|
||||||
|
2. **Interface Utilisateur** : Affiche les notifications aux utilisateurs via des pops-ups, des bannières ou des emails.
|
||||||
|
3. **Template Email** : Modèles prédéfinis pour les notifications par email.
|
||||||
|
4. **Type D'action** : Enum d'action qui déclenche des notifications (ex: nouvel utilisateur, utilisateur actif, ...).
|
||||||
|
|
||||||
|
### Service de Notification
|
||||||
|
Le service de notification est responsable de la gestion des notifications. Il inclut les fonctionnalités suivantes :
|
||||||
|
- Création de notifications basées sur des événements spécifiques.
|
||||||
|
- Envoi de notifications via différents canaux (email, in-app).
|
||||||
|
- Suivi de l'état des notifications (envoyé, lu, etc.).
|
||||||
|
|
||||||
|
### Interface Utilisateur
|
||||||
|
L'interface utilisateur affiche les notifications de manière conviviale. Les notifications peuvent apparaître sous forme
|
||||||
|
de pops-ups, de bannières ou d'emails. (Possibilité d'intéragir avec les notifications)
|
||||||
|
|
||||||
|
### Template Email
|
||||||
|
Les templates email sont utilisés pour formater les notifications envoyées par email. Chaque type de notification
|
||||||
|
a son propre template pour assurer une communication claire et cohérente.
|
||||||
|
|
||||||
|
|
||||||
|
### Type d'action
|
||||||
|
```
|
||||||
|
enum ActionType: String {
|
||||||
|
case NewUser = "NEW_USER";
|
||||||
|
case ActiveUser = "ACTIVE_USER";
|
||||||
|
case PasswordReset = "PASSWORD_RESET";
|
||||||
|
case SubscriptionExpired = "SUBSCRIPTION_EXPIRED";
|
||||||
|
case OrganizationInvited = "ORGANIZATION_INVITED";
|
||||||
|
case OrganizationInactive = "ORGANIZATION_INACTIVE";
|
||||||
|
case OrganizationDeleted = "ORGANIZATION_DELETED";
|
||||||
|
case OrginizationUserInvited = "ORGANIZATION_USER_INVITED";
|
||||||
|
case UserDeleted = "USER_DELETED";
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Flux de travail
|
||||||
|
1. L’administrateur crée un utilisateur depuis l’interface (formulaire “Créer un utilisateur”).
|
||||||
|
2. Le contrôleur valide la requête et appelle le cas d’usage UserAdministrationService->handle(ActionType::NewUser, $admin, $payload).
|
||||||
|
3. Le service crée l’utilisateur en base avec le statut INVITED, associe l’organisation de l’admin, et génère un lien signé/jeton de setup de mot de passe (TTL).
|
||||||
|
4. Le service publie un événement de domaine UserInvitedEvent { userId, adminId, organizationId } sur Messenger (transport async).
|
||||||
|
5. Handler async A — SendUserInvitationEmailHandler:
|
||||||
|
6. Construit l’email via Symfony Mailer + Twig (emails/user_invitation.html.twig) avec le lien de définition de mot de passe.
|
||||||
|
7. Envoie le mail à l’utilisateur invité.
|
||||||
|
8. Handler async B — NotifyAdminInvitationSentHandler:
|
||||||
|
9. Crée une notification interne (Notifier, canal “in‑app”).
|
||||||
|
10. Pousse un événement temps réel via Mercure sur le topic admin/{adminId}/events avec le type INVITATION_EMAIL_SENT.
|
||||||
|
11. L’UI admin affiche un toast/bannière confirmant “Email d’invitation envoyé”.
|
||||||
|
12. L’utilisateur ouvre l’email et clique le lien de définition de mot de passe.
|
||||||
|
13. Le PasswordSetupController vérifie la signature/le jeton et la validité (TTL), affiche le formulaire, puis enregistre le nouveau mot de passe.
|
||||||
|
14. À la réussite, l’utilisateur passe au statut ACTIVE et l’action publie UserActivatedEvent { userId, adminId, organizationId } sur Messenger (async).
|
||||||
|
15. Handler async C — NotifyAdminUserActivatedHandler:
|
||||||
|
16. Crée une notification interne (Notifier, canal “in‑app”) “Compte activé”.
|
||||||
|
17. Pousse un événement Mercure sur admin/{adminId}/events avec le type USER_ACTIVATED.
|
||||||
|
18. L’UI admin met à jour la liste des membres (badge “Actif”) et affiche un toast confirmant l’activation.
|
||||||
|
19. Journalisation/Audit:
|
||||||
|
20. Chaque handler écrit une trace (succès/échec) en base ou dans un EmailLog/NotificationLog.
|
||||||
|
21. En cas d’échec d’envoi, Messenger applique la stratégie de retry puis bascule en file failed si nécessaire (tableau de bord de supervision).
|
||||||
|
22. Cas “utilisateur existant ajouté à une autre organisation”:
|
||||||
|
23. Si l’email existe déjà, on rattache l’utilisateur à la nouvelle organisation et on publie OrganizationUserInvitedEvent.
|
||||||
|
24. Handler dédié envoie un email d’information (“Vous avez été ajouté à une nouvelle organisation”) et notifie l’admin via Notifier + Mercure.
|
||||||
|
25. Cas d’actions dérivées par enum:
|
||||||
|
26. ActionType::NewUser → déclenche UserInvitedEvent (steps 3–6).
|
||||||
|
27. ActionType::ActiveUser (si activé par un flux admin) → déclenche directement UserActivatedEvent (steps 9–10).
|
||||||
|
28. ActionType::OrganizationUserInvited → flux similaire au point 12 pour la multi‑organisation.
|
||||||
|
29. Autres actions (PasswordReset, UserDeleted, etc.) suivent le même patron: contrôleur → service (match enum) → événement Messenger → handlers (Mailer/Notifier/Mercure) → UI temps réel.
|
||||||
|
|
||||||
|
## Stack technologique
|
||||||
|
- Symfony Messenger: asynchrone, retries, découplage des I/O lents.
|
||||||
|
- Symfony Mailer + Twig: emails d’invitation et d’information.
|
||||||
|
- Symfony Notifier (canal in‑app) + Mercure: notifications persistées + push temps réel vers l’UI admin.
|
||||||
|
- Enum ActionType: routage clair dans l’application, évite la logique string‑based.
|
||||||
|
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart LR
|
||||||
|
%% Couche 1: Action initiale
|
||||||
|
A[User action event - Admin cree un utilisateur] --> B[HTTP controller API - Symfony]
|
||||||
|
B --> C[Domain service - UserAdministrationService]
|
||||||
|
C -->|Inspecte enum ActionType::NewUser| C1[Create user - status INVITED - liaison organisation - genere lien jeton mot de passe TTL]
|
||||||
|
C1 --> D[Dispatch UserInvitedEvent - userId adminId organizationId - vers Symfony Messenger bus]
|
||||||
|
|
||||||
|
%% Couche 2: Messaging / Infra
|
||||||
|
D --> E[Transport async - AMQP / Redis / Doctrine]
|
||||||
|
E --> RQ[Retry queue]
|
||||||
|
E --> FQ[Failed queue - dead letter]
|
||||||
|
E --> W[Workers Messenger]
|
||||||
|
F[Supervisor / systemd] --> W
|
||||||
|
|
||||||
|
%% Monolog transversal (logs a chaque etape)
|
||||||
|
A --> LOG_GLOBAL[Monolog - log event initial]
|
||||||
|
B --> LOG_GLOBAL
|
||||||
|
C --> LOG_GLOBAL
|
||||||
|
C1 --> LOG_GLOBAL
|
||||||
|
D --> LOG_GLOBAL
|
||||||
|
E --> LOG_GLOBAL
|
||||||
|
RQ --> LOG_GLOBAL
|
||||||
|
FQ --> LOG_GLOBAL
|
||||||
|
W --> LOG_GLOBAL
|
||||||
|
|
||||||
|
%% Handlers pour l'invitation
|
||||||
|
W --> H1[Handler A - Symfony Mailer + Twig]
|
||||||
|
H1 --> H1o[Email d'invitation avec lien setup mot de passe]
|
||||||
|
H1 --> LOG_GLOBAL
|
||||||
|
|
||||||
|
W --> H2[Handler B - Symfony Notifier in-app]
|
||||||
|
H2 --> UI1[Notification UI admin - Email d'invitation envoye]
|
||||||
|
H2 --> LOG_GLOBAL
|
||||||
|
|
||||||
|
W -. optionnel .-> WH1[Webhook HTTP sortant - invitation envoyee]
|
||||||
|
WH1 --> LOG_GLOBAL
|
||||||
|
W -. optionnel .-> SMS1[SMS gateway - SMS invitation]
|
||||||
|
SMS1 --> LOG_GLOBAL
|
||||||
|
W -. optionnel .-> PUSH1[Mobile push service - notification mobile]
|
||||||
|
PUSH1 --> LOG_GLOBAL
|
||||||
|
|
||||||
|
RQ --> METRICS[Metrics et dashboard]
|
||||||
|
FQ --> METRICS
|
||||||
|
LOG_GLOBAL --> METRICS
|
||||||
|
|
||||||
|
%% Flux activation utilisateur
|
||||||
|
subgraph Activation du compte
|
||||||
|
UA[User action event - Invite clique le lien] --> PS[HTTP controller API - PasswordSetupController]
|
||||||
|
PS -->|Verifie signature et TTL| PSOK[Set password - user status ACTIVE]
|
||||||
|
PS --> LOG_GLOBAL
|
||||||
|
PSOK --> LOG_GLOBAL
|
||||||
|
|
||||||
|
PSOK --> D2[Dispatch UserActivatedEvent - userId adminId organizationId - vers Messenger bus]
|
||||||
|
D2 --> E2[Transport async]
|
||||||
|
E2 --> RQ2[Retry queue]
|
||||||
|
E2 --> FQ2[Failed queue]
|
||||||
|
E2 --> W2[Workers Messenger]
|
||||||
|
F --> W2
|
||||||
|
|
||||||
|
D2 --> LOG_GLOBAL
|
||||||
|
E2 --> LOG_GLOBAL
|
||||||
|
RQ2 --> LOG_GLOBAL
|
||||||
|
FQ2 --> LOG_GLOBAL
|
||||||
|
W2 --> LOG_GLOBAL
|
||||||
|
|
||||||
|
W2 --> H3[Handler C - Notifier in-app]
|
||||||
|
H3 --> UI2[Notification UI admin - Compte active]
|
||||||
|
H3 --> LOG_GLOBAL
|
||||||
|
|
||||||
|
W2 -. optionnel .-> WH2[Webhook HTTP sortant - user active]
|
||||||
|
WH2 --> LOG_GLOBAL
|
||||||
|
W2 -. optionnel .-> MAIL2[Mailer ou SMS ou Push - confirmation utilisateur]
|
||||||
|
MAIL2 --> LOG_GLOBAL
|
||||||
|
|
||||||
|
RQ2 --> METRICS
|
||||||
|
FQ2 --> METRICS
|
||||||
|
end
|
||||||
|
|
||||||
|
%% Cas particulier : utilisateur existant ajoute a une nouvelle organisation
|
||||||
|
C -->|Email deja existant| SP1[Rattache a nouvelle organisation]
|
||||||
|
SP1 --> LOG_GLOBAL
|
||||||
|
SP1 --> D3[Dispatch OrganizationUserInvitedEvent]
|
||||||
|
D3 --> E3[Transport async] --> W3[Workers]
|
||||||
|
F --> W3
|
||||||
|
D3 --> LOG_GLOBAL
|
||||||
|
E3 --> LOG_GLOBAL
|
||||||
|
W3 --> LOG_GLOBAL
|
||||||
|
|
||||||
|
W3 --> M3[Mailer - ajoute a une nouvelle organisation]
|
||||||
|
M3 --> LOG_GLOBAL
|
||||||
|
W3 --> N3[Notifier in-app - toast admin Utilisateur ajoute]
|
||||||
|
N3 --> LOG_GLOBAL
|
||||||
|
W3 -. optionnel .-> WH3[Webhook ou SMS ou Mobile]
|
||||||
|
WH3 --> LOG_GLOBAL
|
||||||
|
|
||||||
|
M3 --> METRICS
|
||||||
|
N3 --> METRICS
|
||||||
|
WH3 --> METRICS
|
||||||
|
|
||||||
|
%% Styles
|
||||||
|
classDef infra fill:#e8f0fe,stroke:#5b8def,stroke-width:1px;
|
||||||
|
classDef handler fill:#dcf7e9,stroke:#2ea66a,stroke-width:1px;
|
||||||
|
classDef ui fill:#f0d9ff,stroke:#9c27b0,stroke-width:1px;
|
||||||
|
classDef audit fill:#eeeeee,stroke:#9e9e9e,stroke-width:1px;
|
||||||
|
|
||||||
|
class E,E2,E3,RQ,FQ,RQ2,FQ2,METRICS infra;
|
||||||
|
class W,W2,W3,H1,H2,H3,M3,N3 handler;
|
||||||
|
class H1o,UI1,UI2 ui;
|
||||||
|
class LOG_GLOBAL audit;
|
||||||
|
```
|
||||||
33
README.MD
|
|
@ -1,23 +1,26 @@
|
||||||
## Template de base pour les applications de la suite Solutions-easy
|
## Template de base pour les applications de la suite Solutions-easy
|
||||||
|
|
||||||
### Stack technique
|
### Stack technique
|
||||||
- Symfony 7.2
|
- Symfony 7.4
|
||||||
- php 8.2 ou supérieur
|
- php 8.2 ou supérieur
|
||||||
- Stimulus
|
- Stimulus
|
||||||
- Turbo
|
- Turbo
|
||||||
- Bootstrap 5.3
|
- 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
|
||||||
|
```
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,20 @@ import './bootstrap.js';
|
||||||
import 'bootstrap/dist/css/bootstrap.min.css';
|
import 'bootstrap/dist/css/bootstrap.min.css';
|
||||||
import './styles/app.css';
|
import './styles/app.css';
|
||||||
import './styles/navbar.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';
|
import 'bootstrap';
|
||||||
|
import './js/template.js';
|
||||||
console.log('This log comes from assets/app.js - welcome to AssetMapper! 🎉');
|
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'
|
||||||
|
|
@ -0,0 +1,169 @@
|
||||||
|
import {Controller} from '@hotwired/stimulus'
|
||||||
|
import Quill from 'quill'
|
||||||
|
|
||||||
|
export default class extends Controller {
|
||||||
|
static values = {
|
||||||
|
application: String,
|
||||||
|
organization: String,
|
||||||
|
user: Number,
|
||||||
|
}
|
||||||
|
static targets = ['hidden', 'submitBtn', 'appList']
|
||||||
|
|
||||||
|
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
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if(this.userValue){
|
||||||
|
this.loadApplications();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
handleAuthorizeSubmit(event) {
|
||||||
|
event.preventDefault();
|
||||||
|
|
||||||
|
const originalText = this.submitBtnTarget.textContent;
|
||||||
|
|
||||||
|
if (!confirm(`Vous vous apprêtez à donner l'accès à ${this.organizationValue} pour ${this.applicationValue}. Êtes‑vous 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}. Êtes‑vous 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');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async loadApplications() {
|
||||||
|
if (!this.userValue) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Note: Ensure the URL matches your route prefix (e.g. /application/user/123)
|
||||||
|
// Adjust the base path below if your controller route is prefixed!
|
||||||
|
const response = await fetch(`/application/user/${this.userValue}`);
|
||||||
|
|
||||||
|
if (!response.ok) throw new Error("Failed to load apps");
|
||||||
|
|
||||||
|
const apps = await response.json();
|
||||||
|
this.renderApps(apps);
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
this.appListTarget.innerHTML = `<span class="text-danger small">Erreur</span>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
renderApps(apps) {
|
||||||
|
if (apps.length === 0) {
|
||||||
|
// Span 2 columns if empty so the message is centered
|
||||||
|
this.appListTarget.innerHTML = `<span class="text-muted small" style="grid-column: span 2; text-align: center;">Aucune application</span>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const html = apps.map(app => {
|
||||||
|
const url = `https://${app.subDomain}.solutions-easy.com`;
|
||||||
|
|
||||||
|
// Check for logo string vs object
|
||||||
|
const logoSrc = (typeof app.logoMiniUrl === 'string') ? app.logoMiniUrl : '';
|
||||||
|
|
||||||
|
// Render Icon (Image or Fallback)
|
||||||
|
const iconHtml = logoSrc
|
||||||
|
? `<img src="${logoSrc}" style="width:32px; height:32px; object-fit:contain; margin-bottom: 5px;">`
|
||||||
|
: `<i class="bi bi-box-arrow-up-right text-primary" style="font-size: 24px; margin-bottom: 5px;"></i>`;
|
||||||
|
|
||||||
|
// Return a Card-like block
|
||||||
|
return `
|
||||||
|
<a href="${url}" target="_blank"
|
||||||
|
class="d-flex flex-column align-items-center justify-content-center p-3 rounded text-decoration-none text-dark bg-light-hover"
|
||||||
|
style="transition: background 0.2s; height: 100%;">
|
||||||
|
|
||||||
|
${iconHtml}
|
||||||
|
|
||||||
|
<span class="fw-bold text-center text-truncate w-100" style="font-size: 0.85rem;">
|
||||||
|
${app.name}
|
||||||
|
</span>
|
||||||
|
</a>
|
||||||
|
`;
|
||||||
|
}).join('');
|
||||||
|
|
||||||
|
this.appListTarget.innerHTML = html;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,27 @@
|
||||||
|
import { Controller } from "@hotwired/stimulus";
|
||||||
|
|
||||||
|
export default class extends Controller {
|
||||||
|
async fetchAndRenderApplications(targetElement) {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/application/data/all');
|
||||||
|
const apps = await response.json();
|
||||||
|
|
||||||
|
targetElement.innerHTML = apps.map(app => `
|
||||||
|
<div class="col-md-4 mb-3">
|
||||||
|
<div class="form-check border p-2 rounded d-flex align-items-center gap-2">
|
||||||
|
<input class="form-check-input ms-1" type="checkbox" name="applications[]" value="${app.id}" id="app_${app.id}">
|
||||||
|
<label class="form-check-label d-flex align-items-center gap-2" for="app_${app.id}">
|
||||||
|
<img src="${app.logoMiniUrl}" alt="${app.name}" style="height: 20px; width: 20px; object-fit: contain;">
|
||||||
|
${app.name}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`).join('');
|
||||||
|
|
||||||
|
return apps;
|
||||||
|
} catch (error) {
|
||||||
|
targetElement.innerHTML = '<div class="text-danger">Erreur de chargement.</div>';
|
||||||
|
console.error("App load error:", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,324 @@
|
||||||
|
import { Controller } from '@hotwired/stimulus';
|
||||||
|
|
||||||
|
export default class extends Controller {
|
||||||
|
static targets = ['badge', 'list'];
|
||||||
|
static values = {
|
||||||
|
userId: Number,
|
||||||
|
mercureUrl: String,
|
||||||
|
url: 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 || `${this.urlValue}/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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,227 @@
|
||||||
|
import {Controller} from '@hotwired/stimulus'
|
||||||
|
// Important: include a build with Ajax + pagination (TabulatorFull is simplest)
|
||||||
|
import {TabulatorFull as Tabulator} from 'tabulator-tables';
|
||||||
|
import {capitalizeFirstLetter, eyeIconLink, TABULATOR_FR_LANG} from "../js/global.js";
|
||||||
|
import { Modal } from "bootstrap";
|
||||||
|
|
||||||
|
export default class extends Controller {
|
||||||
|
static values = {
|
||||||
|
id: Number,
|
||||||
|
activities: Boolean,
|
||||||
|
table: Boolean,
|
||||||
|
sadmin: Boolean,
|
||||||
|
user: Number
|
||||||
|
};
|
||||||
|
|
||||||
|
static targets = ["activityList", "emptyMessage", "modal", "modalTitle", "nameInput", "emailInput", "numberInput", "addressInput"];
|
||||||
|
connect() {
|
||||||
|
if(this.activitiesValue){
|
||||||
|
this.loadActivities();
|
||||||
|
setInterval(() => {
|
||||||
|
this.loadActivities();
|
||||||
|
}, 300000); // Refresh every 5 minutes
|
||||||
|
}
|
||||||
|
if (this.tableValue && this.sadminValue) {
|
||||||
|
this.table();
|
||||||
|
}
|
||||||
|
if (this.hasModalTarget) {
|
||||||
|
this.modal = new Modal(this.modalTarget);
|
||||||
|
this.currentOrgId = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
table(){
|
||||||
|
const table = new Tabulator("#tabulator-org", {
|
||||||
|
// Register locales here
|
||||||
|
langs: TABULATOR_FR_LANG,
|
||||||
|
placeholder: "Aucun résultat trouvé pour cette recherche",
|
||||||
|
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",
|
||||||
|
data: "data"},
|
||||||
|
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",
|
||||||
|
columns: [
|
||||||
|
{
|
||||||
|
title: "Logo",
|
||||||
|
field: "logoUrl",
|
||||||
|
formatter: "image",
|
||||||
|
formatterParams: {
|
||||||
|
height: "50px",
|
||||||
|
width: "50px",
|
||||||
|
urlPrefix: "",
|
||||||
|
urlSuffix: "",
|
||||||
|
},
|
||||||
|
width: 100,
|
||||||
|
},
|
||||||
|
{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 '';
|
||||||
|
}
|
||||||
|
}],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async loadActivities() {
|
||||||
|
try {
|
||||||
|
// 1. Fetch the data using the ID from values
|
||||||
|
const response = await fetch(`/actions/organization/${this.idValue}/activities-ajax`);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Network response was not ok');
|
||||||
|
}
|
||||||
|
|
||||||
|
const activities = await response.json();
|
||||||
|
|
||||||
|
// 2. Render
|
||||||
|
this.renderActivities(activities);
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching activities:', error);
|
||||||
|
this.activityListTarget.innerHTML = `<div class="text-danger">Erreur lors du chargement.</div>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
renderActivities(activities) {
|
||||||
|
// Clear the loading spinner
|
||||||
|
this.activityListTarget.innerHTML = '';
|
||||||
|
|
||||||
|
if (activities.length === 0) {
|
||||||
|
// Show empty message
|
||||||
|
this.activityListTarget.innerHTML = this.emptyMessageTarget.innerHTML;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Loop through JSON and build HTML
|
||||||
|
const html = activities.map(activity => {
|
||||||
|
return `
|
||||||
|
<div class="card shadow-sm mb-3 border-0 bg-white rounded-end"
|
||||||
|
style="border-left: 6px solid ${activity.color} !important;">
|
||||||
|
|
||||||
|
<div class="card-header bg-transparent border-0 pb-0 pt-3">
|
||||||
|
<h6 class="text-muted text-uppercase fw-bold mb-0" style="font-size: 0.85rem;">
|
||||||
|
${activity.date}
|
||||||
|
</h6>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card-body pt-2 pb-4">
|
||||||
|
<div class="card-text fs-5 lh-sm">
|
||||||
|
<span class="fw-bold text-dark">${activity.userName}</span>
|
||||||
|
<div class="text-secondary mt-1">${activity.actionType}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}).join('');
|
||||||
|
//com
|
||||||
|
|
||||||
|
this.activityListTarget.innerHTML = html;
|
||||||
|
}
|
||||||
|
|
||||||
|
openCreateModal() {
|
||||||
|
this.currentOrgId = null;
|
||||||
|
this.modalTitleTarget.textContent = "Créer une organisation";
|
||||||
|
this.resetForm();
|
||||||
|
this.modal.show();
|
||||||
|
}
|
||||||
|
|
||||||
|
async openEditModal(event) {
|
||||||
|
this.currentOrgId = event.currentTarget.dataset.id;
|
||||||
|
this.modalTitleTarget.textContent = "Modifier l'organisation";
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/organization/editModal/${this.currentOrgId}`);
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
// Fill targets
|
||||||
|
this.nameInputTarget.value = data.name;
|
||||||
|
this.emailInputTarget.value = data.email;
|
||||||
|
this.numberInputTarget.value = data.number || '';
|
||||||
|
this.addressInputTarget.value = data.address || '';
|
||||||
|
|
||||||
|
this.modal.show();
|
||||||
|
} catch (error) {
|
||||||
|
alert("Erreur lors du chargement des données.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async submitForm(event) {
|
||||||
|
event.preventDefault();
|
||||||
|
const formData = new FormData(event.target);
|
||||||
|
|
||||||
|
const method = this.currentOrgId ? 'PUT' : 'POST';
|
||||||
|
const url = this.currentOrgId ? `/organization/${this.currentOrgId}` : `/organization/`;
|
||||||
|
|
||||||
|
|
||||||
|
if (this.currentOrgId) {
|
||||||
|
formData.append('_method', 'PUT');
|
||||||
|
}
|
||||||
|
formData.set('name', capitalizeFirstLetter(formData.get('name')));
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(url, {
|
||||||
|
method: 'POST',
|
||||||
|
body: formData,
|
||||||
|
headers: { 'X-Requested-With': 'XMLHttpRequest' }
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
this.modal.hide();
|
||||||
|
location.reload();
|
||||||
|
} else {
|
||||||
|
const result = await response.json();
|
||||||
|
alert(result.error || "Une erreur est survenue.");
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
alert("Erreur réseau.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
resetForm() {
|
||||||
|
this.nameInputTarget.value = "";
|
||||||
|
this.emailInputTarget.value = "";
|
||||||
|
this.numberInputTarget.value = "";
|
||||||
|
this.addressInputTarget.value = "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,247 @@
|
||||||
|
import {Controller} from '@hotwired/stimulus';
|
||||||
|
import { Modal } from "bootstrap";
|
||||||
|
import {TabulatorFull as Tabulator} from 'tabulator-tables';
|
||||||
|
import {eyeIconLink, pencilIcon, TABULATOR_FR_LANG, trashIcon} from "../js/global.js";
|
||||||
|
|
||||||
|
|
||||||
|
export default class extends Controller {
|
||||||
|
static values = {
|
||||||
|
listProject : Boolean,
|
||||||
|
orgId: Number,
|
||||||
|
admin: Boolean
|
||||||
|
}
|
||||||
|
static targets = ["modal", "appList", "nameInput", "formTitle", "timestampSelect", "deletionSelect"];
|
||||||
|
connect(){
|
||||||
|
if(this.listProjectValue){
|
||||||
|
this.table();
|
||||||
|
}
|
||||||
|
this.modal = new Modal(this.modalTarget);
|
||||||
|
}
|
||||||
|
|
||||||
|
table(){
|
||||||
|
const columns = [
|
||||||
|
{title: "<b>ID</b> ", field: "id", visible: false},
|
||||||
|
{title: "<b>Nom du projet</b> ", field: "name", headerFilter: "input", widthGrow: 2, vertAlign: "middle"},
|
||||||
|
{
|
||||||
|
title: "Applications",
|
||||||
|
field: "applications",
|
||||||
|
headerSort: false,
|
||||||
|
hozAlign: "left",
|
||||||
|
formatter: (cell) => {
|
||||||
|
const apps = cell.getValue();
|
||||||
|
if (!apps || apps.length === 0) {
|
||||||
|
return "<span class='text-muted' style='font-size: 0.8rem;'>Aucune</span>";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wrap everything in a flex container to keep them on one line
|
||||||
|
const content = apps.map(app => `
|
||||||
|
<div class="me-1" title="${app.name}">
|
||||||
|
<img src="${app.logoMiniUrl}"
|
||||||
|
alt="${app.name}"
|
||||||
|
style="height: 35px; width: 35px; object-fit: contain; border-radius: 4px;">
|
||||||
|
</div>
|
||||||
|
`).join('');
|
||||||
|
|
||||||
|
return `<div class="d-flex flex-wrap align-items-center">${content}</div>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
];
|
||||||
|
// 2. Add the conditional column if admin value is true
|
||||||
|
if (this.adminValue) {
|
||||||
|
columns.push({
|
||||||
|
title: "<b>Base de données</b>",
|
||||||
|
field: "bddName",
|
||||||
|
hozAlign: "left",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "<b>Actions</b>",
|
||||||
|
field: "id",
|
||||||
|
width: 120,
|
||||||
|
hozAlign: "center",
|
||||||
|
headerSort: false,
|
||||||
|
formatter: (cell) => {
|
||||||
|
const id = cell.getValue();
|
||||||
|
// Return a button that Stimulus can listen to
|
||||||
|
return `<div class="d-flex gap-2 align-content-center">
|
||||||
|
<button class="btn btn-link p-0 border-0" data-action="click->project#openEditModal"
|
||||||
|
data-id="${id}">
|
||||||
|
${pencilIcon()}</button>
|
||||||
|
<button class="btn btn-link p-0 border-0" data-action="click->project#deleteProject"
|
||||||
|
data-id="${id}"> ${trashIcon()} </button>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const tabulator = new Tabulator("#tabulator-projectListOrganization", {
|
||||||
|
langs: TABULATOR_FR_LANG,
|
||||||
|
locale: "fr",
|
||||||
|
ajaxURL: `/project/organization/data`,
|
||||||
|
ajaxConfig: "GET",
|
||||||
|
pagination: true,
|
||||||
|
paginationMode: "remote",
|
||||||
|
paginationSize: 15,
|
||||||
|
|
||||||
|
ajaxParams: {orgId: this.orgIdValue},
|
||||||
|
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();
|
||||||
|
queryParams.append('orgId', params.orgId);
|
||||||
|
queryParams.append('page', params.page || 1);
|
||||||
|
queryParams.append('size', params.size || 15);
|
||||||
|
|
||||||
|
// 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
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async loadApplications() {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/application/data/all');
|
||||||
|
const apps = await response.json();
|
||||||
|
|
||||||
|
this.appListTarget.innerHTML = apps.map(app => `
|
||||||
|
<div class="col-md-4 mb-3">
|
||||||
|
<div class="form-check border p-2 rounded d-flex align-items-center gap-2">
|
||||||
|
<input class="form-check-input ms-1" type="checkbox" name="applications[]" value="${app.id}" id="app_${app.id}">
|
||||||
|
<label class="form-check-label d-flex align-items-center gap-2" for="app_${app.id}">
|
||||||
|
<img src="${app.logoMiniUrl}" alt="${app.name}" style="height: 20px; width: 20px; object-fit: contain;">
|
||||||
|
${app.name}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`).join('');
|
||||||
|
} catch (error) {
|
||||||
|
this.appListTarget.innerHTML = '<div class="text-danger">Erreur de chargement.</div>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async submitForm(event) {
|
||||||
|
event.preventDefault();
|
||||||
|
const form = event.target;
|
||||||
|
const formData = new FormData(form); // This automatically picks up the 'logo' file
|
||||||
|
|
||||||
|
// 1. Validate File Format
|
||||||
|
const logoFile = formData.get('logo');
|
||||||
|
if (logoFile && logoFile.size > 0) {
|
||||||
|
const allowedTypes = ['image/jpeg', 'image/png', 'image/jpg'];
|
||||||
|
if (!allowedTypes.includes(logoFile.type)) {
|
||||||
|
alert("Format invalide. Veuillez utiliser uniquement des fichiers PNG ou JPG.");
|
||||||
|
return; // Stop submission
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Prepare for Multipart sending
|
||||||
|
// Since we are using FormData, we don't need JSON.stringify or 'Content-Type': 'application/json'
|
||||||
|
// We add the extra fields to the formData object
|
||||||
|
formData.append('organizationId', this.orgIdValue);
|
||||||
|
|
||||||
|
const url = this.currentProjectId
|
||||||
|
? `/project/edit/${this.currentProjectId}/ajax`
|
||||||
|
: `/project/new/ajax`;
|
||||||
|
|
||||||
|
const response = await fetch(url, {
|
||||||
|
method: 'POST',
|
||||||
|
// IMPORTANT: Do NOT set Content-Type header when sending FormData with files
|
||||||
|
// The browser will set 'multipart/form-data' and the boundary automatically
|
||||||
|
body: formData
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
this.modal.hide();
|
||||||
|
location.reload();
|
||||||
|
} else {
|
||||||
|
const result = await response.json();
|
||||||
|
if (response.status === 409) {
|
||||||
|
alert("Un projet avec ce nom existe déjà.");
|
||||||
|
} else {
|
||||||
|
alert(result.error || "Une erreur est survenue.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async openEditModal(event) {
|
||||||
|
const projectId = event.currentTarget.dataset.id;
|
||||||
|
this.currentProjectId = projectId;
|
||||||
|
|
||||||
|
this.modal.show();
|
||||||
|
this.nameInputTarget.disabled = true;
|
||||||
|
this.formTitleTarget.textContent = "Modifier le projet";
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 1. Ensure checkboxes are loaded first
|
||||||
|
await this.loadApplications();
|
||||||
|
|
||||||
|
// 2. Fetch the project data
|
||||||
|
const response = await fetch(`/project/data/${projectId}`);
|
||||||
|
const project = await response.json();
|
||||||
|
|
||||||
|
// 3. Set the name
|
||||||
|
this.nameInputTarget.value = project.name;
|
||||||
|
console.log(project);
|
||||||
|
this.timestampSelectTarget.value = project.timestampPrecision;
|
||||||
|
this.deletionSelectTarget.value = project.deletionAllowed;
|
||||||
|
|
||||||
|
// 4. Check the boxes
|
||||||
|
// We look for all checkboxes inside our appList target
|
||||||
|
const checkboxes = this.appListTarget.querySelectorAll('input[type="checkbox"]');
|
||||||
|
|
||||||
|
checkboxes.forEach(cb => {
|
||||||
|
cb.checked = project.applications.includes(cb.value);
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error loading project data", error);
|
||||||
|
alert("Erreur lors de la récupération des données du projet.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Update your openCreateModal to reset the state
|
||||||
|
openCreateModal() {
|
||||||
|
this.currentProjectId = null;
|
||||||
|
this.modal.show();
|
||||||
|
this.nameInputTarget.disabled = false;
|
||||||
|
this.nameInputTarget.value = "";
|
||||||
|
this.formTitleTarget.textContent = "Nouveau Projet";
|
||||||
|
this.loadApplications();
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteProject(event) {
|
||||||
|
const projectId = event.currentTarget.dataset.id;
|
||||||
|
if (!confirm("Êtes-vous sûr de vouloir supprimer ce projet ?")) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/project/delete/${projectId}/ajax`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
location.reload();
|
||||||
|
}
|
||||||
|
}catch (error) {
|
||||||
|
console.error("Error deleting project", error);
|
||||||
|
alert("Erreur lors de la suppression du projet.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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 |
|
|
@ -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 |
|
|
@ -0,0 +1 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill="currentColor" d="M3.5 0a.5.5 0 0 1 .5.5V1h8V.5a.5.5 0 0 1 1 0V1h1a2 2 0 0 1 2 2v11a2 2 0 0 1-2 2H2a2 2 0 0 1-2-2V3a2 2 0 0 1 2-2h1V.5a.5.5 0 0 1 .5-.5M1 4v10a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1V4z"/></svg>
|
||||||
|
After Width: | Height: | Size: 272 B |
|
|
@ -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 |
|
|
@ -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 |
|
|
@ -0,0 +1 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g fill="currentColor"><path fill-rule="evenodd" d="M5 2a.5.5 0 0 1 .5-.5c.862 0 1.573.287 2.06.566c.174.099.321.198.44.286c.119-.088.266-.187.44-.286A4.17 4.17 0 0 1 10.5 1.5a.5.5 0 0 1 0 1c-.638 0-1.177.213-1.564.434a3.5 3.5 0 0 0-.436.294V7.5H9a.5.5 0 0 1 0 1h-.5v4.272c.1.08.248.187.436.294c.387.221.926.434 1.564.434a.5.5 0 0 1 0 1a4.17 4.17 0 0 1-2.06-.566A5 5 0 0 1 8 13.65a5 5 0 0 1-.44.285a4.17 4.17 0 0 1-2.06.566a.5.5 0 0 1 0-1c.638 0 1.177-.213 1.564-.434c.188-.107.335-.214.436-.294V8.5H7a.5.5 0 0 1 0-1h.5V3.228a3.5 3.5 0 0 0-.436-.294A3.17 3.17 0 0 0 5.5 2.5A.5.5 0 0 1 5 2"/><path d="M10 5h4a1 1 0 0 1 1 1v4a1 1 0 0 1-1 1h-4v1h4a2 2 0 0 0 2-2V6a2 2 0 0 0-2-2h-4zM6 5V4H2a2 2 0 0 0-2 2v4a2 2 0 0 0 2 2h4v-1H2a1 1 0 0 1-1-1V6a1 1 0 0 1 1-1z"/></g></svg>
|
||||||
|
After Width: | Height: | Size: 827 B |
|
|
@ -0,0 +1 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill="currentColor" d="M6.5 1h3a.5.5 0 0 1 .5.5v1H6v-1a.5.5 0 0 1 .5-.5M11 2.5v-1A1.5 1.5 0 0 0 9.5 0h-3A1.5 1.5 0 0 0 5 1.5v1H1.5a.5.5 0 0 0 0 1h.538l.853 10.66A2 2 0 0 0 4.885 16h6.23a2 2 0 0 0 1.994-1.84l.853-10.66h.538a.5.5 0 0 0 0-1zm1.958 1l-.846 10.58a1 1 0 0 1-.997.92h-6.23a1 1 0 0 1-.997-.92L3.042 3.5zm-7.487 1a.5.5 0 0 1 .528.47l.5 8.5a.5.5 0 0 1-.998.06L5 5.03a.5.5 0 0 1 .47-.53Zm5.058 0a.5.5 0 0 1 .47.53l-.5 8.5a.5.5 0 1 1-.998-.06l.5-8.5a.5.5 0 0 1 .528-.47M8 4.5a.5.5 0 0 1 .5.5v8.5a.5.5 0 0 1-1 0V5a.5.5 0 0 1 .5-.5"/></svg>
|
||||||
|
After Width: | Height: | Size: 609 B |
|
|
@ -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 |
|
|
@ -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 |
|
|
@ -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 |
|
|
@ -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 |
|
|
@ -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 |
|
|
@ -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 |
|
|
@ -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 |
|
|
@ -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 |
|
|
@ -0,0 +1 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path fill="currentColor" d="M4 20q-.825 0-1.412-.587T2 18V6q0-.825.588-1.412T4 4h16q.825 0 1.413.588T22 6v12q0 .825-.587 1.413T20 20zm0-2h16V6H4zm0 0V6zm8-5h6q.425 0 .713-.288T19 12V8q0-.425-.288-.712T18 7h-6q-.425 0-.712.288T11 8v4q0 .425.288.713T12 13m1-2V9h4v2z"/></svg>
|
||||||
|
After Width: | Height: | Size: 334 B |
|
|
@ -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 |
|
After Width: | Height: | Size: 8.0 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 7.7 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 8.5 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 7.5 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 8.0 KiB |
|
After Width: | Height: | Size: 19 KiB |
|
Before Width: | Height: | Size: 3.0 KiB |
|
After Width: | Height: | Size: 31 KiB |
|
After Width: | Height: | Size: 888 B |
|
After Width: | Height: | Size: 888 B |
|
|
@ -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 "";
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,86 @@
|
||||||
|
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="Accéder au profil" >
|
||||||
|
<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 pencilIcon() {
|
||||||
|
return `
|
||||||
|
<span class="align-middle color-primary">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="35px" height="35px" viewBox="0 0 24 24">
|
||||||
|
<path fill="currentColor" d="M12.146.146a.5.5 0 0 1 .708 0l3 3a.5.5 0 0 1 0 .708l-10 10a.5.5 0 0 1-.168.11l-5 2a.5.5 0 0 1-.65-.65l2-5a.5.5 0 0 1 .11-.168zM11.207 2.5L13.5 4.793L14.793 3.5L12.5 1.207zm1.586 3L10.5 3.207L4 9.707V10h.5a.5.5 0 0 1 .5.5v.5h.5a.5.5 0 0 1 .5.5v.5h.293zm-9.761 5.175l-.106.106l-1.528 3.821l3.821-1.528l.106-.106A.5.5 0 0 1 5 12.5V12h-.5a.5.5 0 0 1-.5-.5V11h-.5a.5.5 0 0 1-.468-.325"/></svg>
|
||||||
|
</span>
|
||||||
|
`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function trashIcon() {
|
||||||
|
return `
|
||||||
|
<span class="align-middle color-delete">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="35px" height="35px" viewBox="0 0 24 24">
|
||||||
|
<path fill="currentColor" d="M6.5 1h3a.5.5 0 0 1 .5.5v1H6v-1a.5.5 0 0 1 .5-.5M11 2.5v-1A1.5 1.5 0 0 0 9.5 0h-3A1.5 1.5 0 0 0 5 1.5v1H1.5a.5.5 0 0 0 0 1h.538l.853 10.66A2 2 0 0 0 4.885 16h6.23a2 2 0 0 0 1.994-1.84l.853-10.66h.538a.5.5 0 0 0 0-1zm1.958 1l-.846 10.58a1 1 0 0 1-.997.92h-6.23a1 1 0 0 1-.997-.92L3.042 3.5zm-7.487 1a.5.5 0 0 1 .528.47l.5 8.5a.5.5 0 0 1-.998.06L5 5.03a.5.5 0 0 1 .47-.53Zm5.058 0a.5.5 0 0 1 .47.53l-.5 8.5a.5.5 0 1 1-.998-.06l.5-8.5a.5.5 0 0 1 .528-.47M8 4.5a.5.5 0 0 1 .5.5v8.5a.5.5 0 0 1-1 0V5a.5.5 0 0 1 .5-.5"/></svg>
|
||||||
|
</span>
|
||||||
|
`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function trashIconForm(url, organizationId) {
|
||||||
|
return `
|
||||||
|
<button class="btn btn-link p-0 border-0 color-delete align-middle"
|
||||||
|
data-action="click->user#removeAdmin"
|
||||||
|
data-url="${url}"
|
||||||
|
data-org-id="${organizationId}">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="35px" height="35px" viewBox="0 0 24 24">
|
||||||
|
<path fill="currentColor" d="M6.5 1h3a.5.5 0 0 1 .5.5v1H6v-1a.5.5 0 0 1 .5-.5M11 2.5v-1A1.5 1.5 0 0 0 9.5 0h-3A1.5 1.5 0 0 0 5 1.5v1H1.5a.5.5 0 0 0 0 1h.538l.853 10.66A2 2 0 0 0 4.885 16h6.23a2 2 0 0 0 1.994-1.84l.853-10.66h.538a.5.5 0 0 0 0-1zm1.958 1l-.846 10.58a1 1 0 0 1-.997.92h-6.23a1 1 0 0 1-.997-.92L3.042 3.5zm-7.487 1a.5.5 0 0 1 .528.47l.5 8.5a.5.5 0 0 1-.998.06L5 5.03a.5.5 0 0 1 .47-.53Zm5.058 0a.5.5 0 0 1 .47.53l-.5 8.5a.5.5 0 1 1-.998-.06l.5-8.5a.5.5 0 0 1 .528-.47M8 4.5a.5.5 0 0 1 .5.5v8.5a.5.5 0 0 1-1 0V5a.5.5 0 0 1 .5-.5"/></svg>
|
||||||
|
|
||||||
|
</button>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
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>`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function capitalizeFirstLetter(string) {
|
||||||
|
return string.charAt(0).toUpperCase() + string.slice(1);
|
||||||
|
}
|
||||||
|
|
@ -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,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);
|
||||||
|
|
@ -1,3 +1,19 @@
|
||||||
|
/*variable*/
|
||||||
|
:root{
|
||||||
|
--primary-blue-light : #086572;
|
||||||
|
--primary-blue-dark : #094754;
|
||||||
|
--black-font: #1D1E1C;
|
||||||
|
--delete : #E42E31;
|
||||||
|
--delete-dark : #aa1618;
|
||||||
|
--disable : #A3A3A3;
|
||||||
|
--check : #5cae09;
|
||||||
|
--check-dark: #3a6e05;
|
||||||
|
--secondary : #cc664c;
|
||||||
|
--secondary-dark : #a5543d;
|
||||||
|
--warning : #d2b200;
|
||||||
|
--warning-dark: #c4a600;
|
||||||
|
}
|
||||||
|
|
||||||
html {
|
html {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
|
|
@ -31,3 +47,141 @@ body {
|
||||||
overflow: hidden;
|
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-success{
|
||||||
|
background: var(--check);
|
||||||
|
color : #FFFFFF;
|
||||||
|
border: var(--check-dark);
|
||||||
|
border-radius: 1rem;
|
||||||
|
}
|
||||||
|
.btn-success:hover{
|
||||||
|
background: var(--check-dark);
|
||||||
|
color : #FFFFFF;
|
||||||
|
border: var(--check);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-warning{
|
||||||
|
background: var(--warning);
|
||||||
|
color : #FFFFFF;
|
||||||
|
border: var(--warning-dark);
|
||||||
|
border-radius: 1rem;
|
||||||
|
}
|
||||||
|
.btn-warning:hover{
|
||||||
|
background: var(--warning-dark);
|
||||||
|
color : #FFFFFF;
|
||||||
|
border: var(--warning);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-danger{
|
||||||
|
background: var(--delete);
|
||||||
|
color : #FFFFFF;
|
||||||
|
border: var(--delete-dark);
|
||||||
|
border-radius: 1rem;
|
||||||
|
}
|
||||||
|
.btn-danger:hover{
|
||||||
|
background: var(--delete-dark);
|
||||||
|
color : #FFFFFF;
|
||||||
|
border: var(--delete);
|
||||||
|
}
|
||||||
|
|
||||||
|
.color-primary{
|
||||||
|
color: var(--primary-blue-light) !important;
|
||||||
|
}
|
||||||
|
.color-primary-dark{
|
||||||
|
color: var(--primary-blue-dark);
|
||||||
|
}
|
||||||
|
|
||||||
|
.color-delete{
|
||||||
|
color: var(--delete) !important;
|
||||||
|
}
|
||||||
|
.color-delete-dark{
|
||||||
|
color: var(--delete-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;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,4 @@
|
||||||
|
.card.no-header-bg .card-header{
|
||||||
|
background-color: transparent !important;
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -69,8 +69,11 @@
|
||||||
border-radius: 0;
|
border-radius: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.navbar-nav-right{
|
.navbar .navbar-menu-wrapper .navbar-toggler:active,
|
||||||
flex-direction: row;
|
.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) {
|
.navbar .navbar-menu-wrapper .navbar-toggler:not(.navbar-toggler-right) {
|
||||||
|
|
@ -82,12 +85,24 @@
|
||||||
transition: transform 0.3s linear;
|
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{
|
.navbar .navbar-menu-wrapper .navbar-nav .nav-item.nav-search .input-group .form-control{
|
||||||
margin-left: 0.7rem;
|
margin-left: 0.7rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
#navbar-search-icon > #search{
|
#navbar-search-icon > #search{
|
||||||
vertical-align: middle;
|
vertical-align: baseline;
|
||||||
}
|
}
|
||||||
|
|
||||||
.navbar .navbar-menu-wrapper .navbar-nav.navbar-nav-right {
|
.navbar .navbar-menu-wrapper .navbar-nav.navbar-nav-right {
|
||||||
|
|
@ -244,4 +259,16 @@
|
||||||
#logo_orga{
|
#logo_orga{
|
||||||
width:auto;
|
width:auto;
|
||||||
max-height:40px;
|
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;
|
||||||
}
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
|
@ -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;*/
|
||||||
|
/*}*/
|
||||||
|
|
@ -8,48 +8,52 @@
|
||||||
"ext-ctype": "*",
|
"ext-ctype": "*",
|
||||||
"ext-iconv": "*",
|
"ext-iconv": "*",
|
||||||
"ext-openssl": "*",
|
"ext-openssl": "*",
|
||||||
|
"aws/aws-sdk-php-symfony": "^2.8",
|
||||||
"doctrine/dbal": "^3",
|
"doctrine/dbal": "^3",
|
||||||
"doctrine/doctrine-bundle": "^2.14",
|
"doctrine/doctrine-bundle": "^2.14",
|
||||||
"doctrine/doctrine-migrations-bundle": "^3.4",
|
"doctrine/doctrine-migrations-bundle": "^3.4",
|
||||||
"doctrine/orm": "^3.3",
|
"doctrine/orm": "^3.3",
|
||||||
"firebase/php-jwt": "^6.11",
|
"firebase/php-jwt": "^7.0.0",
|
||||||
|
"knplabs/knp-time-bundle": "^2.4",
|
||||||
"league/oauth2-server-bundle": "^0.11.0",
|
"league/oauth2-server-bundle": "^0.11.0",
|
||||||
"nelmio/cors-bundle": "^2.5",
|
"nelmio/cors-bundle": "^2.5",
|
||||||
"phpdocumentor/reflection-docblock": "^5.6",
|
"phpdocumentor/reflection-docblock": "^5.6",
|
||||||
"phpstan/phpdoc-parser": "^2.1",
|
"phpstan/phpdoc-parser": "^2.1",
|
||||||
"symfony/asset": "7.2.*",
|
"symfony/asset": "7.4.*",
|
||||||
"symfony/asset-mapper": "7.2.*",
|
"symfony/asset-mapper": "7.4.*",
|
||||||
"symfony/console": "7.2.*",
|
"symfony/console": "7.4.*",
|
||||||
"symfony/doctrine-messenger": "7.2.*",
|
"symfony/doctrine-messenger": "7.4.*",
|
||||||
"symfony/dotenv": "7.2.*",
|
"symfony/dotenv": "7.4.*",
|
||||||
"symfony/expression-language": "7.2.*",
|
"symfony/expression-language": "7.4.*",
|
||||||
"symfony/flex": "^2",
|
"symfony/flex": "^2",
|
||||||
"symfony/form": "7.2.*",
|
"symfony/form": "7.4.*",
|
||||||
"symfony/framework-bundle": "7.2.*",
|
"symfony/framework-bundle": "7.4.*",
|
||||||
"symfony/http-client": "7.2.*",
|
"symfony/http-client": "7.4.*",
|
||||||
"symfony/intl": "7.2.*",
|
"symfony/intl": "7.4.*",
|
||||||
"symfony/mailer": "7.2.*",
|
"symfony/mailer": "7.4.*",
|
||||||
"symfony/mercure-bundle": "^0.3.9",
|
"symfony/mercure-bundle": "^0.3.9",
|
||||||
"symfony/mime": "7.2.*",
|
"symfony/messenger": "7.4.*",
|
||||||
"symfony/monolog-bundle": "^3.0",
|
"symfony/mime": "7.4.*",
|
||||||
"symfony/notifier": "7.2.*",
|
"symfony/monolog-bundle": "^3.10",
|
||||||
"symfony/process": "7.2.*",
|
"symfony/notifier": "7.4.*",
|
||||||
"symfony/property-access": "7.2.*",
|
"symfony/process": "7.4.*",
|
||||||
"symfony/property-info": "7.2.*",
|
"symfony/property-access": "7.4.*",
|
||||||
"symfony/runtime": "7.2.*",
|
"symfony/property-info": "7.4.*",
|
||||||
"symfony/security-bundle": "7.2.*",
|
"symfony/rate-limiter": "7.4.*",
|
||||||
"symfony/serializer": "7.2.*",
|
"symfony/runtime": "7.4.*",
|
||||||
|
"symfony/security-bundle": "7.4.*",
|
||||||
|
"symfony/serializer": "7.4.*",
|
||||||
"symfony/stimulus-bundle": "^2.24",
|
"symfony/stimulus-bundle": "^2.24",
|
||||||
"symfony/string": "7.2.*",
|
"symfony/string": "7.4.*",
|
||||||
"symfony/translation": "7.2.*",
|
"symfony/translation": "7.4.*",
|
||||||
"symfony/twig-bundle": "7.2.*",
|
"symfony/twig-bundle": "7.4.*",
|
||||||
"symfony/ux-icons": "^2.24",
|
"symfony/ux-icons": "^2.24",
|
||||||
"symfony/ux-toggle-password": "^2.24",
|
"symfony/ux-toggle-password": "^2.24",
|
||||||
"symfony/ux-turbo": "^2.24",
|
"symfony/ux-turbo": "^2.24",
|
||||||
"symfony/validator": "7.2.*",
|
"symfony/validator": "7.4.*",
|
||||||
"symfony/web-link": "7.2.*",
|
"symfony/web-link": "7.4.*",
|
||||||
"symfony/yaml": "7.2.*",
|
"symfony/webhook": "7.4.*",
|
||||||
"twig/extra-bundle": "^2.12|^3.0",
|
"symfony/yaml": "7.4.*",
|
||||||
"twig/twig": "^2.12|^3.0"
|
"twig/twig": "^2.12|^3.0"
|
||||||
},
|
},
|
||||||
"config": {
|
"config": {
|
||||||
|
|
@ -100,17 +104,18 @@
|
||||||
"extra": {
|
"extra": {
|
||||||
"symfony": {
|
"symfony": {
|
||||||
"allow-contrib": false,
|
"allow-contrib": false,
|
||||||
"require": "7.2.*"
|
"require": "7.4.*"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"require-dev": {
|
"require-dev": {
|
||||||
"phpunit/phpunit": "^9.5",
|
"dama/doctrine-test-bundle": "^8.3",
|
||||||
"symfony/browser-kit": "7.2.*",
|
"phpunit/phpunit": "^11.0",
|
||||||
"symfony/css-selector": "7.2.*",
|
"symfony/browser-kit": "7.4.*",
|
||||||
"symfony/debug-bundle": "7.2.*",
|
"symfony/css-selector": "7.4.*",
|
||||||
|
"symfony/debug-bundle": "7.4.*",
|
||||||
"symfony/maker-bundle": "^1.62",
|
"symfony/maker-bundle": "^1.62",
|
||||||
"symfony/phpunit-bridge": "^7.2",
|
"symfony/phpunit-bridge": "7.4.*",
|
||||||
"symfony/stopwatch": "7.2.*",
|
"symfony/stopwatch": "7.4.*",
|
||||||
"symfony/web-profiler-bundle": "7.2.*"
|
"symfony/web-profiler-bundle": "7.4.*"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,6 @@ return [
|
||||||
Symfony\Bundle\WebProfilerBundle\WebProfilerBundle::class => ['dev' => true, 'test' => true],
|
Symfony\Bundle\WebProfilerBundle\WebProfilerBundle::class => ['dev' => true, 'test' => true],
|
||||||
Symfony\UX\StimulusBundle\StimulusBundle::class => ['all' => true],
|
Symfony\UX\StimulusBundle\StimulusBundle::class => ['all' => true],
|
||||||
Symfony\UX\Turbo\TurboBundle::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\SecurityBundle\SecurityBundle::class => ['all' => true],
|
||||||
Symfony\Bundle\MonologBundle\MonologBundle::class => ['all' => true],
|
Symfony\Bundle\MonologBundle\MonologBundle::class => ['all' => true],
|
||||||
Symfony\Bundle\MakerBundle\MakerBundle::class => ['dev' => true],
|
Symfony\Bundle\MakerBundle\MakerBundle::class => ['dev' => true],
|
||||||
|
|
@ -18,4 +17,7 @@ return [
|
||||||
League\Bundle\OAuth2ServerBundle\LeagueOAuth2ServerBundle::class => ['all' => true],
|
League\Bundle\OAuth2ServerBundle\LeagueOAuth2ServerBundle::class => ['all' => true],
|
||||||
Nelmio\CorsBundle\NelmioCorsBundle::class => ['all' => true],
|
Nelmio\CorsBundle\NelmioCorsBundle::class => ['all' => true],
|
||||||
Symfony\Bundle\MercureBundle\MercureBundle::class => ['all' => true],
|
Symfony\Bundle\MercureBundle\MercureBundle::class => ['all' => true],
|
||||||
|
Knp\Bundle\TimeBundle\KnpTimeBundle::class => ['all' => true],
|
||||||
|
Aws\Symfony\AwsBundle::class => ['all' => true],
|
||||||
|
DAMA\DoctrineTestBundle\DAMADoctrineTestBundle::class => ['test' => true],
|
||||||
];
|
];
|
||||||
|
|
|
||||||
|
|
@ -1,30 +0,0 @@
|
||||||
-----BEGIN ENCRYPTED PRIVATE KEY-----
|
|
||||||
MIIFHDBOBgkqhkiG9w0BBQ0wQTApBgkqhkiG9w0BBQwwHAQIQ5VGud/OnzsCAggA
|
|
||||||
MAwGCCqGSIb3DQIJBQAwFAYIKoZIhvcNAwcECH23Z4CwP/AxBIIEyCUA/sYfp1Vo
|
|
||||||
x+pBPHs8EH5zW+oeCCqihANPGcYXHp3SusSQEA6jyEQz1GHEQF7nt5SxxqYTfe8D
|
|
||||||
ovU2TsspUlycUcZhQQmtM6n2f375G9L2fvIhnOMvD34YvYQ0yhgkToS3h+pORpqb
|
|
||||||
9abJbqkyTHSHOD4utSEaUnFapAQnKPWgFzYSjt4w6pVfX2AUZCXisC3QlcUh4782
|
|
||||||
S62jIfqggMcDU2mfkBBUHE7z1syC5eqYwWEs2P1S1fvSYjpFv7L8LlkZY+6w/qgf
|
|
||||||
IH1ht0A+rs6cQKILXW9yySqRfuumaGJtqQvlql4M7tb7zvDynVgN3/GQxm6oQmFB
|
|
||||||
pFlNWINN7WmKqhRjnJBpKl5L1+s2EWpxBiHXXQLdznqzKyx3tPuLoeVh2S96NEy6
|
|
||||||
f9380eDhNBYKdyUeZP+IBdwtV6U6vbwhtBQ53T9BQ6ykbKpRUm1fI4Y6AcVYW903
|
|
||||||
KCkFR3j6pF4My41AMgcBSJ+GON/PEzIgIIDlYnM+7DVhlentY71Bhf9la7YSaFtx
|
|
||||||
0r8d/CKdgGwPB2+QAEANOo0Z73RmovNRyadvZTcJqEgRC9R+DRbQHNYXN44TEhIU
|
|
||||||
MZaFLTTZHEmWs0f4kf+UUo7RjUi5HdNCjuXErvKgVhtqMofeKc1p6fnNbBCrFYzs
|
|
||||||
Knbwn+u2aLcI7LNLXaSWMOOGrbBw3g5FWObCxZ/FlNABp4yE+YT5YYeCLcemS/8n
|
|
||||||
ZD0l3b2wkLK7dze0DB3TKv+wZoBHtl1s3RYm9UDjB8ejHSzzRMpLK514+Q8Qthx9
|
|
||||||
IexC6hTzIG3wyjst98spb/6VQqDyA4TvMzW+pAA+9JgXyp7auHiU217nx5rSpdTe
|
|
||||||
hWCyRgaBOP48W/ZoBjVxx81/voKixfvBZbYJSciw7K4iNRMjPXNg2BvCZZLZ9Ubd
|
|
||||||
zLVCE15bzdGDZJis/VLawEFHZ1Urku9HfT4FhLxAweEc5VdnbdjEXIXqgf99VaK2
|
|
||||||
V6xXWF9EQ1pVaavR+jzm0ZHs5Ltwnc095+Y9flmvzm+eD00Ftm1IyrfInMl6f8Sv
|
|
||||||
gkxe1lxo6wbTE12CodhkH0woNd+y4K82ZSRzaTg6cUb+YTDKNAfoAnctghPe5+PX
|
|
||||||
3j1ITAQeBFjVXckDrm8jeqF/gbcQYJwFjY0/wtl84gN/Ffs8DSTt1FYwsxgGew43
|
|
||||||
L0KsVt/1Rd4Xb4czBRk7uUpR0U2R7iZ0yIrnGSh657tywpcn2thsJY4X6QjNRWNw
|
|
||||||
yDg5Yrw10tIsLYJLJuxAauGjXbk23lelwnwn8EtMYPI2XtzGSS3b7uGXjTu2pncQ
|
|
||||||
YVFbI/DxrRi3tyF7JLSC1mfNxikMfY6cPp489djDWC0iQVcltrHiHDkmMrloptE7
|
|
||||||
tTUvPfBRNAbonMaXR+pSSQBlDkUMk6uNpk9EYIlLGg2WggOYnikEKsLoX19Mjg5M
|
|
||||||
ZuX7fu2ETHJjvHBetMnQobtqKEGf5RJn+mdKBzia9ZAcjxhKRqYUCK8Qoi3mjvJa
|
|
||||||
x+5idCj2etNJA5idmH50NSXSFbO69Au+LCj1zSikN6h+YgOjqwF0TPQ7Qw4qQQkH
|
|
||||||
4NwYi6S4wCI13LyLVN1XTh9nn99Y/WnORyyegmAfQif1vbcPcfU6mY3VGK5tX2Yl
|
|
||||||
NhLAyGw7m2xDDmIefpRi+w==
|
|
||||||
-----END ENCRYPTED PRIVATE KEY-----
|
|
||||||
|
|
@ -1,9 +0,0 @@
|
||||||
-----BEGIN PUBLIC KEY-----
|
|
||||||
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAwLRhFcrWQWeuGhgrFclT
|
|
||||||
c08RTX52kl9XMXatgJi/mfa/o7lbZbhQfNzrMXVZFDTs97YTZXotZVwL1pI7yGtv
|
|
||||||
bjbPNuhs+lvOSmWribIacYx47EqKeYhQ29rOYx7fnr+SvK10QZoFnT58tduQVrER
|
|
||||||
79b+lcbqKMKNeI6zZeVdBrhstuI/PtXnM4kMHThaTz3iLbWkoDyl06VIMFVsvHpc
|
|
||||||
HaPJsBH1I45M9l4gTfshknY3Dz5ESVd41XTH2c/PSa6geNUn5kpslCuxKv3DnmWE
|
|
||||||
h+V87ASCIM7tKE6bc0eFQLKPQo52/TUWfDa8nFbeIrbsQJwq5VhYK21TANNFFL3g
|
|
||||||
GwIDAQAB
|
|
||||||
-----END PUBLIC KEY-----
|
|
||||||
|
|
@ -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'
|
||||||
|
|
@ -0,0 +1,5 @@
|
||||||
|
when@test:
|
||||||
|
dama_doctrine_test:
|
||||||
|
enable_static_connection: true
|
||||||
|
enable_static_meta_data_cache: true
|
||||||
|
enable_static_query_cache: true
|
||||||
|
|
@ -1,17 +1,21 @@
|
||||||
# see https://symfony.com/doc/current/reference/configuration/framework.html
|
# see https://symfony.com/doc/current/reference/configuration/framework.html
|
||||||
framework:
|
framework:
|
||||||
secret: '%env(APP_SECRET)%'
|
secret: '%env(APP_SECRET)%'
|
||||||
trusted_proxies: '%env(TRUSTED_PROXY)%'
|
csrf_protection: true
|
||||||
|
http_method_override: true
|
||||||
|
annotations: false
|
||||||
|
handle_all_throwables: true
|
||||||
|
trusted_proxies: '%env(TRUSTED_PROXY)%'
|
||||||
|
|
||||||
# Note that the session will be started ONLY if you read or write from it.
|
|
||||||
session: true
|
|
||||||
|
|
||||||
#esi: true
|
# Note that the session will be started ONLY if you read or write from it.
|
||||||
#fragments: true
|
session: true
|
||||||
|
|
||||||
|
#esi: true
|
||||||
|
#fragments: true
|
||||||
|
|
||||||
when@test:
|
when@test:
|
||||||
framework:
|
framework:
|
||||||
test: true
|
test: true
|
||||||
session:
|
session:
|
||||||
storage_factory_id: session.storage.factory.mock_file
|
storage_factory_id: session.storage.factory.mock_file
|
||||||
|
|
|
||||||
|
|
@ -3,9 +3,9 @@ league_oauth2_server:
|
||||||
private_key: '%env(resolve:OAUTH_PRIVATE_KEY)%'
|
private_key: '%env(resolve:OAUTH_PRIVATE_KEY)%'
|
||||||
private_key_passphrase: '%env(resolve:OAUTH_PASSPHRASE)%'
|
private_key_passphrase: '%env(resolve:OAUTH_PASSPHRASE)%'
|
||||||
encryption_key: '%env(resolve:OAUTH_ENCRYPTION_KEY)%'
|
encryption_key: '%env(resolve:OAUTH_ENCRYPTION_KEY)%'
|
||||||
access_token_ttl: PT3H # 3 hours
|
access_token_ttl: PT15M # 15 minutes
|
||||||
refresh_token_ttl: P1M # 1 month
|
refresh_token_ttl: P7D # 7 days
|
||||||
auth_code_ttl: PT3H # 10 minutes
|
auth_code_ttl: PT30M # 30 minutes
|
||||||
require_code_challenge_for_public_clients: false
|
require_code_challenge_for_public_clients: false
|
||||||
resource_server:
|
resource_server:
|
||||||
public_key: '%env(resolve:OAUTH_PUBLIC_KEY)%'
|
public_key: '%env(resolve:OAUTH_PUBLIC_KEY)%'
|
||||||
|
|
|
||||||
|
|
@ -6,3 +6,4 @@ mercure:
|
||||||
jwt:
|
jwt:
|
||||||
secret: '%env(MERCURE_JWT_SECRET)%'
|
secret: '%env(MERCURE_JWT_SECRET)%'
|
||||||
publish: '*'
|
publish: '*'
|
||||||
|
subscribe: '*'
|
||||||
|
|
|
||||||
|
|
@ -1,27 +1,99 @@
|
||||||
monolog:
|
monolog:
|
||||||
channels:
|
channels:
|
||||||
- deprecation # Deprecations are logged in the dedicated "deprecation" channel when it exists
|
- user_management
|
||||||
|
- authentication
|
||||||
|
- organization_management
|
||||||
|
- access_control
|
||||||
|
- email_notifications
|
||||||
|
- admin_actions
|
||||||
|
- security
|
||||||
|
- php
|
||||||
|
- error
|
||||||
|
- aws_management
|
||||||
|
- deprecation # Deprecations are logged in the dedicated "deprecation" channel when it exists
|
||||||
|
|
||||||
when@dev:
|
when@dev:
|
||||||
monolog:
|
monolog:
|
||||||
handlers:
|
handlers:
|
||||||
main:
|
critical_errors:
|
||||||
type: stream
|
type: fingers_crossed
|
||||||
path: "%kernel.logs_dir%/%kernel.environment%.log"
|
action_level: critical
|
||||||
level: debug
|
handler: error_nested
|
||||||
channels: ["!event"]
|
buffer_size: 50
|
||||||
# uncomment to get logging in your browser
|
|
||||||
# you may have to allow bigger header sizes in your Web server configuration
|
error_nested:
|
||||||
#firephp:
|
type: rotating_file
|
||||||
# type: firephp
|
path: "%kernel.logs_dir%/error.log"
|
||||||
# level: info
|
level: debug
|
||||||
#chromephp:
|
max_files: 30
|
||||||
# type: chromephp
|
|
||||||
# level: info
|
error:
|
||||||
console:
|
type: rotating_file
|
||||||
type: console
|
path: "%kernel.logs_dir%/error.log"
|
||||||
process_psr_3_messages: false
|
level: error # logs error, critical, alert, emergency
|
||||||
channels: ["!event", "!doctrine", "!console"]
|
max_files: 30
|
||||||
|
channels: [ error ]
|
||||||
|
php_errors:
|
||||||
|
type: rotating_file
|
||||||
|
path: "%kernel.logs_dir%/php_error.log"
|
||||||
|
level: warning # warnings, errors, fatals…
|
||||||
|
max_files: 30
|
||||||
|
channels: [ php ]
|
||||||
|
# User Management
|
||||||
|
user_management:
|
||||||
|
type: rotating_file
|
||||||
|
path: "%kernel.logs_dir%/user_management.log"
|
||||||
|
level: debug
|
||||||
|
channels: [ user_management ]
|
||||||
|
max_files: 30
|
||||||
|
|
||||||
|
# Authentication
|
||||||
|
authentication:
|
||||||
|
type: rotating_file
|
||||||
|
path: "%kernel.logs_dir%/authentication.log"
|
||||||
|
level: debug
|
||||||
|
channels: [ authentication ]
|
||||||
|
max_files: 30
|
||||||
|
|
||||||
|
# Organization Management
|
||||||
|
organization_management:
|
||||||
|
type: rotating_file
|
||||||
|
path: "%kernel.logs_dir%/organization_management.log"
|
||||||
|
level: debug
|
||||||
|
channels: [ organization_management ]
|
||||||
|
max_files: 30
|
||||||
|
|
||||||
|
# Access Control
|
||||||
|
access_control:
|
||||||
|
type: rotating_file
|
||||||
|
path: "%kernel.logs_dir%/access_control.log"
|
||||||
|
level: debug
|
||||||
|
channels: [ access_control ]
|
||||||
|
max_files: 30
|
||||||
|
|
||||||
|
# Email Notifications
|
||||||
|
email_notifications:
|
||||||
|
type: rotating_file
|
||||||
|
path: "%kernel.logs_dir%/email_notifications.log"
|
||||||
|
level: debug
|
||||||
|
channels: [ email_notifications ]
|
||||||
|
max_files: 30
|
||||||
|
|
||||||
|
# Admin Actions
|
||||||
|
admin_actions:
|
||||||
|
type: rotating_file
|
||||||
|
path: "%kernel.logs_dir%/admin_actions.log"
|
||||||
|
level: debug
|
||||||
|
channels: [ admin_actions ]
|
||||||
|
max_files: 30
|
||||||
|
|
||||||
|
# Security
|
||||||
|
security:
|
||||||
|
type: rotating_file
|
||||||
|
path: "%kernel.logs_dir%/security.log"
|
||||||
|
level: debug
|
||||||
|
channels: [ security ]
|
||||||
|
max_files: 30
|
||||||
|
|
||||||
when@test:
|
when@test:
|
||||||
monolog:
|
monolog:
|
||||||
|
|
@ -40,23 +112,89 @@ when@test:
|
||||||
when@prod:
|
when@prod:
|
||||||
monolog:
|
monolog:
|
||||||
handlers:
|
handlers:
|
||||||
main:
|
critical_error:
|
||||||
type: fingers_crossed
|
type: fingers_crossed
|
||||||
action_level: error
|
action_level: critical
|
||||||
handler: nested
|
handler: error_nested
|
||||||
excluded_http_codes: [404, 405]
|
buffer_size: 50
|
||||||
buffer_size: 50 # How many messages should be saved? Prevent memory leaks
|
|
||||||
nested:
|
error_nested:
|
||||||
type: stream
|
type: rotating_file
|
||||||
path: php://stderr
|
path: "%kernel.logs_dir%/critical.log"
|
||||||
level: debug
|
level: debug
|
||||||
formatter: monolog.formatter.json
|
max_files: 30
|
||||||
console:
|
|
||||||
type: console
|
error:
|
||||||
process_psr_3_messages: false
|
type: rotating_file
|
||||||
channels: ["!event", "!doctrine"]
|
path: "%kernel.logs_dir%/error.log"
|
||||||
deprecation:
|
level: error # logs error, critical, alert, emergency
|
||||||
type: stream
|
max_files: 30
|
||||||
channels: [deprecation]
|
channels: [ error ]
|
||||||
path: php://stderr
|
|
||||||
formatter: monolog.formatter.json
|
php_errors:
|
||||||
|
type: rotating_file
|
||||||
|
path: "%kernel.logs_dir%/php_error.log"
|
||||||
|
level: warning # warnings, errors, fatals…
|
||||||
|
max_files: 30
|
||||||
|
channels: [ php ]
|
||||||
|
# User Management
|
||||||
|
user_management:
|
||||||
|
type: rotating_file
|
||||||
|
path: "%kernel.logs_dir%/user_management.log"
|
||||||
|
level: info
|
||||||
|
channels: [user_management]
|
||||||
|
max_files: 30
|
||||||
|
#AWS
|
||||||
|
aws_management:
|
||||||
|
type: rotating_file
|
||||||
|
path: "%kernel.logs_dir%/aws_management.log"
|
||||||
|
level: info
|
||||||
|
channels: [aws_management]
|
||||||
|
max_files: 30
|
||||||
|
# Authentication
|
||||||
|
authentication:
|
||||||
|
type: rotating_file
|
||||||
|
path: "%kernel.logs_dir%/authentication.log"
|
||||||
|
level: info
|
||||||
|
channels: [authentication]
|
||||||
|
max_files: 30
|
||||||
|
|
||||||
|
# Organization Management
|
||||||
|
organization_management:
|
||||||
|
type: rotating_file
|
||||||
|
path: "%kernel.logs_dir%/organization_management.log"
|
||||||
|
level: info
|
||||||
|
channels: [organization_management]
|
||||||
|
max_files: 30
|
||||||
|
|
||||||
|
# Access Control
|
||||||
|
access_control:
|
||||||
|
type: rotating_file
|
||||||
|
path: "%kernel.logs_dir%/access_control.log"
|
||||||
|
level: info
|
||||||
|
channels: [access_control]
|
||||||
|
max_files: 30
|
||||||
|
|
||||||
|
# Email Notifications
|
||||||
|
email_notifications:
|
||||||
|
type: rotating_file
|
||||||
|
path: "%kernel.logs_dir%/email_notifications.log"
|
||||||
|
level: info
|
||||||
|
channels: [email_notifications]
|
||||||
|
max_files: 30
|
||||||
|
|
||||||
|
# Admin Actions
|
||||||
|
admin_actions:
|
||||||
|
type: rotating_file
|
||||||
|
path: "%kernel.logs_dir%/admin_actions.log"
|
||||||
|
level: info
|
||||||
|
channels: [admin_actions]
|
||||||
|
max_files: 30
|
||||||
|
|
||||||
|
# Security
|
||||||
|
security:
|
||||||
|
type: rotating_file
|
||||||
|
path: "%kernel.logs_dir%/security.log"
|
||||||
|
level: warning
|
||||||
|
channels: [security]
|
||||||
|
max_files: 30
|
||||||
|
|
|
||||||
|
|
@ -11,13 +11,18 @@ security:
|
||||||
property: email
|
property: email
|
||||||
|
|
||||||
role_hierarchy:
|
role_hierarchy:
|
||||||
ROLE_ADMIN: ROLE_USER
|
ROLE_ADMIN: ROLE_USER
|
||||||
ROLE_SUPER_ADMIN: [ROLE_ADMIN, ROLE_ALLOWED_TO_SWITCH]
|
ROLE_SUPER_ADMIN: [ROLE_ALLOWED_TO_SWITCH, ROLE_ADMIN]
|
||||||
|
|
||||||
|
|
||||||
firewalls:
|
firewalls:
|
||||||
dev:
|
dev:
|
||||||
pattern: ^/(_(profiler|wdt)|css|images|js)/
|
pattern: ^/(_(profiler|wdt)|css|images|js)/
|
||||||
security: false
|
security: false
|
||||||
|
api_token_validation:
|
||||||
|
pattern: ^/api/validate-token
|
||||||
|
stateless: true
|
||||||
|
oauth2: true
|
||||||
oauth_userinfo:
|
oauth_userinfo:
|
||||||
pattern: ^/oauth2/userinfo
|
pattern: ^/oauth2/userinfo
|
||||||
stateless: true
|
stateless: true
|
||||||
|
|
@ -29,23 +34,35 @@ security:
|
||||||
auth_token:
|
auth_token:
|
||||||
pattern: ^/token
|
pattern: ^/token
|
||||||
stateless: true
|
stateless: true
|
||||||
|
api_m2m:
|
||||||
|
pattern: ^/api/v1/
|
||||||
|
stateless: true
|
||||||
|
oauth2: true
|
||||||
api:
|
api:
|
||||||
pattern: ^/oauth/api
|
pattern: ^/oauth/api
|
||||||
security: true
|
security: true
|
||||||
stateless: true
|
stateless: true
|
||||||
oauth2: true
|
oauth2: true
|
||||||
|
password_setup:
|
||||||
|
pattern: ^/password_setup
|
||||||
|
stateless: true
|
||||||
main:
|
main:
|
||||||
|
user_checker: App\Security\UserChecker
|
||||||
lazy: true
|
lazy: true
|
||||||
provider: app_user_provider
|
provider: app_user_provider
|
||||||
|
login_throttling:
|
||||||
|
max_attempts: 3
|
||||||
|
interval: '1 minute'
|
||||||
form_login:
|
form_login:
|
||||||
login_path: app_login
|
login_path: app_login
|
||||||
check_path: app_login
|
check_path: app_login
|
||||||
enable_csrf: true
|
enable_csrf: true
|
||||||
default_target_path: app_index
|
default_target_path: app_index
|
||||||
use_referer: true
|
always_use_default_target_path: false
|
||||||
# logout:
|
logout:
|
||||||
# path: app_logout
|
path: app_logout
|
||||||
# target: app_login
|
enable_csrf: false
|
||||||
|
target: app_login
|
||||||
|
|
||||||
# activate different ways to authenticate
|
# activate different ways to authenticate
|
||||||
# https://symfony.com/doc/current/security.html#the-firewall
|
# https://symfony.com/doc/current/security.html#the-firewall
|
||||||
|
|
@ -57,6 +74,9 @@ security:
|
||||||
# Note: Only the *first* access control that matches will be used
|
# Note: Only the *first* access control that matches will be used
|
||||||
access_control:
|
access_control:
|
||||||
- { path: ^/login, roles: PUBLIC_ACCESS }
|
- { path: ^/login, roles: PUBLIC_ACCESS }
|
||||||
|
- { path: ^/api/validate-token, roles: PUBLIC_ACCESS }
|
||||||
|
- { path: ^/password_setup, roles: PUBLIC_ACCESS }
|
||||||
|
- { path: ^/password_reset, roles: PUBLIC_ACCESS }
|
||||||
- { path: ^/sso_logout, roles: IS_AUTHENTICATED_FULLY }
|
- { path: ^/sso_logout, roles: IS_AUTHENTICATED_FULLY }
|
||||||
- { path: ^/token, roles: PUBLIC_ACCESS }
|
- { path: ^/token, roles: PUBLIC_ACCESS }
|
||||||
- { path: ^/oauth2/revoke_tokens, roles: PUBLIC_ACCESS }
|
- { path: ^/oauth2/revoke_tokens, roles: PUBLIC_ACCESS }
|
||||||
|
|
@ -66,8 +86,6 @@ security:
|
||||||
- { path: ^/oauth2/userinfo, roles: IS_AUTHENTICATED_FULLY }
|
- { path: ^/oauth2/userinfo, roles: IS_AUTHENTICATED_FULLY }
|
||||||
- { path: ^/, roles: ROLE_USER }
|
- { path: ^/, roles: ROLE_USER }
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
when@test:
|
when@test:
|
||||||
security:
|
security:
|
||||||
password_hashers:
|
password_hashers:
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
framework:
|
framework:
|
||||||
default_locale: en
|
default_locale: fr
|
||||||
translator:
|
translator:
|
||||||
default_path: '%kernel.project_dir%/translations'
|
default_path: '%kernel.project_dir%/translations'
|
||||||
fallbacks:
|
fallbacks:
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,18 @@ twig:
|
||||||
file_name_pattern: '*.twig'
|
file_name_pattern: '*.twig'
|
||||||
form_themes: ['bootstrap_5_layout.html.twig']
|
form_themes: ['bootstrap_5_layout.html.twig']
|
||||||
|
|
||||||
|
globals:
|
||||||
|
application: '%env(APPLICATION)%'
|
||||||
|
domain: '%env(APP_DOMAIN)%'
|
||||||
|
aws_url: '%env(AWS_S3_PORTAL_URL)%'
|
||||||
|
app_url: '%env(APP_URL)%'
|
||||||
|
version: '0.5'
|
||||||
|
|
||||||
|
paths:
|
||||||
|
'%kernel.project_dir%/assets/img': images
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
when@test:
|
when@test:
|
||||||
twig:
|
twig:
|
||||||
strict_variables: true
|
strict_variables: true
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,17 @@
|
||||||
# Put parameters here that don't need to change on each machine where the app is deployed
|
# 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
|
# https://symfony.com/doc/current/best_practices.html#use-parameters-for-application-configuration
|
||||||
parameters:
|
parameters:
|
||||||
|
aws_url: '%env(AWS_ENDPOINT)%'
|
||||||
|
aws_public_url: '%env(AWS_ENDPOINT)%'
|
||||||
|
aws_bucket: '%env(S3_PORTAL_BUCKET)%'
|
||||||
|
app_url: '%env(APP_URL)%'
|
||||||
|
app_domain: '%env(APP_DOMAIN)%'
|
||||||
|
mercure_secret: '%env(MERCURE_JWT_SECRET)%'
|
||||||
|
logos_directory: '%kernel.project_dir%/public/uploads/logos'
|
||||||
|
oauth_sso_identifier: '%env(OAUTH_SSO_IDENTIFIER)%'
|
||||||
|
oauth_sso_identifier_login: '%env(OAUTH_SSO_IDENTIFIER_LOGIN)%'
|
||||||
|
easycheck_url: '%env(EASYCHECK_URL)%'
|
||||||
|
webhook_secret: '%env(WEBHOOK_SECRET)%'
|
||||||
|
|
||||||
services:
|
services:
|
||||||
# default configuration for services in *this* file
|
# default configuration for services in *this* file
|
||||||
|
|
@ -19,15 +30,42 @@ services:
|
||||||
- '../src/DependencyInjection/'
|
- '../src/DependencyInjection/'
|
||||||
- '../src/Entity/'
|
- '../src/Entity/'
|
||||||
- '../src/Kernel.php'
|
- '../src/Kernel.php'
|
||||||
|
App\MessageHandler\NotificationMessageHandler:
|
||||||
|
arguments:
|
||||||
|
$appUrl: '%app_url%'
|
||||||
|
App\Service\SSO\ProjectService:
|
||||||
|
arguments:
|
||||||
|
$appUrl: '%app_url%'
|
||||||
|
$clientIdentifier: '%oauth_sso_identifier%'
|
||||||
App\EventSubscriber\:
|
App\EventSubscriber\:
|
||||||
resource: '../src/EventSubscriber/'
|
resource: '../src/EventSubscriber/'
|
||||||
tags: ['kernel.event_subscriber']
|
tags: ['kernel.event_subscriber']
|
||||||
|
App\EventSubscriber\LoginSubscriber:
|
||||||
|
arguments:
|
||||||
|
$clientIdentifier: '%oauth_sso_identifier_login%'
|
||||||
|
$easycheckUrl: '%env(EASYCHECK_URL)%'
|
||||||
|
App\Service\AwsService:
|
||||||
|
arguments:
|
||||||
|
$awsPublicUrl: '%aws_public_url%'
|
||||||
|
App\Service\OrganizationsService:
|
||||||
|
arguments:
|
||||||
|
$logoDirectory: '%logos_directory%'
|
||||||
App\EventSubscriber\ScopeResolveListener:
|
App\EventSubscriber\ScopeResolveListener:
|
||||||
tags:
|
tags:
|
||||||
- { name: kernel.event_listener, event: league.oauth2_server.event.scope_resolve, method: onScopeResolve }
|
- { name: kernel.event_listener, event: league.oauth2_server.event.scope_resolve, method: onScopeResolve }
|
||||||
League\OAuth2\Server\Repositories\AccessTokenRepositoryInterface:
|
League\OAuth2\Server\Repositories\AccessTokenRepositoryInterface:
|
||||||
class: App\Repository\AccessTokenRepository
|
class: App\Repository\AccessTokenRepository
|
||||||
decorates: 'League\Bundle\OAuth2ServerBundle\Repository\AccessTokenRepository'
|
decorates: 'League\Bundle\OAuth2ServerBundle\Repository\AccessTokenRepository'
|
||||||
|
App\Command\CreateSuperAdminCommand:
|
||||||
# add more service definitions when explicit configuration is needed
|
arguments:
|
||||||
# please note that last definitions always *replace* previous ones
|
$environment: '%kernel.environment%'
|
||||||
|
|
||||||
|
App\EventListener\LogoutSubscriber:
|
||||||
|
arguments:
|
||||||
|
$easycheckUrl: '%env(EASYCHECK_URL)%'
|
||||||
|
tags:
|
||||||
|
- { name: kernel.event_subscriber }
|
||||||
|
App\Webhook\OrganizationNotifier:
|
||||||
|
arguments:
|
||||||
|
$easycheckUrl: '%easycheck_url%'
|
||||||
|
$webhookSecret: '%webhook_secret%'
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,225 @@
|
||||||
|
# Intro
|
||||||
|
|
||||||
|
The Api made some changes to the current structure of the project. These changes include the following:
|
||||||
|
- **Security** : the security.yaml file has been updated both on the server and the client side
|
||||||
|
- **Controllers**: Controllers need to be updated on the client side
|
||||||
|
- **Services**: Services and token management need updates on the client side
|
||||||
|
- **Health**: I now want to commit a Talon E over a wall IRL
|
||||||
|
- **Entities**: Entities need to be updated on the client side to include a new field (sso_id).
|
||||||
|
- This field will be used a lot, so add it to an entity if you are going to work with the API and the SSO. PLEASE.
|
||||||
|
- **Roles**: A new role was added because we are doing M2M. Only God know how that work now, but it works, so I might start praying from now on.
|
||||||
|
|
||||||
|
|
||||||
|
# Security
|
||||||
|
new firewall was added. Keep the same structure for future firewalls.
|
||||||
|
### Client side
|
||||||
|
```yaml
|
||||||
|
api_project:
|
||||||
|
pattern: ^/api/v1/project #ofc, this is an example, please THINK and change the name
|
||||||
|
stateless: true
|
||||||
|
access_token:
|
||||||
|
token_handler: App\Security\SsoTokenHandler
|
||||||
|
```
|
||||||
|
Same thing, new firewall was added.
|
||||||
|
### Server side
|
||||||
|
```yaml
|
||||||
|
api_token_validation:
|
||||||
|
pattern: ^/api/validate-token #this is NOT an example. DON'T change or it will all go to sh.t
|
||||||
|
stateless: true
|
||||||
|
oauth2: true
|
||||||
|
```
|
||||||
|
```yaml
|
||||||
|
# A rajouter dans l'access_control aussi !!! IMPORTANT !!!
|
||||||
|
- { path: ^/api/validate-token, roles: PUBLIC_ACCESS }
|
||||||
|
```
|
||||||
|
|
||||||
|
# Controllers
|
||||||
|
On the client side, create a new controller for the API. This controller need will work in a REST manner.
|
||||||
|
The route should be as follows:
|
||||||
|
```php
|
||||||
|
#[Route('/api/v1/project', name: 'api_project')] //ofc, this is an example, please THINK and change the name
|
||||||
|
```
|
||||||
|
Keep the same structure for the project tree, create an Api folder in the controller, a V1 folder and then create your controller.
|
||||||
|
Here is a full example of a controller with the create method.
|
||||||
|
On crée une organization si elle n'existe pas
|
||||||
|
```php
|
||||||
|
<?php
|
||||||
|
#[Route('/api/v2/project', name: 'api_project_')]
|
||||||
|
#[IsGranted('ROLE_API_INTERNAL')]
|
||||||
|
class ProjectApi extends AbstractController{
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
private readonly EntityManagerInterface $entityManager)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Route('/create', name: 'create', methods: ['POST'])]
|
||||||
|
public function createProject(Request $request): JSONResponse
|
||||||
|
{
|
||||||
|
$data = json_decode($request->getContent(), true, 512, JSON_THROW_ON_ERROR);
|
||||||
|
$projet = new Projet();
|
||||||
|
|
||||||
|
$entity = $this->entityManager->getRepository(Entity::class)->findOneBy([ 'ssoId' => $data['orgId']]);
|
||||||
|
// Si l'entité n'existe pas, on la crée
|
||||||
|
if(!$entity){
|
||||||
|
$this->createEntity($data['orgId'], $data['orgName']);
|
||||||
|
$entity = $this->entityManager->getRepository(Entity::class)->findOneBy([ 'ssoId' => $data['orgId']]);
|
||||||
|
}
|
||||||
|
$precision= $data['timestamp'];
|
||||||
|
$validPrecisions = array_map(fn($case) => $case->value, TimestampPrecision::cases());
|
||||||
|
if (!in_array($precision, $validPrecisions, true)) {
|
||||||
|
return $this->json(['success' => false, 'message' => 'Précision d\'horodatage invalide.'], 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$timestampPrecision = TimestampPrecision::from($precision);
|
||||||
|
$projet->setTimestampPrecision($timestampPrecision);
|
||||||
|
$projet->setProjet($data['name']);
|
||||||
|
$projet->setEntityId($entity);
|
||||||
|
$projet->setBdd($data['bdd']);
|
||||||
|
$projet->setIsactive($data['isActive']);
|
||||||
|
$projet->setLogo($data['logo']);
|
||||||
|
$projet->setDeletionAllowed($data['deletion']);
|
||||||
|
$projet->setSsoId($data['id']); // c'est l'id du projet dans le portail, pas la bdd local
|
||||||
|
|
||||||
|
$this->entityManager->persist($projet);
|
||||||
|
$this->entityManager->flush();
|
||||||
|
return new JsonResponse(['message' => 'Project created successfully', 'project_id' => $projet->getId()], 201);
|
||||||
|
}catch ( \Exception $e){
|
||||||
|
return new JsonResponse(['error' => 'Failed to create project: ' . $e->getMessage()], 500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
# Services
|
||||||
|
So, now we are getting into the thick of it. We are just COOK 🍗 ( a lot of bugs can come from here ).
|
||||||
|
We implement a new pretty service called SsoTokenHandler. This is used to get the token received from the portal request, and validate it.
|
||||||
|
It is validaded by doing a call back to the SSO and asking if the token is valid. ( we create a new token for the SSO so it handles M2M)
|
||||||
|
```php
|
||||||
|
<?php
|
||||||
|
|
||||||
|
// src/Security/SsoTokenHandler.php
|
||||||
|
namespace App\Security;
|
||||||
|
|
||||||
|
use Symfony\Component\Security\Http\AccessToken\AccessTokenHandlerInterface;
|
||||||
|
use Symfony\Component\Security\Http\Authenticator\Passport\Badge\UserBadge;
|
||||||
|
use Symfony\Component\Security\Core\Exception\BadCredentialsException;
|
||||||
|
use Symfony\Component\Security\Core\User\InMemoryUser;
|
||||||
|
use Symfony\Contracts\HttpClient\HttpClientInterface;
|
||||||
|
|
||||||
|
class SsoTokenHandler implements AccessTokenHandlerInterface
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private HttpClientInterface $httpClient,
|
||||||
|
private string $ssoUrl
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function getUserBadgeFrom(string $accessToken): UserBadge
|
||||||
|
{
|
||||||
|
// 1. Call the SSO to validate the token
|
||||||
|
// Note: You need an endpoint on your SSO that returns 200 for M2M tokens
|
||||||
|
try {
|
||||||
|
$response = $this->httpClient->request('GET', $this->ssoUrl . '/api/validate-token', [
|
||||||
|
'headers' => ['Authorization' => 'Bearer ' . $accessToken]
|
||||||
|
]);
|
||||||
|
|
||||||
|
// If the SSO redirects, HttpClient might follow it to the login page.
|
||||||
|
// Let's see the first response code.
|
||||||
|
if ($response->getStatusCode() !== 200) {
|
||||||
|
// Log the content to see the HTML "Login" page that is causing the JSON error
|
||||||
|
// dump($response->getContent(false));
|
||||||
|
throw new BadCredentialsException();
|
||||||
|
}
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
// This will show you if you are hitting a 302 Redirect
|
||||||
|
throw new BadCredentialsException('SSO returned invalid response: ' . $e->getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
|
if (200 !== $response->getStatusCode()) {
|
||||||
|
throw new BadCredentialsException('Invalid SSO Token');
|
||||||
|
}
|
||||||
|
|
||||||
|
$data = $response->toArray();
|
||||||
|
|
||||||
|
// 2. Identify if it's a User or a Machine
|
||||||
|
$identifier = $data['email'] ?? 'SYSTEM_SSO_SERVER';
|
||||||
|
|
||||||
|
// 3. Return the badge with a "loader" closure
|
||||||
|
return new UserBadge($identifier, function($userIdentifier) use ($data) {
|
||||||
|
// If it's the SSO server calling, give it a specific role
|
||||||
|
if ($userIdentifier === 'SYSTEM_SSO_SERVER') {
|
||||||
|
return new InMemoryUser($userIdentifier, null, ['ROLE_API_INTERNAL']);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Otherwise, let the normal user provider handle it (for standard users)
|
||||||
|
// You might need to inject your actual UserProvider here if needed
|
||||||
|
return new InMemoryUser($userIdentifier, null, ['ROLE_USER']);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
# Important note 1
|
||||||
|
we need to add the portal url to the .env and declare it as a parameter in services.yaml
|
||||||
|
```dotenv
|
||||||
|
SSO_URL='http://portail.solutions-easy.moi'
|
||||||
|
```
|
||||||
|
```yaml
|
||||||
|
parameters:
|
||||||
|
sso_url: '%env(SSO_URL)%'
|
||||||
|
|
||||||
|
App\Security\SsoTokenHandler:
|
||||||
|
arguments:
|
||||||
|
$ssoUrl: '%sso_url%'
|
||||||
|
```
|
||||||
|
# Server side
|
||||||
|
On the server side, we need to create a new client, which will be himself, same as earlier, create it, and boom we are good.
|
||||||
|
The validate route is already created, so dw abt it.
|
||||||
|
|
||||||
|
```cmd
|
||||||
|
php bin/console league:oauth2-server:create-client sso_internal_service --grant-type "client_credentials"
|
||||||
|
```
|
||||||
|
|
||||||
|
now, copy the identifier, and paste it in the .env file
|
||||||
|
please note that we have 2 client for the application because one is used for m2m and the other is used for the user, so implement both, the one ending with _LOGIN is the one for the user
|
||||||
|
```dotenv
|
||||||
|
OAUTH_SSO_IDENTIFIER='sso-own-identifier'
|
||||||
|
```
|
||||||
|
and we are smart so what do we do? we add it to the services.yaml
|
||||||
|
```yaml
|
||||||
|
parameters:
|
||||||
|
oauth_sso_identifier: '%env(OAUTH_SSO_IDENTIFIER)%'
|
||||||
|
|
||||||
|
App\Service\SSO\ProjectService:
|
||||||
|
arguments:
|
||||||
|
$appUrl: '%app_url%'
|
||||||
|
$clientIdentifier: '%oauth_sso_identifier%'
|
||||||
|
```
|
||||||
|
|
||||||
|
We should be good now ( I hope ). Open the portal, try your call and check if it works, if it doesn't, check the logs and debug, you are a dev for a reason, so use your brain and debug.
|
||||||
|
If it still doesn't work, start praying, because I have no idea what to do anymore, but it works on my side, so it should work on yours, if not, well, I don't know what to say. Good luck.
|
||||||
|
Jokes aside, bugs often come from security problem, if the client returns a 401 error, it can be for multiple reasons and not necessarily because of the token but maybe because of the token validation.
|
||||||
|
Another commun bug is mismatching of the data you send, so double check. GLHF ( you won't have fun, but good luck anyway )
|
||||||
|
|
||||||
|
.
|
||||||
|
|
||||||
|
.
|
||||||
|
|
||||||
|
.
|
||||||
|
|
||||||
|
.
|
||||||
|
|
||||||
|
.
|
||||||
|
|
||||||
|
.
|
||||||
|
|
||||||
|
.
|
||||||
|
|
||||||
|
.
|
||||||
|
|
||||||
|
.
|
||||||
|
|
||||||
|
.
|
||||||
|
|
||||||
|
.
|
||||||
|
If you have a problem and that you copy pasted everything, check client controller, I've put the route as /api/v2 just cause I felt like it 🤷 ENJOY
|
||||||
|
|
@ -0,0 +1,365 @@
|
||||||
|
# Client setup
|
||||||
|
## Add needed dependencies
|
||||||
|
```bash
|
||||||
|
composer require nelmio/cors-bundle
|
||||||
|
composer require knpuniversity/oauth2-client-bundle
|
||||||
|
```
|
||||||
|
|
||||||
|
## Configure the bundle
|
||||||
|
### nelmio/cors-bundle
|
||||||
|
```yaml
|
||||||
|
# config/packages/nelmio_cors.yaml
|
||||||
|
nelmio_cors:
|
||||||
|
defaults:
|
||||||
|
origin_regex: true
|
||||||
|
allow_origin: ['%env(CORS_ALLOW_ORIGIN)%']
|
||||||
|
allow_methods: ['GET', 'OPTIONS', 'POST', 'PUT', 'PATCH', 'DELETE']
|
||||||
|
allow_headers: ['Content-Type', 'Authorization']
|
||||||
|
expose_headers: ['Link']
|
||||||
|
max_age: 3600
|
||||||
|
paths:
|
||||||
|
'^/token$':
|
||||||
|
origin_regex: true
|
||||||
|
allow_origin: ['*']
|
||||||
|
allow_headers: ['Content-Type', 'Authorization']
|
||||||
|
allow_methods: ['POST', 'OPTIONS']
|
||||||
|
allow_credentials: true
|
||||||
|
max_age: 3600
|
||||||
|
'^/authorize$':
|
||||||
|
origin_regex: true
|
||||||
|
allow_origin: ['*']
|
||||||
|
allow_headers: ['Content-Type', 'Authorization']
|
||||||
|
allow_methods: ['GET', 'POST', 'OPTIONS']
|
||||||
|
allow_credentials: true
|
||||||
|
max_age: 3600
|
||||||
|
```
|
||||||
|
### knpuniversity/oauth2-client-bundle
|
||||||
|
```yaml
|
||||||
|
# config/packages/knpu_oauth2_client.yaml
|
||||||
|
knpu_oauth2_client:
|
||||||
|
clients:
|
||||||
|
# configure your clients as described here: https://github.com/knpuniversity/oauth2-client-bundle#configuration
|
||||||
|
sudalys:
|
||||||
|
type: generic
|
||||||
|
provider_class: Sudalys\OAuth2\Client\Provider\Sudalys
|
||||||
|
client_id: '%env(OAUTH2_CLIENT_ID)%'
|
||||||
|
client_secret: '%env(OAUTH2_CLIENT_SECRET)%'
|
||||||
|
redirect_route: uri # The route to redirect to after authentication (must match the one in the server DB uri DB)
|
||||||
|
provider_options: {
|
||||||
|
domain: <link to domain>
|
||||||
|
}
|
||||||
|
use_state: false
|
||||||
|
```
|
||||||
|
|
||||||
|
### .env
|
||||||
|
```dotenv
|
||||||
|
# .env
|
||||||
|
# CORS
|
||||||
|
CORS_ALLOW_ORIGIN=http://*.your domain/*'
|
||||||
|
# OAUTH2
|
||||||
|
OAUTH2_CLIENT_ID=<client_id>
|
||||||
|
OAUTH2_CLIENT_SECRET=<client_secret>
|
||||||
|
```
|
||||||
|
|
||||||
|
Copy and paste the client library then modify the conposer.json autoloard directive to include the new library
|
||||||
|
```json
|
||||||
|
"autoload": {
|
||||||
|
"psr-4": {
|
||||||
|
"App\\": "src/",
|
||||||
|
"Sudalys\\OAuth2\\Client\\": "libs/sudalys/oauth2-client/src"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
```
|
||||||
|
|
||||||
|
```php
|
||||||
|
<?php
|
||||||
|
/** @var string */
|
||||||
|
public $domain = 'link to SSO portal ';
|
||||||
|
```
|
||||||
|
Copy and paste the SSOAuthenticator class modify the target url to match the route in server DB and redirect route
|
||||||
|
### SsoAuthenticator.php
|
||||||
|
```php
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Security;
|
||||||
|
|
||||||
|
use App\Entity\User;
|
||||||
|
|
||||||
|
use KnpU\OAuth2ClientBundle\Client\ClientRegistry;
|
||||||
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
|
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
|
||||||
|
use Symfony\Component\Routing\RouterInterface;
|
||||||
|
use Symfony\Component\HttpFoundation\Request;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
use Symfony\Component\HttpFoundation\RedirectResponse;
|
||||||
|
use Symfony\Component\Security\Http\Util\TargetPathTrait;
|
||||||
|
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
|
||||||
|
use KnpU\OAuth2ClientBundle\Security\Authenticator\OAuth2Authenticator;
|
||||||
|
use Symfony\Component\Security\Http\Authenticator\Passport\Passport;
|
||||||
|
use Symfony\Component\Security\Core\Exception\AuthenticationException;
|
||||||
|
use Symfony\Component\Security\Http\Authenticator\Passport\Badge\UserBadge;
|
||||||
|
use Symfony\Component\Security\Http\Authenticator\Passport\SelfValidatingPassport;
|
||||||
|
use Symfony\Component\Security\Http\EntryPoint\AuthenticationEntryPointInterface;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Class SudalysSSoAuthenticator
|
||||||
|
*/
|
||||||
|
class SsoAuthenticator extends OAuth2Authenticator implements AuthenticationEntryPointInterface
|
||||||
|
{
|
||||||
|
private $clientRegistry;
|
||||||
|
private $em;
|
||||||
|
private $router;
|
||||||
|
private $urlGenerator;
|
||||||
|
|
||||||
|
use TargetPathTrait;
|
||||||
|
|
||||||
|
public function __construct(ClientRegistry $clientRegistry, EntityManagerInterface $em, RouterInterface $router, UrlGeneratorInterface $urlGenerator)
|
||||||
|
{
|
||||||
|
$this->clientRegistry = $clientRegistry;
|
||||||
|
$this->em = $em;
|
||||||
|
$this->router = $router;
|
||||||
|
$this->urlGenerator = $urlGenerator;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function start(Request $request, AuthenticationException $authException = null): Response
|
||||||
|
{
|
||||||
|
// Use the KnpU client to generate the correct authorization URL,
|
||||||
|
// including state / redirect_uri / scope / pkce as configured.
|
||||||
|
$client = $this->getSudalysClient();
|
||||||
|
|
||||||
|
// Option A: let the client use the configured redirect uri and default scopes:
|
||||||
|
return $client->redirect();
|
||||||
|
|
||||||
|
// Option B (explicit): specify scopes and an explicit redirect_uri (absolute URL)
|
||||||
|
// $redirectUri = $this->urlGenerator->generate('sudalys_check', [], UrlGeneratorInterface::ABSOLUTE_URL);
|
||||||
|
// return $client->redirect(['openid', 'profile'], ['redirect_uri' => $redirectUri]);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
public function supports(Request $request): ?bool
|
||||||
|
{
|
||||||
|
// If your OAuth redirect route is named 'sudalys_check', check by route:
|
||||||
|
if ($request->attributes->get('_route') === 'sudalys_check') {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// fallback: also support requests containing the authorization code
|
||||||
|
return (bool) $request->query->get('code');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function authenticate(Request $request): Passport
|
||||||
|
{
|
||||||
|
$client = $this->getSudalysClient();
|
||||||
|
$accessToken = $this->fetchAccessToken($client);
|
||||||
|
$session = $request->getSession();
|
||||||
|
$session->set('access_token', $accessToken->getToken());
|
||||||
|
|
||||||
|
// Stocker également le refresh token s'il est disponible
|
||||||
|
if ($accessToken->getRefreshToken()) {
|
||||||
|
$session->set('refresh_token', $accessToken->getRefreshToken());
|
||||||
|
}
|
||||||
|
return new SelfValidatingPassport(
|
||||||
|
new UserBadge($accessToken->getToken(), function () use ($accessToken, $client) {
|
||||||
|
//show in log the access token
|
||||||
|
$sudalysSsoUser = $client->fetchUserFromToken($accessToken);
|
||||||
|
|
||||||
|
$ssoId = $sudalysSsoUser->getId();
|
||||||
|
|
||||||
|
/*
|
||||||
|
* On regarde si le token est valide
|
||||||
|
*/
|
||||||
|
if($accessToken->getExpires() > time()) {
|
||||||
|
// Token valide, on regarde si l'utilisateur existe en bdd locale
|
||||||
|
/** @var User $userInDatabase */
|
||||||
|
$user = $this->em->getRepository(User::class)->findOneBy(['ssoId' => $ssoId]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* on cree l'utilisateur s'il n'existe pas
|
||||||
|
**/
|
||||||
|
if (!$user) {
|
||||||
|
$user = new User();
|
||||||
|
$user->setEmail($ssoData->getEmail());
|
||||||
|
$user->setPrenom($ssoData->getName());
|
||||||
|
$user->setNom($ssoData->getSurname());
|
||||||
|
$user->setSsoId($ssoData->getId());
|
||||||
|
$this->em->persist($user);
|
||||||
|
}else{
|
||||||
|
// On met a jour l'utilisateur
|
||||||
|
$user->setEmail($ssoData->getEmail());
|
||||||
|
$user->setPrenom($ssoData->getName());
|
||||||
|
$user->setNom($ssoData->getSurname());
|
||||||
|
$this->em->persist($user);
|
||||||
|
}
|
||||||
|
|
||||||
|
//handle UOs links
|
||||||
|
$ssoArray = $ssoData->toArray();
|
||||||
|
$uoData = $ssoArray['uos'] ?? [];
|
||||||
|
foreach ($uoData as $uo) {
|
||||||
|
$ssoOrgId = $uo['id'];
|
||||||
|
|
||||||
|
$entity = $this->em->getRepository(Entity::class)->findOneBy(['ssoId' => $ssoOrgId]);
|
||||||
|
if (!$entity) {
|
||||||
|
$entity = new Entity();
|
||||||
|
$entity->setSsoId($ssoOrgId);
|
||||||
|
$entity->setNom($uo['name']);
|
||||||
|
$this->em->persist($entity);
|
||||||
|
}
|
||||||
|
$role = $this->em->getRepository(Roles::class)->findOneBy(['name' => $uo['role']]);
|
||||||
|
|
||||||
|
// Check if the user-organization link already exists
|
||||||
|
$existingLink = $this->em->getRepository(UsersOrganizations::class)->findOneBy([
|
||||||
|
'users' => $user,
|
||||||
|
'organizations' => $entity
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (!$existingLink) {
|
||||||
|
// Create a new link if it doesn't exist
|
||||||
|
$newLink = new UsersOrganizations();
|
||||||
|
$newLink->setUsers($user);
|
||||||
|
$newLink->setOrganizations($entity);
|
||||||
|
$newLink->setRole($role);
|
||||||
|
$this->em->persist($newLink);
|
||||||
|
} else {
|
||||||
|
// Update the role if the link already exists
|
||||||
|
$existingLink->setRole($role);
|
||||||
|
$existingLink->setModifiedAt(new \DateTimeImmutable());
|
||||||
|
$this->em->persist($existingLink);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$this->em->flush();
|
||||||
|
return $user;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function onAuthenticationSuccess(Request $request, TokenInterface $token, string $firewallName): ?Response
|
||||||
|
{
|
||||||
|
// change "app_homepage" to some route in your app
|
||||||
|
$targetUrl = $this->router->generate('app_index');
|
||||||
|
return new RedirectResponse($targetUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function onAuthenticationFailure(Request $request, AuthenticationException $exception): ?Response
|
||||||
|
{
|
||||||
|
$message = strtr($exception->getMessageKey(), $exception->getMessageData());
|
||||||
|
return new Response($message, Response::HTTP_FORBIDDEN);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
private function getSudalysClient()
|
||||||
|
{
|
||||||
|
return $this->clientRegistry->getClient('sudalys');
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
```php
|
||||||
|
namespace App\Security\SsoAuthenticator;
|
||||||
|
|
||||||
|
public function onAuthenticationSuccess(Request $request, TokenInterface $token, string $firewallName): ?Response
|
||||||
|
{
|
||||||
|
// change "app_homepage" to some route in your app
|
||||||
|
$targetUrl = $this->router->generate('your redirect route');
|
||||||
|
return new RedirectResponse($targetUrl);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Security.yaml
|
||||||
|
```yaml
|
||||||
|
app_user_provider:
|
||||||
|
entity:
|
||||||
|
class: App\Entity\User
|
||||||
|
property: email
|
||||||
|
|
||||||
|
firewalls:
|
||||||
|
main:
|
||||||
|
lazy: true
|
||||||
|
provider: app_user_provider
|
||||||
|
custom_authenticators:
|
||||||
|
- App\Security\SsoAuthenticator
|
||||||
|
entry_point: App\Security\SsoAuthenticator
|
||||||
|
logout:
|
||||||
|
path: app_logout
|
||||||
|
target: app_after_logout
|
||||||
|
invalidate_session: true
|
||||||
|
delete_cookies: ['PHPSESSID']
|
||||||
|
|
||||||
|
access_control:
|
||||||
|
- { path: ^/sso/login, roles: PUBLIC_ACCESS }
|
||||||
|
- { path: ^/sso/check, roles: PUBLIC_ACCESS }
|
||||||
|
- { path: ^/, roles: IS_AUTHENTICATED_FULLY }
|
||||||
|
```
|
||||||
|
### Setup oauth controller
|
||||||
|
```php
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Controller;
|
||||||
|
|
||||||
|
use KnpU\OAuth2ClientBundle\Client\ClientRegistry;
|
||||||
|
use Psr\Log\LoggerInterface;
|
||||||
|
use Psr\Log\LogLevel;
|
||||||
|
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||||
|
use Symfony\Bundle\SecurityBundle\Security;
|
||||||
|
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||||
|
use Symfony\Component\HttpFoundation\RedirectResponse;
|
||||||
|
use Symfony\Component\HttpFoundation\Request;
|
||||||
|
use Symfony\Component\HttpFoundation\RequestStack;
|
||||||
|
use Symfony\Component\Routing\Annotation\Route;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
|
||||||
|
use Symfony\Contracts\HttpClient\HttpClientInterface;
|
||||||
|
|
||||||
|
class SsoController extends AbstractController
|
||||||
|
{
|
||||||
|
|
||||||
|
#[Route('/sso/login', name: 'sudalys_sso_login')]
|
||||||
|
public function login(ClientRegistry $clientRegistry): RedirectResponse
|
||||||
|
{
|
||||||
|
return $clientRegistry->getClient('sudalys')->redirect();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Route('/sso/check', name: 'sudalys_sso_check')]
|
||||||
|
public function connectCheckAction(Request $request)
|
||||||
|
{
|
||||||
|
return $this->redirectToRoute('app_index');
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
#[Route('/logout', name: 'app_logout')]
|
||||||
|
public function logout(): void
|
||||||
|
{
|
||||||
|
throw new \Exception('This should never be reached!');
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Route('/logout-redirect', name: 'app_after_logout')]
|
||||||
|
public function afterLogout(): RedirectResponse
|
||||||
|
{
|
||||||
|
// SSO logout URL — adjust if necessary
|
||||||
|
$ssoLogout = 'http://portail.solutions-easy.moi/sso_logout';
|
||||||
|
|
||||||
|
return new RedirectResponse($ssoLogout);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
# Server setup
|
||||||
|
## Create OAuth2 client
|
||||||
|
```cmd
|
||||||
|
php bin/console league:oauth2-server:create-client <name> --redirect-uri="http://your-client-domain/sso/check" --scope="openid" --scope="profile" --scope="email" --grant-type=authorization_code
|
||||||
|
```
|
||||||
|
If there is a scope or grand error, delete the client do the following first
|
||||||
|
```cmd
|
||||||
|
php bin/console league:oauth2-server:delete-client <identifier>
|
||||||
|
```
|
||||||
|
Identifier can be found in the database oauth2_client table
|
||||||
|
To recreate the client and enter the scopes and grant types after creating the client directly in the db
|
||||||
|
```text
|
||||||
|
scopes = email profile openid
|
||||||
|
grants = authorization_code
|
||||||
|
```
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -0,0 +1,35 @@
|
||||||
|
# Intro
|
||||||
|
Each organization are a collection of users and projects.
|
||||||
|
Users will be able to have multiple organizations and different roles in each of them. For example, a user can be an
|
||||||
|
admin in one organization and a member in another organization.
|
||||||
|
|
||||||
|
Each organization will have a unique slug that will consist of 4 lowercase letters and this slug will be used for the
|
||||||
|
database name of each project contained in an organization.
|
||||||
|
|
||||||
|
## Projects
|
||||||
|
Each project will have a unique name. Each project will be associated with an organization and will have a unique slug
|
||||||
|
that will come from the organization. The project will have a JSON field that will contain the different applications it has access to
|
||||||
|
|
||||||
|
## Organization Management
|
||||||
|
The organization management will have different features, such as creating an organization, inviting users to an organization,
|
||||||
|
managing the roles of users in an organization(admin or not), and deleting an organization.
|
||||||
|
|
||||||
|
### CRUD Operations
|
||||||
|
- **Create Organization**: Super Admin
|
||||||
|
- **Read Organization**: Super Admin, Admin and admin of the organization
|
||||||
|
- **Update Organization**: Super Admin, Admin
|
||||||
|
- **Delete Organization**: Super Admin
|
||||||
|
|
||||||
|
### User Management
|
||||||
|
- **Invite User**: Super Admin, Admin and admin of the organization
|
||||||
|
- **Remove User**: Super Admin, Admin and admin of the organization
|
||||||
|
- **Change User Role**: Super Admin, Admin and admin of the organization
|
||||||
|
- **List Users**: Super Admin, Admin and admin of the organization
|
||||||
|
- **Accept Invitation**: User
|
||||||
|
- **Decline Invitation**: User
|
||||||
|
|
||||||
|
### Project Management
|
||||||
|
- **Create Project**: Super Admin
|
||||||
|
- **Read Project**: Super Admin, Admin and admin of the organization
|
||||||
|
- **Update Project**: Super Admin
|
||||||
|
- **Delete Project**: Super Admin
|
||||||
|
|
@ -0,0 +1,169 @@
|
||||||
|
# Intro
|
||||||
|
Roles will be split into two categories: **System Roles** and **Organizations Roles**.
|
||||||
|
System roles are global and apply to the entire system, while Organizations roles are specific to individual Organizations.
|
||||||
|
|
||||||
|
## System Roles
|
||||||
|
System roles are global and apply to the entire system. They include:
|
||||||
|
- **System Super Admin**: Has full access to all system features and settings. Can manage users, projects, organizations and applications. (SI)
|
||||||
|
- **System Admin**: Has access to most system features and settings. Can manage users, organizations, applications authorizations by projects. (BE)
|
||||||
|
- **System User**: Has limited access to system features and settings. Can view projects and applications, can manage own information, and organization where they are admin. (Others)
|
||||||
|
|
||||||
|
### System Super Admin
|
||||||
|
Get Access to the following with the following authorisations:
|
||||||
|
- **Users**: READ, CREATE, UPDATE, DELETE
|
||||||
|
- **Projects**: READ, CREATE, UPDATE, DELETE
|
||||||
|
- **Organizations**: READ, CREATE, UPDATE, DELETE
|
||||||
|
- **Applications**: READ, UPDATE
|
||||||
|
|
||||||
|
### System Admin
|
||||||
|
Get Access to the following with the following authorisations:
|
||||||
|
- **Users**: READ, CREATE, UPDATE, DELETE
|
||||||
|
- **Organizations**: READ, UPDATE
|
||||||
|
- **Applications**: READ
|
||||||
|
|
||||||
|
### System User
|
||||||
|
Get Access to the following with the following authorisations:
|
||||||
|
- **Users**: READ, UPDATE (own information only), READ (organization where they are admin), CREATE ( organization where they are admin), UPDATE (organization where they are admin), DELETE (organization where they are admin)
|
||||||
|
- **Projects**: READ ( of organization they are part of)
|
||||||
|
- **Organizations**: READ
|
||||||
|
- **Applications**: READ
|
||||||
|
|
||||||
|
## Organizations Roles
|
||||||
|
Organizations roles are specific to individual Organizations. They include:
|
||||||
|
- **Organization Admin**: Has full access to all organization features and settings. Can manage users of the organizations.
|
||||||
|
- **Organization User**: Has limited access to organization features and settings. Can view projects and applications, can manage own information
|
||||||
|
|
||||||
|
|
||||||
|
# Set up
|
||||||
|
Like for the sso, we need to create roles in the system. create the following command and the create the roles.
|
||||||
|
``` php
|
||||||
|
|
||||||
|
#[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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
```php
|
||||||
|
#[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\UsersOrganizations::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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
``` bash
|
||||||
|
php bin/console app:create-role USER
|
||||||
|
php bin/console app:create-role ADMIN
|
||||||
|
```
|
||||||
|
|
@ -0,0 +1,208 @@
|
||||||
|
# Documentation SSO/SLO - EasyPortal & EasyCheck
|
||||||
|
|
||||||
|
## Vue d'ensemble
|
||||||
|
|
||||||
|
Cette documentation décrit l'implémentation du **Single Sign-On (SSO)** et du **Single Logout (SLO)** entre deux applications Symfony :
|
||||||
|
- **EasyPortal** : Serveur d'autorisation OAuth2 (Identity Provider)
|
||||||
|
- **EasyCheck** : Application cliente OAuth2
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────┐ ┌─────────────────┐
|
||||||
|
│ EasyPortal │ │ EasyCheck │
|
||||||
|
│ (OAuth2 Server)│◄──────────────────►│ (OAuth2 Client) │
|
||||||
|
│ │ │ │
|
||||||
|
│ - Authentifie │ │ - Utilise le │
|
||||||
|
│ - Émet tokens │ │ token OAuth2 │
|
||||||
|
│ - Révoque │ │ - Valide token │
|
||||||
|
└─────────────────┘ └─────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
## Single Sign-On (SSO)
|
||||||
|
|
||||||
|
### Principe
|
||||||
|
|
||||||
|
L'utilisateur s'authentifie **une seule fois** sur le portail et accède ensuite à toutes les applications sans re-saisir ses identifiants.
|
||||||
|
|
||||||
|
### Flux d'authentification
|
||||||
|
|
||||||
|
```
|
||||||
|
1. Utilisateur → EasyCheck
|
||||||
|
└─> Pas de session active
|
||||||
|
|
||||||
|
2. EasyCheck → Redirection vers EasyPortal
|
||||||
|
└─> /authorize?client_id=...&redirect_uri=...
|
||||||
|
|
||||||
|
3. Utilisateur → Connexion sur EasyPortal
|
||||||
|
└─> Login/Password ou session existante
|
||||||
|
|
||||||
|
4. EasyPortal → Redirection vers EasyCheck
|
||||||
|
└─> /sso_check?code=AUTHORIZATION_CODE
|
||||||
|
|
||||||
|
5. EasyCheck → Échange du code contre un token
|
||||||
|
└─> POST /token avec authorization_code
|
||||||
|
└─> Reçoit access_token + refresh_token
|
||||||
|
|
||||||
|
6. EasyCheck → Création de session locale
|
||||||
|
└─> Stockage du token en session
|
||||||
|
└─> Utilisateur connecté
|
||||||
|
```
|
||||||
|
|
||||||
|
## Single Logout (SLO)
|
||||||
|
|
||||||
|
### Principe
|
||||||
|
|
||||||
|
Lorsqu'un utilisateur se déconnecte d'une application, il est **automatiquement déconnecté de toutes les applications** SSO via un appel API asynchrone, évitant les boucles de redirections.
|
||||||
|
|
||||||
|
### Flux de déconnexion depuis EasyCheck
|
||||||
|
|
||||||
|
```
|
||||||
|
1. Utilisateur → Clic "Déconnexion" sur EasyCheck
|
||||||
|
└─> GET /logout
|
||||||
|
|
||||||
|
2. Symfony → Invalide la session EasyCheck
|
||||||
|
└─> Session détruite, cookies supprimés
|
||||||
|
|
||||||
|
3. LogoutSubscriber → Interception de l'événement
|
||||||
|
└─> Détecte que ce n'est pas une déconnexion depuis le portail
|
||||||
|
|
||||||
|
4. EasyCheck → Redirection vers portail
|
||||||
|
└─> GET https://portail.../sso_logout?from_easycheck=1
|
||||||
|
|
||||||
|
5. EasyPortal → Révocation des tokens OAuth2
|
||||||
|
└─> Tous les access_token de l'utilisateur sont révoqués
|
||||||
|
└─> Paramètre redirect_app=easycheck propagé dans l'URL
|
||||||
|
|
||||||
|
6. EasyPortal → Redirection vers /logout
|
||||||
|
└─> GET /logout
|
||||||
|
|
||||||
|
7. Symfony → Invalide la session EasyPortal
|
||||||
|
└─> Session détruite
|
||||||
|
|
||||||
|
8. LogoutSubscriber → Redirection vers EasyCheck
|
||||||
|
└─> GET https://check.../logout?from_portal=1&redirect_app=easycheck
|
||||||
|
|
||||||
|
9. EasyCheck → Détecte from_portal=1
|
||||||
|
└─> Invalide la session (si elle existe encore)
|
||||||
|
└─> Redirection vers portail login avec redirect_app
|
||||||
|
|
||||||
|
10. EasyPortal → Affichage page login
|
||||||
|
└─> GET /login?redirect_app=easycheck
|
||||||
|
|
||||||
|
11. Utilisateur se reconnecte → LoginSubscriber détecte redirect_app=easycheck
|
||||||
|
└─> Redirection automatique vers EasyCheck /sso/login
|
||||||
|
```
|
||||||
|
|
||||||
|
### Flux de déconnexion depuis EasyPortal
|
||||||
|
|
||||||
|
```
|
||||||
|
1. Utilisateur → Clic "Déconnexion" sur EasyPortal
|
||||||
|
└─> GET /sso_logout
|
||||||
|
|
||||||
|
2. EasyPortal → Révocation des tokens OAuth2
|
||||||
|
└─> Tous les access_token de l'utilisateur sont révoqués
|
||||||
|
|
||||||
|
3. EasyPortal → Redirection vers /logout
|
||||||
|
└─> GET /logout
|
||||||
|
|
||||||
|
4. Symfony → Invalide la session EasyPortal
|
||||||
|
└─> Session détruite
|
||||||
|
|
||||||
|
5. LogoutSubscriber → Redirection vers EasyCheck
|
||||||
|
└─> GET https://check.../logout?from_portal=1
|
||||||
|
|
||||||
|
6. EasyCheck → Détecte from_portal=1
|
||||||
|
└─> Invalide la session EasyCheck
|
||||||
|
└─> Session détruite, cookies supprimés
|
||||||
|
|
||||||
|
7. EasyCheck → Redirection finale vers portail
|
||||||
|
└─> GET https://portail.../login
|
||||||
|
```
|
||||||
|
|
||||||
|
## Variables d'environnement
|
||||||
|
|
||||||
|
### EasyCheck (.env)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# URL du serveur SSO (EasyPortal)
|
||||||
|
SSO_URL='https://portail.solutions-easy.moi'
|
||||||
|
|
||||||
|
# Configuration OAuth2
|
||||||
|
OAUTH_CLIENT_ID='easycheck-client-id'
|
||||||
|
OAUTH_CLIENT_SECRET='secret-key'
|
||||||
|
```
|
||||||
|
|
||||||
|
### EasyPortal (.env)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# URL de l'application cliente (EasyCheck)
|
||||||
|
EASYCHECK_URL='https://check.solutions-easy.moi'
|
||||||
|
|
||||||
|
# Configuration OAuth2 Server
|
||||||
|
OAUTH_PRIVATE_KEY=%kernel.project_dir%/config/jwt/private.key
|
||||||
|
OAUTH_PUBLIC_KEY=%kernel.project_dir%/config/jwt/public.key
|
||||||
|
OAUTH_PASSPHRASE='passphrase'
|
||||||
|
OAUTH_ENCRYPTION_KEY='encryption-key'
|
||||||
|
```
|
||||||
|
|
||||||
|
## Système de redirection multi-applications
|
||||||
|
|
||||||
|
### Paramètre `redirect_app`
|
||||||
|
|
||||||
|
Pour gérer plusieurs applications SSO (EasyCheck, EasyAudit, EasyMaintenance, etc.), le système utilise un **paramètre URL `redirect_app`** au lieu de cookies.
|
||||||
|
|
||||||
|
**Avantages** :
|
||||||
|
- ✅ Fonctionne avec plusieurs onglets ouverts simultanément
|
||||||
|
- ✅ Chaque onglet garde son contexte de déconnexion
|
||||||
|
- ✅ Pas de conflit entre applications
|
||||||
|
- ✅ Facilement extensible pour de nouvelles applications
|
||||||
|
|
||||||
|
**Fonctionnement** :
|
||||||
|
1. Lors de la déconnexion depuis une application, elle envoie `redirect_app=nom_app`
|
||||||
|
2. Ce paramètre est propagé dans toutes les redirections de logout
|
||||||
|
3. Il arrive sur la page `/login?redirect_app=nom_app`
|
||||||
|
4. Après reconnexion, l'utilisateur est redirigé vers l'application d'origine
|
||||||
|
|
||||||
|
**Configuration dans LoginSubscriber** :
|
||||||
|
```php
|
||||||
|
$appUrls = [
|
||||||
|
'easycheck' => $easycheckUrl . '/sso/login',
|
||||||
|
'easyaudit' => $easyauditUrl . '/sso/login',
|
||||||
|
'easymaintenance' => $easymaintenanceUrl . '/sso/login',
|
||||||
|
];
|
||||||
|
```
|
||||||
|
|
||||||
|
## Endpoints
|
||||||
|
|
||||||
|
### EasyCheck - API Logout
|
||||||
|
|
||||||
|
**Route** : `POST /api/logout`
|
||||||
|
|
||||||
|
**Description** : Endpoint API pour invalider la session EasyCheck sans redirection (non utilisé actuellement, prévu pour usage futur).
|
||||||
|
|
||||||
|
**Réponse** :
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"message": "Session invalidated successfully"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Points importants
|
||||||
|
|
||||||
|
### Sécurité
|
||||||
|
|
||||||
|
1. **CSRF désactivé sur logout** : Les routes de logout utilisent `enable_csrf: false` car ce sont des liens GET simples
|
||||||
|
2. **Tokens révoqués** : Lors du logout, tous les access_token de l'utilisateur sont révoqués côté portail
|
||||||
|
3. **Sessions invalidées** : Les sessions PHP sont complètement détruites des deux côtés
|
||||||
|
4. **Cookies supprimés** : Les cookies de session sont explicitement supprimés
|
||||||
|
|
||||||
|
### Architecture
|
||||||
|
|
||||||
|
1. **Pas de boucles infinies** : Utilisation du paramètre `from_portal` pour éviter les boucles de redirections
|
||||||
|
2. **Déconnexion bidirectionnelle** : Chaque application invalide la session de l'autre lors de la déconnexion
|
||||||
|
3. **Flux prévisible** : Chaque déconnexion suit un chemin clair avec des paramètres explicites
|
||||||
|
4. **Retour automatique** : Paramètre URL `redirect_app` pour rediriger l'utilisateur vers l'application d'origine après reconnexion
|
||||||
|
5. **Single Logout complet** : Toutes les sessions (portail + applications) sont toujours invalidées, quelle que soit l'origine de la déconnexion
|
||||||
|
6. **Multi-applications** : Support natif de plusieurs applications SSO sans conflit entre onglets
|
||||||
|
|
@ -0,0 +1,79 @@
|
||||||
|
{
|
||||||
|
# Global options
|
||||||
|
frankenphp {
|
||||||
|
# Number of workers for better performance
|
||||||
|
num_threads {$NUM_THREADS:4}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Order directives properly
|
||||||
|
order mercure after encode
|
||||||
|
order php_server before file_server
|
||||||
|
}
|
||||||
|
|
||||||
|
# HTTP server - HTTPS is handled by caddy-proxy
|
||||||
|
{$SERVER_NAME:80} {
|
||||||
|
# Root directory
|
||||||
|
root * /app/public
|
||||||
|
|
||||||
|
# Enable compression
|
||||||
|
encode zstd gzip
|
||||||
|
|
||||||
|
# Mercure hub configuration (built-in)
|
||||||
|
mercure {
|
||||||
|
# Publisher JWT key
|
||||||
|
publisher_jwt {env.MERCURE_PUBLISHER_JWT_KEY} {
|
||||||
|
algorithm hs256
|
||||||
|
}
|
||||||
|
# Subscriber JWT key
|
||||||
|
subscriber_jwt {env.MERCURE_SUBSCRIBER_JWT_KEY} {
|
||||||
|
algorithm hs256
|
||||||
|
}
|
||||||
|
# Allow anonymous subscribers
|
||||||
|
anonymous
|
||||||
|
# CORS configuration
|
||||||
|
cors_origins *
|
||||||
|
}
|
||||||
|
|
||||||
|
# Client max body size (for uploads)
|
||||||
|
request_body {
|
||||||
|
max_size 20MB
|
||||||
|
}
|
||||||
|
|
||||||
|
# Security: Deny access to sensitive directories
|
||||||
|
@forbidden {
|
||||||
|
path /bin/* /config/* /src/* /templates/* /tests/* /translations/* /var/* /vendor/*
|
||||||
|
}
|
||||||
|
handle @forbidden {
|
||||||
|
respond "Access Denied" 404
|
||||||
|
}
|
||||||
|
|
||||||
|
# Security: Deny access to dot files (except .well-known for Mercure)
|
||||||
|
@dotfiles {
|
||||||
|
path */.*
|
||||||
|
not path /.well-known/*
|
||||||
|
}
|
||||||
|
handle @dotfiles {
|
||||||
|
respond "Access Denied" 404
|
||||||
|
}
|
||||||
|
|
||||||
|
# Cache static assets (30 days)
|
||||||
|
@static {
|
||||||
|
path *.jpg *.jpeg *.png *.gif *.ico *.css *.js *.svg *.woff *.woff2 *.ttf *.eot *.xlsx
|
||||||
|
}
|
||||||
|
handle @static {
|
||||||
|
header Cache-Control "public, max-age=2592000, no-transform"
|
||||||
|
file_server
|
||||||
|
}
|
||||||
|
|
||||||
|
# PHP FrankenPHP handler
|
||||||
|
php_server {
|
||||||
|
# Resolve symlinks
|
||||||
|
resolve_root_symlink
|
||||||
|
}
|
||||||
|
|
||||||
|
# Logging
|
||||||
|
log {
|
||||||
|
output file /var/log/caddy/access.log
|
||||||
|
format json
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,104 @@
|
||||||
|
{
|
||||||
|
# Global options
|
||||||
|
frankenphp {
|
||||||
|
# Number of workers for better performance
|
||||||
|
num_threads {$NUM_THREADS:4}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Order directives properly
|
||||||
|
order mercure after encode
|
||||||
|
order php_server before file_server
|
||||||
|
}
|
||||||
|
|
||||||
|
# HTTP - redirect to HTTPS
|
||||||
|
http://{$SERVER_NAME:localhost} {
|
||||||
|
redir https://{host}{uri} permanent
|
||||||
|
}
|
||||||
|
|
||||||
|
# HTTPS server
|
||||||
|
https://{$SERVER_NAME:localhost} {
|
||||||
|
# Root directory
|
||||||
|
root * /app/public
|
||||||
|
|
||||||
|
# TLS configuration - Caddy will automatically obtain and renew Let's Encrypt certificates
|
||||||
|
tls {
|
||||||
|
protocols tls1.2 tls1.3
|
||||||
|
}
|
||||||
|
|
||||||
|
# Enable compression
|
||||||
|
encode zstd gzip
|
||||||
|
|
||||||
|
# Mercure hub configuration (built-in)
|
||||||
|
mercure {
|
||||||
|
# Publisher JWT key
|
||||||
|
publisher_jwt {env.MERCURE_PUBLISHER_JWT_KEY} {
|
||||||
|
algorithm hs256
|
||||||
|
}
|
||||||
|
# Subscriber JWT key
|
||||||
|
subscriber_jwt {env.MERCURE_SUBSCRIBER_JWT_KEY} {
|
||||||
|
algorithm hs256
|
||||||
|
}
|
||||||
|
# Allow anonymous subscribers
|
||||||
|
anonymous
|
||||||
|
# Enable subscriptions
|
||||||
|
subscriptions
|
||||||
|
# CORS configuration
|
||||||
|
cors_origins *
|
||||||
|
}
|
||||||
|
|
||||||
|
# Client max body size (for uploads)
|
||||||
|
request_body {
|
||||||
|
max_size 20MB
|
||||||
|
}
|
||||||
|
|
||||||
|
# Security: Deny access to sensitive directories
|
||||||
|
@forbidden {
|
||||||
|
path /bin/* /config/* /src/* /templates/* /tests/* /translations/* /var/* /vendor/*
|
||||||
|
}
|
||||||
|
handle @forbidden {
|
||||||
|
respond "Access Denied" 404
|
||||||
|
}
|
||||||
|
|
||||||
|
# Security: Deny access to dot files (except .well-known for Mercure)
|
||||||
|
@dotfiles {
|
||||||
|
path */.*
|
||||||
|
not path /.well-known/*
|
||||||
|
}
|
||||||
|
handle @dotfiles {
|
||||||
|
respond "Access Denied" 404
|
||||||
|
}
|
||||||
|
|
||||||
|
# Cache static assets (30 days)
|
||||||
|
@static {
|
||||||
|
path *.jpg *.jpeg *.png *.gif *.ico *.css *.js *.svg *.woff *.woff2 *.ttf *.eot *.xlsx *.pdf
|
||||||
|
file
|
||||||
|
}
|
||||||
|
handle @static {
|
||||||
|
header Cache-Control "public, max-age=2592000, no-transform"
|
||||||
|
file_server
|
||||||
|
}
|
||||||
|
|
||||||
|
# Serve files from /assets directory
|
||||||
|
handle /assets/* {
|
||||||
|
root * /app/public
|
||||||
|
file_server
|
||||||
|
}
|
||||||
|
|
||||||
|
# PHP FrankenPHP handler
|
||||||
|
php_server {
|
||||||
|
# Resolve symlinks
|
||||||
|
resolve_root_symlink
|
||||||
|
}
|
||||||
|
|
||||||
|
# Logging
|
||||||
|
log {
|
||||||
|
output file /app/var/log/access.log
|
||||||
|
format json
|
||||||
|
# Redact sensitive data
|
||||||
|
format filter {
|
||||||
|
request>uri query {
|
||||||
|
replace authorization REDACTED
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,13 @@
|
||||||
|
expose_php = 0
|
||||||
|
date.timezone = UTC
|
||||||
|
apc.enable_cli = 1
|
||||||
|
session.use_strict_mode = 1
|
||||||
|
zend.detect_unicode = 0
|
||||||
|
|
||||||
|
; https://symfony.com/doc/current/performance.html
|
||||||
|
realpath_cache_size = 4096K
|
||||||
|
realpath_cache_ttl = 600
|
||||||
|
opcache.interned_strings_buffer = 16
|
||||||
|
opcache.max_accelerated_files = 20000
|
||||||
|
opcache.memory_consumption = 256
|
||||||
|
opcache.enable_file_override = 1
|
||||||
|
|
@ -0,0 +1,5 @@
|
||||||
|
; See https://docs.docker.com/desktop/networking/#i-want-to-connect-from-a-container-to-a-service-on-the-host
|
||||||
|
; See https://github.com/docker/for-linux/issues/264
|
||||||
|
; The `client_host` below may optionally be replaced with `discover_client_host=yes`
|
||||||
|
; Add `start_with_request=yes` to start debug session on each request
|
||||||
|
xdebug.client_host = host.docker.internal
|
||||||
|
|
@ -0,0 +1,5 @@
|
||||||
|
; https://symfony.com/doc/current/performance.html#use-the-opcache-class-preloading
|
||||||
|
opcache.preload_user = root
|
||||||
|
opcache.preload = /app/config/preload.php
|
||||||
|
; https://symfony.com/doc/current/performance.html#don-t-check-php-files-timestamps
|
||||||
|
opcache.validate_timestamps = 0
|
||||||
|
|
@ -0,0 +1,66 @@
|
||||||
|
#!/bin/sh
|
||||||
|
set -e
|
||||||
|
|
||||||
|
if [ "$1" = 'frankenphp' ] || [ "$1" = 'php' ] || [ "$1" = 'bin/console' ]; then
|
||||||
|
# Install the project the first time PHP is started
|
||||||
|
# After the installation, the following block can be deleted
|
||||||
|
if [ ! -f composer.json ]; then
|
||||||
|
rm -Rf tmp/
|
||||||
|
composer create-project "symfony/skeleton $SYMFONY_VERSION" tmp --stability="$STABILITY" --prefer-dist --no-progress --no-interaction --no-install
|
||||||
|
|
||||||
|
cd tmp
|
||||||
|
cp -Rp . ..
|
||||||
|
cd -
|
||||||
|
rm -Rf tmp/
|
||||||
|
|
||||||
|
composer require "php:>=$PHP_VERSION" runtime/frankenphp-symfony
|
||||||
|
composer config --json extra.symfony.docker 'true'
|
||||||
|
|
||||||
|
if grep -q ^DATABASE_URL= .env; then
|
||||||
|
echo 'To finish the installation please press Ctrl+C to stop Docker Compose and run: docker compose up --build --wait'
|
||||||
|
sleep infinity
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -z "$(ls -A 'vendor/' 2>/dev/null)" ]; then
|
||||||
|
composer install --prefer-dist --no-progress --no-interaction
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Display information about the current project
|
||||||
|
# Or about an error in project initialization
|
||||||
|
php bin/console -V
|
||||||
|
|
||||||
|
if grep -q ^DATABASE_URL= .env; then
|
||||||
|
echo 'Waiting for database to be ready...'
|
||||||
|
ATTEMPTS_LEFT_TO_REACH_DATABASE=60
|
||||||
|
until [ $ATTEMPTS_LEFT_TO_REACH_DATABASE -eq 0 ] || DATABASE_ERROR=$(php bin/console dbal:run-sql -q "SELECT 1" 2>&1); do
|
||||||
|
if [ $? -eq 255 ]; then
|
||||||
|
# If the Doctrine command exits with 255, an unrecoverable error occurred
|
||||||
|
ATTEMPTS_LEFT_TO_REACH_DATABASE=0
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
sleep 1
|
||||||
|
ATTEMPTS_LEFT_TO_REACH_DATABASE=$((ATTEMPTS_LEFT_TO_REACH_DATABASE - 1))
|
||||||
|
echo "Still waiting for database to be ready... Or maybe the database is not reachable. $ATTEMPTS_LEFT_TO_REACH_DATABASE attempts left."
|
||||||
|
done
|
||||||
|
|
||||||
|
if [ $ATTEMPTS_LEFT_TO_REACH_DATABASE -eq 0 ]; then
|
||||||
|
echo 'The database is not up or not reachable:'
|
||||||
|
echo "$DATABASE_ERROR"
|
||||||
|
exit 1
|
||||||
|
else
|
||||||
|
echo 'The database is now ready and reachable'
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$( find ./migrations -iname '*.php' -print -quit )" ]; then
|
||||||
|
php bin/console doctrine:migrations:migrate --no-interaction --all-or-nothing
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
setfacl -R -m u:www-data:rwX -m u:"$(whoami)":rwX var
|
||||||
|
setfacl -dR -m u:www-data:rwX -m u:"$(whoami)":rwX var
|
||||||
|
|
||||||
|
echo 'PHP app ready!'
|
||||||
|
fi
|
||||||
|
|
||||||
|
exec docker-php-entrypoint "$@"
|
||||||
|
|
@ -35,4 +35,42 @@ return [
|
||||||
'version' => '5.3.5',
|
'version' => '5.3.5',
|
||||||
'type' => 'css',
|
'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',
|
||||||
|
],
|
||||||
];
|
];
|
||||||
|
|
|
||||||
|
|
@ -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');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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)\'');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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');
|
||||||
|
}
|
||||||
|
}
|
||||||