Compare commits

..

1 Commits

Author SHA1 Message Date
Charles-Edouard 400aeedb8f Merge pull request 'SSO' (#1) from SSO into main
Reviewed-on: #1
2025-07-29 16:46:45 +02:00
289 changed files with 11965 additions and 21644 deletions

View File

@ -1,49 +0,0 @@
# 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
View File

@ -17,7 +17,6 @@
###> symfony/framework-bundle ### ###> symfony/framework-bundle ###
APP_ENV=prod APP_ENV=prod
APP_SECRET='kjuusshgvk35434judshfgvkusd224444hvg' APP_SECRET='kjuusshgvk35434judshfgvkusd224444hvg'
APPLICATION=EasyPortal
###< symfony/framework-bundle ### ###< symfony/framework-bundle ###
###> doctrine/doctrine-bundle ### ###> doctrine/doctrine-bundle ###
@ -44,12 +43,10 @@ MAILER_DSN=null://null
TRUSTED_PROXY='185.116.130.121','10.8.34.21' TRUSTED_PROXY='185.116.130.121','10.8.34.21'
###> league/oauth2-server-bundle ### ###> league/oauth2-server-bundle ###
OAUTH_PRIVATE_KEY=%kernel.project_dir%/config/jwt/private.key OAUTH_PRIVATE_KEY=%kernel.project_dir%/config/jwt/private.pem
OAUTH_PUBLIC_KEY=%kernel.project_dir%/config/jwt/public.key OAUTH_PUBLIC_KEY=%kernel.project_dir%/config/jwt/public.pem
OAUTH_PASSPHRASE=8170ea18d2e3e05b5c7ae0672a754bf4 OAUTH_PASSPHRASE=8170ea18d2e3e05b5c7ae0672a754bf4
OAUTH_ENCRYPTION_KEY=f1b7c279f7992205a0df45e295d07066 OAUTH_ENCRYPTION_KEY=f1b7c279f7992205a0df45e295d07066
OAUTH_SSO_IDENTIFIER='sso-own-identifier'
OAUTH_SSO_IDENTIFIER_LOGIN='sso-own-identifier'
###< league/oauth2-server-bundle ### ###< league/oauth2-server-bundle ###
###> nelmio/cors-bundle ### ###> nelmio/cors-bundle ###
@ -65,15 +62,3 @@ MERCURE_PUBLIC_URL=https://example.com/.well-known/mercure
# The secret used to sign the JWTs # The secret used to sign the JWTs
MERCURE_JWT_SECRET="!ChangeThisMercureHubJWTSecretKey!" MERCURE_JWT_SECRET="!ChangeThisMercureHubJWTSecretKey!"
###< symfony/mercure-bundle ### ###< symfony/mercure-bundle ###
###> aws/aws-sdk-php-symfony ###
AWS_KEY=not-a-real-key
AWS_SECRET=@@not-a-real-secret
AWS_REGION=us-east-1
AWS_ENDPOINT=https://s3.amazonaws.com
AWS_S3_PORTAL_URL=https://s3.amazonaws.com/portal
###< aws/aws-sdk-php-symfony ###
APP_URL='https://example.com'
APP_DOMAIN='example.com'
EASYCHECK_URL='https://testcheck.solutions-easy.com'

4
.env.dev Normal file
View File

@ -0,0 +1,4 @@
###> symfony/framework-bundle ###
APP_SECRET=bba5a2c490c3f92030618ecb97b5138e
###< symfony/framework-bundle ###

6
.env.test Normal file
View File

@ -0,0 +1,6 @@
# 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

18
.gitignore vendored
View File

@ -1,12 +1,10 @@
###> symfony/framework-bundle ### ###> symfony/framework-bundle ###
/.env.local /.env.local
/.env.test
/.env.local.php /.env.local.php
/.env.*.local /.env.*.local
/config/secrets/prod/prod.decrypt.private.php /config/secrets/prod/prod.decrypt.private.php
/public/bundles/ /public/bundles/
/public/uploads/
/var/ /var/
/vendor/ /vendor/
###< symfony/framework-bundle ### ###< symfony/framework-bundle ###
@ -16,18 +14,12 @@
.phpunit.result.cache .phpunit.result.cache
###< phpunit/phpunit ### ###< phpunit/phpunit ###
###> symfony/phpunit-bridge ###
.phpunit.result.cache
/phpunit.xml
###< symfony/phpunit-bridge ###
###> symfony/asset-mapper ### ###> symfony/asset-mapper ###
/public/assets/ /public/assets/
/assets/vendor/ /assets/vendor/
###< symfony/asset-mapper ### ###< symfony/asset-mapper ###
###> IntelliJ IDEA ###
.idea/
*.iml
###< IntelliJ IDEA ###
###>jwt keys###
/config/jwt/private.key
/config/jwt/public.key
###<jwt keys###
/composer.lock

View File

@ -1,302 +0,0 @@
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"

View File

@ -8,23 +8,8 @@
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/mercure" /> <excludeFolder url="file://$MODULE_DIR$/vendor/symfony/mercure" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/mercure-bundle" /> <excludeFolder url="file://$MODULE_DIR$/vendor/symfony/mercure-bundle" />
<excludeFolder url="file://$MODULE_DIR$/vendor/firebase/php-jwt" /> <excludeFolder url="file://$MODULE_DIR$/vendor/firebase/php-jwt" />
<excludeFolder url="file://$MODULE_DIR$/vendor/aws/aws-crt-php" />
<excludeFolder url="file://$MODULE_DIR$/vendor/aws/aws-sdk-php" />
<excludeFolder url="file://$MODULE_DIR$/vendor/aws/aws-sdk-php-symfony" />
<excludeFolder url="file://$MODULE_DIR$/vendor/guzzlehttp/guzzle" />
<excludeFolder url="file://$MODULE_DIR$/vendor/guzzlehttp/promises" />
<excludeFolder url="file://$MODULE_DIR$/vendor/guzzlehttp/psr7" />
<excludeFolder url="file://$MODULE_DIR$/vendor/knplabs/knp-time-bundle" />
<excludeFolder url="file://$MODULE_DIR$/vendor/mtdowling/jmespath.php" />
<excludeFolder url="file://$MODULE_DIR$/vendor/psr/http-client" />
<excludeFolder url="file://$MODULE_DIR$/vendor/ralouphie/getallheaders" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/rate-limiter" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/remote-event" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/webhook" />
</content> </content>
<orderEntry type="inheritedJdk" /> <orderEntry type="inheritedJdk" />
<orderEntry type="sourceFolder" forTests="false" /> <orderEntry type="sourceFolder" forTests="false" />
<orderEntry type="library" name="quill.snow" level="application" />
<orderEntry type="library" name="quill" level="application" />
</component> </component>
</module> </module>

View File

@ -10,11 +10,6 @@
<option name="highlightLevel" value="WARNING" /> <option name="highlightLevel" value="WARNING" />
<option name="transferred" value="true" /> <option name="transferred" value="true" />
</component> </component>
<component name="PhpCodeSniffer">
<phpcs_settings>
<phpcs_by_interpreter asDefaultInterpreter="true" interpreter_id="e61c7a46-7290-4f1b-ace7-5462f6da9ae0" timeout="30000" />
</phpcs_settings>
</component>
<component name="PhpIncludePathManager"> <component name="PhpIncludePathManager">
<include_path> <include_path>
<path value="$PROJECT_DIR$/vendor/symfony/twig-bridge" /> <path value="$PROJECT_DIR$/vendor/symfony/twig-bridge" />
@ -88,6 +83,7 @@
<path value="$PROJECT_DIR$/vendor/lcobucci/jwt" /> <path value="$PROJECT_DIR$/vendor/lcobucci/jwt" />
<path value="$PROJECT_DIR$/vendor/lcobucci/clock" /> <path value="$PROJECT_DIR$/vendor/lcobucci/clock" />
<path value="$PROJECT_DIR$/vendor/twig/twig" /> <path value="$PROJECT_DIR$/vendor/twig/twig" />
<path value="$PROJECT_DIR$/vendor/twig/extra-bundle" />
<path value="$PROJECT_DIR$/vendor/composer" /> <path value="$PROJECT_DIR$/vendor/composer" />
<path value="$PROJECT_DIR$/vendor/theseer/tokenizer" /> <path value="$PROJECT_DIR$/vendor/theseer/tokenizer" />
<path value="$PROJECT_DIR$/vendor/symfony/deprecation-contracts" /> <path value="$PROJECT_DIR$/vendor/symfony/deprecation-contracts" />
@ -145,6 +141,7 @@
<path value="$PROJECT_DIR$/vendor/defuse/php-encryption" /> <path value="$PROJECT_DIR$/vendor/defuse/php-encryption" />
<path value="$PROJECT_DIR$/vendor/symfony/ux-turbo" /> <path value="$PROJECT_DIR$/vendor/symfony/ux-turbo" />
<path value="$PROJECT_DIR$/vendor/symfony/stimulus-bundle" /> <path value="$PROJECT_DIR$/vendor/symfony/stimulus-bundle" />
<path value="$PROJECT_DIR$/vendor/doctrine/cache" />
<path value="$PROJECT_DIR$/vendor/symfony/type-info" /> <path value="$PROJECT_DIR$/vendor/symfony/type-info" />
<path value="$PROJECT_DIR$/vendor/doctrine/event-manager" /> <path value="$PROJECT_DIR$/vendor/doctrine/event-manager" />
<path value="$PROJECT_DIR$/vendor/symfony/console" /> <path value="$PROJECT_DIR$/vendor/symfony/console" />
@ -167,32 +164,9 @@
<path value="$PROJECT_DIR$/vendor/symfony/mercure" /> <path value="$PROJECT_DIR$/vendor/symfony/mercure" />
<path value="$PROJECT_DIR$/vendor/symfony/mercure-bundle" /> <path value="$PROJECT_DIR$/vendor/symfony/mercure-bundle" />
<path value="$PROJECT_DIR$/vendor/firebase/php-jwt" /> <path value="$PROJECT_DIR$/vendor/firebase/php-jwt" />
<path value="$PROJECT_DIR$/vendor/doctrine/cache" />
<path value="$PROJECT_DIR$/vendor/knplabs/knp-time-bundle" />
<path value="$PROJECT_DIR$/vendor/aws/aws-sdk-php" />
<path value="$PROJECT_DIR$/vendor/aws/aws-crt-php" />
<path value="$PROJECT_DIR$/vendor/ralouphie/getallheaders" />
<path value="$PROJECT_DIR$/vendor/aws/aws-sdk-php-symfony" />
<path value="$PROJECT_DIR$/vendor/guzzlehttp/psr7" />
<path value="$PROJECT_DIR$/vendor/guzzlehttp/promises" />
<path value="$PROJECT_DIR$/vendor/guzzlehttp/guzzle" />
<path value="$PROJECT_DIR$/vendor/mtdowling/jmespath.php" />
<path value="$PROJECT_DIR$/vendor/psr/http-client" />
<path value="$PROJECT_DIR$/vendor/twig/extra-bundle" />
<path value="$PROJECT_DIR$/vendor/staabm/side-effects-detector" />
<path value="$PROJECT_DIR$/vendor/symfony/rate-limiter" />
<path value="$PROJECT_DIR$/vendor/dama/doctrine-test-bundle" />
<path value="$PROJECT_DIR$/vendor/symfony/polyfill-php85" />
<path value="$PROJECT_DIR$/vendor/symfony/remote-event" />
<path value="$PROJECT_DIR$/vendor/symfony/webhook" />
</include_path> </include_path>
</component> </component>
<component name="PhpProjectSharedConfiguration" php_language_level="8.2" /> <component name="PhpProjectSharedConfiguration" php_language_level="8.2" />
<component name="PhpStan">
<PhpStan_settings>
<phpstan_by_interpreter asDefaultInterpreter="true" interpreter_id="e61c7a46-7290-4f1b-ace7-5462f6da9ae0" timeout="60000" />
</PhpStan_settings>
</component>
<component name="PhpStanOptionsConfiguration"> <component name="PhpStanOptionsConfiguration">
<option name="transferred" value="true" /> <option name="transferred" value="true" />
</component> </component>
@ -201,11 +175,6 @@
<PhpUnitSettings configuration_file_path="$PROJECT_DIR$/phpunit.xml.dist" custom_loader_path="$PROJECT_DIR$/vendor/autoload.php" use_configuration_file="true" /> <PhpUnitSettings configuration_file_path="$PROJECT_DIR$/phpunit.xml.dist" custom_loader_path="$PROJECT_DIR$/vendor/autoload.php" use_configuration_file="true" />
</phpunit_settings> </phpunit_settings>
</component> </component>
<component name="Psalm">
<Psalm_settings>
<psalm_fixer_by_interpreter asDefaultInterpreter="true" interpreter_id="e61c7a46-7290-4f1b-ace7-5462f6da9ae0" timeout="60000" />
</Psalm_settings>
</component>
<component name="PsalmOptionsConfiguration"> <component name="PsalmOptionsConfiguration">
<option name="transferred" value="true" /> <option name="transferred" value="true" />
</component> </component>

File diff suppressed because one or more lines are too long

View File

@ -1,104 +0,0 @@
#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;

View File

@ -1,41 +0,0 @@
## 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

View File

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

View File

@ -1,26 +1,23 @@
## Template de base pour les applications de la suite Solutions-easy ## Template de base pour les applications de la suite Solutions-easy
### Stack technique ### Stack technique
- Symfony 7.4 - Symfony 7.2
- php 8.2 ou supérieur - php 8.2 ou supérieur
- Stimulus - Stimulus
- Turbo - Turbo
- Bootstrap 5.3 - Bootstrap 5.3
- Symfony UX toogle password (https://ux.symfony.com/toggle-password)
- Les icones sont gérées via symfony UX (https://ux.symfony.com/icons)
- Les icones sont prises en prioritées dans la bibliothèque bootstrap
- Les icones n'éxistants pas dans cette bibliothèques seront prises en priorité dans fontawesome regular (pour une cohérence visuelle)
- Sinon privilégier la bibliothèque ayant le visuel le plus proche
### Version 0.1 : (17/03/2025)
- Contient la logique de login mot de passe avec une entité user (email et password seuelement)
- Une base de template twig public est gérée pour les page n'ayant pas besoin de menu
- La page de login est designé
- Une base de template est gérée pour toutes les pages de l'application aya,t besoin de l'entête et du menu général
- Une ébauche de page d'accueil est en cours
### Installation
#### Database
```bash
php bin/console doctrine:database:create
php bin/console doctrine:schema:update --force
```
#### Roles
```bash
php bin/console app:create-role USER
php bin/console app:create-role ADMIN
php bin/console app:create-role "SUPER ADMIN"
```
#### Choices.js
```bash
php bin/console importmap:require choices.js
php bin/console importmap:require choices.js/public/assets/styles/choices.min.css
```

View File

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

View File

@ -1,169 +0,0 @@
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}. Êtesvous sûr(e) ?`)) {
return;
}
this.submitBtnTarget.textContent = 'En cours...';
this.submitBtnTarget.disabled = true;
fetch(event.target.action, {
method: 'POST',
body: new FormData(event.target)
})
.then(response => {
if (response.ok) {
this.submitBtnTarget.textContent = 'Autorisé ✓';
this.submitBtnTarget.classList.replace('btn-secondary', 'btn-success');
} else {
this.submitBtnTarget.textContent = originalText;
this.submitBtnTarget.disabled = false;
alert('Erreur lors de l\'action');
}
})
.catch(error => {
this.submitBtnTarget.textContent = originalText;
this.submitBtnTarget.disabled = false;
alert('Erreur lors de l\'action');
});
}
handleRemoveSubmit(event) {
event.preventDefault();
const originalText = this.submitBtnTarget.textContent;
if (!confirm(`Vous vous apprêtez à retirer l'accès à ${this.applicationValue} pour ${this.organizationValue}. Êtesvous sûr(e) ?`)) {
return;
}
this.submitBtnTarget.textContent = 'En cours...';
this.submitBtnTarget.disabled = true;
fetch(event.target.action, {
method: 'POST',
body: new FormData(event.target)
})
.then(response => {
if (response.ok) {
this.submitBtnTarget.textContent = 'Retiré ✓';
this.submitBtnTarget.classList.replace('btn-secondary', 'btn-danger');
} else {
this.submitBtnTarget.textContent = originalText;
this.submitBtnTarget.disabled = false;
alert('Erreur lors de l\'action');
}
})
.catch(error => {
this.submitBtnTarget.textContent = originalText;
this.submitBtnTarget.disabled = false;
alert('Erreur lors de l\'action');
});
}
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;
}
}

