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) <noreply@anthropic.com>
This commit is contained in:
23
.env
Normal file
23
.env
Normal file
@@ -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
|
||||||
92
.env.example
Normal file
92
.env.example
Normal file
@@ -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
|
||||||
46
.gitignore
vendored
Normal file
46
.gitignore
vendored
Normal file
@@ -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 ###
|
||||||
56
.php-cs-fixer.dist.php
Normal file
56
.php-cs-fixer.dist.php
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use PhpCsFixer\Config;
|
||||||
|
use PhpCsFixer\Finder;
|
||||||
|
|
||||||
|
$finder = Finder::create()
|
||||||
|
->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)
|
||||||
|
;
|
||||||
107
CLAUDE.md
Normal file
107
CLAUDE.md
Normal file
@@ -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 : `<type>(<scope optionnel>) : <message>` (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<X.Y.Z>`
|
||||||
|
- Puis créer le tag et pusher : `git tag v<X.Y.Z> && 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)
|
||||||
18
README.md
Normal file
18
README.md
Normal file
@@ -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`
|
||||||
19
bin/console
Executable file
19
bin/console
Executable file
@@ -0,0 +1,19 @@
|
|||||||
|
#!/usr/bin/env php
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use App\Kernel;
|
||||||
|
use Symfony\Bundle\FrameworkBundle\Console\Application;
|
||||||
|
|
||||||
|
if (!is_file(dirname(__DIR__).'/vendor/autoload_runtime.php')) {
|
||||||
|
throw new LogicException('Symfony Runtime is missing. Try running "composer require symfony/runtime".');
|
||||||
|
}
|
||||||
|
|
||||||
|
require_once dirname(__DIR__).'/vendor/autoload_runtime.php';
|
||||||
|
|
||||||
|
return function (array $context) {
|
||||||
|
$kernel = new Kernel($context['APP_ENV'], (bool) $context['APP_DEBUG']);
|
||||||
|
|
||||||
|
return new Application($kernel);
|
||||||
|
};
|
||||||
94
composer.json
Normal file
94
composer.json
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
{
|
||||||
|
"type": "project",
|
||||||
|
"license": "proprietary",
|
||||||
|
"minimum-stability": "stable",
|
||||||
|
"prefer-stable": true,
|
||||||
|
"require": {
|
||||||
|
"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"
|
||||||
|
}
|
||||||
|
}
|
||||||
25
config/bundles.php
Normal file
25
config/bundles.php
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use ApiPlatform\Symfony\Bundle\ApiPlatformBundle;
|
||||||
|
use Doctrine\Bundle\DoctrineBundle\DoctrineBundle;
|
||||||
|
use Doctrine\Bundle\FixturesBundle\DoctrineFixturesBundle;
|
||||||
|
use Doctrine\Bundle\MigrationsBundle\DoctrineMigrationsBundle;
|
||||||
|
use Lexik\Bundle\JWTAuthenticationBundle\LexikJWTAuthenticationBundle;
|
||||||
|
use Nelmio\CorsBundle\NelmioCorsBundle;
|
||||||
|
use Symfony\Bundle\FrameworkBundle\FrameworkBundle;
|
||||||
|
use Symfony\Bundle\MonologBundle\MonologBundle;
|
||||||
|
use Symfony\Bundle\SecurityBundle\SecurityBundle;
|
||||||
|
|
||||||
|
return [
|
||||||
|
FrameworkBundle::class => ['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],
|
||||||
|
];
|
||||||
12
config/packages/api_platform.yaml
Normal file
12
config/packages/api_platform.yaml
Normal file
@@ -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']
|
||||||
43
config/packages/doctrine.yaml
Normal file
43
config/packages/doctrine.yaml
Normal file
@@ -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
|
||||||
12
config/packages/framework.yaml
Normal file
12
config/packages/framework.yaml
Normal file
@@ -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
|
||||||
25
config/packages/lexik_jwt_authentication.yaml
Normal file
25
config/packages/lexik_jwt_authentication.yaml
Normal file
@@ -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
|
||||||
11
config/packages/nelmio_cors.yaml
Normal file
11
config/packages/nelmio_cors.yaml
Normal file
@@ -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
|
||||||
61
config/packages/security.yaml
Normal file
61
config/packages/security.yaml
Normal file
@@ -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
|
||||||
7
config/preload.php
Normal file
7
config/preload.php
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
if (file_exists(dirname(__DIR__).'/var/cache/prod/App_KernelProdContainer.preload.php')) {
|
||||||
|
require dirname(__DIR__).'/var/cache/prod/App_KernelProdContainer.preload.php';
|
||||||
|
}
|
||||||
4
config/routes.yaml
Normal file
4
config/routes.yaml
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
# yaml-language-server: $schema=../vendor/symfony/routing/Loader/schema/routing.schema.json
|
||||||
|
|
||||||
|
controllers:
|
||||||
|
resource: routing.controllers
|
||||||
4
config/routes/api_platform.yaml
Normal file
4
config/routes/api_platform.yaml
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
api_platform:
|
||||||
|
resource: .
|
||||||
|
type: api_platform
|
||||||
|
prefix: /api
|
||||||
7
config/routes/security.yaml
Normal file
7
config/routes/security.yaml
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
_security_logout:
|
||||||
|
resource: security.route_loader.logout
|
||||||
|
type: service
|
||||||
|
|
||||||
|
api_login:
|
||||||
|
path: /login_check
|
||||||
|
methods: [POST]
|
||||||
17
config/services.yaml
Normal file
17
config/services.yaml
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
# yaml-language-server: $schema=../vendor/symfony/dependency-injection/Loader/schema/services.schema.json
|
||||||
|
|
||||||
|
parameters:
|
||||||
|
|
||||||
|
imports:
|
||||||
|
- { resource: version.yaml }
|
||||||
|
|
||||||
|
services:
|
||||||
|
# default configuration for services in *this* file
|
||||||
|
_defaults:
|
||||||
|
autowire: true # Automatically injects dependencies in your services.
|
||||||
|
autoconfigure: true # Automatically registers your services as commands, event subscribers, etc.
|
||||||
|
|
||||||
|
# makes classes in src/ available to be used as services
|
||||||
|
# this creates a service per class whose id is the fully-qualified class name
|
||||||
|
App\:
|
||||||
|
resource: '../src/'
|
||||||
2
config/version.yaml
Normal file
2
config/version.yaml
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
parameters:
|
||||||
|
app.version: '0.1.0'
|
||||||
60
docker-compose.yml
Normal file
60
docker-compose.yml
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
services:
|
||||||
|
php:
|
||||||
|
container_name: php-${DOCKER_APP_NAME}-fpm
|
||||||
|
build:
|
||||||
|
context: ./infra/dev
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
args:
|
||||||
|
DOCKER_PHP_VERSION: ${DOCKER_PHP_VERSION}
|
||||||
|
DOCKER_NODE_VERSION: ${DOCKER_NODE_VERSION}
|
||||||
|
CURRENT_UID: ${CURRENT_UID}
|
||||||
|
CURRENT_GID: ${CURRENT_GID}
|
||||||
|
environment:
|
||||||
|
PHP_IDE_CONFIG: serverName=${DOCKER_APP_NAME}-docker
|
||||||
|
XDEBUG_CLIENT_HOST: ${XDEBUG_CLIENT_HOST:-host.docker.internal}
|
||||||
|
XDEBUG_CONFIG: client_host=${XDEBUG_CLIENT_HOST:-host.docker.internal} client_port=9003
|
||||||
|
DATABASE_URL: "postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:${POSTGRES_PORT}/${POSTGRES_DB}?serverVersion=16&charset=utf8"
|
||||||
|
COMPOSER_HOME: /tmp/composer
|
||||||
|
COMPOSER_CACHE_DIR: /tmp/composer/cache
|
||||||
|
volumes:
|
||||||
|
- ./:/var/www/html
|
||||||
|
- ~/.cache:/var/www/.cache
|
||||||
|
- ~/.config:/var/www/.config
|
||||||
|
- ~/.composer:/var/www/.composer
|
||||||
|
- ./infra/dev/php.ini:/usr/local/etc/php/php.ini
|
||||||
|
- ./infra/dev/xdebug.ini:/usr/local/etc/php/conf.d/docker-php-ext-xdebug.ini
|
||||||
|
- ./LOG:/var/www/html/LOG
|
||||||
|
- uploads_data:/var/www/html/var/uploads
|
||||||
|
extra_hosts:
|
||||||
|
- "host.docker.internal:host-gateway"
|
||||||
|
depends_on:
|
||||||
|
- db
|
||||||
|
ports:
|
||||||
|
- "3003:3003"
|
||||||
|
restart: unless-stopped
|
||||||
|
nginx:
|
||||||
|
image: nginx:1.27-alpine
|
||||||
|
container_name: nginx-${DOCKER_APP_NAME}
|
||||||
|
depends_on:
|
||||||
|
- php
|
||||||
|
ports:
|
||||||
|
- "8083:80"
|
||||||
|
volumes:
|
||||||
|
- ./:/var/www/html:ro
|
||||||
|
- ./infra/dev/nginx.conf:/etc/nginx/conf.d/central.conf:ro
|
||||||
|
restart: unless-stopped
|
||||||
|
db:
|
||||||
|
image: postgres:16-alpine
|
||||||
|
command: -p ${POSTGRES_PORT:-5436}
|
||||||
|
environment:
|
||||||
|
POSTGRES_DB: ${POSTGRES_DB}
|
||||||
|
POSTGRES_USER: ${POSTGRES_USER}
|
||||||
|
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
|
||||||
|
volumes:
|
||||||
|
- pg_data:/var/lib/postgresql/data
|
||||||
|
ports:
|
||||||
|
- "${POSTGRES_PORT:-5436}:${POSTGRES_PORT:-5436}"
|
||||||
|
restart: unless-stopped
|
||||||
|
volumes:
|
||||||
|
pg_data:
|
||||||
|
uploads_data:
|
||||||
1
frontend/.npmrc
Normal file
1
frontend/.npmrc
Normal file
@@ -0,0 +1 @@
|
|||||||
|
@malio:registry=https://gitea.malio.fr/api/packages/MALIO-DEV/npm/
|
||||||
13
frontend/app.vue
Normal file
13
frontend/app.vue
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
<template>
|
||||||
|
<NuxtLayout>
|
||||||
|
<NuxtPage />
|
||||||
|
</NuxtLayout>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
const { load } = useAppVersion()
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
load()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
248
frontend/assets/css/dark.css
Normal file
248
frontend/assets/css/dark.css
Normal file
@@ -0,0 +1,248 @@
|
|||||||
|
/*
|
||||||
|
* Dark theme overrides
|
||||||
|
* Automatically applied when <html class="dark"> 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;
|
||||||
|
}
|
||||||
56
frontend/components/ui/AppTopNav.vue
Normal file
56
frontend/components/ui/AppTopNav.vue
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
<template>
|
||||||
|
<header class="border-b border-neutral-200 bg-primary-500 px-3 py-2 text-white sm:px-5 sm:py-2 max-h-[60px]">
|
||||||
|
<div class="flex h-full items-center justify-between">
|
||||||
|
<MalioButtonIcon
|
||||||
|
icon="mdi:menu"
|
||||||
|
aria-label="Menu"
|
||||||
|
variant="ghost"
|
||||||
|
icon-size="24"
|
||||||
|
button-class="lg:hidden text-white hover:bg-primary-600"
|
||||||
|
@click="ui.openMobileSidebar()"
|
||||||
|
/>
|
||||||
|
<div class="hidden items-center gap-2 lg:flex">
|
||||||
|
<h1 class="text-lg font-bold tracking-tight">Central</h1>
|
||||||
|
</div>
|
||||||
|
<div class="ml-auto flex items-center gap-4 text-xl text-white sm:gap-8">
|
||||||
|
<MalioButtonIcon
|
||||||
|
:icon="ui.darkMode ? 'mdi:weather-sunny' : 'mdi:weather-night'"
|
||||||
|
:aria-label="ui.darkMode ? 'Mode clair' : 'Mode sombre'"
|
||||||
|
variant="ghost"
|
||||||
|
icon-size="22"
|
||||||
|
button-class="text-white/70 hover:bg-primary-600 hover:text-white"
|
||||||
|
@click="ui.toggleDarkMode()"
|
||||||
|
/>
|
||||||
|
<div class="group relative flex gap-2 sm:gap-4">
|
||||||
|
<Icon name="mdi:account-circle-outline" class="self-center cursor-pointer" size="36" />
|
||||||
|
<p class="hidden self-center cursor-pointer sm:block">{{ user?.username }}</p>
|
||||||
|
<div class="invisible absolute right-0 top-full z-50 mt-2 w-44 rounded-md border border-neutral-200 bg-white py-1 text-sm text-neutral-800 opacity-0 shadow-lg transition-all group-hover:visible group-hover:opacity-100">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="block w-full px-3 py-2 text-left hover:bg-neutral-100"
|
||||||
|
@click="handleLogout"
|
||||||
|
>
|
||||||
|
Déconnexion
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type { UserData } from '~/services/dto/user-data'
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
user?: UserData
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const auth = useAuthStore()
|
||||||
|
const ui = useUiStore()
|
||||||
|
|
||||||
|
async function handleLogout() {
|
||||||
|
await auth.logout()
|
||||||
|
await navigateTo('/login')
|
||||||
|
}
|
||||||
|
</script>
|
||||||
52
frontend/components/ui/SidebarLink.vue
Normal file
52
frontend/components/ui/SidebarLink.vue
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
<template>
|
||||||
|
<NuxtLink
|
||||||
|
:to="to"
|
||||||
|
class="group/link relative flex items-center transition-colors hover:text-primary-500"
|
||||||
|
:class="linkClasses"
|
||||||
|
:active-class="exact ? '' : activeClass"
|
||||||
|
:exact-active-class="exact ? activeClass : ''"
|
||||||
|
>
|
||||||
|
<Icon :name="icon" :size="sub ? '20' : '24'" class="flex-shrink-0" />
|
||||||
|
<span
|
||||||
|
v-if="!collapsed"
|
||||||
|
class="self-baseline whitespace-nowrap overflow-hidden transition-opacity duration-300"
|
||||||
|
:class="sub ? 'text-sm' : 'text-md'"
|
||||||
|
>
|
||||||
|
{{ label }}
|
||||||
|
</span>
|
||||||
|
<div
|
||||||
|
v-if="collapsed"
|
||||||
|
class="pointer-events-none absolute left-full z-50 ml-2 rounded-md bg-neutral-800 px-2 py-1 text-xs text-white opacity-0 shadow-lg transition-opacity group-hover/link:pointer-events-auto group-hover/link:opacity-100 whitespace-nowrap"
|
||||||
|
>
|
||||||
|
{{ label }}
|
||||||
|
</div>
|
||||||
|
</NuxtLink>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
const props = defineProps<{
|
||||||
|
to: string
|
||||||
|
icon: string
|
||||||
|
label: string
|
||||||
|
collapsed: boolean
|
||||||
|
sub?: boolean
|
||||||
|
exact?: boolean
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const activeClass = computed(() => {
|
||||||
|
if (props.collapsed) {
|
||||||
|
return '!text-primary-500 bg-primary-500/10'
|
||||||
|
}
|
||||||
|
return '!text-primary-500 bg-tertiary-500'
|
||||||
|
})
|
||||||
|
|
||||||
|
const linkClasses = computed(() => {
|
||||||
|
if (props.collapsed) {
|
||||||
|
return 'justify-center w-10 h-10 mx-auto my-1 p-2 rounded-lg text-neutral-600 hover:text-primary-500 hover:bg-primary-500/10'
|
||||||
|
}
|
||||||
|
if (props.sub) {
|
||||||
|
return 'gap-3 px-4 py-2 pl-12 text-sm font-semibold text-neutral-700'
|
||||||
|
}
|
||||||
|
return 'gap-3 px-4 py-3 text-md font-semibold text-neutral-700'
|
||||||
|
})
|
||||||
|
</script>
|
||||||
217
frontend/composables/useApi.ts
Normal file
217
frontend/composables/useApi.ts
Normal file
@@ -0,0 +1,217 @@
|
|||||||
|
import type { FetchOptions } from 'ofetch'
|
||||||
|
import { $fetch, FetchError } from 'ofetch'
|
||||||
|
import { useAuthStore } from '~/stores/auth'
|
||||||
|
|
||||||
|
export type AnyObject = Record<string, unknown>
|
||||||
|
|
||||||
|
export type BlobResponse = {
|
||||||
|
data: Blob
|
||||||
|
headers: Headers
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ApiClient = {
|
||||||
|
get<T>(url: string, query?: AnyObject, options?: ApiFetchOptions<'json'>): Promise<T>
|
||||||
|
getBlob(url: string, query?: AnyObject, options?: ApiFetchOptions<'blob'>): Promise<BlobResponse>
|
||||||
|
post<T>(url: string, body?: AnyObject, options?: ApiFetchOptions<'json'>): Promise<T>
|
||||||
|
put<T>(url: string, body?: AnyObject, options?: ApiFetchOptions<'json'>): Promise<T>
|
||||||
|
patch<T>(url: string, body?: AnyObject, options?: ApiFetchOptions<'json'>): Promise<T>
|
||||||
|
delete<T>(url: string, query?: AnyObject, options?: ApiFetchOptions<'json'>): Promise<T>
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ApiFetchOptions<ResponseType extends 'json' | 'blob'> =
|
||||||
|
FetchOptions<ResponseType> & {
|
||||||
|
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<string, unknown>
|
||||||
|
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<string, string> = {
|
||||||
|
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<T>(
|
||||||
|
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<T>(url, { ...options, method, headers })
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
get<T>(url: string, query: AnyObject = {}, options: ApiFetchOptions<'json'> = {}) {
|
||||||
|
return request<T>('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<T>(url: string, body: AnyObject = {}, options: ApiFetchOptions<'json'> = {}) {
|
||||||
|
return request<T>('POST', url, { ...options, body })
|
||||||
|
},
|
||||||
|
put<T>(url: string, body: AnyObject = {}, options: ApiFetchOptions<'json'> = {}) {
|
||||||
|
return request<T>('PUT', url, { ...options, body })
|
||||||
|
},
|
||||||
|
patch<T>(url: string, body: AnyObject = {}, options: ApiFetchOptions<'json'> = {}) {
|
||||||
|
return request<T>('PATCH', url, { ...options, body })
|
||||||
|
},
|
||||||
|
delete<T>(url: string, query: AnyObject = {}, options: ApiFetchOptions<'json'> = {}) {
|
||||||
|
return request<T>('DELETE', url, { ...options, query })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
17
frontend/composables/useAppVersion.ts
Normal file
17
frontend/composables/useAppVersion.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
export function useAppVersion() {
|
||||||
|
const api = useApi()
|
||||||
|
const version = useState<string | null>('app-version', () => null)
|
||||||
|
|
||||||
|
async function load(): Promise<string | null> {
|
||||||
|
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 }
|
||||||
|
}
|
||||||
4
frontend/i18n/i18n.config.ts
Normal file
4
frontend/i18n/i18n.config.ts
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
export default defineI18nConfig(() => ({
|
||||||
|
legacy: false,
|
||||||
|
locale: 'fr',
|
||||||
|
}))
|
||||||
25
frontend/i18n/locales/fr.json
Normal file
25
frontend/i18n/locales/fr.json
Normal file
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
7
frontend/layouts/auth.vue
Normal file
7
frontend/layouts/auth.vue
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
<template>
|
||||||
|
<div class="min-h-screen bg-tertiary-500 from-tertiary-500 via-white to-neutral-100 text-neutral-900">
|
||||||
|
<main class="mx-auto flex min-h-screen w-full max-w-[720px] items-center px-6 py-12">
|
||||||
|
<slot />
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
112
frontend/layouts/default.vue
Normal file
112
frontend/layouts/default.vue
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
<template>
|
||||||
|
<div class="h-screen overflow-hidden">
|
||||||
|
<div class="flex h-full">
|
||||||
|
<!-- Mobile sidebar overlay -->
|
||||||
|
<Transition name="sidebar-overlay">
|
||||||
|
<div
|
||||||
|
v-if="ui.sidebarOpen"
|
||||||
|
class="fixed inset-0 z-40 bg-black/50 lg:hidden"
|
||||||
|
@click="ui.closeMobileSidebar()"
|
||||||
|
/>
|
||||||
|
</Transition>
|
||||||
|
|
||||||
|
<aside
|
||||||
|
class="fixed inset-y-0 left-0 z-50 flex h-full flex-shrink-0 flex-col border-r border-neutral-200 bg-tertiary-500 transition-transform duration-300 lg:static lg:z-auto lg:translate-x-0"
|
||||||
|
:class="[
|
||||||
|
ui.sidebarCollapsed ? 'lg:w-16' : 'lg:w-64',
|
||||||
|
ui.sidebarOpen ? 'w-64 translate-x-0' : '-translate-x-full',
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
<div class="flex items-center overflow-hidden" :class="sidebarIsCollapsed ? 'justify-center p-3' : 'justify-between'">
|
||||||
|
<img
|
||||||
|
v-if="!sidebarIsCollapsed"
|
||||||
|
src="/malio.png"
|
||||||
|
alt="Logo"
|
||||||
|
class="w-auto"
|
||||||
|
/>
|
||||||
|
<img
|
||||||
|
v-else
|
||||||
|
src="/LOGO_CARRE.png"
|
||||||
|
alt="Logo"
|
||||||
|
class="w-[46px] h-[55px]"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
class="mr-2 rounded-md p-2 text-neutral-500 hover:bg-neutral-200 hover:text-neutral-700 transition-colors lg:hidden"
|
||||||
|
@click="ui.closeMobileSidebar()"
|
||||||
|
>
|
||||||
|
<Icon name="mdi:close" size="20" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<nav class="flex-1 overflow-hidden" :class="sidebarIsCollapsed ? 'px-1 pb-6' : 'px-4 pb-6'">
|
||||||
|
<SidebarLink
|
||||||
|
to="/"
|
||||||
|
icon="mdi:view-dashboard-outline"
|
||||||
|
label="Tableau de bord"
|
||||||
|
:collapsed="sidebarIsCollapsed"
|
||||||
|
:class="sidebarIsCollapsed ? 'mt-4' : 'border-t border-secondary-500 pt-6'"
|
||||||
|
@click="ui.closeMobileSidebar()"
|
||||||
|
/>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div class="flex items-center justify-center p-4">
|
||||||
|
<p v-if="!sidebarIsCollapsed" class="font-bold">v {{ version }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Collapse toggle button centered vertically on the sidebar edge -->
|
||||||
|
<button
|
||||||
|
class="absolute top-1/2 -right-4 z-10 hidden h-8 w-8 -translate-y-1/2 items-center justify-center rounded-full border border-neutral-200 bg-white text-neutral-400 shadow-sm hover:text-neutral-700 transition-colors lg:flex"
|
||||||
|
:title="ui.sidebarCollapsed ? 'Ouvrir le menu' : 'Réduire le menu'"
|
||||||
|
@click="ui.toggleSidebar()"
|
||||||
|
>
|
||||||
|
<Icon
|
||||||
|
:name="ui.sidebarCollapsed ? 'mdi:chevron-right' : 'mdi:chevron-left'"
|
||||||
|
size="18"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<div class="h-full flex-1 flex flex-col min-h-0 min-w-0">
|
||||||
|
<AppTopNav :user="auth.user" />
|
||||||
|
<main class="flex flex-1 flex-col overflow-y-auto overflow-x-hidden bg-white px-4 pb-24 sm:px-8 lg:px-16">
|
||||||
|
<div aria-hidden="true" class="pointer-events-none sticky top-0 z-30 h-8 flex-shrink-0 bg-white sm:h-12" />
|
||||||
|
<slot/>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { useAppVersion } from '~/composables/useAppVersion'
|
||||||
|
|
||||||
|
const auth = useAuthStore()
|
||||||
|
const ui = useUiStore()
|
||||||
|
const {version} = useAppVersion()
|
||||||
|
const route = useRoute()
|
||||||
|
|
||||||
|
// On mobile, sidebar is always expanded (not collapsed icon mode)
|
||||||
|
const sidebarIsCollapsed = computed(() => {
|
||||||
|
if (ui.sidebarOpen) return false
|
||||||
|
return ui.sidebarCollapsed
|
||||||
|
})
|
||||||
|
|
||||||
|
// Close mobile sidebar on route change
|
||||||
|
watch(() => route.path, () => {
|
||||||
|
ui.closeMobileSidebar()
|
||||||
|
})
|
||||||
|
|
||||||
|
useHead({
|
||||||
|
titleTemplate: (title) => title || 'Central',
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.sidebar-overlay-enter-active,
|
||||||
|
.sidebar-overlay-leave-active {
|
||||||
|
transition: opacity 0.3s ease;
|
||||||
|
}
|
||||||
|
.sidebar-overlay-enter-from,
|
||||||
|
.sidebar-overlay-leave-to {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
16
frontend/middleware/auth.global.ts
Normal file
16
frontend/middleware/auth.global.ts
Normal file
@@ -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('/')
|
||||||
|
}
|
||||||
|
})
|
||||||
59
frontend/nuxt.config.ts
Normal file
59
frontend/nuxt.config.ts
Normal file
@@ -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
|
||||||
|
},
|
||||||
|
})
|
||||||
25
frontend/package.json
Normal file
25
frontend/package.json
Normal file
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
12
frontend/pages/index.vue
Normal file
12
frontend/pages/index.vue
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<h1 class="text-2xl font-bold text-neutral-900">Tableau de bord</h1>
|
||||||
|
<p class="mt-4 text-neutral-600">Bienvenue sur Central.</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
useHead({
|
||||||
|
title: 'Tableau de bord'
|
||||||
|
})
|
||||||
|
</script>
|
||||||
68
frontend/pages/login.vue
Normal file
68
frontend/pages/login.vue
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
<template>
|
||||||
|
<div class="mx-auto w-full max-w-lg">
|
||||||
|
<span
|
||||||
|
class="flex items-center justify-center bg-white text-xl font-bold uppercase text-primary-500 p-4"
|
||||||
|
>
|
||||||
|
<img src="/malio.png" alt="Logo" class="w-[150px]"/>
|
||||||
|
</span>
|
||||||
|
<form
|
||||||
|
class="mt-8 space-y-6 rounded-lg border border-neutral-200 bg-white p-6 shadow-sm"
|
||||||
|
@submit.prevent="handleSubmit"
|
||||||
|
>
|
||||||
|
<MalioInputText
|
||||||
|
label="Nom d'utilisateur"
|
||||||
|
autocomplete="username"
|
||||||
|
group-class="mt-0"
|
||||||
|
inputClass="w-full"
|
||||||
|
v-model="username"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="text-sm font-semibold text-neutral-700" for="password">
|
||||||
|
Mot de passe
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="password"
|
||||||
|
v-model="password"
|
||||||
|
type="password"
|
||||||
|
autocomplete="current-password"
|
||||||
|
class="mt-2 w-full rounded-md border border-neutral-300 bg-white px-3 py-2 text-base text-neutral-900 focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-secondary-500/20"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<MalioButton
|
||||||
|
label="Se connecter"
|
||||||
|
button-class="w-full"
|
||||||
|
type="submit"
|
||||||
|
:disabled="isSubmitting"
|
||||||
|
/>
|
||||||
|
<p class="font-bold">v{{ version }}</p>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
definePageMeta({layout: 'auth'})
|
||||||
|
useHead({
|
||||||
|
title: 'Connexion'
|
||||||
|
})
|
||||||
|
|
||||||
|
const auth = useAuthStore()
|
||||||
|
const {version} = useAppVersion()
|
||||||
|
|
||||||
|
const username = ref('')
|
||||||
|
const password = ref('')
|
||||||
|
const isSubmitting = ref(false)
|
||||||
|
|
||||||
|
async function handleSubmit() {
|
||||||
|
if (isSubmitting.value) return
|
||||||
|
|
||||||
|
isSubmitting.value = true
|
||||||
|
try {
|
||||||
|
await auth.login(username.value, password.value)
|
||||||
|
await navigateTo('/')
|
||||||
|
} finally {
|
||||||
|
isSubmitting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
BIN
frontend/public/LOGO_CARRE.png
Normal file
BIN
frontend/public/LOGO_CARRE.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 36 KiB |
BIN
frontend/public/favicon.ico
Normal file
BIN
frontend/public/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.1 KiB |
BIN
frontend/public/malio.png
Normal file
BIN
frontend/public/malio.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 7.9 KiB |
22
frontend/services/auth.ts
Normal file
22
frontend/services/auth.ts
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import type { UserData } from './dto/user-data'
|
||||||
|
|
||||||
|
export function getCurrentUser() {
|
||||||
|
const api = useApi()
|
||||||
|
return api.get<UserData>('/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'
|
||||||
|
})
|
||||||
|
}
|
||||||
12
frontend/services/dto/user-data.ts
Normal file
12
frontend/services/dto/user-data.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
export type UserData = {
|
||||||
|
id: number
|
||||||
|
'@id'?: string
|
||||||
|
username: string
|
||||||
|
roles: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export type UserWrite = {
|
||||||
|
username: string
|
||||||
|
plainPassword?: string
|
||||||
|
roles: string[]
|
||||||
|
}
|
||||||
71
frontend/stores/auth.ts
Normal file
71
frontend/stores/auth.ts
Normal file
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
57
frontend/stores/ui.ts
Normal file
57
frontend/stores/ui.ts
Normal file
@@ -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 }
|
||||||
|
})
|
||||||
48
frontend/tailwind.config.ts
Normal file
48
frontend/tailwind.config.ts
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
import type {Config} from 'tailwindcss'
|
||||||
|
|
||||||
|
export default <Partial<Config>>{
|
||||||
|
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) / <alpha-value>)',
|
||||||
|
secondary: 'rgb(var(--m-secondary, 75 77 237) / <alpha-value>)',
|
||||||
|
tertiary: 'rgb(var(--m-tertiary, 243 244 248) / <alpha-value>)',
|
||||||
|
border: 'rgb(var(--m-border) / <alpha-value>)',
|
||||||
|
text: 'rgb(var(--m-text) / <alpha-value>)',
|
||||||
|
muted: 'rgb(var(--m-muted) / <alpha-value>)',
|
||||||
|
bg: 'rgb(var(--m-bg) / <alpha-value>)',
|
||||||
|
surface: 'rgb(var(--m-surface) / <alpha-value>)',
|
||||||
|
disabled: 'rgb(var(--m-disabled) / <alpha-value>)',
|
||||||
|
danger: 'rgb(var(--m-danger) / <alpha-value>)',
|
||||||
|
success: 'rgb(var(--m-success) / <alpha-value>)',
|
||||||
|
'btn-primary': 'rgb(var(--m-btn-primary) / <alpha-value>)',
|
||||||
|
'btn-primary-hover': 'rgb(var(--m-btn-primary-hover) / <alpha-value>)',
|
||||||
|
'btn-primary-active': 'rgb(var(--m-btn-primary-active) / <alpha-value>)',
|
||||||
|
'btn-secondary': 'rgb(var(--m-btn-secondary) / <alpha-value>)',
|
||||||
|
'btn-secondary-hover': 'rgb(var(--m-btn-secondary-hover) / <alpha-value>)',
|
||||||
|
'btn-secondary-active': 'rgb(var(--m-btn-secondary-active) / <alpha-value>)',
|
||||||
|
'btn-danger': 'rgb(var(--m-btn-danger) / <alpha-value>)',
|
||||||
|
'btn-danger-hover': 'rgb(var(--m-btn-danger-hover) / <alpha-value>)',
|
||||||
|
'btn-danger-active': 'rgb(var(--m-btn-danger-active) / <alpha-value>)',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
17
frontend/tsconfig.json
Normal file
17
frontend/tsconfig.json
Normal file
@@ -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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
10
frontend/utils/api.ts
Normal file
10
frontend/utils/api.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
export type HydraCollection<T> = {
|
||||||
|
'hydra:member'?: T[]
|
||||||
|
'hydra:totalItems'?: number
|
||||||
|
'member'?: T[]
|
||||||
|
'totalItems'?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export function extractHydraMembers<T>(response: HydraCollection<T>): T[] {
|
||||||
|
return response['hydra:member'] ?? response['member'] ?? []
|
||||||
|
}
|
||||||
9
infra/dev/.env.docker
Normal file
9
infra/dev/.env.docker
Normal file
@@ -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
|
||||||
102
infra/dev/Dockerfile
Normal file
102
infra/dev/Dockerfile
Normal file
@@ -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
|
||||||
54
infra/dev/nginx.conf
Normal file
54
infra/dev/nginx.conf
Normal file
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
8
infra/dev/php.ini
Normal file
8
infra/dev/php.ini
Normal file
@@ -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
|
||||||
9
infra/dev/xdebug.ini
Normal file
9
infra/dev/xdebug.ini
Normal file
@@ -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
|
||||||
119
makefile
Normal file
119
makefile
Normal file
@@ -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
|
||||||
44
phpunit.dist.xml
Normal file
44
phpunit.dist.xml
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
|
||||||
|
<!-- https://phpunit.readthedocs.io/en/latest/configuration.html -->
|
||||||
|
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||||
|
xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd"
|
||||||
|
colors="true"
|
||||||
|
failOnDeprecation="true"
|
||||||
|
failOnNotice="true"
|
||||||
|
failOnWarning="true"
|
||||||
|
bootstrap="tests/bootstrap.php"
|
||||||
|
cacheDirectory=".phpunit.cache"
|
||||||
|
>
|
||||||
|
<php>
|
||||||
|
<ini name="display_errors" value="1" />
|
||||||
|
<ini name="error_reporting" value="-1" />
|
||||||
|
<server name="APP_ENV" value="test" force="true" />
|
||||||
|
<server name="SHELL_VERBOSITY" value="-1" />
|
||||||
|
</php>
|
||||||
|
|
||||||
|
<testsuites>
|
||||||
|
<testsuite name="Project Test Suite">
|
||||||
|
<directory>tests</directory>
|
||||||
|
</testsuite>
|
||||||
|
</testsuites>
|
||||||
|
|
||||||
|
<source ignoreSuppressionOfDeprecations="true"
|
||||||
|
ignoreIndirectDeprecations="true"
|
||||||
|
restrictNotices="true"
|
||||||
|
restrictWarnings="true"
|
||||||
|
>
|
||||||
|
<include>
|
||||||
|
<directory>src</directory>
|
||||||
|
</include>
|
||||||
|
|
||||||
|
<deprecationTrigger>
|
||||||
|
<method>Doctrine\Deprecations\Deprecation::trigger</method>
|
||||||
|
<method>Doctrine\Deprecations\Deprecation::delegateTriggerToBackend</method>
|
||||||
|
<function>trigger_deprecation</function>
|
||||||
|
</deprecationTrigger>
|
||||||
|
</source>
|
||||||
|
|
||||||
|
<extensions>
|
||||||
|
</extensions>
|
||||||
|
</phpunit>
|
||||||
11
public/index.php
Normal file
11
public/index.php
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use App\Kernel;
|
||||||
|
|
||||||
|
require_once dirname(__DIR__).'/vendor/autoload_runtime.php';
|
||||||
|
|
||||||
|
return function (array $context) {
|
||||||
|
return new Kernel($context['APP_ENV'], (bool) $context['APP_DEBUG']);
|
||||||
|
};
|
||||||
25
src/ApiResource/AppVersion.php
Normal file
25
src/ApiResource/AppVersion.php
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\ApiResource;
|
||||||
|
|
||||||
|
use ApiPlatform\Metadata\ApiResource;
|
||||||
|
use ApiPlatform\Metadata\Get;
|
||||||
|
use App\State\AppVersionProvider;
|
||||||
|
use Symfony\Component\Serializer\Attribute\Groups;
|
||||||
|
|
||||||
|
#[ApiResource(
|
||||||
|
operations: [
|
||||||
|
new Get(
|
||||||
|
uriTemplate: '/version',
|
||||||
|
normalizationContext: ['groups' => ['version:read']],
|
||||||
|
provider: AppVersionProvider::class,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)]
|
||||||
|
final class AppVersion
|
||||||
|
{
|
||||||
|
#[Groups(['version:read'])]
|
||||||
|
public string $version = '';
|
||||||
|
}
|
||||||
40
src/DataFixtures/AppFixtures.php
Normal file
40
src/DataFixtures/AppFixtures.php
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\DataFixtures;
|
||||||
|
|
||||||
|
use App\Entity\User;
|
||||||
|
use Doctrine\Bundle\FixturesBundle\Fixture;
|
||||||
|
use Doctrine\Persistence\ObjectManager;
|
||||||
|
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
|
||||||
|
|
||||||
|
class AppFixtures extends Fixture
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly UserPasswordHasherInterface $passwordHasher,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function load(ObjectManager $manager): void
|
||||||
|
{
|
||||||
|
$admin = new User();
|
||||||
|
$admin->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();
|
||||||
|
}
|
||||||
|
}
|
||||||
154
src/Entity/User.php
Normal file
154
src/Entity/User.php
Normal file
@@ -0,0 +1,154 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Entity;
|
||||||
|
|
||||||
|
use ApiPlatform\Metadata\ApiResource;
|
||||||
|
use ApiPlatform\Metadata\Delete;
|
||||||
|
use ApiPlatform\Metadata\Get;
|
||||||
|
use ApiPlatform\Metadata\GetCollection;
|
||||||
|
use ApiPlatform\Metadata\Patch;
|
||||||
|
use ApiPlatform\Metadata\Post;
|
||||||
|
use App\Repository\UserRepository;
|
||||||
|
use App\State\MeProvider;
|
||||||
|
use App\State\UserPasswordHasherProcessor;
|
||||||
|
use DateTimeImmutable;
|
||||||
|
use Doctrine\DBAL\Types\Types;
|
||||||
|
use Doctrine\ORM\Mapping as ORM;
|
||||||
|
use Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface;
|
||||||
|
use Symfony\Component\Security\Core\User\UserInterface;
|
||||||
|
use Symfony\Component\Serializer\Attribute\Groups;
|
||||||
|
|
||||||
|
#[ApiResource(
|
||||||
|
operations: [
|
||||||
|
new Get(
|
||||||
|
uriTemplate: '/me',
|
||||||
|
provider: MeProvider::class,
|
||||||
|
normalizationContext: ['groups' => ['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<string> */
|
||||||
|
#[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<string> */
|
||||||
|
public function getRoles(): array
|
||||||
|
{
|
||||||
|
$roles = $this->roles;
|
||||||
|
$roles[] = 'ROLE_USER';
|
||||||
|
|
||||||
|
return array_values(array_unique($roles));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param list<string> $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;
|
||||||
|
}
|
||||||
|
}
|
||||||
13
src/Kernel.php
Normal file
13
src/Kernel.php
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App;
|
||||||
|
|
||||||
|
use Symfony\Bundle\FrameworkBundle\Kernel\MicroKernelTrait;
|
||||||
|
use Symfony\Component\HttpKernel\Kernel as BaseKernel;
|
||||||
|
|
||||||
|
class Kernel extends BaseKernel
|
||||||
|
{
|
||||||
|
use MicroKernelTrait;
|
||||||
|
}
|
||||||
20
src/Repository/UserRepository.php
Normal file
20
src/Repository/UserRepository.php
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Repository;
|
||||||
|
|
||||||
|
use App\Entity\User;
|
||||||
|
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||||
|
use Doctrine\Persistence\ManagerRegistry;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @extends ServiceEntityRepository<User>
|
||||||
|
*/
|
||||||
|
class UserRepository extends ServiceEntityRepository
|
||||||
|
{
|
||||||
|
public function __construct(ManagerRegistry $registry)
|
||||||
|
{
|
||||||
|
parent::__construct($registry, User::class);
|
||||||
|
}
|
||||||
|
}
|
||||||
26
src/State/AppVersionProvider.php
Normal file
26
src/State/AppVersionProvider.php
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\State;
|
||||||
|
|
||||||
|
use ApiPlatform\Metadata\Operation;
|
||||||
|
use ApiPlatform\State\ProviderInterface;
|
||||||
|
use App\ApiResource\AppVersion;
|
||||||
|
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||||
|
|
||||||
|
final readonly class AppVersionProvider implements ProviderInterface
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
#[Autowire('%app.version%')]
|
||||||
|
private string $version,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function provide(Operation $operation, array $uriVariables = [], array $context = []): AppVersion
|
||||||
|
{
|
||||||
|
$dto = new AppVersion();
|
||||||
|
$dto->version = $this->version;
|
||||||
|
|
||||||
|
return $dto;
|
||||||
|
}
|
||||||
|
}
|
||||||
26
src/State/MeProvider.php
Normal file
26
src/State/MeProvider.php
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\State;
|
||||||
|
|
||||||
|
use ApiPlatform\Metadata\Operation;
|
||||||
|
use ApiPlatform\State\ProviderInterface;
|
||||||
|
use App\Entity\User;
|
||||||
|
use Symfony\Bundle\SecurityBundle\Security;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @implements ProviderInterface<User>
|
||||||
|
*/
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
41
src/State/UserPasswordHasherProcessor.php
Normal file
41
src/State/UserPasswordHasherProcessor.php
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\State;
|
||||||
|
|
||||||
|
use ApiPlatform\Metadata\Operation;
|
||||||
|
use ApiPlatform\State\ProcessorInterface;
|
||||||
|
use App\Entity\User;
|
||||||
|
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||||
|
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @implements ProcessorInterface<User, User>
|
||||||
|
*/
|
||||||
|
final readonly class UserPasswordHasherProcessor implements ProcessorInterface
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @param ProcessorInterface<User, User> $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);
|
||||||
|
}
|
||||||
|
}
|
||||||
15
tests/bootstrap.php
Normal file
15
tests/bootstrap.php
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use Symfony\Component\Dotenv\Dotenv;
|
||||||
|
|
||||||
|
require dirname(__DIR__).'/vendor/autoload.php';
|
||||||
|
|
||||||
|
if (method_exists(Dotenv::class, 'bootEnv')) {
|
||||||
|
new Dotenv()->bootEnv(dirname(__DIR__).'/.env');
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($_SERVER['APP_DEBUG']) {
|
||||||
|
umask(0o000);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user