commit 8e59f59679ceea552191fb2bb7a55236c3d5fcf7 Author: matthieu Date: Fri Apr 3 10:14:39 2026 +0200 feat : init Central project — Symfony 8 + Nuxt 4 + Docker starter 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) 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 0000000..9ff58fd Binary files /dev/null and b/frontend/public/LOGO_CARRE.png differ diff --git a/frontend/public/favicon.ico b/frontend/public/favicon.ico new file mode 100644 index 0000000..0c9b311 Binary files /dev/null and b/frontend/public/favicon.ico differ diff --git a/frontend/public/malio.png b/frontend/public/malio.png new file mode 100644 index 0000000..ab1ea2f Binary files /dev/null and b/frontend/public/malio.png differ 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); +}