View File

@ -1,27 +0,0 @@
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);
}
}
}

View File

@ -1,324 +0,0 @@
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);
}
}

View File

@ -1,227 +0,0 @@
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 = "";
}
}

View File

@ -1,247 +0,0 @@
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.");
}
}
}

File diff suppressed because it is too large Load Diff

View File

@ -1 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 965 B

View File

@ -1 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 598 B

View File

@ -1 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 272 B

View File

@ -1 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 236 B

View File

@ -1 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 236 B

View File

@ -1 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 827 B

View File

@ -1 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 609 B

View File

@ -1 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 425 B

View File

@ -1 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 428 B

View File

@ -1 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 528 B

View File

@ -1 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 787 B

View File

@ -1 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 548 B

View File

@ -1 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 152 B

View File

@ -1 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 473 B

View File

@ -1 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 560 B

View File

@ -1 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 334 B

View File

@ -1 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 577 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

BIN
assets/img/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 888 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 888 B

View File

@ -1,39 +0,0 @@
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 "";
}

View File

@ -1,86 +0,0 @@
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);
}

View File

@ -1,39 +0,0 @@
//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);

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,156 +0,0 @@
.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;
}

View File

@ -1,290 +0,0 @@
.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);
}

View File

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

View File

@ -8,52 +8,48 @@
"ext-ctype": "*", "ext-ctype": "*",
"ext-iconv": "*", "ext-iconv": "*",
"ext-openssl": "*", "ext-openssl": "*",
"aws/aws-sdk-php-symfony": "^2.8",
"doctrine/dbal": "^3", "doctrine/dbal": "^3",
"doctrine/doctrine-bundle": "^2.14", "doctrine/doctrine-bundle": "^2.14",
"doctrine/doctrine-migrations-bundle": "^3.4", "doctrine/doctrine-migrations-bundle": "^3.4",
"doctrine/orm": "^3.3", "doctrine/orm": "^3.3",
"firebase/php-jwt": "^7.0.0", "firebase/php-jwt": "^6.11",
"knplabs/knp-time-bundle": "^2.4",
"league/oauth2-server-bundle": "^0.11.0", "league/oauth2-server-bundle": "^0.11.0",
"nelmio/cors-bundle": "^2.5", "nelmio/cors-bundle": "^2.5",
"phpdocumentor/reflection-docblock": "^5.6", "phpdocumentor/reflection-docblock": "^5.6",
"phpstan/phpdoc-parser": "^2.1", "phpstan/phpdoc-parser": "^2.1",
"symfony/asset": "7.4.*", "symfony/asset": "7.2.*",
"symfony/asset-mapper": "7.4.*", "symfony/asset-mapper": "7.2.*",
"symfony/console": "7.4.*", "symfony/console": "7.2.*",
"symfony/doctrine-messenger": "7.4.*", "symfony/doctrine-messenger": "7.2.*",
"symfony/dotenv": "7.4.*", "symfony/dotenv": "7.2.*",
"symfony/expression-language": "7.4.*", "symfony/expression-language": "7.2.*",
"symfony/flex": "^2", "symfony/flex": "^2",
"symfony/form": "7.4.*", "symfony/form": "7.2.*",
"symfony/framework-bundle": "7.4.*", "symfony/framework-bundle": "7.2.*",
"symfony/http-client": "7.4.*", "symfony/http-client": "7.2.*",
"symfony/intl": "7.4.*", "symfony/intl": "7.2.*",
"symfony/mailer": "7.4.*", "symfony/mailer": "7.2.*",
"symfony/mercure-bundle": "^0.3.9", "symfony/mercure-bundle": "^0.3.9",
"symfony/messenger": "7.4.*", "symfony/mime": "7.2.*",
"symfony/mime": "7.4.*", "symfony/monolog-bundle": "^3.0",
"symfony/monolog-bundle": "^3.10", "symfony/notifier": "7.2.*",
"symfony/notifier": "7.4.*", "symfony/process": "7.2.*",
"symfony/process": "7.4.*", "symfony/property-access": "7.2.*",
"symfony/property-access": "7.4.*", "symfony/property-info": "7.2.*",
"symfony/property-info": "7.4.*", "symfony/runtime": "7.2.*",
"symfony/rate-limiter": "7.4.*", "symfony/security-bundle": "7.2.*",
"symfony/runtime": "7.4.*", "symfony/serializer": "7.2.*",
"symfony/security-bundle": "7.4.*",
"symfony/serializer": "7.4.*",
"symfony/stimulus-bundle": "^2.24", "symfony/stimulus-bundle": "^2.24",
"symfony/string": "7.4.*", "symfony/string": "7.2.*",
"symfony/translation": "7.4.*", "symfony/translation": "7.2.*",
"symfony/twig-bundle": "7.4.*", "symfony/twig-bundle": "7.2.*",
"symfony/ux-icons": "^2.24", "symfony/ux-icons": "^2.24",
"symfony/ux-toggle-password": "^2.24", "symfony/ux-toggle-password": "^2.24",
"symfony/ux-turbo": "^2.24", "symfony/ux-turbo": "^2.24",
"symfony/validator": "7.4.*", "symfony/validator": "7.2.*",
"symfony/web-link": "7.4.*", "symfony/web-link": "7.2.*",
"symfony/webhook": "7.4.*", "symfony/yaml": "7.2.*",
"symfony/yaml": "7.4.*", "twig/extra-bundle": "^2.12|^3.0",
"twig/twig": "^2.12|^3.0" "twig/twig": "^2.12|^3.0"
}, },
"config": { "config": {
@ -104,18 +100,17 @@
"extra": { "extra": {
"symfony": { "symfony": {
"allow-contrib": false, "allow-contrib": false,
"require": "7.4.*" "require": "7.2.*"
} }
}, },
"require-dev": { "require-dev": {
"dama/doctrine-test-bundle": "^8.3", "phpunit/phpunit": "^9.5",
"phpunit/phpunit": "^11.0", "symfony/browser-kit": "7.2.*",
"symfony/browser-kit": "7.4.*", "symfony/css-selector": "7.2.*",
"symfony/css-selector": "7.4.*", "symfony/debug-bundle": "7.2.*",
"symfony/debug-bundle": "7.4.*",
"symfony/maker-bundle": "^1.62", "symfony/maker-bundle": "^1.62",
"symfony/phpunit-bridge": "7.4.*", "symfony/phpunit-bridge": "^7.2",
"symfony/stopwatch": "7.4.*", "symfony/stopwatch": "7.2.*",
"symfony/web-profiler-bundle": "7.4.*" "symfony/web-profiler-bundle": "7.2.*"
} }
} }

