Nous avons vu dans un article précédent comment
installer Symfony 6 + Sonata Admin 5 avec une gestion
d’utilisateurs et de médias.
Nous allons
poursuivre cette installation avec la configuration des langues et
des fuseaux horaires.
Nous allons installer intlBundle
qui permet de gérer l’affichage localisé.
php8.1 composer.phar require sonata-project/intl-bundle
On en profite pour ajouter un champs timezone et locale à
l’utilisateur.
Pour cela, nous allons étendre
userBundle et ajouter un champs dans l’admin.
On étend user en déplaçant
l’entité user dans :
src/Application/Sonata/UserBundle/Entity/User.php
Et on lui
ajoute un champs timezone et un champs locale.
<?php
namespace App\Application\Sonata\UserBundle\Entity;
use Doctrine\ORM\Mapping as ORM;
use Sonata\UserBundle\Entity\BaseUser;
/**
* @ORM\Entity
* @ORM\Table(name="user__user")
*/
class User extends BaseUser
{
/**
* @ORM\Id
* @ORM\GeneratedValue
* @ORM\Column(type="integer")
*/
protected $id;
/**
* @var string|null
*
* @ORM\Column(name="timezone", type="string", length=64, nullable=true)
*/
protected $timezone;
/**
* @var string|null
*
* @ORM\Column(name="locale", type="string", length=8, nullable=true)
*/
protected $locale;
public function getTimezone(){
return $this->timezone;
}
public function setTimezone($timezone){
$this->timezone=$timezone;
return $this;
}
public function getLocale(){
return $this->locale;
}
public function setLocale($locale){
$this->locale=$locale;
return $this;
}
}
On duplique l’admin de Sonata et on le place dans
src/Application/Sonata/UserBundle/Admin/Model/UserAdmin.php
On
lui ajoute notre champs timezone.
<?php
declare(strict_types=1);
/*
* This file is part of the Sonata Project package.
*
* (c) Thomas Rabaix <thomas.rabaix@sonata-project.org>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Application\Sonata\UserBundle\Admin\Model;
use Sonata\AdminBundle\Admin\AbstractAdmin;
use Sonata\AdminBundle\Datagrid\DatagridMapper;
use Sonata\AdminBundle\Datagrid\ListMapper;
use Sonata\AdminBundle\FieldDescription\FieldDescriptionInterface;
use Sonata\AdminBundle\Form\FormMapper;
use Sonata\AdminBundle\Show\ShowMapper;
use Sonata\UserBundle\Form\Type\RolesMatrixType;
use Sonata\UserBundle\Model\UserManagerInterface;
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
use Symfony\Component\Form\Extension\Core\Type\LocaleType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\Extension\Core\Type\TimezoneType;
use Symfony\Component\Form\Extension\Core\Type\UrlType;
/**
* @phpstan-extends AbstractAdmin<\Sonata\UserBundle\Model\UserInterface>
*/
class UserAdmin extends AbstractAdmin
{
protected UserManagerInterface $userManager;
public function __construct(UserManagerInterface $userManager)
{
parent::__construct();
$this->userManager = $userManager;
}
protected function preUpdate(object $object): void
{
$this->userManager->updatePassword($object);
}
protected function configureFormOptions(array &$formOptions): void
{
$formOptions['validation_groups'] = ['Default'];
if (!$this->hasSubject() || null === $this->getSubject()->getId()) {
$formOptions['validation_groups'][] = 'Registration';
} else {
$formOptions['validation_groups'][] = 'Profile';
}
}
protected function configureListFields(ListMapper $list): void
{
$list
->addIdentifier('username')
->add('email')
->add('enabled', null, ['editable' => true])
->add('createdAt');
if ($this->isGranted('ROLE_ALLOWED_TO_SWITCH')) {
$list
->add('impersonating', FieldDescriptionInterface::TYPE_STRING, [
'virtual_field' => true,
'template' => '@SonataUser/Admin/Field/impersonating.html.twig',
]);
}
}
protected function configureDatagridFilters(DatagridMapper $filter): void
{
$filter
->add('id')
->add('username')
->add('email');
}
protected function configureShowFields(ShowMapper $show): void
{
$show
->add('username')
->add('email');
}
protected function configureFormFields(FormMapper $form): void
{
$form
->with('general', ['class' => 'col-md-4'])
->add('username')
->add('email')
->add('locale', LocaleType::class, ['required' => false])
->add('timezone', TimezoneType::class, ['required' => false])
->add('plainPassword', TextType::class, [
'required' => (!$this->hasSubject() || null === $this->getSubject()->getId()),
])
->add('enabled', null)
->end()
->with('roles', ['class' => 'col-md-8'])
->add('realRoles', RolesMatrixType::class, [
'label' => false,
'multiple' => true,
'required' => false,
])
->end();
}
protected function configureExportFields(): array
{
// Avoid sensitive properties to be exported.
return array_filter(parent::configureExportFields(), static function (string $v): bool {
return !\in_array($v, ['password', 'salt'], true);
});
}
}
On change les références de notre entité et de notre admin dans sonata_user.yml
sonata_user:
class:
user: App\Application\Sonata\UserBundle\Entity\User
admin:
user:
class: App\Application\Sonata\UserBundle\Admin\Model\UserAdmin
resetting:
email:
address: "test@test.com"
sender_name: Backoffice
security_acl: true
manager_type: orm # can be orm or mongodb
et on enregistre notre extension dans doctrine.yml
doctrine:
dbal:
url: '%env(resolve:DATABASE_URL)%'
# IMPORTANT: You MUST configure your server version,
# either here or in the DATABASE_URL env var (see .env file)
#server_version: '13'
orm:
auto_generate_proxy_classes: true
naming_strategy: doctrine.orm.naming_strategy.underscore_number_aware
auto_mapping: true
mappings:
App:
is_bundle: false
dir: '%kernel.project_dir%/src/Entity'
prefix: 'App\Entity'
alias: App
App\Application\Sonata\MediaBundle:
is_bundle: false
dir: '%kernel.project_dir%/src/Application/Sonata/MediaBundle/Entity'
prefix: 'App\Application\Sonata\MediaBundle\Entity'
alias: App\Application\Sonata\MediaBundle
App\Application\Sonata\UserBundle:
is_bundle: false
dir: '%kernel.project_dir%/src/Application/Sonata/UserBundle/Entity'
prefix: 'App\Application\Sonata\UserBundle\Entity'
alias: App
On met à jour notre schéma et on teste.
php8.1 bin/console doctrine:schema:update --force
Notre nouveau champs est bien pris en compte.
On met la locale par défaut en FR
#config/packages/translation.yaml
framework:
default_locale: fr
translator:
default_path: '%kernel.project_dir%/translations'
fallbacks:
- en
- fr
On configure sonata_intl
sonata_intl:
timezone:
default: Europe/Paris
locales:
fr: Europe/Paris
en_UK: Europe/London
detectors:
- sonata.intl.timezone_detector.user
- sonata.intl.timezone_detector.locale
On souhaite maintenant que les paramètres timezone et le
langage par défaut de notre utilisateur soient pris en
compte.
Pour cela, nous allons récupérer ses
configurations au moment du login et les mettre en
session.
Ensuite, nous allons récupérer ces
informations à chaque chargement de la page et surcharger
l’interface avec ces paramètres.
Première étape, on récupère les infos
au login. Pour cela, on va spécifier le service à
utiliser lors du handler_success dans security.yaml.
On
met success_handler: login_success_handler
security:
enable_authenticator_manager: true
password_hashers:
Sonata\UserBundle\Model\UserInterface:
algorithm: auto
providers:
sonata_user_bundle:
id: sonata.user.security.user_provider
access_decision_manager:
strategy: unanimous
firewalls:
dev:
pattern: ^/(_(profiler|wdt)|css|images|js)/
security: false
admin:
lazy: true
pattern: /admin(.*)
provider: sonata_user_bundle
context: user
switch_user: true
form_login:
login_path: sonata_user_admin_security_login
check_path: sonata_user_admin_security_check
default_target_path: sonata_admin_dashboard
success_handler: login_success_handler
logout:
path: sonata_user_admin_security_logout
target: sonata_user_admin_security_login
remember_me:
#secret: "%env(APP_SECRET)%"
secret: "123456"
lifetime: 2629746
path: /admin
access_control:
- { path: ^/admin/login$, role: PUBLIC_ACCESS }
- { path: ^/admin/logout$, role: PUBLIC_ACCESS }
- { path: ^/admin/login_check$, role: PUBLIC_ACCESS }
- { path: ^/admin/request$, role: PUBLIC_ACCESS }
- { path: ^/admin/check-email$, role: PUBLIC_ACCESS }
- { path: ^/admin/reset/.*$, role: PUBLIC_ACCESS }
- { path: ^/admin/, role: ROLE_ADMIN }
role_hierarchy:
ROLE_ADMIN:
- ROLE_USER
- ROLE_SONATA_ADMIN
- ROLE_SONATA_USER_ADMIN_USER_VIEW
ROLE_SUPER_ADMIN:
- ROLE_ADMIN
- ROLE_ALLOWED_TO_SWITCH
when@test:
security:
password_hashers:
# By default, password hashers are resource intensive and take time. This is
# important to generate secure password hashes. In tests however, secure hashes
# are not important, waste resources and increase test times. The following
# reduces the work factor to the lowest possible values.
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
Ensuite on ajoute notre service dans service.yaml
services:
_defaults:
autowire: true # Automatically injects dependencies in your services.
autoconfigure: true # Automatically registers your services as commands, event subscribers, etc.
App\:
resource: '../src/'
exclude:
- '../src/DependencyInjection/'
- '../src/Entity/'
- '../src/Kernel.php'
login_success_handler:
parent: security.authentication.success_handler
class: App\EventListener\LoginSuccessHandler
Et on crée notre Handler dans src/EventListener/LoginSuccessHandler.php
<?php
namespace App\EventListener;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Http\HttpUtils;
use Symfony\Component\Security\Http\Authentication\DefaultAuthenticationSuccessHandler;
class LoginSuccessHandler extends DefaultAuthenticationSuccessHandler
{
public function onAuthenticationSuccess(Request $request, TokenInterface $token): Response
{
$locale = $token->getUser()->getLocale();
$request->getSession()->set('_locale', $locale);
$request->setLocale($locale);
$timezone = $token->getUser()->getTimezone();
$request->getSession()->set('_timezone', $timezone);
return $this->httpUtils->createRedirectResponse($request, $this->determineTargetUrl($request));
}
}
A ce stade nous avons les préférences en session au
moment du login. On ajoute maintenant 2 Listener. LocaleListener
et TimezoneListener.
On les ajoute dans les services.
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.
App\:
resource: '../src/'
exclude:
- '../src/DependencyInjection/'
- '../src/Entity/'
- '../src/Kernel.php'
# add more service definitions when explicit configuration is needed
# please note that last definitions always *replace* previous ones
login_success_handler:
parent: security.authentication.success_handler
class: App\EventListener\LoginSuccessHandler
App\EventListener\LocaleListener:
class: App\EventListener\LocaleListener
arguments: [ "%kernel.default_locale%"]
tags:
- { name: kernel.event_subscriber }
App\EventListener\TimeZoneListener:
class: App\EventListener\TimeZoneListener
arguments: ["@twig"]
tags:
- { name: kernel.event_subscriber }
Et on crée nos listeners.
<?php
#src/EventListener/LocaleListener.php
namespace App\EventListener;
use Symfony\Component\HttpKernel\Event\RequestEvent;
use Symfony\Component\HttpKernel\KernelEvents;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
class LocaleListener implements EventSubscriberInterface
{
private $defaultLocale;
public function __construct($defaultLocale = 'fr')
{
$this->defaultLocale = $defaultLocale;
}
public function onKernelRequest(RequestEvent $event)
{
$request = $event->getRequest();
if (!$request->hasPreviousSession()) {
return;
}
$request->setLocale($request->getSession()->get('_locale', $this->defaultLocale));
}
public static function getSubscribedEvents()
{
return array(
KernelEvents::REQUEST => array(array('onKernelRequest', 17)),
);
}
}
<?php
#src/EventListener/TimeZoneListener.php
namespace App\EventListener;
use Symfony\Component\HttpKernel\Event\RequestEvent;
use Symfony\Component\HttpKernel\KernelEvents;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Twig\Environment;
use Twig\Extension\CoreExtension;
/**
* Class TimeZoneListener
*
* @package App\EventListener
*/
class TimeZoneListener implements EventSubscriberInterface
{
/** @var twig */
private $twig;
public function __construct( $twig)
{
$this->twig = $twig;
}
public function onKernelRequest( RequestEvent $request)
{
$request = $request->getRequest();
$timezone = $request->getSession()->get('_timezone', "Europe/Paris");
$core = $this->twig->getExtension('Twig\Extension\CoreExtension');
$core->setTimezone($timezone);
return $core;
}
public static function getSubscribedEvents()
{
return array(
KernelEvents::REQUEST => array(array('onKernelRequest', 18)),
);
}
}
Il ne nous reste plus qu’à vérifier que
c’est bien pris en compte.
On met d’abord nos
préférences en locale anglaise et timezone à
l’autre bout du monde.
On a pas de traduction vietnamienne, donc on tombe sur le fallback
EN. On a bien la liste des locales de traduites en revanche.
On a bien l’interface en anglais et les dates traduites en
vietnamien, et l’horaire correspond bien au fuseau
d’HCMC.
On revient sur du FR et Europe/Paris
maintenant.
Notre interface est en Français et les listesbox aussi.
Et nos dates sont écrites au format FR et l’horaire est calé sur Paris. 11:24 à Paris vs 17h24 au Vietnam.