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 ###
|
||||
APP_ENV=prod
|
||||
APP_SECRET='kjuusshgvk35434judshfgvkusd224444hvg'
|
||||
APPLICATION=EasyPortal
|
||||
###< symfony/framework-bundle ###
|
||||
|
||||
###> doctrine/doctrine-bundle ###
|
||||
|
|
@ -43,10 +44,12 @@ MAILER_DSN=null://null
|
|||
TRUSTED_PROXY='185.116.130.121','10.8.34.21'
|
||||
|
||||
###> league/oauth2-server-bundle ###
|
||||
OAUTH_PRIVATE_KEY=%kernel.project_dir%/config/jwt/private.pem
|
||||
OAUTH_PUBLIC_KEY=%kernel.project_dir%/config/jwt/public.pem
|
||||
OAUTH_PRIVATE_KEY=%kernel.project_dir%/config/jwt/private.key
|
||||
OAUTH_PUBLIC_KEY=%kernel.project_dir%/config/jwt/public.key
|
||||
OAUTH_PASSPHRASE=8170ea18d2e3e05b5c7ae0672a754bf4
|
||||
OAUTH_ENCRYPTION_KEY=f1b7c279f7992205a0df45e295d07066
|
||||
OAUTH_SSO_IDENTIFIER='sso-own-identifier'
|
||||
OAUTH_SSO_IDENTIFIER_LOGIN='sso-own-identifier'
|
||||
###< league/oauth2-server-bundle ###
|
||||
|
||||
###> nelmio/cors-bundle ###
|
||||
|
|
@ -62,3 +65,15 @@ MERCURE_PUBLIC_URL=https://example.com/.well-known/mercure
|
|||
# The secret used to sign the JWTs
|
||||
MERCURE_JWT_SECRET="!ChangeThisMercureHubJWTSecretKey!"
|
||||
###< symfony/mercure-bundle ###
|
||||
|
||||
###> aws/aws-sdk-php-symfony ###
|
||||
AWS_KEY=not-a-real-key
|
||||
AWS_SECRET=@@not-a-real-secret
|
||||
AWS_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 ###
|
||||
/.env.local
|
||||
/.env.test
|
||||
/.env.local.php
|
||||
/.env.*.local
|
||||
/config/secrets/prod/prod.decrypt.private.php
|
||||
/public/bundles/
|
||||
/public/uploads/
|
||||
/var/
|
||||
/vendor/
|
||||
###< symfony/framework-bundle ###
|
||||
|
|
@ -14,12 +16,18 @@
|
|||
.phpunit.result.cache
|
||||
###< phpunit/phpunit ###
|
||||
|
||||
###> symfony/phpunit-bridge ###
|
||||
.phpunit.result.cache
|
||||
/phpunit.xml
|
||||
###< symfony/phpunit-bridge ###
|
||||
|
||||
###> symfony/asset-mapper ###
|
||||
/public/assets/
|
||||
/assets/vendor/
|
||||
###< 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-bundle" />
|
||||
<excludeFolder url="file://$MODULE_DIR$/vendor/firebase/php-jwt" />
|
||||
<excludeFolder url="file://$MODULE_DIR$/vendor/aws/aws-crt-php" />
|
||||
<excludeFolder url="file://$MODULE_DIR$/vendor/aws/aws-sdk-php" />
|
||||
<excludeFolder url="file://$MODULE_DIR$/vendor/aws/aws-sdk-php-symfony" />
|
||||
<excludeFolder url="file://$MODULE_DIR$/vendor/guzzlehttp/guzzle" />
|
||||
<excludeFolder url="file://$MODULE_DIR$/vendor/guzzlehttp/promises" />
|
||||
<excludeFolder url="file://$MODULE_DIR$/vendor/guzzlehttp/psr7" />
|
||||
<excludeFolder url="file://$MODULE_DIR$/vendor/knplabs/knp-time-bundle" />
|
||||
<excludeFolder url="file://$MODULE_DIR$/vendor/mtdowling/jmespath.php" />
|
||||
<excludeFolder url="file://$MODULE_DIR$/vendor/psr/http-client" />
|
||||
<excludeFolder url="file://$MODULE_DIR$/vendor/ralouphie/getallheaders" />
|
||||
<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>
|
||||
<orderEntry type="inheritedJdk" />
|
||||
<orderEntry type="sourceFolder" forTests="false" />
|
||||
<orderEntry type="library" name="quill.snow" level="application" />
|
||||
<orderEntry type="library" name="quill" level="application" />
|
||||
</component>
|
||||
</module>
|
||||
|
|
@ -10,6 +10,11 @@
|
|||
<option name="highlightLevel" value="WARNING" />
|
||||
<option name="transferred" value="true" />
|
||||
</component>
|
||||
<component name="PhpCodeSniffer">
|
||||
<phpcs_settings>
|
||||
<phpcs_by_interpreter asDefaultInterpreter="true" interpreter_id="e61c7a46-7290-4f1b-ace7-5462f6da9ae0" timeout="30000" />
|
||||
</phpcs_settings>
|
||||
</component>
|
||||
<component name="PhpIncludePathManager">
|
||||
<include_path>
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/twig-bridge" />
|
||||
|
|
@ -83,7 +88,6 @@
|
|||
<path value="$PROJECT_DIR$/vendor/lcobucci/jwt" />
|
||||
<path value="$PROJECT_DIR$/vendor/lcobucci/clock" />
|
||||
<path value="$PROJECT_DIR$/vendor/twig/twig" />
|
||||
<path value="$PROJECT_DIR$/vendor/twig/extra-bundle" />
|
||||
<path value="$PROJECT_DIR$/vendor/composer" />
|
||||
<path value="$PROJECT_DIR$/vendor/theseer/tokenizer" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/deprecation-contracts" />
|
||||
|
|
@ -141,7 +145,6 @@
|
|||
<path value="$PROJECT_DIR$/vendor/defuse/php-encryption" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/ux-turbo" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/stimulus-bundle" />
|
||||
<path value="$PROJECT_DIR$/vendor/doctrine/cache" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/type-info" />
|
||||
<path value="$PROJECT_DIR$/vendor/doctrine/event-manager" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/console" />
|
||||
|
|
@ -164,9 +167,32 @@
|
|||
<path value="$PROJECT_DIR$/vendor/symfony/mercure" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/mercure-bundle" />
|
||||
<path value="$PROJECT_DIR$/vendor/firebase/php-jwt" />
|
||||
<path value="$PROJECT_DIR$/vendor/doctrine/cache" />
|
||||
<path value="$PROJECT_DIR$/vendor/knplabs/knp-time-bundle" />
|
||||
<path value="$PROJECT_DIR$/vendor/aws/aws-sdk-php" />
|
||||
<path value="$PROJECT_DIR$/vendor/aws/aws-crt-php" />
|
||||
<path value="$PROJECT_DIR$/vendor/ralouphie/getallheaders" />
|
||||
<path value="$PROJECT_DIR$/vendor/aws/aws-sdk-php-symfony" />
|
||||
<path value="$PROJECT_DIR$/vendor/guzzlehttp/psr7" />
|
||||
<path value="$PROJECT_DIR$/vendor/guzzlehttp/promises" />
|
||||
<path value="$PROJECT_DIR$/vendor/guzzlehttp/guzzle" />
|
||||
<path value="$PROJECT_DIR$/vendor/mtdowling/jmespath.php" />
|
||||
<path value="$PROJECT_DIR$/vendor/psr/http-client" />
|
||||
<path value="$PROJECT_DIR$/vendor/twig/extra-bundle" />
|
||||
<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>
|
||||
</component>
|
||||
<component name="PhpProjectSharedConfiguration" php_language_level="8.2" />
|
||||
<component name="PhpStan">
|
||||
<PhpStan_settings>
|
||||
<phpstan_by_interpreter asDefaultInterpreter="true" interpreter_id="e61c7a46-7290-4f1b-ace7-5462f6da9ae0" timeout="60000" />
|
||||
</PhpStan_settings>
|
||||
</component>
|
||||
<component name="PhpStanOptionsConfiguration">
|
||||
<option name="transferred" value="true" />
|
||||
</component>
|
||||
|
|
@ -175,6 +201,11 @@
|
|||
<PhpUnitSettings configuration_file_path="$PROJECT_DIR$/phpunit.xml.dist" custom_loader_path="$PROJECT_DIR$/vendor/autoload.php" use_configuration_file="true" />
|
||||
</phpunit_settings>
|
||||
</component>
|
||||
<component name="Psalm">
|
||||
<Psalm_settings>
|
||||
<psalm_fixer_by_interpreter asDefaultInterpreter="true" interpreter_id="e61c7a46-7290-4f1b-ace7-5462f6da9ae0" timeout="60000" />
|
||||
</Psalm_settings>
|
||||
</component>
|
||||
<component name="PsalmOptionsConfiguration">
|
||||
<option name="transferred" value="true" />
|
||||
</component>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
### Stack technique
|
||||
- Symfony 7.2
|
||||
- Symfony 7.4
|
||||
- php 8.2 ou supérieur
|
||||
- Stimulus
|
||||
- Turbo
|
||||
- Bootstrap 5.3
|
||||
- Symfony UX toogle password (https://ux.symfony.com/toggle-password)
|
||||
- Les icones sont gérées via symfony UX (https://ux.symfony.com/icons)
|
||||
- Les icones sont prises en prioritées dans la bibliothèque bootstrap
|
||||
- Les icones n'éxistants pas dans cette bibliothèques seront prises en priorité dans fontawesome regular (pour une cohérence visuelle)
|
||||
- Sinon privilégier la bibliothèque ayant le visuel le plus proche
|
||||
|
||||
### Version 0.1 : (17/03/2025)
|
||||
- Contient la logique de login mot de passe avec une entité user (email et password seuelement)
|
||||
- Une base de template twig public est gérée pour les page n'ayant pas besoin de menu
|
||||
- La page de login est designé
|
||||
- Une base de template est gérée pour toutes les pages de l'application aya,t besoin de l'entête et du menu général
|
||||
- Une ébauche de page d'accueil est en cours
|
||||
|
||||
|
||||
|
||||
### Installation
|
||||
#### Database
|
||||
```bash
|
||||
php bin/console doctrine:database:create
|
||||
php bin/console doctrine:schema:update --force
|
||||
```
|
||||
#### Roles
|
||||
```bash
|
||||
php bin/console app:create-role USER
|
||||
php bin/console app:create-role ADMIN
|
||||
php bin/console app:create-role "SUPER ADMIN"
|
||||
```
|
||||
#### Choices.js
|
||||
```bash
|
||||
php bin/console importmap:require choices.js
|
||||
php bin/console importmap:require choices.js/public/assets/styles/choices.min.css
|
||||
```
|
||||
|
|
|
|||
|
|
@ -8,7 +8,20 @@ import './bootstrap.js';
|
|||
import 'bootstrap/dist/css/bootstrap.min.css';
|
||||
import './styles/app.css';
|
||||
import './styles/navbar.css';
|
||||
import './styles/sidebar.css';
|
||||
import './styles/choices.css'
|
||||
import 'choices.js/public/assets/styles/choices.min.css';
|
||||
import 'tabulator-tables/dist/css/tabulator.min.css';
|
||||
import './styles/tabulator.css';
|
||||
import './styles/card.css';
|
||||
import './styles/notifications.css';
|
||||
|
||||
import 'bootstrap';
|
||||
|
||||
console.log('This log comes from assets/app.js - welcome to AssetMapper! 🎉');
|
||||
import './js/template.js';
|
||||
import './js/off_canvas.js';
|
||||
import './js/hoverable-collapse.js';
|
||||
import './js/cookies.js';
|
||||
import 'choices.js';
|
||||
import 'quill'
|
||||
import 'tabulator-tables'
|
||||
import './js/global.js'
|
||||
|
|
@ -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 {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
|
|
@ -31,3 +47,141 @@ body {
|
|||
overflow: hidden;
|
||||
}
|
||||
|
||||
.page-body-wrapper {
|
||||
min-height: calc(100vh - 60px);
|
||||
display: -webkit-flex;
|
||||
display: flex;
|
||||
-webkit-flex-direction: row;
|
||||
flex-direction: row;
|
||||
padding-left: 0;
|
||||
padding-right: 0;
|
||||
padding-top: 60px;
|
||||
|
||||
&.full-page-wrapper {
|
||||
width: 100%;
|
||||
min-height: 100vh;
|
||||
padding-top: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.main-panel {
|
||||
transition: width 0.25s ease, margin 0.25s ease;
|
||||
width: calc(100% - 235px);
|
||||
min-height: calc(100vh - 60px);
|
||||
display: -webkit-flex;
|
||||
display: flex;
|
||||
-webkit-flex-direction: column;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.content-wrapper {
|
||||
background: #EEF0FD;
|
||||
width: 100%;
|
||||
-webkit-flex-grow: 1;
|
||||
flex-grow: 1;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.footer {
|
||||
background: #fff;
|
||||
padding: 10px 2.45rem;
|
||||
transition: all 0.25s ease;
|
||||
-moz-transition: all 0.25s ease;
|
||||
-webkit-transition: all 0.25s ease;
|
||||
-ms-transition: all 0.25s ease;
|
||||
font-size: calc(0.875rem - 0.05rem);
|
||||
font-family: "Nunito", sans-serif;
|
||||
font-weight: 400;
|
||||
border-top: 1px solid rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
|
||||
.btn-primary{
|
||||
background: var(--primary-blue-light);
|
||||
color : #FFFFFF;
|
||||
border: var(--primary-blue-dark);
|
||||
border-radius: 1rem;
|
||||
}
|
||||
.btn-primary:hover{
|
||||
background: var(--primary-blue-dark);
|
||||
color : #FFFFFF;
|
||||
border: var(--primary-blue-light);
|
||||
}
|
||||
|
||||
.btn-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;
|
||||
}
|
||||
|
||||
.navbar-nav-right{
|
||||
flex-direction: row;
|
||||
.navbar .navbar-menu-wrapper .navbar-toggler:active,
|
||||
.navbar .navbar-menu-wrapper .navbar-toggler:focus {
|
||||
outline: none !important;
|
||||
box-shadow: none !important;
|
||||
outline: 0;
|
||||
}
|
||||
|
||||
.navbar .navbar-menu-wrapper .navbar-toggler:not(.navbar-toggler-right) {
|
||||
|
|
@ -82,12 +85,24 @@
|
|||
transition: transform 0.3s linear;
|
||||
}
|
||||
|
||||
.sidebar-icon-only .navbar .navbar-menu-wrapper .navbar-toggler:not(.navbar-toggler-right) {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
.navbar-nav-right{
|
||||
flex-direction: row;
|
||||
}
|
||||
|
||||
.navbar .navbar-menu-wrapper .navbar-nav .nav-item.nav-search .input-group{
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.navbar .navbar-menu-wrapper .navbar-nav .nav-item.nav-search .input-group .form-control{
|
||||
margin-left: 0.7rem;
|
||||
}
|
||||
|
||||
#navbar-search-icon > #search{
|
||||
vertical-align: middle;
|
||||
vertical-align: baseline;
|
||||
}
|
||||
|
||||
.navbar .navbar-menu-wrapper .navbar-nav.navbar-nav-right {
|
||||
|
|
@ -245,3 +260,15 @@
|
|||
width:auto;
|
||||
max-height:40px;
|
||||
}
|
||||
|
||||
.navbar .navbar-menu-wrapper .navbar-nav .nav-item.nav-search .input-group .form-control{
|
||||
background: transparent;
|
||||
border: 0;
|
||||
color: #000;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
#change-project{
|
||||
padding: 2px 5px;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
|
@ -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-iconv": "*",
|
||||
"ext-openssl": "*",
|
||||
"aws/aws-sdk-php-symfony": "^2.8",
|
||||
"doctrine/dbal": "^3",
|
||||
"doctrine/doctrine-bundle": "^2.14",
|
||||
"doctrine/doctrine-migrations-bundle": "^3.4",
|
||||
"doctrine/orm": "^3.3",
|
||||
"firebase/php-jwt": "^6.11",
|
||||
"firebase/php-jwt": "^7.0.0",
|
||||
"knplabs/knp-time-bundle": "^2.4",
|
||||
"league/oauth2-server-bundle": "^0.11.0",
|
||||
"nelmio/cors-bundle": "^2.5",
|
||||
"phpdocumentor/reflection-docblock": "^5.6",
|
||||
"phpstan/phpdoc-parser": "^2.1",
|
||||
"symfony/asset": "7.2.*",
|
||||
"symfony/asset-mapper": "7.2.*",
|
||||
"symfony/console": "7.2.*",
|
||||
"symfony/doctrine-messenger": "7.2.*",
|
||||
"symfony/dotenv": "7.2.*",
|
||||
"symfony/expression-language": "7.2.*",
|
||||
"symfony/asset": "7.4.*",
|
||||
"symfony/asset-mapper": "7.4.*",
|
||||
"symfony/console": "7.4.*",
|
||||
"symfony/doctrine-messenger": "7.4.*",
|
||||
"symfony/dotenv": "7.4.*",
|
||||
"symfony/expression-language": "7.4.*",
|
||||
"symfony/flex": "^2",
|
||||
"symfony/form": "7.2.*",
|
||||
"symfony/framework-bundle": "7.2.*",
|
||||
"symfony/http-client": "7.2.*",
|
||||
"symfony/intl": "7.2.*",
|
||||
"symfony/mailer": "7.2.*",
|
||||
"symfony/form": "7.4.*",
|
||||
"symfony/framework-bundle": "7.4.*",
|
||||
"symfony/http-client": "7.4.*",
|
||||
"symfony/intl": "7.4.*",
|
||||
"symfony/mailer": "7.4.*",
|
||||
"symfony/mercure-bundle": "^0.3.9",
|
||||
"symfony/mime": "7.2.*",
|
||||
"symfony/monolog-bundle": "^3.0",
|
||||
"symfony/notifier": "7.2.*",
|
||||
"symfony/process": "7.2.*",
|
||||
"symfony/property-access": "7.2.*",
|
||||
"symfony/property-info": "7.2.*",
|
||||
"symfony/runtime": "7.2.*",
|
||||
"symfony/security-bundle": "7.2.*",
|
||||
"symfony/serializer": "7.2.*",
|
||||
"symfony/messenger": "7.4.*",
|
||||
"symfony/mime": "7.4.*",
|
||||
"symfony/monolog-bundle": "^3.10",
|
||||
"symfony/notifier": "7.4.*",
|
||||
"symfony/process": "7.4.*",
|
||||
"symfony/property-access": "7.4.*",
|
||||
"symfony/property-info": "7.4.*",
|
||||
"symfony/rate-limiter": "7.4.*",
|
||||
"symfony/runtime": "7.4.*",
|
||||
"symfony/security-bundle": "7.4.*",
|
||||
"symfony/serializer": "7.4.*",
|
||||
"symfony/stimulus-bundle": "^2.24",
|
||||
"symfony/string": "7.2.*",
|
||||
"symfony/translation": "7.2.*",
|
||||
"symfony/twig-bundle": "7.2.*",
|
||||
"symfony/string": "7.4.*",
|
||||
"symfony/translation": "7.4.*",
|
||||
"symfony/twig-bundle": "7.4.*",
|
||||
"symfony/ux-icons": "^2.24",
|
||||
"symfony/ux-toggle-password": "^2.24",
|
||||
"symfony/ux-turbo": "^2.24",
|
||||
"symfony/validator": "7.2.*",
|
||||
"symfony/web-link": "7.2.*",
|
||||
"symfony/yaml": "7.2.*",
|
||||
"twig/extra-bundle": "^2.12|^3.0",
|
||||
"symfony/validator": "7.4.*",
|
||||
"symfony/web-link": "7.4.*",
|
||||
"symfony/webhook": "7.4.*",
|
||||
"symfony/yaml": "7.4.*",
|
||||
"twig/twig": "^2.12|^3.0"
|
||||
},
|
||||
"config": {
|
||||
|
|
@ -100,17 +104,18 @@
|
|||
"extra": {
|
||||
"symfony": {
|
||||
"allow-contrib": false,
|
||||
"require": "7.2.*"
|
||||
"require": "7.4.*"
|
||||
}
|
||||
},
|
||||
"require-dev": {
|
||||
"phpunit/phpunit": "^9.5",
|
||||
"symfony/browser-kit": "7.2.*",
|
||||
"symfony/css-selector": "7.2.*",
|
||||
"symfony/debug-bundle": "7.2.*",
|
||||
"dama/doctrine-test-bundle": "^8.3",
|
||||
"phpunit/phpunit": "^11.0",
|
||||
"symfony/browser-kit": "7.4.*",
|
||||
"symfony/css-selector": "7.4.*",
|
||||
"symfony/debug-bundle": "7.4.*",
|
||||
"symfony/maker-bundle": "^1.62",
|
||||
"symfony/phpunit-bridge": "^7.2",
|
||||
"symfony/stopwatch": "7.2.*",
|
||||
"symfony/web-profiler-bundle": "7.2.*"
|
||||
"symfony/phpunit-bridge": "7.4.*",
|
||||
"symfony/stopwatch": "7.4.*",
|
||||
"symfony/web-profiler-bundle": "7.4.*"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,7 +9,6 @@ return [
|
|||
Symfony\Bundle\WebProfilerBundle\WebProfilerBundle::class => ['dev' => true, 'test' => true],
|
||||
Symfony\UX\StimulusBundle\StimulusBundle::class => ['all' => true],
|
||||
Symfony\UX\Turbo\TurboBundle::class => ['all' => true],
|
||||
Twig\Extra\TwigExtraBundle\TwigExtraBundle::class => ['all' => true],
|
||||
Symfony\Bundle\SecurityBundle\SecurityBundle::class => ['all' => true],
|
||||
Symfony\Bundle\MonologBundle\MonologBundle::class => ['all' => true],
|
||||
Symfony\Bundle\MakerBundle\MakerBundle::class => ['dev' => true],
|
||||
|
|
@ -18,4 +17,7 @@ return [
|
|||
League\Bundle\OAuth2ServerBundle\LeagueOAuth2ServerBundle::class => ['all' => true],
|
||||
Nelmio\CorsBundle\NelmioCorsBundle::class => ['all' => true],
|
||||
Symfony\Bundle\MercureBundle\MercureBundle::class => ['all' => true],
|
||||
Knp\Bundle\TimeBundle\KnpTimeBundle::class => ['all' => true],
|
||||
Aws\Symfony\AwsBundle::class => ['all' => true],
|
||||
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
|
||||
framework:
|
||||
secret: '%env(APP_SECRET)%'
|
||||
trusted_proxies: '%env(TRUSTED_PROXY)%'
|
||||
secret: '%env(APP_SECRET)%'
|
||||
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
|
||||
# Note that the session will be started ONLY if you read or write from it.
|
||||
session: true
|
||||
|
||||
#esi: true
|
||||
#fragments: true
|
||||
#esi: true
|
||||
#fragments: true
|
||||
|
||||
when@test:
|
||||
framework:
|
||||
test: true
|
||||
session:
|
||||
storage_factory_id: session.storage.factory.mock_file
|
||||
framework:
|
||||
test: true
|
||||
session:
|
||||
storage_factory_id: session.storage.factory.mock_file
|
||||
|
|
|
|||
|
|
@ -3,9 +3,9 @@ league_oauth2_server:
|
|||
private_key: '%env(resolve:OAUTH_PRIVATE_KEY)%'
|
||||
private_key_passphrase: '%env(resolve:OAUTH_PASSPHRASE)%'
|
||||
encryption_key: '%env(resolve:OAUTH_ENCRYPTION_KEY)%'
|
||||
access_token_ttl: PT3H # 3 hours
|
||||
refresh_token_ttl: P1M # 1 month
|
||||
auth_code_ttl: PT3H # 10 minutes
|
||||
access_token_ttl: PT15M # 15 minutes
|
||||
refresh_token_ttl: P7D # 7 days
|
||||
auth_code_ttl: PT30M # 30 minutes
|
||||
require_code_challenge_for_public_clients: false
|
||||
resource_server:
|
||||
public_key: '%env(resolve:OAUTH_PUBLIC_KEY)%'
|
||||
|
|
|
|||
|
|
@ -6,3 +6,4 @@ mercure:
|
|||
jwt:
|
||||
secret: '%env(MERCURE_JWT_SECRET)%'
|
||||
publish: '*'
|
||||
subscribe: '*'
|
||||
|
|
|
|||
|
|
@ -1,27 +1,99 @@
|
|||
monolog:
|
||||
channels:
|
||||
- deprecation # Deprecations are logged in the dedicated "deprecation" channel when it exists
|
||||
channels:
|
||||
- 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:
|
||||
monolog:
|
||||
handlers:
|
||||
main:
|
||||
type: stream
|
||||
path: "%kernel.logs_dir%/%kernel.environment%.log"
|
||||
level: debug
|
||||
channels: ["!event"]
|
||||
# uncomment to get logging in your browser
|
||||
# you may have to allow bigger header sizes in your Web server configuration
|
||||
#firephp:
|
||||
# type: firephp
|
||||
# level: info
|
||||
#chromephp:
|
||||
# type: chromephp
|
||||
# level: info
|
||||
console:
|
||||
type: console
|
||||
process_psr_3_messages: false
|
||||
channels: ["!event", "!doctrine", "!console"]
|
||||
monolog:
|
||||
handlers:
|
||||
critical_errors:
|
||||
type: fingers_crossed
|
||||
action_level: critical
|
||||
handler: error_nested
|
||||
buffer_size: 50
|
||||
|
||||
error_nested:
|
||||
type: rotating_file
|
||||
path: "%kernel.logs_dir%/error.log"
|
||||
level: debug
|
||||
max_files: 30
|
||||
|
||||
error:
|
||||
type: rotating_file
|
||||
path: "%kernel.logs_dir%/error.log"
|
||||
level: error # logs error, critical, alert, emergency
|
||||
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:
|
||||
monolog:
|
||||
|
|
@ -40,23 +112,89 @@ when@test:
|
|||
when@prod:
|
||||
monolog:
|
||||
handlers:
|
||||
main:
|
||||
critical_error:
|
||||
type: fingers_crossed
|
||||
action_level: error
|
||||
handler: nested
|
||||
excluded_http_codes: [404, 405]
|
||||
buffer_size: 50 # How many messages should be saved? Prevent memory leaks
|
||||
nested:
|
||||
type: stream
|
||||
path: php://stderr
|
||||
action_level: critical
|
||||
handler: error_nested
|
||||
buffer_size: 50
|
||||
|
||||
error_nested:
|
||||
type: rotating_file
|
||||
path: "%kernel.logs_dir%/critical.log"
|
||||
level: debug
|
||||
formatter: monolog.formatter.json
|
||||
console:
|
||||
type: console
|
||||
process_psr_3_messages: false
|
||||
channels: ["!event", "!doctrine"]
|
||||
deprecation:
|
||||
type: stream
|
||||
channels: [deprecation]
|
||||
path: php://stderr
|
||||
formatter: monolog.formatter.json
|
||||
max_files: 30
|
||||
|
||||
error:
|
||||
type: rotating_file
|
||||
path: "%kernel.logs_dir%/error.log"
|
||||
level: error # logs error, critical, alert, emergency
|
||||
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: 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
|
||||
|
||||
role_hierarchy:
|
||||
ROLE_ADMIN: ROLE_USER
|
||||
ROLE_SUPER_ADMIN: [ROLE_ADMIN, ROLE_ALLOWED_TO_SWITCH]
|
||||
ROLE_ADMIN: ROLE_USER
|
||||
ROLE_SUPER_ADMIN: [ROLE_ALLOWED_TO_SWITCH, ROLE_ADMIN]
|
||||
|
||||
|
||||
firewalls:
|
||||
dev:
|
||||
pattern: ^/(_(profiler|wdt)|css|images|js)/
|
||||
security: false
|
||||
api_token_validation:
|
||||
pattern: ^/api/validate-token
|
||||
stateless: true
|
||||
oauth2: true
|
||||
oauth_userinfo:
|
||||
pattern: ^/oauth2/userinfo
|
||||
stateless: true
|
||||
|
|
@ -29,23 +34,35 @@ security:
|
|||
auth_token:
|
||||
pattern: ^/token
|
||||
stateless: true
|
||||
api_m2m:
|
||||
pattern: ^/api/v1/
|
||||
stateless: true
|
||||
oauth2: true
|
||||
api:
|
||||
pattern: ^/oauth/api
|
||||
security: true
|
||||
stateless: true
|
||||
oauth2: true
|
||||
password_setup:
|
||||
pattern: ^/password_setup
|
||||
stateless: true
|
||||
main:
|
||||
user_checker: App\Security\UserChecker
|
||||
lazy: true
|
||||
provider: app_user_provider
|
||||
login_throttling:
|
||||
max_attempts: 3
|
||||
interval: '1 minute'
|
||||
form_login:
|
||||
login_path: app_login
|
||||
check_path: app_login
|
||||
enable_csrf: true
|
||||
default_target_path: app_index
|
||||
use_referer: true
|
||||
# logout:
|
||||
# path: app_logout
|
||||
# target: app_login
|
||||
always_use_default_target_path: false
|
||||
logout:
|
||||
path: app_logout
|
||||
enable_csrf: false
|
||||
target: app_login
|
||||
|
||||
# activate different ways to authenticate
|
||||
# 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
|
||||
access_control:
|
||||
- { 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: ^/token, roles: PUBLIC_ACCESS }
|
||||
- { path: ^/oauth2/revoke_tokens, roles: PUBLIC_ACCESS }
|
||||
|
|
@ -66,8 +86,6 @@ security:
|
|||
- { path: ^/oauth2/userinfo, roles: IS_AUTHENTICATED_FULLY }
|
||||
- { path: ^/, roles: ROLE_USER }
|
||||
|
||||
|
||||
|
||||
when@test:
|
||||
security:
|
||||
password_hashers:
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
framework:
|
||||
default_locale: en
|
||||
default_locale: fr
|
||||
translator:
|
||||
default_path: '%kernel.project_dir%/translations'
|
||||
fallbacks:
|
||||
|
|
|
|||
|
|
@ -2,6 +2,18 @@ twig:
|
|||
file_name_pattern: '*.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:
|
||||
twig:
|
||||
strict_variables: true
|
||||
|
|
|
|||
|
|
@ -4,6 +4,17 @@
|
|||
# Put parameters here that don't need to change on each machine where the app is deployed
|
||||
# https://symfony.com/doc/current/best_practices.html#use-parameters-for-application-configuration
|
||||
parameters:
|
||||
aws_url: '%env(AWS_ENDPOINT)%'
|
||||
aws_public_url: '%env(AWS_ENDPOINT)%'
|
||||
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:
|
||||
# default configuration for services in *this* file
|
||||
|
|
@ -19,15 +30,42 @@ services:
|
|||
- '../src/DependencyInjection/'
|
||||
- '../src/Entity/'
|
||||
- '../src/Kernel.php'
|
||||
App\MessageHandler\NotificationMessageHandler:
|
||||
arguments:
|
||||
$appUrl: '%app_url%'
|
||||
App\Service\SSO\ProjectService:
|
||||
arguments:
|
||||
$appUrl: '%app_url%'
|
||||
$clientIdentifier: '%oauth_sso_identifier%'
|
||||
App\EventSubscriber\:
|
||||
resource: '../src/EventSubscriber/'
|
||||
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:
|
||||
tags:
|
||||
- { name: kernel.event_listener, event: league.oauth2_server.event.scope_resolve, method: onScopeResolve }
|
||||
League\OAuth2\Server\Repositories\AccessTokenRepositoryInterface:
|
||||
class: App\Repository\AccessTokenRepository
|
||||
decorates: 'League\Bundle\OAuth2ServerBundle\Repository\AccessTokenRepository'
|
||||
App\Command\CreateSuperAdminCommand:
|
||||
arguments:
|
||||
$environment: '%kernel.environment%'
|
||||
|
||||
# add more service definitions when explicit configuration is needed
|
||||
# please note that last definitions always *replace* previous ones
|
||||
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',
|
||||
'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');
|
||||
}
|
||||
}
|
||||