11436
composer.lock generated Normal file

File diff suppressed because it is too large Load Diff

View File

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

30
config/jwt/private.key Normal file
View File

@ -0,0 +1,30 @@
-----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-----

9
config/jwt/public.key Normal file
View File

@ -0,0 +1,9 @@
-----BEGIN PUBLIC KEY-----
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAwLRhFcrWQWeuGhgrFclT
c08RTX52kl9XMXatgJi/mfa/o7lbZbhQfNzrMXVZFDTs97YTZXotZVwL1pI7yGtv
bjbPNuhs+lvOSmWribIacYx47EqKeYhQ29rOYx7fnr+SvK10QZoFnT58tduQVrER
79b+lcbqKMKNeI6zZeVdBrhstuI/PtXnM4kMHThaTz3iLbWkoDyl06VIMFVsvHpc
HaPJsBH1I45M9l4gTfshknY3Dz5ESVd41XTH2c/PSa6geNUn5kpslCuxKv3DnmWE
h+V87ASCIM7tKE6bc0eFQLKPQo52/TUWfDa8nFbeIrbsQJwq5VhYK21TANNFFL3g
GwIDAQAB
-----END PUBLIC KEY-----

View File

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

View File

@ -1,5 +0,0 @@
when@test:
dama_doctrine_test:
enable_static_connection: true
enable_static_meta_data_cache: true
enable_static_query_cache: true

