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:
2026-04-03 10:14:39 +02:00
commit 8e59f59679
65 changed files with 2630 additions and 0 deletions

23
.env Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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],
];

View 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']

View 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

View 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

View 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

View 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

View 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
View 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
View File

@@ -0,0 +1,4 @@
# yaml-language-server: $schema=../vendor/symfony/routing/Loader/schema/routing.schema.json
controllers:
resource: routing.controllers

View File

@@ -0,0 +1,4 @@
api_platform:
resource: .
type: api_platform
prefix: /api

View 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
View 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
View File

@@ -0,0 +1,2 @@
parameters:
app.version: '0.1.0'

60
docker-compose.yml Normal file
View 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
View File

@@ -0,0 +1 @@
@malio:registry=https://gitea.malio.fr/api/packages/MALIO-DEV/npm/

13
frontend/app.vue Normal file
View File

@@ -0,0 +1,13 @@
<template>
<NuxtLayout>
<NuxtPage />
</NuxtLayout>
</template>
<script setup lang="ts">
const { load } = useAppVersion()
onMounted(() => {
load()
})
</script>

View 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;
}

View 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>

View 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>

View 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 })
}
}
}

View 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 }
}

View File

@@ -0,0 +1,4 @@
export default defineI18nConfig(() => ({
legacy: false,
locale: 'fr',
}))

View 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"
}
}

View 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>

View 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>

View 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
View 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
View 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
View 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
View 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>

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.9 KiB

22
frontend/services/auth.ts Normal file
View 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'
})
}

View 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
View 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
View 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 }
})

View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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']);
};

View 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 = '';
}

View 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
View 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
View 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;
}

View 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);
}
}

View 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
View 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();
}
}

View 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
View 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);
}