From 8e59f59679ceea552191fb2bb7a55236c3d5fcf7 Mon Sep 17 00:00:00 2001 From: matthieu Date: Fri, 3 Apr 2026 10:14:39 +0200 Subject: [PATCH] =?UTF-8?q?feat=20:=20init=20Central=20project=20=E2=80=94?= =?UTF-8?q?=20Symfony=208=20+=20Nuxt=204=20+=20Docker=20starter?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Same architecture as Lesstime: API Platform 4, JWT auth, @malio/layer-ui, PostgreSQL 16, Docker Compose (ports 8083/3003/5436), dark mode theme. Co-Authored-By: Claude Opus 4.6 (1M context) --- .env | 23 ++ .env.example | 92 +++++++ .gitignore | 46 ++++ .php-cs-fixer.dist.php | 56 ++++ CLAUDE.md | 107 ++++++++ README.md | 18 ++ bin/console | 19 ++ composer.json | 94 +++++++ config/bundles.php | 25 ++ config/packages/api_platform.yaml | 12 + config/packages/doctrine.yaml | 43 +++ config/packages/framework.yaml | 12 + config/packages/lexik_jwt_authentication.yaml | 25 ++ config/packages/nelmio_cors.yaml | 11 + config/packages/security.yaml | 61 +++++ config/preload.php | 7 + config/routes.yaml | 4 + config/routes/api_platform.yaml | 4 + config/routes/security.yaml | 7 + config/services.yaml | 17 ++ config/version.yaml | 2 + docker-compose.yml | 60 +++++ frontend/.npmrc | 1 + frontend/app.vue | 13 + frontend/assets/css/dark.css | 248 ++++++++++++++++++ frontend/components/ui/AppTopNav.vue | 56 ++++ frontend/components/ui/SidebarLink.vue | 52 ++++ frontend/composables/useApi.ts | 217 +++++++++++++++ frontend/composables/useAppVersion.ts | 17 ++ frontend/i18n/i18n.config.ts | 4 + frontend/i18n/locales/fr.json | 25 ++ frontend/layouts/auth.vue | 7 + frontend/layouts/default.vue | 112 ++++++++ frontend/middleware/auth.global.ts | 16 ++ frontend/nuxt.config.ts | 59 +++++ frontend/package.json | 25 ++ frontend/pages/index.vue | 12 + frontend/pages/login.vue | 68 +++++ frontend/public/LOGO_CARRE.png | Bin 0 -> 36583 bytes frontend/public/favicon.ico | Bin 0 -> 1150 bytes frontend/public/malio.png | Bin 0 -> 8091 bytes frontend/services/auth.ts | 22 ++ frontend/services/dto/user-data.ts | 12 + frontend/stores/auth.ts | 71 +++++ frontend/stores/ui.ts | 57 ++++ frontend/tailwind.config.ts | 48 ++++ frontend/tsconfig.json | 17 ++ frontend/utils/api.ts | 10 + infra/dev/.env.docker | 9 + infra/dev/Dockerfile | 102 +++++++ infra/dev/nginx.conf | 54 ++++ infra/dev/php.ini | 8 + infra/dev/xdebug.ini | 9 + makefile | 119 +++++++++ phpunit.dist.xml | 44 ++++ public/index.php | 11 + src/ApiResource/AppVersion.php | 25 ++ src/DataFixtures/AppFixtures.php | 40 +++ src/Entity/User.php | 154 +++++++++++ src/Kernel.php | 13 + src/Repository/UserRepository.php | 20 ++ src/State/AppVersionProvider.php | 26 ++ src/State/MeProvider.php | 26 ++ src/State/UserPasswordHasherProcessor.php | 41 +++ tests/bootstrap.php | 15 ++ 65 files changed, 2630 insertions(+) create mode 100644 .env create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 .php-cs-fixer.dist.php create mode 100644 CLAUDE.md create mode 100644 README.md create mode 100755 bin/console create mode 100644 composer.json create mode 100644 config/bundles.php create mode 100644 config/packages/api_platform.yaml create mode 100644 config/packages/doctrine.yaml create mode 100644 config/packages/framework.yaml create mode 100644 config/packages/lexik_jwt_authentication.yaml create mode 100644 config/packages/nelmio_cors.yaml create mode 100644 config/packages/security.yaml create mode 100644 config/preload.php create mode 100644 config/routes.yaml create mode 100644 config/routes/api_platform.yaml create mode 100644 config/routes/security.yaml create mode 100644 config/services.yaml create mode 100644 config/version.yaml create mode 100644 docker-compose.yml create mode 100644 frontend/.npmrc create mode 100644 frontend/app.vue create mode 100644 frontend/assets/css/dark.css create mode 100644 frontend/components/ui/AppTopNav.vue create mode 100644 frontend/components/ui/SidebarLink.vue create mode 100644 frontend/composables/useApi.ts create mode 100644 frontend/composables/useAppVersion.ts create mode 100644 frontend/i18n/i18n.config.ts create mode 100644 frontend/i18n/locales/fr.json create mode 100644 frontend/layouts/auth.vue create mode 100644 frontend/layouts/default.vue create mode 100644 frontend/middleware/auth.global.ts create mode 100644 frontend/nuxt.config.ts create mode 100644 frontend/package.json create mode 100644 frontend/pages/index.vue create mode 100644 frontend/pages/login.vue create mode 100644 frontend/public/LOGO_CARRE.png create mode 100644 frontend/public/favicon.ico create mode 100644 frontend/public/malio.png create mode 100644 frontend/services/auth.ts create mode 100644 frontend/services/dto/user-data.ts create mode 100644 frontend/stores/auth.ts create mode 100644 frontend/stores/ui.ts create mode 100644 frontend/tailwind.config.ts create mode 100644 frontend/tsconfig.json create mode 100644 frontend/utils/api.ts create mode 100644 infra/dev/.env.docker create mode 100644 infra/dev/Dockerfile create mode 100644 infra/dev/nginx.conf create mode 100644 infra/dev/php.ini create mode 100644 infra/dev/xdebug.ini create mode 100644 makefile create mode 100644 phpunit.dist.xml create mode 100644 public/index.php create mode 100644 src/ApiResource/AppVersion.php create mode 100644 src/DataFixtures/AppFixtures.php create mode 100644 src/Entity/User.php create mode 100644 src/Kernel.php create mode 100644 src/Repository/UserRepository.php create mode 100644 src/State/AppVersionProvider.php create mode 100644 src/State/MeProvider.php create mode 100644 src/State/UserPasswordHasherProcessor.php create mode 100644 tests/bootstrap.php diff --git a/.env b/.env new file mode 100644 index 0000000..eba8096 --- /dev/null +++ b/.env @@ -0,0 +1,23 @@ +APP_ENV=dev +APP_SECRET="change_me_in_env_local" +APP_DEBUG=1 + +DEFAULT_URI=http://localhost/ + +###> nelmio/cors-bundle ### +CORS_ALLOW_ORIGIN='^https?://(localhost|127.0.0.1)(:[0-9]+)?$' +###< nelmio/cors-bundle ### + +###> lexik/jwt-authentication-bundle ### +JWT_SECRET_KEY=%kernel.project_dir%/config/jwt/private.pem +JWT_PUBLIC_KEY=%kernel.project_dir%/config/jwt/public.pem +JWT_PASSPHRASE=change_me_in_env_local +JWT_COOKIE_SECURE=0 +JWT_TOKEN_TTL=86400 +JWT_COOKIE_TTL=86400 +###< lexik/jwt-authentication-bundle ### + + +DATABASE_URL="postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:${POSTGRES_PORT}/${POSTGRES_DB}?serverVersion=16&charset=utf8" + +ENCRYPTION_KEY=change_me_in_env_local diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..a5d5fba --- /dev/null +++ b/.env.example @@ -0,0 +1,92 @@ +############################################################################### +# Central — Fichier d'environnement de reference +# +# Copiez ce fichier en .env.local et remplissez les valeurs sensibles. +# Les valeurs par defaut dans .env suffisent pour le developpement ; +# seuls les secrets (APP_SECRET, JWT_PASSPHRASE, ENCRYPTION_KEY) doivent +# etre definis dans .env.local. +# +# Ne commitez JAMAIS de vrais secrets dans .env ou .env.example. +############################################################################### + +# =========================================================================== +# App +# =========================================================================== + +# Environnement Symfony : dev, test, prod +APP_ENV=dev + +# Secret applicatif Symfony (32 chars hex) — a generer pour chaque installation +# Generer avec : php -r "echo bin2hex(random_bytes(16));" +APP_SECRET="change_me_in_env_local" + +# Active/desactive le mode debug (1 = oui, 0 = non) +APP_DEBUG=1 + +# URI par defaut de l'application (utilise pour les liens absolus) +DEFAULT_URI=http://localhost/ + +# =========================================================================== +# CORS (nelmio/cors-bundle) +# =========================================================================== + +# Origines autorisees pour les requetes cross-origin (regex) +CORS_ALLOW_ORIGIN='^https?://(localhost|127\.0\.0\.1)(:[0-9]+)?$' + +# =========================================================================== +# JWT (lexik/jwt-authentication-bundle) +# =========================================================================== + +# Chemin vers la cle privee RSA pour signer les tokens JWT +JWT_SECRET_KEY=%kernel.project_dir%/config/jwt/private.pem + +# Chemin vers la cle publique RSA pour verifier les tokens JWT +JWT_PUBLIC_KEY=%kernel.project_dir%/config/jwt/public.pem + +# Passphrase de la cle privee JWT — a generer pour chaque installation +# Generer avec : php -r "echo bin2hex(random_bytes(32));" +JWT_PASSPHRASE=change_me_in_env_local + +# Cookie securise (1 = HTTPS uniquement, 0 = HTTP autorise — dev seulement) +JWT_COOKIE_SECURE=0 + +# Duree de vie du token JWT en secondes (86400 = 24h) +JWT_TOKEN_TTL=86400 + +# Duree de vie du cookie JWT en secondes (86400 = 24h) +JWT_COOKIE_TTL=86400 + +# =========================================================================== +# Base de donnees (Doctrine / PostgreSQL) +# =========================================================================== + +# Les variables POSTGRES_* sont definies dans infra/dev/.env.docker +# et injectees automatiquement par Docker Compose. +# DATABASE_URL est construite a partir de ces variables. +DATABASE_URL="postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:${POSTGRES_PORT}/${POSTGRES_DB}?serverVersion=16&charset=utf8" + +# =========================================================================== +# Chiffrement +# =========================================================================== + +# Cle de chiffrement pour les donnees sensibles (64 chars hex = 256 bits) +# Generer avec : php -r "echo bin2hex(random_bytes(32));" +ENCRYPTION_KEY=change_me_in_env_local + +# =========================================================================== +# Docker (infra/dev/.env.docker) +# +# Ces variables sont lues par Docker Compose. Voir infra/dev/.env.docker +# pour les valeurs par defaut. Creez infra/dev/.env.docker.local pour +# surcharger localement. +# =========================================================================== + +# DOCKER_APP_NAME=central +# DOCKER_PHP_VERSION=8.4.6 +# DOCKER_NODE_VERSION=24.12.0 +# APP_USER=www-data +# POSTGRES_DB=central +# POSTGRES_USER=root +# POSTGRES_PASSWORD=root +# POSTGRES_PORT=5436 +# XDEBUG_CLIENT_HOST=host.docker.internal diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..aa31c4f --- /dev/null +++ b/.gitignore @@ -0,0 +1,46 @@ +###> symfony/framework-bundle ### +/.env.local +/.env.local.php +/.env.*.local +/config/secrets/prod/prod.decrypt.private.php +/public/bundles/ +/var/ +/vendor/ +###< symfony/framework-bundle ### + +###> lexik/jwt-authentication-bundle ### +/config/jwt/*.pem +###< lexik/jwt-authentication-bundle ### + +###> phpunit ### +/phpunit.xml +.phpunit.cache +###< phpunit ### + +###> friendsofphp/php-cs-fixer ### +.php-cs-fixer.cache +###< friendsofphp/php-cs-fixer ### + +###> docker ### +infra/dev/.env.docker.local +###< docker ### + +###> node ### +frontend/node_modules/ +frontend/.nuxt/ +frontend/.output/ +frontend/dist/ +###< node ### + +###> ide ### +.idea/ +.vscode/ +###< ide ### + +###> logs ### +LOG/ +###< logs ### + +###> claude ### +.claude/ +###< claude ### diff --git a/.php-cs-fixer.dist.php b/.php-cs-fixer.dist.php new file mode 100644 index 0000000..0b01813 --- /dev/null +++ b/.php-cs-fixer.dist.php @@ -0,0 +1,56 @@ +in('src') + ->notName('Kernel.php') +; + +$rules = [ + '@Symfony' => true, + '@PSR12' => true, + '@PHP84Migration' => true, + '@PER-CS' => true, + '@PhpCsFixer' => true, + 'strict_param' => true, + 'strict_comparison' => true, + 'no_useless_else' => true, + 'no_useless_return' => true, + 'binary_operator_spaces' => [ + 'operators' => [ + '=' => 'align_single_space_minimal', + '||' => 'align_single_space_minimal', + '=>' => 'align_single_space_minimal', + ], + ], + 'global_namespace_import' => [ + 'import_classes' => true, + 'import_constants' => true, + 'import_functions' => true, + ], + 'modernize_strpos' => true, + 'no_superfluous_phpdoc_tags' => true, + 'echo_tag_syntax' => true, + 'semicolon_after_instruction' => true, + 'combine_consecutive_unsets' => true, + 'ternary_to_null_coalescing' => true, + 'declare_strict_types' => true, + 'operator_linebreak' => [ + 'position' => 'beginning', + ], + 'no_unused_imports' => true, + 'single_line_throw' => false, + 'php_unit_test_class_requires_covers' => false, +]; + +$config = new Config(); + +return $config + ->setRiskyAllowed(true) + ->setRules($rules) + ->setFinder($finder) +; diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..39eadf1 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,107 @@ +# Central + +Application de gestion du SI Malio. Monorepo Symfony 8 (API Platform 4) + Nuxt 4. + +## Stack + +- **Backend** : PHP 8.4, Symfony 8.0, API Platform 4, Doctrine ORM, PostgreSQL 16 +- **Frontend** : Nuxt 4 (SSR off / SPA), Vue 3, Pinia, Tailwind CSS, @malio/layer-ui, nuxt-toast, @nuxtjs/i18n, @nuxt/icon +- **Auth** : JWT HTTP-only cookie (lexik/jwt-authentication-bundle), login à `/login_check`, cookie `BEARER` +- **Docker** : PHP-FPM + Node 24, Nginx (port 8083), PostgreSQL (port 5436) + +## Structure + +``` +src/Entity/ # Entités Doctrine (User) +src/ApiResource/ # Ressources API Platform (AppVersion) +src/State/ # Providers et Processors API Platform (MeProvider, AppVersionProvider, UserPasswordHasherProcessor) +src/Repository/ # Repositories Doctrine +src/DataFixtures/ # Fixtures +config/ # Config Symfony (security, api_platform, lexik_jwt, nelmio_cors, doctrine) +config/jwt/ # Clés JWT (private.pem, public.pem) +migrations/ # Migrations Doctrine +frontend/ # App Nuxt 4 +frontend/pages/ # Pages (index, login) +frontend/layouts/ # Layouts (default, auth) +frontend/components/ # Composants Vue (ui/SidebarLink, ui/AppTopNav) +frontend/composables/# Composables (useApi, useAppVersion) +frontend/stores/ # Stores Pinia (auth, ui) +frontend/services/ # Services API (auth) +frontend/services/dto/ # Types TypeScript +frontend/i18n/locales/ # Fichiers de traduction +``` + +## Commandes + +```bash +make start # Démarrer les containers +make stop # Arrêter les containers +make restart # Redémarrer les containers +make install # Install complet (composer, migrations, fixtures, build Nuxt) +make reset # Tout supprimer et réinstaller (supprime la BDD) +make dev-nuxt # Dev server Nuxt (hot reload, port 3003) +make shell # Shell dans le container PHP +make shell-root # Shell root dans le container PHP +make cache-clear # Vider le cache Symfony +make migration-migrate # Lancer les migrations +make fixtures # Charger les fixtures +make db-reset # Reset BDD + migrations + fixtures +make test # PHPUnit +make php-cs-fixer-allow-risky # Fix code style PHP +make logs-dev # Tail logs Symfony +``` + +## Conventions + +### Commits + +Format : `() : ` (espace avant et après `:`) + +Types autorisés (minuscules) : `build`, `chore`, `ci`, `docs`, `feat`, `fix`, `perf`, `refactor`, `revert`, `style`, `test` + +Exemples : `feat : add login page`, `fix(auth) : prevent null token crash` + +### Tags & Versioning + +- La version de l'app est dans `config/version.yaml` (paramètre `app.version`) +- À chaque création de tag, **toujours** mettre à jour `config/version.yaml` avec la même version +- Faire un commit séparé de bump : `chore : bump version to v` +- Puis créer le tag et pusher : `git tag v && git push origin main --tags` + +### Backend + +- Toujours `declare(strict_types=1)` en haut des fichiers PHP +- API Platform : utiliser ApiResource, Providers (`src/State/`), Processors — pas de controllers +- Routes API préfixées `/api` (via `config/routes/api_platform.yaml`) +- Le login (`/login_check`) est hors prefix `/api`, nginx réécrit `REQUEST_URI` vers `/login_check` +- PHP CS Fixer : règles Symfony + PSR-12 + strict types +- Rôles : `ROLE_ADMIN`, `ROLE_USER` — hiérarchie dans `security.yaml` +- Controllers custom sous `/api/` : ajouter `priority: 1` sur `#[Route]` pour éviter le conflit avec API Platform `{id}` + +### Frontend + +- TypeScript strict +- Composable `useApi()` pour tous les appels API (gère cookies, erreurs, toasts, i18n) +- Stores Pinia : `useAuthStore` (auth), `useUiStore` (ui) +- Middleware global `auth.global.ts` protège les routes +- Traductions dans `frontend/i18n/locales/` (le module résout `langDir` depuis `i18n/`) +- 4 espaces d'indentation + +### Nginx + +- `/api/*` → Symfony (via try_files + index.php) +- `/api/login_check` → location exact match, fastcgi direct avec REQUEST_URI réécrit en `/login_check` +- `/` → SPA frontend (`frontend/dist/`) + +## Docker + +- Container PHP : `php-central-fpm` +- Container Nginx : `nginx-central` +- Container DB : PostgreSQL sur port **5436** (interne et externe) +- Config Docker : `infra/dev/.env.docker` (override local : `infra/dev/.env.docker.local`) +- Après modif nginx : `docker restart nginx-central` + +## Fixtures + +- User admin : `admin` / `admin` (ROLE_ADMIN) +- Users internes : `alice` / `alice`, `bob` / `bob` (ROLE_USER) diff --git a/README.md b/README.md new file mode 100644 index 0000000..c6b638b --- /dev/null +++ b/README.md @@ -0,0 +1,18 @@ +# Central + +Application de gestion du SI Malio — gestion des applications, bases de données, mode maintenance, déploiements. + +## Installation + +```bash +make start +make install +make fixtures +``` + +## Accès + +- Frontend : http://localhost:8083 +- API : http://localhost:8083/api +- Dev Nuxt (hot reload) : http://localhost:3003 +- Login : `admin` / `admin` diff --git a/bin/console b/bin/console new file mode 100755 index 0000000..d047973 --- /dev/null +++ b/bin/console @@ -0,0 +1,19 @@ +#!/usr/bin/env php +=8.4", + "ext-ctype": "*", + "ext-iconv": "*", + "api-platform/doctrine-orm": "^4.2", + "api-platform/symfony": "^4.2", + "doctrine/doctrine-bundle": "^3.2", + "doctrine/doctrine-migrations-bundle": "^4.0", + "doctrine/orm": "^3.6", + "lexik/jwt-authentication-bundle": "^3.2", + "nelmio/cors-bundle": "^2.6", + "nyholm/psr7": "^1.8", + "phpdocumentor/reflection-docblock": "^5.6|^6.0", + "phpstan/phpdoc-parser": "^2.3", + "symfony/asset": "8.0.*", + "symfony/console": "8.0.*", + "symfony/dotenv": "8.0.*", + "symfony/expression-language": "8.0.*", + "symfony/flex": "^2", + "symfony/framework-bundle": "8.0.*", + "symfony/http-client": "8.0.*", + "symfony/mime": "8.0.*", + "symfony/monolog-bundle": "^4.0", + "symfony/property-access": "8.0.*", + "symfony/property-info": "8.0.*", + "symfony/runtime": "8.0.*", + "symfony/security-bundle": "8.0.*", + "symfony/serializer": "8.0.*", + "symfony/validator": "8.0.*", + "symfony/yaml": "8.0.*" + }, + "config": { + "allow-plugins": { + "php-http/discovery": true, + "symfony/flex": true, + "symfony/runtime": true + }, + "bump-after-update": true, + "sort-packages": true + }, + "autoload": { + "psr-4": { + "App\\": "src/" + } + }, + "autoload-dev": { + "psr-4": { + "App\\Tests\\": "tests/" + } + }, + "replace": { + "symfony/polyfill-ctype": "*", + "symfony/polyfill-iconv": "*", + "symfony/polyfill-php72": "*", + "symfony/polyfill-php73": "*", + "symfony/polyfill-php74": "*", + "symfony/polyfill-php80": "*", + "symfony/polyfill-php81": "*", + "symfony/polyfill-php82": "*", + "symfony/polyfill-php83": "*", + "symfony/polyfill-php84": "*" + }, + "scripts": { + "auto-scripts": { + "cache:clear": "symfony-cmd", + "assets:install %PUBLIC_DIR%": "symfony-cmd" + }, + "post-install-cmd": [ + "@auto-scripts" + ], + "post-update-cmd": [ + "@auto-scripts" + ] + }, + "conflict": { + "symfony/symfony": "*" + }, + "extra": { + "symfony": { + "allow-contrib": false, + "require": "8.0.*" + } + }, + "require-dev": { + "doctrine/doctrine-fixtures-bundle": "^4.3", + "friendsofphp/php-cs-fixer": "^3.94", + "phpunit/phpunit": "^13.0" + } +} diff --git a/config/bundles.php b/config/bundles.php new file mode 100644 index 0000000..a7c2c43 --- /dev/null +++ b/config/bundles.php @@ -0,0 +1,25 @@ + ['all' => true], + SecurityBundle::class => ['all' => true], + DoctrineBundle::class => ['all' => true], + DoctrineMigrationsBundle::class => ['all' => true], + NelmioCorsBundle::class => ['all' => true], + ApiPlatformBundle::class => ['all' => true], + DoctrineFixturesBundle::class => ['dev' => true, 'test' => true], + LexikJWTAuthenticationBundle::class => ['all' => true], + MonologBundle::class => ['all' => true], +]; diff --git a/config/packages/api_platform.yaml b/config/packages/api_platform.yaml new file mode 100644 index 0000000..137b51a --- /dev/null +++ b/config/packages/api_platform.yaml @@ -0,0 +1,12 @@ +api_platform: + title: Central API + version: 1.0.0 + formats: + jsonld: ['application/ld+json'] + json: ['application/json'] + patch_formats: + json: ['application/merge-patch+json'] + defaults: + stateless: true + cache_headers: + vary: ['Content-Type', 'Authorization', 'Origin'] diff --git a/config/packages/doctrine.yaml b/config/packages/doctrine.yaml new file mode 100644 index 0000000..90d2cb1 --- /dev/null +++ b/config/packages/doctrine.yaml @@ -0,0 +1,43 @@ +doctrine: + dbal: + url: '%env(resolve:DATABASE_URL)%' + profiling_collect_backtrace: '%kernel.debug%' + orm: + validate_xml_mapping: true + naming_strategy: doctrine.orm.naming_strategy.underscore_number_aware + identity_generation_preferences: + Doctrine\DBAL\Platforms\PostgreSQLPlatform: identity + auto_mapping: true + mappings: + App: + type: attribute + is_bundle: false + dir: '%kernel.project_dir%/src/Entity' + prefix: 'App\Entity' + alias: App + controller_resolver: + auto_mapping: false + +when@test: + doctrine: + dbal: + # "TEST_TOKEN" is typically set by ParaTest + dbname_suffix: '_test%env(default::TEST_TOKEN)%' + +when@prod: + doctrine: + orm: + query_cache_driver: + type: pool + pool: doctrine.system_cache_pool + result_cache_driver: + type: pool + pool: doctrine.result_cache_pool + + framework: + cache: + pools: + doctrine.result_cache_pool: + adapter: cache.app + doctrine.system_cache_pool: + adapter: cache.system diff --git a/config/packages/framework.yaml b/config/packages/framework.yaml new file mode 100644 index 0000000..f92c233 --- /dev/null +++ b/config/packages/framework.yaml @@ -0,0 +1,12 @@ +# see https://symfony.com/doc/current/reference/configuration/framework.html +framework: + secret: '%env(APP_SECRET)%' + + # Note that the session will be started ONLY if you read or write from it. + session: true + +when@test: + framework: + test: true + session: + storage_factory_id: session.storage.factory.mock_file diff --git a/config/packages/lexik_jwt_authentication.yaml b/config/packages/lexik_jwt_authentication.yaml new file mode 100644 index 0000000..ade6ecd --- /dev/null +++ b/config/packages/lexik_jwt_authentication.yaml @@ -0,0 +1,25 @@ +lexik_jwt_authentication: + secret_key: '%env(resolve:JWT_SECRET_KEY)%' + public_key: '%env(resolve:JWT_PUBLIC_KEY)%' + pass_phrase: '%env(JWT_PASSPHRASE)%' + token_ttl: '%env(int:JWT_TOKEN_TTL)%' + remove_token_from_body_when_cookies_used: true + token_extractors: + authorization_header: + enabled: false + cookie: + enabled: true + name: BEARER + query_parameter: + enabled: false + set_cookies: + BEARER: + lifetime: '%env(int:JWT_COOKIE_TTL)%' + samesite: lax + path: / + secure: '%env(bool:JWT_COOKIE_SECURE)%' + httpOnly: true + api_platform: + check_path: /api/login_check + username_path: username + password_path: password diff --git a/config/packages/nelmio_cors.yaml b/config/packages/nelmio_cors.yaml new file mode 100644 index 0000000..ff85e9e --- /dev/null +++ b/config/packages/nelmio_cors.yaml @@ -0,0 +1,11 @@ +nelmio_cors: + defaults: + origin_regex: true + allow_origin: ['%env(CORS_ALLOW_ORIGIN)%'] + allow_credentials: true + allow_methods: ['GET', 'OPTIONS', 'POST', 'PUT', 'PATCH', 'DELETE'] + allow_headers: ['Content-Type', 'Authorization'] + expose_headers: ['Link'] + max_age: 3600 + paths: + '^/': null diff --git a/config/packages/security.yaml b/config/packages/security.yaml new file mode 100644 index 0000000..4a2f0d5 --- /dev/null +++ b/config/packages/security.yaml @@ -0,0 +1,61 @@ +security: + role_hierarchy: + ROLE_ADMIN: [ROLE_USER] + + # https://symfony.com/doc/current/security.html#registering-the-user-hashing-passwords + password_hashers: + Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface: 'auto' + + # https://symfony.com/doc/current/security.html#loading-the-user-the-user-provider + providers: + app_user_provider: + entity: + class: App\Entity\User + property: username + + firewalls: + dev: + # Ensure dev tools and static assets are always allowed + pattern: ^/(_profiler|_wdt|assets|build)/ + security: false + login: + pattern: ^/login_check + stateless: true + provider: app_user_provider + login_throttling: + max_attempts: 5 + interval: '1 minute' + json_login: + check_path: /login_check + username_path: username + password_path: password + success_handler: lexik_jwt_authentication.handler.authentication_success + failure_handler: lexik_jwt_authentication.handler.authentication_failure + api: + pattern: ^/api + stateless: true + provider: app_user_provider + jwt: ~ + logout: + path: /api/logout + target: /login + enable_csrf: false + delete_cookies: + BEARER: + path: / + + # Note: Only the *first* matching rule is applied + access_control: + - { path: ^/login_check, roles: PUBLIC_ACCESS } + - { path: ^/api/docs, roles: PUBLIC_ACCESS } + - { path: ^/api/version, roles: PUBLIC_ACCESS, methods: [ GET ] } + - { path: ^/api, roles: IS_AUTHENTICATED_FULLY } + +when@test: + security: + password_hashers: + Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface: + algorithm: auto + cost: 4 # Lowest possible value for bcrypt + time_cost: 3 # Lowest possible value for argon + memory_cost: 10 # Lowest possible value for argon diff --git a/config/preload.php b/config/preload.php new file mode 100644 index 0000000..7cbe578 --- /dev/null +++ b/config/preload.php @@ -0,0 +1,7 @@ + + + + + + + diff --git a/frontend/assets/css/dark.css b/frontend/assets/css/dark.css new file mode 100644 index 0000000..70782bb --- /dev/null +++ b/frontend/assets/css/dark.css @@ -0,0 +1,248 @@ +/* + * Dark theme overrides + * Automatically applied when is set. + * Overrides existing Tailwind utilities so components need zero changes. + */ + +/* ── Backgrounds ── */ + +.dark .bg-white { + background-color: #1e1f2b !important; +} + +.dark .bg-tertiary-500 { + background-color: #262838 !important; +} + +.dark .bg-neutral-50 { + background-color: #262838 !important; +} + +.dark .bg-neutral-100 { + background-color: #2e3045 !important; +} + +.dark .bg-neutral-200 { + background-color: #363952 !important; +} + +/* ── Hover backgrounds ── */ + +.dark .hover\:bg-neutral-50:hover { + background-color: #2e3045 !important; +} + +.dark .hover\:bg-neutral-100:hover { + background-color: #363952 !important; +} + +.dark .hover\:bg-neutral-200:hover { + background-color: #3a3d54 !important; +} + +.dark .hover\:bg-neutral-300:hover { + background-color: #3a3d54 !important; +} + +.dark .hover\:shadow-md:hover { + box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.3), 0 2px 4px -2px rgb(0 0 0 / 0.3) !important; +} + +/* ── Text ── */ + +.dark .text-neutral-900 { + color: #e5e5e5 !important; +} + +.dark .text-neutral-800 { + color: #d4d4d8 !important; +} + +.dark .text-neutral-700 { + color: #a1a1aa !important; +} + +.dark .text-neutral-600 { + color: #8b8b9a !important; +} + +.dark .text-neutral-500 { + color: #71717a !important; +} + +.dark .text-neutral-400 { + color: #606070 !important; +} + +.dark .text-neutral-300 { + color: #52525b !important; +} + +/* ── Hover text ── */ + +.dark .hover\:text-neutral-700:hover { + color: #d4d4d8 !important; +} + +.dark .hover\:text-neutral-600:hover { + color: #a1a1aa !important; +} + +/* ── Borders ── */ + +.dark .border-neutral-200 { + border-color: #3a3d54 !important; +} + +.dark .border-neutral-100 { + border-color: #2e3045 !important; +} + +.dark .border-neutral-300 { + border-color: #3a3d54 !important; +} + +.dark .hover\:border-neutral-300:hover { + border-color: #4a4d64 !important; +} + +.dark .hover\:border-neutral-400:hover { + border-color: #4a4d64 !important; +} + +/* ── Ring ── */ + +.dark .ring-black\/5 { + --tw-ring-color: rgb(255 255 255 / 0.05) !important; +} + +/* ── Specific component overrides ── */ + +/* Modal header bg */ +.dark .bg-neutral-50\/80 { + background-color: rgb(38 40 56 / 0.8) !important; +} + +/* Sidebar collapse button */ +.dark .shadow-sm { + box-shadow: 0 1px 2px 0 rgb(0 0 0 / 0.2) !important; +} + +/* User dropdown */ +.dark .shadow-lg { + box-shadow: 0 10px 15px -3px rgb(0 0 0 / 0.4), 0 4px 6px -4px rgb(0 0 0 / 0.3) !important; +} + +/* Forms: inputs, selects, textareas */ +.dark input:not([type="checkbox"]):not([type="radio"]), +.dark textarea, +.dark select { + background-color: #1e1f2b !important; + color: #e5e5e5 !important; + border-color: #3a3d54 !important; +} + +.dark input:not([type="checkbox"]):not([type="radio"])::placeholder, +.dark textarea::placeholder { + color: #606070 !important; +} + +.dark input:not([type="checkbox"]):not([type="radio"]):focus, +.dark textarea:focus, +.dark select:focus { + border-color: #222783 !important; +} + +/* Labels */ +.dark label { + color: #a1a1aa; +} + +/* ── Malio Layer UI components ── */ + +/* MalioSelect: floating label has hardcoded background: white */ +.dark .floating-label { + background: #1e1f2b !important; + color: #a1a1aa !important; +} + +/* MalioSelect: text-black used for selected value and options */ +.dark .text-black { + color: #e5e5e5 !important; +} + +.dark .text-black\/60 { + color: #71717a !important; +} + +.dark .text-black\/40 { + color: #606070 !important; +} + +/* MalioSelect: border-black used when option is selected */ +.dark .border-black { + border-color: #a1a1aa !important; +} + +/* MalioSelect: border-m-muted default border */ +.dark .border-m-muted { + border-color: #3a3d54 !important; +} + +/* MalioSelect: dropdown option hover background */ +.dark .bg-m-muted\/10 { + background-color: rgb(160 174 192 / 0.15) !important; +} + +/* MalioSelect: dropdown shadow */ +.dark .shadow-2xl { + box-shadow: 0 25px 50px -12px rgb(0 0 0 / 0.5) !important; +} + +/* Checkbox/radio hardcoded black borders */ +.dark .inp-cbx + .cbx svg { + stroke: #e5e5e5 !important; +} + +.dark .inp-cbx + .cbx { + border-color: #a1a1aa !important; +} + +/* Red/colored backgrounds for buttons */ +.dark .bg-red-50 { + background-color: rgb(127 29 29 / 0.2) !important; +} + +.dark .hover\:bg-red-100:hover { + background-color: rgb(127 29 29 / 0.3) !important; +} + +.dark .bg-blue-50 { + background-color: rgb(30 58 138 / 0.2) !important; +} + +/* Datetime/date input color-scheme for dark mode */ +.dark input[type="datetime-local"], +.dark input[type="date"], +.dark input[type="time"] { + color-scheme: dark; +} + +/* Scrollbar */ +.dark ::-webkit-scrollbar { + width: 8px; + height: 8px; +} + +.dark ::-webkit-scrollbar-track { + background: #1e1f2b; +} + +.dark ::-webkit-scrollbar-thumb { + background: #3a3d54; + border-radius: 4px; +} + +.dark ::-webkit-scrollbar-thumb:hover { + background: #4a4d64; +} diff --git a/frontend/components/ui/AppTopNav.vue b/frontend/components/ui/AppTopNav.vue new file mode 100644 index 0000000..087b7b8 --- /dev/null +++ b/frontend/components/ui/AppTopNav.vue @@ -0,0 +1,56 @@ + + + diff --git a/frontend/components/ui/SidebarLink.vue b/frontend/components/ui/SidebarLink.vue new file mode 100644 index 0000000..f008b77 --- /dev/null +++ b/frontend/components/ui/SidebarLink.vue @@ -0,0 +1,52 @@ + + + diff --git a/frontend/composables/useApi.ts b/frontend/composables/useApi.ts new file mode 100644 index 0000000..bac2544 --- /dev/null +++ b/frontend/composables/useApi.ts @@ -0,0 +1,217 @@ +import type { FetchOptions } from 'ofetch' +import { $fetch, FetchError } from 'ofetch' +import { useAuthStore } from '~/stores/auth' + +export type AnyObject = Record + +export type BlobResponse = { + data: Blob + headers: Headers +} + +export type ApiClient = { + get(url: string, query?: AnyObject, options?: ApiFetchOptions<'json'>): Promise + getBlob(url: string, query?: AnyObject, options?: ApiFetchOptions<'blob'>): Promise + post(url: string, body?: AnyObject, options?: ApiFetchOptions<'json'>): Promise + put(url: string, body?: AnyObject, options?: ApiFetchOptions<'json'>): Promise + patch(url: string, body?: AnyObject, options?: ApiFetchOptions<'json'>): Promise + delete(url: string, query?: AnyObject, options?: ApiFetchOptions<'json'>): Promise +} + +export type ApiFetchOptions = + FetchOptions & { + toast?: boolean + toastOn401?: boolean + toastTitle?: string + toastErrorMessage?: string + toastSuccessMessage?: string + toastErrorKey?: string + toastSuccessKey?: string + } + +let isHandlingUnauthorized = false + +export function useApi(): ApiClient { + const config = useRuntimeConfig() + const baseURL = config.public.apiBase || '/api' + const toast = useToast() + const auth = useAuthStore() + const nuxtApp = useNuxtApp() + const i18n = nuxtApp.$i18n as + | { + t: (key: string) => string + te?: (key: string) => boolean + } + | undefined + const t = (key: string) => (i18n?.t ? String(i18n.t(key)) : key) + const te = (key: string) => (i18n?.te ? i18n.te(key) : false) + + function extractErrorMessage(error: unknown, responseData?: unknown): string { + const data = responseData ?? (error as FetchError)?.data + + if (typeof data === 'string') { + return data + } + + if (data && typeof data === 'object') { + const record = data as Record + return ( + (record['hydra:description'] as string) || + (record.detail as string) || + (record.message as string) || + (record.error as string) || + (record.title as string) || + (record['hydra:title'] as string) || + '' + ) + } + + return (error as FetchError)?.message ?? 'Erreur inconnue.' + } + + const methodErrorKeys: Record = { + GET: 'errors.http.get', + POST: 'errors.http.post', + PUT: 'errors.http.put', + PATCH: 'errors.http.patch', + DELETE: 'errors.http.delete' + } + + const client = $fetch.create({ + baseURL, + retry: 0, + credentials: 'include', + onResponse({ options, response }) { + const apiOptions = options as ApiFetchOptions<'json'> + if (apiOptions?.toast === false) { + return + } + + if (response?.status && response.status >= 400) { + return + } + + const successKey = apiOptions?.toastSuccessKey + const successMessage = + apiOptions?.toastSuccessMessage || + (successKey ? (te(successKey) ? t(successKey) : successKey) : '') + + if (successMessage) { + toast.success({ + title: 'Succès', + message: successMessage + }) + } + }, + async onResponseError({ response, error, options }) { + const apiOptions = options as ApiFetchOptions<'json'> + if (response?.status === 401) { + const requestUrl = typeof options?.url === 'string' ? options.url : '' + const isLoginCheck = requestUrl.includes('/login_check') + const isLogout = requestUrl.includes('/logout') + const shouldToast401 = apiOptions?.toastOn401 === true && apiOptions?.toast !== false + + if (shouldToast401) { + const errorKey = apiOptions?.toastErrorKey + const errorMessage = + errorKey ? (te(errorKey) ? t(errorKey) : errorKey) : '' + const extractedMessage = extractErrorMessage(error, response?._data) + const message = + apiOptions?.toastErrorMessage || + errorMessage || + extractedMessage || + 'Une erreur est survenue.' + + toast.error({ + title: apiOptions?.toastTitle ?? 'Erreur', + message + }) + } + + if (!isLoginCheck && !isLogout) { + if (!isHandlingUnauthorized) { + isHandlingUnauthorized = true + auth.clearSession() + const route = useRoute() + if (route.path !== '/login') { + await navigateTo('/login') + } + isHandlingUnauthorized = false + } + } + + return + } + + if (apiOptions?.toast === false) { + return + } + + const method = + typeof options?.method === 'string' ? options.method.toUpperCase() : 'GET' + const defaultKey = methodErrorKeys[method] + const defaultMessage = + defaultKey && te(defaultKey) ? t(defaultKey) : '' + const errorKey = apiOptions?.toastErrorKey + const errorMessage = + errorKey ? (te(errorKey) ? t(errorKey) : errorKey) : '' + const extractedMessage = extractErrorMessage(error, response?._data) + const message = + apiOptions?.toastErrorMessage || + errorMessage || + defaultMessage || + extractedMessage || + 'Une erreur est survenue.' + + toast.error({ + title: apiOptions?.toastTitle ?? 'Erreur', + message + }) + } + }) + + function request( + method: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE', + url: string, + options: ApiFetchOptions<'json'> = {} + ) { + const needsJsonBody = method === 'POST' || method === 'PUT' + const needsMergePatch = method === 'PATCH' + const isFormData = typeof FormData !== 'undefined' && options.body instanceof FormData + + const headers = new Headers(options.headers as HeadersInit | undefined) + + if (!isFormData) { + if (needsMergePatch && !headers.has('Content-Type')) { + headers.set('Content-Type', 'application/merge-patch+json') + } else if (needsJsonBody && !headers.has('Content-Type')) { + headers.set('Content-Type', 'application/json') + } + } + + return client(url, { ...options, method, headers }) + } + + return { + get(url: string, query: AnyObject = {}, options: ApiFetchOptions<'json'> = {}) { + return request('GET', url, { ...options, query }) + }, + getBlob(url: string, query: AnyObject = {}, options: ApiFetchOptions<'blob'> = {}) { + return client + .raw(url, { ...options, method: 'GET', query, responseType: 'blob' }) + .then((res) => ({ data: res._data as Blob, headers: res.headers })) + }, + post(url: string, body: AnyObject = {}, options: ApiFetchOptions<'json'> = {}) { + return request('POST', url, { ...options, body }) + }, + put(url: string, body: AnyObject = {}, options: ApiFetchOptions<'json'> = {}) { + return request('PUT', url, { ...options, body }) + }, + patch(url: string, body: AnyObject = {}, options: ApiFetchOptions<'json'> = {}) { + return request('PATCH', url, { ...options, body }) + }, + delete(url: string, query: AnyObject = {}, options: ApiFetchOptions<'json'> = {}) { + return request('DELETE', url, { ...options, query }) + } + } +} diff --git a/frontend/composables/useAppVersion.ts b/frontend/composables/useAppVersion.ts new file mode 100644 index 0000000..921f546 --- /dev/null +++ b/frontend/composables/useAppVersion.ts @@ -0,0 +1,17 @@ +export function useAppVersion() { + const api = useApi() + const version = useState('app-version', () => null) + + async function load(): Promise { + if (version.value) { + return version.value + } + const response = await api.get<{ version: string }>('version', {}, { + toast: false + }) + version.value = response.version + return version.value + } + + return { version, load } +} diff --git a/frontend/i18n/i18n.config.ts b/frontend/i18n/i18n.config.ts new file mode 100644 index 0000000..1cff0ad --- /dev/null +++ b/frontend/i18n/i18n.config.ts @@ -0,0 +1,4 @@ +export default defineI18nConfig(() => ({ + legacy: false, + locale: 'fr', +})) diff --git a/frontend/i18n/locales/fr.json b/frontend/i18n/locales/fr.json new file mode 100644 index 0000000..80cd732 --- /dev/null +++ b/frontend/i18n/locales/fr.json @@ -0,0 +1,25 @@ +{ + "errors": { + "http": { + "get": "Impossible de récupérer les données.", + "post": "Impossible de créer la ressource.", + "put": "Impossible de mettre à jour la ressource.", + "patch": "Impossible de mettre à jour la ressource.", + "delete": "Impossible de supprimer la ressource." + }, + "auth": { + "login": "Identifiants invalides.", + "logout": "Impossible de se déconnecter.", + "session": "Session expirée" + } + }, + "success": { + "auth": { + "login": "Connexion réussie.", + "logout": "Déconnexion réussie." + } + }, + "dashboard": { + "title": "Tableau de bord" + } +} diff --git a/frontend/layouts/auth.vue b/frontend/layouts/auth.vue new file mode 100644 index 0000000..117c591 --- /dev/null +++ b/frontend/layouts/auth.vue @@ -0,0 +1,7 @@ + diff --git a/frontend/layouts/default.vue b/frontend/layouts/default.vue new file mode 100644 index 0000000..82889e8 --- /dev/null +++ b/frontend/layouts/default.vue @@ -0,0 +1,112 @@ + + + + + diff --git a/frontend/middleware/auth.global.ts b/frontend/middleware/auth.global.ts new file mode 100644 index 0000000..18799ca --- /dev/null +++ b/frontend/middleware/auth.global.ts @@ -0,0 +1,16 @@ +export default defineNuxtRouteMiddleware(async (to) => { + const auth = useAuthStore() + const isLogin = to.path === '/login' + + if (!auth.checked) { + await auth.ensureSession() + } + + if (!isLogin && !auth.isAuthenticated) { + return navigateTo('/login') + } + + if (isLogin && auth.isAuthenticated) { + return navigateTo('/') + } +}) diff --git a/frontend/nuxt.config.ts b/frontend/nuxt.config.ts new file mode 100644 index 0000000..3999031 --- /dev/null +++ b/frontend/nuxt.config.ts @@ -0,0 +1,59 @@ +export default defineNuxtConfig({ + compatibilityDate: '2025-07-15', + devtools: {enabled: false}, + ssr: false, + css: ['~/assets/css/dark.css'], + app: { + baseURL: process.env.NODE_ENV === 'production' + ? (process.env.NUXT_PUBLIC_APP_BASE || '/') + : '/' + }, + extends: ['@malio/layer-ui'], + modules: [ + '@nuxtjs/tailwindcss', + '@pinia/nuxt', + 'nuxt-toast', + '@nuxtjs/i18n', + '@nuxt/icon', + ], + runtimeConfig: { + public: { + apiBase: process.env.NUXT_PUBLIC_API_BASE + } + }, + devServer: { + port: 3003, + }, + components: [ + {path: '~/components', pathPrefix: false}, + ], + vite: { + server: { + allowedHosts: true, + proxy: { + '/api': { + target: 'http://nginx', + changeOrigin: true, + }, + }, + }, + }, + toast: { + settings: { + timeout: 2000, + closeOnClick: true, + progressBar: false + } + }, + i18n: { + strategy: 'no_prefix', + defaultLocale: 'fr', + langDir: 'locales', + locales: [ + {code: 'fr', file: 'fr.json', name: 'Français'} + ], + }, + typescript: { + strict: true + }, +}) diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..990eb14 --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,25 @@ +{ + "name": "nuxt-app", + "type": "module", + "private": true, + "scripts": { + "build": "nuxt build", + "dev": "nuxt dev", + "generate": "nuxt generate", + "preview": "nuxt preview", + "postinstall": "nuxt prepare", + "build:dist": "nuxt generate && rm -rf dist && cp -R .output/public dist" + }, + "dependencies": { + "@malio/layer-ui": "^1.2.0", + "@nuxt/icon": "^2.2.1", + "@nuxtjs/i18n": "^10.2.3", + "@nuxtjs/tailwindcss": "^6.14.0", + "@pinia/nuxt": "^0.11.3", + "nuxt": "^4.3.1", + "nuxt-toast": "^1.4.0", + "pinia": "^3.0.4", + "vue": "^3.5.29", + "vue-router": "^4.6.4" + } +} diff --git a/frontend/pages/index.vue b/frontend/pages/index.vue new file mode 100644 index 0000000..993b81f --- /dev/null +++ b/frontend/pages/index.vue @@ -0,0 +1,12 @@ + + + diff --git a/frontend/pages/login.vue b/frontend/pages/login.vue new file mode 100644 index 0000000..f4b59aa --- /dev/null +++ b/frontend/pages/login.vue @@ -0,0 +1,68 @@ + + + diff --git a/frontend/public/LOGO_CARRE.png b/frontend/public/LOGO_CARRE.png new file mode 100644 index 0000000000000000000000000000000000000000..9ff58fd89c2674b69d126d5e2ad848d7181a6b84 GIT binary patch literal 36583 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 U5AUyfm4h=Z)3j%2x6yIsAJ!X>Pyhe` literal 0 HcmV?d00001 diff --git a/frontend/public/favicon.ico b/frontend/public/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..0c9b311439cb1ff5b21637fe6e12b3708795408e GIT binary patch literal 1150 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 Cec#^z literal 0 HcmV?d00001 diff --git a/frontend/services/auth.ts b/frontend/services/auth.ts new file mode 100644 index 0000000..b09cfb9 --- /dev/null +++ b/frontend/services/auth.ts @@ -0,0 +1,22 @@ +import type { UserData } from './dto/user-data' + +export function getCurrentUser() { + const api = useApi() + return api.get('/me', {}, { toastErrorKey: 'errors.auth.session' }) +} + +export function login(username: string, password: string) { + const api = useApi() + return api.post('/login_check', { username, password }, { + toastOn401: true, + toastErrorKey: 'errors.auth.login' + }) +} + +export function logout() { + const api = useApi() + return api.post('/logout', {}, { + toastErrorKey: 'errors.auth.logout', + toastSuccessKey: 'success.auth.logout' + }) +} diff --git a/frontend/services/dto/user-data.ts b/frontend/services/dto/user-data.ts new file mode 100644 index 0000000..fb8413f --- /dev/null +++ b/frontend/services/dto/user-data.ts @@ -0,0 +1,12 @@ +export type UserData = { + id: number + '@id'?: string + username: string + roles: string[] +} + +export type UserWrite = { + username: string + plainPassword?: string + roles: string[] +} diff --git a/frontend/stores/auth.ts b/frontend/stores/auth.ts new file mode 100644 index 0000000..ad2f727 --- /dev/null +++ b/frontend/stores/auth.ts @@ -0,0 +1,71 @@ +import { defineStore } from 'pinia' +import type { UserData } from '~/services/dto/user-data' +import { getCurrentUser, login, logout } from '~/services/auth' + +export const useAuthStore = defineStore('auth', { + state: () => ({ + user: null as UserData | null, + isLoading: false, + checked: false + }), + getters: { + isAuthenticated: (state) => Boolean(state.user) + }, + actions: { + clearSession() { + this.user = null + this.checked = true + this.isLoading = false + }, + async ensureSession() { + if (this.checked) { + return this.user + } + + this.checked = true + + try { + const me = await getCurrentUser() + this.user = me + return me + } catch { + this.user = null + return null + } + }, + async login(username: string, password: string) { + this.isLoading = true + + try { + await login(username, password) + const me = await getCurrentUser() + this.user = me + this.checked = true + return me + } finally { + this.isLoading = false + } + }, + async logout() { + this.isLoading = true + + try { + await logout() + } catch { + // Ignore logout errors so we can still clear local auth state. + } finally { + this.user = null + this.checked = true + this.isLoading = false + } + }, + async refreshUser() { + try { + const me = await getCurrentUser() + this.user = me + } catch { + // Silently fail — user session might have expired + } + } + } +}) diff --git a/frontend/stores/ui.ts b/frontend/stores/ui.ts new file mode 100644 index 0000000..e61499b --- /dev/null +++ b/frontend/stores/ui.ts @@ -0,0 +1,57 @@ +export const useUiStore = defineStore('ui', () => { + const sidebarCollapsed = ref(false) + const sidebarOpen = ref(false) + const darkMode = ref(false) + + if (import.meta.client) { + const saved = localStorage.getItem('ui-sidebar-collapsed') + if (saved !== null) { + sidebarCollapsed.value = saved === 'true' + } + + const savedDark = localStorage.getItem('ui-dark-mode') + if (savedDark !== null) { + darkMode.value = savedDark === 'true' + } + applyDarkClass(darkMode.value) + } + + watch(sidebarCollapsed, (val) => { + if (import.meta.client) { + localStorage.setItem('ui-sidebar-collapsed', String(val)) + } + }) + + watch(darkMode, (val) => { + if (import.meta.client) { + localStorage.setItem('ui-dark-mode', String(val)) + applyDarkClass(val) + } + }) + + function applyDarkClass(dark: boolean) { + if (dark) { + document.documentElement.classList.add('dark') + } else { + document.documentElement.classList.remove('dark') + } + } + + function toggleDarkMode() { + darkMode.value = !darkMode.value + } + + function toggleSidebar() { + sidebarCollapsed.value = !sidebarCollapsed.value + } + + function openMobileSidebar() { + sidebarOpen.value = true + } + + function closeMobileSidebar() { + sidebarOpen.value = false + } + + return { sidebarCollapsed, sidebarOpen, darkMode, toggleSidebar, openMobileSidebar, closeMobileSidebar, toggleDarkMode } +}) diff --git a/frontend/tailwind.config.ts b/frontend/tailwind.config.ts new file mode 100644 index 0000000..bbd44cc --- /dev/null +++ b/frontend/tailwind.config.ts @@ -0,0 +1,48 @@ +import type {Config} from 'tailwindcss' + +export default >{ + darkMode: 'class', + theme: { + extend: { + fontFamily: { + sans: ['"Helvetica Neue"', 'Helvetica', 'Arial', 'sans-serif'] + }, + colors: { + primary: { + 500: '#222783', + }, + secondary: { + 500: '#304998' + }, + tertiary: { + 500: '#F3F4F8' + }, + blue: { + 500: '#056CF2' + }, + m: { + primary: 'rgb(var(--m-primary) / )', + secondary: 'rgb(var(--m-secondary, 75 77 237) / )', + tertiary: 'rgb(var(--m-tertiary, 243 244 248) / )', + border: 'rgb(var(--m-border) / )', + text: 'rgb(var(--m-text) / )', + muted: 'rgb(var(--m-muted) / )', + bg: 'rgb(var(--m-bg) / )', + surface: 'rgb(var(--m-surface) / )', + disabled: 'rgb(var(--m-disabled) / )', + danger: 'rgb(var(--m-danger) / )', + success: 'rgb(var(--m-success) / )', + 'btn-primary': 'rgb(var(--m-btn-primary) / )', + 'btn-primary-hover': 'rgb(var(--m-btn-primary-hover) / )', + 'btn-primary-active': 'rgb(var(--m-btn-primary-active) / )', + 'btn-secondary': 'rgb(var(--m-btn-secondary) / )', + 'btn-secondary-hover': 'rgb(var(--m-btn-secondary-hover) / )', + 'btn-secondary-active': 'rgb(var(--m-btn-secondary-active) / )', + 'btn-danger': 'rgb(var(--m-btn-danger) / )', + 'btn-danger-hover': 'rgb(var(--m-btn-danger-hover) / )', + 'btn-danger-active': 'rgb(var(--m-btn-danger-active) / )', + } + } + } + } +} diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json new file mode 100644 index 0000000..6ae5970 --- /dev/null +++ b/frontend/tsconfig.json @@ -0,0 +1,17 @@ +{ + "files": [], + "references": [ + { + "path": "./.nuxt/tsconfig.app.json" + }, + { + "path": "./.nuxt/tsconfig.server.json" + }, + { + "path": "./.nuxt/tsconfig.shared.json" + }, + { + "path": "./.nuxt/tsconfig.node.json" + } + ] +} diff --git a/frontend/utils/api.ts b/frontend/utils/api.ts new file mode 100644 index 0000000..00c033b --- /dev/null +++ b/frontend/utils/api.ts @@ -0,0 +1,10 @@ +export type HydraCollection = { + 'hydra:member'?: T[] + 'hydra:totalItems'?: number + 'member'?: T[] + 'totalItems'?: number +} + +export function extractHydraMembers(response: HydraCollection): T[] { + return response['hydra:member'] ?? response['member'] ?? [] +} diff --git a/infra/dev/.env.docker b/infra/dev/.env.docker new file mode 100644 index 0000000..ab92413 --- /dev/null +++ b/infra/dev/.env.docker @@ -0,0 +1,9 @@ +DOCKER_APP_NAME=central +DOCKER_PHP_VERSION=8.4.6 +DOCKER_NODE_VERSION=24.12.0 +APP_USER=www-data +POSTGRES_DB=central +POSTGRES_USER=root +POSTGRES_PASSWORD=root +POSTGRES_PORT=5436 +XDEBUG_CLIENT_HOST=host.docker.internal diff --git a/infra/dev/Dockerfile b/infra/dev/Dockerfile new file mode 100644 index 0000000..b250909 --- /dev/null +++ b/infra/dev/Dockerfile @@ -0,0 +1,102 @@ +ARG DOCKER_PHP_VERSION + +FROM php:${DOCKER_PHP_VERSION}-fpm-bullseye + +ARG DOCKER_NODE_VERSION +ENV DOCKER_NODE_VERSION="${DOCKER_NODE_VERSION}" + +# Installer les dépendances et extensions PHP nécessaires +RUN apt-get update && apt-get install -y \ + libicu-dev \ + libpq-dev \ + libpng-dev \ + libzip-dev \ + libxml2-dev \ + ca-certificates \ + gnupg \ + libbz2-dev \ + libgmp-dev \ + libldap2-dev \ + libonig-dev \ + libsodium-dev \ + libxslt1-dev \ + unixodbc-dev \ + libsqlite3-dev \ + zlib1g-dev \ + libssl-dev \ + libc-client-dev \ + libkrb5-dev \ + freetds-dev \ + vim \ + tcpdump \ + dnsutils \ + wget \ + git \ + unzip \ + && docker-php-ext-install -j$(nproc) \ + intl \ + zip \ + bcmath \ + bz2 \ + calendar \ + exif \ + gd \ + gettext \ + gmp \ + ldap \ + pcntl \ + pdo_pgsql \ + soap \ + sockets \ + sysvsem \ + xsl + + +# Installation de node +RUN wget -qO- "https://nodejs.org/dist/v${DOCKER_NODE_VERSION}/node-v${DOCKER_NODE_VERSION}-linux-x64.tar.xz" | tar xJC /tmp/ && \ + cp -r /tmp/node-v${DOCKER_NODE_VERSION}-linux-x64/bin /usr/ && \ + cp -r /tmp/node-v${DOCKER_NODE_VERSION}-linux-x64/include /usr/ && \ + cp -r /tmp/node-v${DOCKER_NODE_VERSION}-linux-x64/lib /usr/ && \ + cp -r /tmp/node-v${DOCKER_NODE_VERSION}-linux-x64/share /usr/ && \ + npm install --global yarn + +# installation/activation d'extensions php +RUN pecl install xdebug +RUN docker-php-ext-enable xdebug && \ + docker-php-ext-install zip && \ + docker-php-ext-install gd && \ + docker-php-ext-install soap && \ + docker-php-ext-configure intl && \ + docker-php-ext-install intl + +RUN docker-php-ext-enable opcache + +# installation de composer +RUN rm -rf /var/cache/apk/* && rm -rf /tmp/* && \ + curl --insecure https://getcomposer.org/composer.phar -o /usr/bin/composer && chmod +x /usr/bin/composer + +# cache Composer pour www-data +RUN mkdir -p /var/www/.composer/cache/vcs \ +&& chown -R www-data:www-data /var/www/.composer +ENV COMPOSER_HOME=/var/www/.composer + +# Création de la structure du projet +RUN mkdir /var/www/html/LOG + +###> User ### +ARG CURRENT_UID +ARG CURRENT_GID +# mapping du user host avec www-data +RUN usermod -o -u ${CURRENT_UID} www-data && groupmod -o -g ${CURRENT_GID} www-data +RUN chown www-data:www-data -R /var/www/* +RUN chown www-data:www-data -R /var/www/.* +###< User ### + +RUN rm -rf \ + /var/lib/apt/lists/* \ + /tmp/* \ + /var/tmp/* + +WORKDIR /var/www/html + +EXPOSE 80 diff --git a/infra/dev/nginx.conf b/infra/dev/nginx.conf new file mode 100644 index 0000000..d5e1b00 --- /dev/null +++ b/infra/dev/nginx.conf @@ -0,0 +1,54 @@ +server { + listen 80; + server_name localhost; + + root /var/www/html/frontend/dist; + index index.html; + + client_max_body_size 55m; + + location ^~ /api/ { + root /var/www/html/public; + try_files $uri /index.php?$query_string; + } + + location ^~ /_wdt/ { + root /var/www/html/public; + try_files $uri /index.php?$query_string; + } + + location ^~ /_profiler/ { + root /var/www/html/public; + try_files $uri /index.php?$query_string; + } + + location ^~ /bundles/ { + root /var/www/html/public; + try_files $uri =404; + } + + location = /api/login_check { + include fastcgi_params; + fastcgi_param SCRIPT_FILENAME /var/www/html/public/index.php; + fastcgi_param DOCUMENT_ROOT /var/www/html/public; + fastcgi_param SCRIPT_NAME /index.php; + fastcgi_param PATH_INFO /login_check; + fastcgi_param REQUEST_URI /login_check; + fastcgi_pass php:9000; + } + + location ~ ^/index\.php(/|$) { + include fastcgi_params; + fastcgi_param SCRIPT_FILENAME /var/www/html/public/index.php; + fastcgi_param DOCUMENT_ROOT /var/www/html/public; + fastcgi_pass php:9000; + } + + location ~ \.php$ { + return 404; + } + + location / { + try_files $uri $uri/ /index.html; + } +} diff --git a/infra/dev/php.ini b/infra/dev/php.ini new file mode 100644 index 0000000..482519d --- /dev/null +++ b/infra/dev/php.ini @@ -0,0 +1,8 @@ +[Date] +; Defines the default timezone used by the date functions +; http://php.net/date.timezone +date.timezone = Europe/Paris + +[Upload] +upload_max_filesize = 50M +post_max_size = 55M diff --git a/infra/dev/xdebug.ini b/infra/dev/xdebug.ini new file mode 100644 index 0000000..e09a954 --- /dev/null +++ b/infra/dev/xdebug.ini @@ -0,0 +1,9 @@ +zend_extension = /usr/local/lib/php/extensions/no-debug-non-zts-20240924/xdebug.so +xdebug.mode=debug +xdebug.idekey=PHPSTORM +xdebug.start_with_request=yes +xdebug.discover_client_host=1 +xdebug.client_port=9003 +xdebug.log="/var/www/html/LOG/xdebug.log" +xdebug.log_level=0 +xdebug.connect_timeout_ms=2 diff --git a/makefile b/makefile new file mode 100644 index 0000000..0680d3d --- /dev/null +++ b/makefile @@ -0,0 +1,119 @@ +# Permet d'utiliser un .env.docker.local pour override +ENV_DEFAULT = infra/dev/.env.docker +ENV_LOCAL = infra/dev/.env.docker.local +ENV_FILE := $(if $(wildcard $(ENV_LOCAL)),$(ENV_LOCAL),$(ENV_DEFAULT)) + +# Permet d'avoir les variables du fichier .env.docker.local +include $(ENV_DEFAULT) +-include $(ENV_LOCAL) + +PHP_CONTAINER = php-$(DOCKER_APP_NAME)-fpm +SYMFONY_CONSOLE = $(EXEC_PHP) php bin/console + +DOCKER_COMPOSE = docker compose --env-file $(ENV_FILE) +DOCKER = docker + +EXEC_PHP = $(DOCKER) exec -t -u $(APP_USER) $(PHP_CONTAINER) +EXEC_PHP_CS_FIXER = $(EXEC_PHP) php vendor/bin/php-cs-fixer +EXEC_PHP_ROOT = $(DOCKER) exec -t -u root $(PHP_CONTAINER) +EXEC_PHP_INTERACTIVE = $(DOCKER) exec -it -u $(APP_USER) $(PHP_CONTAINER) +EXEC_PHP_INTERACTIVE_ROOT = $(DOCKER) exec -it -u root $(PHP_CONTAINER) +FILES = + +#======================================================================================== + +env-init: + @cp --update=none $(ENV_DEFAULT) $(ENV_LOCAL) + +# Lance le container +start: env-init + @echo "**** START CONTAINERS ****" + CURRENT_UID=$(shell id -u) CURRENT_GID=$(shell id -g) $(DOCKER_COMPOSE) up -d + +# Éteint le container +stop: + $(DOCKER_COMPOSE) stop + +restart: env-init + $(DOCKER_COMPOSE) down + CURRENT_UID=$(shell id -u) CURRENT_GID=$(shell id -g) $(DOCKER_COMPOSE) up -d + +install: composer-install cache-clear node-use build-nuxtJS migration-migrate + +# Supprime tout est réinstalle tout (Attention ça supprime la bdd aussi) +reset: delete_built_dir remove_orphans build-without-cache start wait install + +composer-install: + $(EXEC_PHP) composer install + $(SYMFONY_CONSOLE) lexik:jwt:generate-keypair --skip-if-exists + +build-nuxtJS: + $(EXEC_PHP) sh -lc "cd frontend && npm install && npm run build:dist" + +dev-nuxt: + $(EXEC_PHP) sh -c "cd frontend && npm run dev" + +delete_built_dir: + CURRENT_UID=$(shell id -u) CURRENT_GID=$(shell id -g) $(DOCKER_COMPOSE) up -d + $(DOCKER) exec -u root $(PHP_CONTAINER) rm -rf vendor/ + $(DOCKER) exec -u root $(PHP_CONTAINER) rm -rf frontend/node_modules + +remove_orphans: + $(DOCKER_COMPOSE) kill + $(DOCKER_COMPOSE) down --volumes --remove-orphans + +build-without-cache: + $(DOCKER_COMPOSE) build \ + --build-arg="DOCKER_PHP_VERSION=$(DOCKER_PHP_VERSION)" \ + --build-arg="DOCKER_NODE_VERSION=$(DOCKER_NODE_VERSION)" \ + --build-arg="CURRENT_UID=$(shell id -u)" \ + --build-arg="CURRENT_GID=$(shell id -g)" \ + --no-cache + +migration-migrate: + $(SYMFONY_CONSOLE) doctrine:migrations:migrate --no-interaction + +fixtures: + $(SYMFONY_CONSOLE) --no-interaction doctrine:fixtures:load + +# Attention, supprime votre bdd local +db-reset: + $(DOCKER_COMPOSE) down -v + $(DOCKER_COMPOSE) up -d + $(MAKE) wait + $(SYMFONY_CONSOLE) doctrine:database:create --if-not-exists + $(MAKE) migration-migrate + $(MAKE) fixtures + +# Restart la bdd +db-restart: + $(DOCKER_COMPOSE) down + $(DOCKER_COMPOSE) up -d + +cache-clear: + $(SYMFONY_CONSOLE) cache:clear + +shell: + $(EXEC_PHP_INTERACTIVE) bash + +shell-root: + $(EXEC_PHP_INTERACTIVE_ROOT) bash + +# Suivi temps réel des logs dev +logs-dev: + $(EXEC_PHP_INTERACTIVE) sh -lc "tail -f var/log/dev.log" + +# Force la version node +node-use: + bash -lc 'source "$$HOME/.nvm/nvm.sh" && nvm install && nvm use' + +# Utilisé par le pre-commit pour fix les fichiers modifiés +php-cs-fixer-allow-risky: + @echo "Fixing files: $(FILES)" + $(EXEC_PHP_CS_FIXER) fix --config=.php-cs-fixer.dist.php --allow-risky=yes $(FILES) + +test: + $(EXEC_PHP) php -d memory_limit="512M" vendor/bin/phpunit $(FILES) + +wait: + sleep 10 diff --git a/phpunit.dist.xml b/phpunit.dist.xml new file mode 100644 index 0000000..22bd879 --- /dev/null +++ b/phpunit.dist.xml @@ -0,0 +1,44 @@ + + + + + + + + + + + + + + tests + + + + + + src + + + + Doctrine\Deprecations\Deprecation::trigger + Doctrine\Deprecations\Deprecation::delegateTriggerToBackend + trigger_deprecation + + + + + + diff --git a/public/index.php b/public/index.php new file mode 100644 index 0000000..97e228d --- /dev/null +++ b/public/index.php @@ -0,0 +1,11 @@ + ['version:read']], + provider: AppVersionProvider::class, + ), + ], +)] +final class AppVersion +{ + #[Groups(['version:read'])] + public string $version = ''; +} diff --git a/src/DataFixtures/AppFixtures.php b/src/DataFixtures/AppFixtures.php new file mode 100644 index 0000000..3f5a54c --- /dev/null +++ b/src/DataFixtures/AppFixtures.php @@ -0,0 +1,40 @@ +setUsername('admin'); + $admin->setRoles(['ROLE_ADMIN']); + $admin->setPassword($this->passwordHasher->hashPassword($admin, 'admin')); + $manager->persist($admin); + + $userAlice = new User(); + $userAlice->setUsername('alice'); + $userAlice->setRoles(['ROLE_USER']); + $userAlice->setPassword($this->passwordHasher->hashPassword($userAlice, 'alice')); + $manager->persist($userAlice); + + $userBob = new User(); + $userBob->setUsername('bob'); + $userBob->setRoles(['ROLE_USER']); + $userBob->setPassword($this->passwordHasher->hashPassword($userBob, 'bob')); + $manager->persist($userBob); + + $manager->flush(); + } +} diff --git a/src/Entity/User.php b/src/Entity/User.php new file mode 100644 index 0000000..4e1f44f --- /dev/null +++ b/src/Entity/User.php @@ -0,0 +1,154 @@ + ['me:read']], + ), + new Get( + normalizationContext: ['groups' => ['user:list']], + ), + new GetCollection( + normalizationContext: ['groups' => ['user:list']], + ), + new Post(security: "is_granted('ROLE_ADMIN')", processor: UserPasswordHasherProcessor::class), + new Patch(security: "is_granted('ROLE_ADMIN')", processor: UserPasswordHasherProcessor::class), + new Delete(security: "is_granted('ROLE_ADMIN')"), + ], + denormalizationContext: ['groups' => ['user:write']], +)] +#[ORM\Entity(repositoryClass: UserRepository::class)] +#[ORM\Table(name: '`user`')] +class User implements UserInterface, PasswordAuthenticatedUserInterface +{ + #[ORM\Id] + #[ORM\GeneratedValue] + #[ORM\Column] + #[Groups(['me:read', 'user:list'])] + private ?int $id = null; + + #[ORM\Column(length: 180, unique: true)] + #[Groups(['me:read', 'user:list', 'user:write'])] + private ?string $username = null; + + /** @var list */ + #[ORM\Column] + #[Groups(['me:read', 'user:list', 'user:write'])] + private array $roles = []; + + #[ORM\Column] + private ?string $password = null; + + #[Groups(['user:write'])] + private ?string $plainPassword = null; + + #[ORM\Column(type: Types::DATETIME_IMMUTABLE)] + private ?DateTimeImmutable $createdAt = null; + + public function __construct() + { + $this->createdAt = new DateTimeImmutable(); + } + + public function getId(): ?int + { + return $this->id; + } + + public function getUsername(): ?string + { + return $this->username; + } + + public function setUsername(string $username): static + { + $this->username = $username; + + return $this; + } + + public function getUserIdentifier(): string + { + return (string) $this->username; + } + + /** @return list */ + public function getRoles(): array + { + $roles = $this->roles; + $roles[] = 'ROLE_USER'; + + return array_values(array_unique($roles)); + } + + /** @param list $roles */ + public function setRoles(array $roles): static + { + $this->roles = $roles; + + return $this; + } + + public function getPassword(): ?string + { + return $this->password; + } + + public function setPassword(string $password): static + { + $this->password = $password; + + return $this; + } + + public function getCreatedAt(): ?DateTimeImmutable + { + return $this->createdAt; + } + + public function setCreatedAt(DateTimeImmutable $createdAt): static + { + $this->createdAt = $createdAt; + + return $this; + } + + public function getPlainPassword(): ?string + { + return $this->plainPassword; + } + + public function setPlainPassword(?string $plainPassword): static + { + $this->plainPassword = $plainPassword; + + return $this; + } + + public function eraseCredentials(): void + { + $this->plainPassword = null; + } +} diff --git a/src/Kernel.php b/src/Kernel.php new file mode 100644 index 0000000..ad0fb48 --- /dev/null +++ b/src/Kernel.php @@ -0,0 +1,13 @@ + + */ +class UserRepository extends ServiceEntityRepository +{ + public function __construct(ManagerRegistry $registry) + { + parent::__construct($registry, User::class); + } +} diff --git a/src/State/AppVersionProvider.php b/src/State/AppVersionProvider.php new file mode 100644 index 0000000..cc65752 --- /dev/null +++ b/src/State/AppVersionProvider.php @@ -0,0 +1,26 @@ +version = $this->version; + + return $dto; + } +} diff --git a/src/State/MeProvider.php b/src/State/MeProvider.php new file mode 100644 index 0000000..f2866a7 --- /dev/null +++ b/src/State/MeProvider.php @@ -0,0 +1,26 @@ + + */ +final readonly class MeProvider implements ProviderInterface +{ + public function __construct( + private Security $security, + ) {} + + public function provide(Operation $operation, array $uriVariables = [], array $context = []): User + { + // @var User $user + return $this->security->getUser(); + } +} diff --git a/src/State/UserPasswordHasherProcessor.php b/src/State/UserPasswordHasherProcessor.php new file mode 100644 index 0000000..88e9d1c --- /dev/null +++ b/src/State/UserPasswordHasherProcessor.php @@ -0,0 +1,41 @@ + + */ +final readonly class UserPasswordHasherProcessor implements ProcessorInterface +{ + /** + * @param ProcessorInterface $persistProcessor + */ + public function __construct( + #[Autowire(service: 'api_platform.doctrine.orm.state.persist_processor')] + private ProcessorInterface $persistProcessor, + private UserPasswordHasherInterface $passwordHasher, + ) {} + + /** + * @param User $data + */ + public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): mixed + { + $plainPassword = $data->getPlainPassword(); + + if (null !== $plainPassword && '' !== $plainPassword) { + $data->setPassword($this->passwordHasher->hashPassword($data, $plainPassword)); + $data->setPlainPassword(null); + } + + return $this->persistProcessor->process($data, $operation, $uriVariables, $context); + } +} diff --git a/tests/bootstrap.php b/tests/bootstrap.php new file mode 100644 index 0000000..c4f2193 --- /dev/null +++ b/tests/bootstrap.php @@ -0,0 +1,15 @@ +bootEnv(dirname(__DIR__).'/.env'); +} + +if ($_SERVER['APP_DEBUG']) { + umask(0o000); +}