View File

@ -1,10 +1,6 @@
# see https://symfony.com/doc/current/reference/configuration/framework.html # see https://symfony.com/doc/current/reference/configuration/framework.html
framework: framework:
secret: '%env(APP_SECRET)%' secret: '%env(APP_SECRET)%'
csrf_protection: true
http_method_override: true
annotations: false
handle_all_throwables: true
trusted_proxies: '%env(TRUSTED_PROXY)%' trusted_proxies: '%env(TRUSTED_PROXY)%'

View File

@ -3,9 +3,9 @@ league_oauth2_server:
private_key: '%env(resolve:OAUTH_PRIVATE_KEY)%' private_key: '%env(resolve:OAUTH_PRIVATE_KEY)%'
private_key_passphrase: '%env(resolve:OAUTH_PASSPHRASE)%' private_key_passphrase: '%env(resolve:OAUTH_PASSPHRASE)%'
encryption_key: '%env(resolve:OAUTH_ENCRYPTION_KEY)%' encryption_key: '%env(resolve:OAUTH_ENCRYPTION_KEY)%'
access_token_ttl: PT15M # 15 minutes access_token_ttl: PT3H # 3 hours
refresh_token_ttl: P7D # 7 days refresh_token_ttl: P1M # 1 month
auth_code_ttl: PT30M # 30 minutes auth_code_ttl: PT3H # 10 minutes
require_code_challenge_for_public_clients: false require_code_challenge_for_public_clients: false
resource_server: resource_server:
public_key: '%env(resolve:OAUTH_PUBLIC_KEY)%' public_key: '%env(resolve:OAUTH_PUBLIC_KEY)%'

View File

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

View File

@ -1,99 +1,27 @@
monolog: monolog:
channels: 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 - deprecation # Deprecations are logged in the dedicated "deprecation" channel when it exists
when@dev: when@dev:
monolog: monolog:
handlers: handlers:
critical_errors: main:
type: fingers_crossed type: stream
action_level: critical path: "%kernel.logs_dir%/%kernel.environment%.log"
handler: error_nested
buffer_size: 50
error_nested:
type: rotating_file
path: "%kernel.logs_dir%/error.log"
level: debug level: debug
max_files: 30 channels: ["!event"]
# uncomment to get logging in your browser
error: # you may have to allow bigger header sizes in your Web server configuration
type: rotating_file #firephp:
path: "%kernel.logs_dir%/error.log" # type: firephp
level: error # logs error, critical, alert, emergency # level: info
max_files: 30 #chromephp:
channels: [ error ] # type: chromephp
php_errors: # level: info
type: rotating_file console:
path: "%kernel.logs_dir%/php_error.log" type: console
level: warning # warnings, errors, fatals… process_psr_3_messages: false
max_files: 30 channels: ["!event", "!doctrine", "!console"]
channels: [ php ]
# User Management
user_management:
type: rotating_file
path: "%kernel.logs_dir%/user_management.log"
level: debug
channels: [ user_management ]
max_files: 30
# Authentication
authentication:
type: rotating_file
path: "%kernel.logs_dir%/authentication.log"
level: debug
channels: [ authentication ]
max_files: 30
# Organization Management
organization_management:
type: rotating_file
path: "%kernel.logs_dir%/organization_management.log"
level: debug
channels: [ organization_management ]
max_files: 30
# Access Control
access_control:
type: rotating_file
path: "%kernel.logs_dir%/access_control.log"
level: debug
channels: [ access_control ]
max_files: 30
# Email Notifications
email_notifications:
type: rotating_file
path: "%kernel.logs_dir%/email_notifications.log"
level: debug
channels: [ email_notifications ]
max_files: 30
# Admin Actions
admin_actions:
type: rotating_file
path: "%kernel.logs_dir%/admin_actions.log"
level: debug
channels: [ admin_actions ]
max_files: 30
# Security
security:
type: rotating_file
path: "%kernel.logs_dir%/security.log"
level: debug
channels: [ security ]
max_files: 30
when@test: when@test:
monolog: monolog:
@ -112,89 +40,23 @@ when@test:
when@prod: when@prod:
monolog: monolog:
handlers: handlers:
critical_error: main:
type: fingers_crossed type: fingers_crossed
action_level: critical action_level: error
handler: error_nested handler: nested
buffer_size: 50 excluded_http_codes: [404, 405]
buffer_size: 50 # How many messages should be saved? Prevent memory leaks
error_nested: nested:
type: rotating_file type: stream
path: "%kernel.logs_dir%/critical.log" path: php://stderr
level: debug level: debug
max_files: 30 formatter: monolog.formatter.json
console:
error: type: console
type: rotating_file process_psr_3_messages: false
path: "%kernel.logs_dir%/error.log" channels: ["!event", "!doctrine"]
level: error # logs error, critical, alert, emergency deprecation:
max_files: 30 type: stream
channels: [ error ] channels: [deprecation]
path: php://stderr
php_errors: formatter: monolog.formatter.json
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

View File

@ -12,17 +12,12 @@ security:
role_hierarchy: role_hierarchy:
ROLE_ADMIN: ROLE_USER ROLE_ADMIN: ROLE_USER
ROLE_SUPER_ADMIN: [ROLE_ALLOWED_TO_SWITCH, ROLE_ADMIN] ROLE_SUPER_ADMIN: [ROLE_ADMIN, ROLE_ALLOWED_TO_SWITCH]
firewalls: firewalls:
dev: dev:
pattern: ^/(_(profiler|wdt)|css|images|js)/ pattern: ^/(_(profiler|wdt)|css|images|js)/
security: false security: false
api_token_validation:
pattern: ^/api/validate-token
stateless: true
oauth2: true
oauth_userinfo: oauth_userinfo:
pattern: ^/oauth2/userinfo pattern: ^/oauth2/userinfo
stateless: true stateless: true
@ -34,35 +29,23 @@ security:
auth_token: auth_token:
pattern: ^/token pattern: ^/token
stateless: true stateless: true
api_m2m:
pattern: ^/api/v1/
stateless: true
oauth2: true
api: api:
pattern: ^/oauth/api pattern: ^/oauth/api
security: true security: true
stateless: true stateless: true
oauth2: true oauth2: true
password_setup:
pattern: ^/password_setup
stateless: true
main: main:
user_checker: App\Security\UserChecker
lazy: true lazy: true
provider: app_user_provider provider: app_user_provider
login_throttling:
max_attempts: 3
interval: '1 minute'
form_login: form_login:
login_path: app_login login_path: app_login
check_path: app_login check_path: app_login
enable_csrf: true enable_csrf: true
default_target_path: app_index default_target_path: app_index
always_use_default_target_path: false use_referer: true
logout: # logout:
path: app_logout # path: app_logout
enable_csrf: false # target: app_login
target: app_login
# activate different ways to authenticate # activate different ways to authenticate
# https://symfony.com/doc/current/security.html#the-firewall # https://symfony.com/doc/current/security.html#the-firewall
@ -74,9 +57,6 @@ security:
# Note: Only the *first* access control that matches will be used # Note: Only the *first* access control that matches will be used
access_control: access_control:
- { path: ^/login, roles: PUBLIC_ACCESS } - { path: ^/login, roles: PUBLIC_ACCESS }
- { path: ^/api/validate-token, roles: PUBLIC_ACCESS }
- { path: ^/password_setup, roles: PUBLIC_ACCESS }
- { path: ^/password_reset, roles: PUBLIC_ACCESS }
- { path: ^/sso_logout, roles: IS_AUTHENTICATED_FULLY } - { path: ^/sso_logout, roles: IS_AUTHENTICATED_FULLY }
- { path: ^/token, roles: PUBLIC_ACCESS } - { path: ^/token, roles: PUBLIC_ACCESS }
- { path: ^/oauth2/revoke_tokens, roles: PUBLIC_ACCESS } - { path: ^/oauth2/revoke_tokens, roles: PUBLIC_ACCESS }
@ -86,6 +66,8 @@ security:
- { path: ^/oauth2/userinfo, roles: IS_AUTHENTICATED_FULLY } - { path: ^/oauth2/userinfo, roles: IS_AUTHENTICATED_FULLY }
- { path: ^/, roles: ROLE_USER } - { path: ^/, roles: ROLE_USER }
when@test: when@test:
security: security:
password_hashers: password_hashers:

View File

@ -1,5 +1,5 @@
framework: framework:
default_locale: fr default_locale: en
translator: translator:
default_path: '%kernel.project_dir%/translations' default_path: '%kernel.project_dir%/translations'
fallbacks: fallbacks:

View File

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

File diff suppressed because it is too large Load Diff

View File

@ -4,17 +4,6 @@
# Put parameters here that don't need to change on each machine where the app is deployed # Put parameters here that don't need to change on each machine where the app is deployed
# https://symfony.com/doc/current/best_practices.html#use-parameters-for-application-configuration # https://symfony.com/doc/current/best_practices.html#use-parameters-for-application-configuration
parameters: parameters:
aws_url: '%env(AWS_ENDPOINT)%'
aws_public_url: '%env(AWS_ENDPOINT)%'
aws_bucket: '%env(S3_PORTAL_BUCKET)%'
app_url: '%env(APP_URL)%'
app_domain: '%env(APP_DOMAIN)%'
mercure_secret: '%env(MERCURE_JWT_SECRET)%'
logos_directory: '%kernel.project_dir%/public/uploads/logos'
oauth_sso_identifier: '%env(OAUTH_SSO_IDENTIFIER)%'
oauth_sso_identifier_login: '%env(OAUTH_SSO_IDENTIFIER_LOGIN)%'
easycheck_url: '%env(EASYCHECK_URL)%'
webhook_secret: '%env(WEBHOOK_SECRET)%'
services: services:
# default configuration for services in *this* file # default configuration for services in *this* file
@ -30,42 +19,15 @@ services:
- '../src/DependencyInjection/' - '../src/DependencyInjection/'
- '../src/Entity/' - '../src/Entity/'
- '../src/Kernel.php' - '../src/Kernel.php'
App\MessageHandler\NotificationMessageHandler:
arguments:
$appUrl: '%app_url%'
App\Service\SSO\ProjectService:
arguments:
$appUrl: '%app_url%'
$clientIdentifier: '%oauth_sso_identifier%'
App\EventSubscriber\: App\EventSubscriber\:
resource: '../src/EventSubscriber/' resource: '../src/EventSubscriber/'
tags: ['kernel.event_subscriber'] tags: ['kernel.event_subscriber']
App\EventSubscriber\LoginSubscriber:
arguments:
$clientIdentifier: '%oauth_sso_identifier_login%'
$easycheckUrl: '%env(EASYCHECK_URL)%'
App\Service\AwsService:
arguments:
$awsPublicUrl: '%aws_public_url%'
App\Service\OrganizationsService:
arguments:
$logoDirectory: '%logos_directory%'
App\EventSubscriber\ScopeResolveListener: App\EventSubscriber\ScopeResolveListener:
tags: tags:
- { name: kernel.event_listener, event: league.oauth2_server.event.scope_resolve, method: onScopeResolve } - { name: kernel.event_listener, event: league.oauth2_server.event.scope_resolve, method: onScopeResolve }
League\OAuth2\Server\Repositories\AccessTokenRepositoryInterface: League\OAuth2\Server\Repositories\AccessTokenRepositoryInterface:
class: App\Repository\AccessTokenRepository class: App\Repository\AccessTokenRepository
decorates: 'League\Bundle\OAuth2ServerBundle\Repository\AccessTokenRepository' decorates: 'League\Bundle\OAuth2ServerBundle\Repository\AccessTokenRepository'
App\Command\CreateSuperAdminCommand:
arguments:
$environment: '%kernel.environment%'
App\EventListener\LogoutSubscriber: # add more service definitions when explicit configuration is needed
arguments: # please note that last definitions always *replace* previous ones
$easycheckUrl: '%env(EASYCHECK_URL)%'
tags:
- { name: kernel.event_subscriber }
App\Webhook\OrganizationNotifier:
arguments:
$easycheckUrl: '%easycheck_url%'
$webhookSecret: '%webhook_secret%'

View File

@ -1,225 +0,0 @@
# 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

View File

@ -1,365 +0,0 @@
# 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
```

View File

@ -1,35 +0,0 @@
# 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

View File

@ -1,169 +0,0 @@
# 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
```

View File

@ -1,208 +0,0 @@
# 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

View File

@ -1,79 +0,0 @@
{
# 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
}
}

View File

@ -1,104 +0,0 @@
{
# 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
}
}
}
}

View File

@ -1,13 +0,0 @@
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

View File

@ -1,5 +0,0 @@
; 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

View File

@ -1,5 +0,0 @@
; 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

View File

@ -1,66 +0,0 @@
#!/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 "$@"

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,32 +0,0 @@
<?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');
}
}

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