Il est parfois nécessaire dans une application web d’inviter des administrateurs avec différents rôles afin qu’ils puissent accéder à un back-office.

Pour bien structurer l’espace d’administration, il faut identifier les actions que les utilisateurs auront le droit d’effectuer afin de bien découper les rôles qui leur seront attribués.

Dans cet article, nous allons mettre en place une gestion des utilisateurs à partir de l’interface d’une application Symfony.

À la fin de cet article, nous aurons un projet de base Symfony 4 / 5 réutilisable et permettant d’administrer les utilisateurs de différentes applications.

Nous allons donc créer un squelette de back-office dans lequel nous pourrons :

  • Inviter des utilisateurs par e-mail afin qu’ils puissent se connecter au back-office;
  • Rechercher et filtrer les utilisateurs à partir de leur nom, prénom, e-mail ou de leurs rôles;
  • Visualiser un profil utilisateur, attribuer des rôles, activer, désactiver et supprimer des comptes;
  • Se connecter en tant qu’un autre utilisateur sans connaître son mot de passe à partir du compte super administrateur;

Cet article fait suite à :

Nous allons repartir du projet précédent afin d’y ajouter le back-office.

Pour cloner le projet :

git clone --single-branch --branch user-area-with-commands git@github.com:official-dev-fusion/dev-fusion-skeleton-user.git

Les rôles utilisateur

Dans une application Symfony standard, nous avons des rôles récurrents.

Nous avons toujours un rôle super admin qui a tous les privilèges.

Celui-ci est généralement octroyé au compte développeur afin de pouvoir accéder à toutes les fonctionnalités.

Nous avons également souvent un rôle admin destiné à gérer toute l’application.

Il est pratiquement identique au super admin, mais on lui enlève l’accès à certaines fonctionnalités critiques qui pourraient compromettre la sécurité ou le bon fonctionnement de l’application.

Nous pouvons également avoir des rôles spécifiques à certaines fonctionnalités.

Par exemple, un rôle auteur permettant de publier des articles et un rôle post admin permettant de créer, modifier et supprimer tous les articles.

Dans notre cas, nous allons créer un rôle user admin dédié à la gestion des utilisateurs.

Et il peut également y avoir des rôles intermédiaires.

Par exemple, un rôle back permettant de protéger l’accès au back-office.

Et finalement, un rôle utilisateur de base pour identifier un utilisateur connecté.

Il existe un rôle spécial dans Symfony (ROLE_ALLOWED_TO_SWITCH) permettant d’utiliser un autre compte sans connaître son mot de passe.

Modification de security.yaml

Voici les rôles et la hiérarchie que nous allons utiliser dans notre projet de base :

# config/packages/security.yaml
# ...
    role_hierarchy:
        ROLE_USER:              
        ROLE_BACK:              [ ROLE_USER ]
        ROLE_USER_ADMIN:        [ ROLE_BACK ]
        ROLE_ADMIN:             [ ROLE_USER_ADMIN ]
        ROLE_SUPER_ADMIN:       [ ROLE_ADMIN, ROLE_ALLOWED_TO_SWITCH ]
# ...

Nous allons restreindre l’accès au back-office : Seule les utilisateurs ayant le rôle ROLE_BACK peuvent accéder aux chemins commençant par /back/*.

# config/packages/security.yaml
# ...
    access_control:
        - { path: ^/back/*, roles: ROLE_BACK }
# ...

Ensuite, pour pouvoir utiliser l’exploration de compte, il faut le spécifier dans le firewall :

# config/packages/security.yaml
# ...
    firewalls:
        main:
            # ...
            switch_user: true
# ...

Une sidebar pour naviguer dans le back-office

Dans les précédents articles, nous avons déjà préparé la mise en place d’une sidebar en créant un layout pour le back.

Nous allons maintenant ajouter un lien pour accéder à la liste des utilisateurs et un autre pour les inviter.

Nous allons également afficher le nom, prénom, email et rôles de l’utilisateur courant ainsi que la date actuelle.

{# templates/back/block/_sidebar.html.twig #}
{% trans_default_domain 'back_messages' %}
{% set route = app.request.get('_route') %}

<!-- Sidebar  -->
<nav id="sidebar">
    <div class="sidebar-header">
       <div class="logo-sidebar">
            <img src="{{ asset("build/images/logo-white.png") }}" class="img-fluid" alt="Logo sidebar">
        </div>
        <br>
        <div class="row">
            <div class="mx-auto">
                <p class="text-center"><small>{{ "now"|format_date }}</small></p>
                <div class="row">
                    <div class="col-4">
                        <div class="icon-rounded text-center">
                            <i class="fas fa-user fa-2x"></i>
                        </div>
                    </div>
                    <div class="col-8">
                        <ul class="list-unstyled">
                            <li>{{ app.user }}</li>
                            <li>{{ app.user.email }}</li>
                            {% for role in app.user.roles %}
                                <li><small>{{ role }}</small></li>
                            {% endfor %}
                        </ul>
                    </div>
                </div>
            </div>
        </div>
    </div>
    <ul class="list-unstyled components">
        <li class="{{ route == 'back_home' ? 'active' }}">
            <a href="{{ path('back_home') }}">{{ 'sidebar.home'|trans }}</a>
        </li>
        <li class="{{ route in ['back_user_search', 'back_user_create'] ? 'active' }}">
            <a href="#user_sub_menu" data-toggle="collapse" aria-expanded="false" class="dropdown-toggle">{{ 'sidebar.user'|trans() }}</a>
            <ul class="collapse list-unstyled" id="user_sub_menu">
                
                <li>
                    <a href="{{ path('back_user_search') }}">{{ 'sidebar.user_search'|trans() }}</a>
                </li>
                
                <li>
                    <a href="{{ path('back_user_create') }}">{{ 'sidebar.user_create'|trans() }}</a>
                </li>
                
            </ul>
        </li>
    </ul>
</nav>

Pour l’instant, les routes n’existent pas. Nous allons les créer un peu plus loin avec le devfusion/maker-bundle.

Installation de twig/extra-bundle

Ce package est un bundle Symfony qui permet d’utiliser toutes les extensions twig de twig/intl-extra sans aucune configuration.

Il permet d’internationaliser et de formater des données comme les nombres et les dates.

Nous pouvons l’installer, car nous avons utilisé le filtre format_date dans la sidebar et nous allons utiliser d’autres filtres twig.

composer require twig/intl-extra
composer require twig/extra-bundle

Mise à jour de la navbar du back-office

{# templates/back/block/_navbar.html.twig #}
{% trans_default_domain 'back_messages' %}
{% set route = app.request.get('_route') %}
<div class="navbar navbar-expand-md navbar-dark bg-dark mb-4" role="navigation">
    <a class="navbar-brand" href="#">DevFusion</a>
    <button class="navbar-toggler" type="button" data-toggle="collapse" data-target="#navbarCollapse" aria-controls="navbarCollapse" aria-expanded="false" aria-label="Toggle navigation">
        <span class="navbar-toggler-icon"></span>
    </button>
    <div class="collapse navbar-collapse" id="navbarCollapse">
        <ul class="navbar-nav mr-auto">
            <li class="nav-item">
                <a class="nav-link" href="{{ path('front_home') }}">{{ 'nav.home'|trans() }}</a>
            </li>
            <li class="nav-item">
                <a class="nav-link" href="https://github.com/official-dev-fusion/dev-fusion-skeleton-user" target="_blank">Github</a>
            </li>
            
            <li class="nav-item dropdown">
                <a class="nav-link dropdown-toggle" id="dropdown_account" 
                    data-toggle="dropdown" aria-haspopup="true" aria-expanded="false"
                >
                    <i class="fas fa-user"></i> {{ 'nav.account'|trans() }}
                </a>
                <ul class="dropdown-menu" aria-labelledby="dropdown_account">
                     
                    <li class="dropdown-item{{ route == 'back_user_read' and app.request.get('id') == app.user.id ? ' active' }}">
                        <a href="{{ path('back_user_read', { 'id': app.user.id }) }}">
                            <i class="fas fa-user-cog"></i> {{ 'nav.account_profile'|trans() }}
                        </a>
                    </li>
                    
                    {% if is_granted('ROLE_PREVIOUS_ADMIN') %}
                        <li class="dropdown-item">
                            <a href="{{ path('back_user_search', { '_switch_user': '_exit' }) }}">
                                <i class="fas fa-"></i> {{ 'nav.end_switch'|trans() }}
                            </a>
                        </li>
                    {% endif %}
                
                    <li class="dropdown-item{{ route == 'app_reset_password' ? ' active' }}">
                        <a href="{{ path('app_reset_password', { 'id': app.user.id }) }}">
                            <i class="fas fa-"></i> {{ 'nav.account_reset_password'|trans() }}
                        </a>
                    </li>
                    
                    <li class="dropdown-item">
                        <a href="{{ path('app_logout') }}">
                            <i class="fas fa-sign-out-alt"></i> {{ 'nav.account_logout'|trans() }}
                        </a>
                    </li>
                    
                </ul>
            </li>            
        </ul>
    </div>
</div>

La barre de menu du back ressemble beaucoup à celle du front avec quelques différances.

On ajoute :

  • Un lien pour visualiser les informations de notre compte;
  • Si on a le rôle ROLE_PREVIOUS_ADMIN, un lien pour sortir de l’exploration du compte;

Traduction de la sidebar et de la navbar

# translations/back_messages.fr.yaml
nav:
    home: Accueil Front
    end_switch: Fin de l'exploration
    account: Compte
    account_login: Connexion
    account_logout: Déconnection
    account_profile: Mes informations
    account_reset_password: Modifier le mot de passe
sidebar:
    home: Accueil Back
    user: Utilisateur
    user_search: Liste
    user_create: Inviter
# translations/back_messages.en.yaml
nav:
    home: Home Front
    end_switch: End switch
    account: Account
    account_login: Login
    account_logout: Logout
    account_profile: My profile
    account_reset_password: Reset password
sidebar:
    home: Home Back
    user: Utilisateur
    user_search: List
    user_create: Invite

Invitation par e-mail

Pour inviter un utilisateur, nous allons devoir lui envoyer un e-mail.

Le principe est le même que pour la confirmation de l’inscription.

Voici la méthode du service Mailer :

// src/Mailer/Mailer.php
// ...
    public function sendInvitation(User $user, string $password)
    {
        $url = $this->router->generate(
            'app_registration_confirm',
            [
                'token' => $user->getConfirmationToken(),
            ],
            UrlGeneratorInterface::ABSOLUTE_URL
        );
        $subject = $this->translator->trans('invitation.email.subject', [
            '%user%' => $user,
            '%website_name%' => $this->parameters->get('configuration')['name'],
        ], 'back_messages');
        $template = 'back/email/invite.html.twig';
        $from = [
            $this->parameters->get('configuration')['from_email'] => $this->parameters->get('configuration')['name'],
        ];
        $to = $user->getEmail();
        $body = $this->templating->render($template, [
            'user' => $user,
            'password' => $password,
            'website_name' => $this->parameters->get('configuration')['name'],
            'confirmation_url' => $url,
        ]);
        $message = (new \Swift_Message())
            ->setSubject($subject)
            ->setFrom($from)
            ->setTo($to)
            ->setContentType("text/html")
            ->setBody($body);
        $this->mailer->send($message);
    }
// ...

Ainsi que le template :

{# templates/back/email/invite.html.twig #}
{{ 'invitation.email.message'|trans({
        '%user%': user,
        '%email%': user.email,
        '%password%': password,
        '%confirmation_url%': confirmation_url,
        '%host%': app.request.schemeAndHttpHost,
        '%website_name%': website_name
    }, 'back_messages')|raw|nl2br
}}

Et finalement la traduction :

# translations/back_messages.fr.yaml
invitation:
    email:
        subject: '%website_name% - Invitation'
        message: |
            Bonjour %user%,
            
            Vous avez été invité à rejoindre %website_name% par un administrateur.
            
            Identifiant : %email%
            Mot de passe : %password%

            Pour valider l'invitation, merci de cliquer <a href="%confirmation_url%">ici</a>.

            Ce lien ne peut être utilisé qu'une seule fois pour valider votre compte.

            Cordialement,
            <a href="%host%">%website_name%</a>
# ...
# translations/back_messages.en.yaml
invitation:
    email:
        subject: '%website_name% - Invitation'
        message: |
            Hello %user%,

            Email : %email%
            Password : %password%
            
            To finish activating your account - please click <a href="%confirmation_url%">here</a>.

            This link can only be used once to validate your account.

            Regards,
            
            <a href="%host%">%website_name%</a>
# ...

Génération d’un scrud avec le devfusion/maker-bundle

Nous allons générer pour les utilisateurs :

  • Une page pour les lister, les filtrer et les sélectionner;
  • Une page pour les inviter;
  • Une page pour modifier les rôles;
  • Une page pour les supprimer;

Pour nous faciliter la tâche, nous allons utiliser un outil de génération de code.

Le devfusion/maker-bundle va nous permettre de configurer précisément les pages qu’on souhaite générer.

Installation du devfusion/maker-bundle

composer require devfusion/maker-bundle --dev

./composer.json has been updated
Loading composer repositories with package information
Updating dependencies (including require-dev)
Restricting packages listed in "symfony/symfony" to "4.4.*"
Package operations: 1 install, 0 updates, 0 removals
  - Installing devfusion/maker-bundle (dev-master c0d0581): Downloading (100%)

Fichier de configuration scrud

Nous allons générer un fichier afin de configurer la génération du code scrud pour l’entité User.

php bin/console df:scrud:config User

OK
 Next: Check your new SCRUD configuration file by going to config/dev_fusion/scrud/user.yaml

Nous pouvons maintenant personnaliser le fichier de configuration pour qu’il réponde à nos besoins :

entities:
    User:
        class: App\Entity\User
        skeleton: scrud_bootstrap_4
        prefix_directory: back # Le code sera ajouté dans un sous-dossier back
        prefix_route: back # L'URI des routes seront préfixés par back/
        voter: true # Un Voter sera généré pour contrôler l'accès aux actions du contrôleur
        # Champs affichés par défaut
        fields:    
            - firstname
            - lastname
            - email
            # Ajout d'un filtre twig join pour afficher tout les rôles de l'utilisateur s'il en a plusieurs.
            - { property: roles, twig_filters: [ "join(', ')" ] }
            - enabled
            - lastLoginAt
            - createdAt
            - updatedAt
        # Pour inviter un utilisateur, il faudra renseigner les champs suivants :
        forms:
            - firstname
            - lastname
            - email
            # Un utilisateur peut avoir plusieurs rôles
            - { property: roles, type: ChoiceType, 
                type_options: {
                    choices: { 'ROLE_USER_ADMIN': 'ROLE_USER_ADMIN', 'ROLE_ADMIN': 'ROLE_ADMIN' }
                    expanded: true,
                    multiple: true
                }
            }
        search:
            # La liste sera triée du plus récent mis à jour au plus ancien 
            order:
                - { by: entity.updatedAt, direction: DESC }
            pagination: true
            multi_select: true
            filter_view:
                activate: true
                # Le filtre pourra rechercher dans les champs suivants :
                str_fields:
                    - firstname
                    - lastname
                    - email
        create:
            activate: true
        read:
            activate: true
            # Des boutons de modification et de suppression seront ajoutés en haut à droite
            action_up: true
            action_down: false
        update:
            activate: true
            multi_select: false
            # Seulement les rôles peuvent être modifiés.
            forms:
                - { property: roles, type: ChoiceType,
                    type_options: {
                        choices: { 'ROLE_USER_ADMIN': 'ROLE_USER_ADMIN', 'ROLE_ADMIN': 'ROLE_ADMIN' }
                        expanded: true,
                        multiple: true
                    }
                }
        delete:
            activate: true
            multi_select: true

Génération du code

Nous pouvons maintenant générer le code à partir de notre fichier de configuration :

php bin/console df:scrud:exec user.yaml

 updated: src/Repository/UserRepository.php
 created: src/Form/Back/UserType.php
 created: src/Form/Back/UserUpdateType.php
 created: src/Form/Back/UserFilterType.php
 created: src/Manager/UserManager.php
 created: src/Form/Back/UserBatchType.php
 created: src/Controller/Back/UserController.php
 created: src/Security/Voter/Back/UserVoter.php
 created: templates/back/user/search/_filter.html.twig
 created: templates/back/user/search/index.html.twig
 created: templates/back/user/search/_list.html.twig
 created: templates/back/user/search/_pagination.html.twig
 created: templates/back/user/create.html.twig
 created: templates/back/user/read.html.twig
 created: templates/back/user/update.html.twig
 created: templates/back/user/delete.html.twig


  Success!


 Next: Check your new SCRUD by going to /back/user/search/

Et voilà, un code scrud a été généré, il ne reste plus qu’à le personnaliser.

Vous pouvez le parcourir rapidement pour découvrir la structure.

Pour voir le résultat, vous pouvez créer un super admin et aller à l’URI /back/user/search.

php bin/console app:user:create Prénom Nom e-mail --super-admin
php bin/console cache:clear
symfony serve

À partir d’ici, je vous laisse customiser les fichiers de traduction.

Personnalisation du contrôleur

Nous allons modifier le contrôleur pour pouvoir inviter des utilisateurs et pour activer et désactiver des comptes.

Modification de l’action create

  • On injecte dans les paramètres de l’action le UserPasswordEncoderInterface pour encoder le mot de passe;
  • On injecte dans les paramètres de l’action le service Mailer pour envoyer l’invitation avec la méthode qu’on a créée auparavant;
  • Une fois le formulaire soumis et validé :
    • On génère le mot de passe aléatoirement et on renvoie une chaîne ASCII;
    • On encode le mot de passe et on l’affecte à l’utilisateur;
    • On désactive l’utilisateur et on génère un jeton afin de l’identifier lorsqu’il va cliquer sur le lien d’activation;
    • On persiste l’utilisateur et on lui envoie le mail avec la méthode créée auparavant;
// src/Controller/Back/UserController
// ...
    public function create(Request $request, UserPasswordEncoderInterface $passwordEncoder, Mailer $mailer): Response
    {
        // ...
        if ($form->isSubmitted() && $form->isValid()) {
            $password = bin2hex(random_bytes(4));
            $user->setPassword(
                $passwordEncoder->encodePassword(
                    $user,
                    $password
                )
            );

            $user->setEnabled(false);
            $user->setConfirmationToken(random_bytes(24));
            
            $em = $this->getDoctrine()->getManager();
            $em->persist($user);
            $em->flush();
            
            $mailer->sendInvitation($user, $password);
            
            $msg = $this->translator->trans('user.create.flash.success', [ '%identifier%' => $user, ], 'back_messages');
            $this->addFlash('success', $msg);
            return $this->redirectToRoute('back_user_search');
        }
        // ...
    }
// ...

Action de contrôleur pour activer et désactiver un ou plusieurs comptes

Cette action va s’appeler permuteEnabled:

La méthode getUsers() du UserManager nous permet de récupérer la liste d’utilisateurs à partir des identifiants passés en GET à la requête.

On utilise le UserVoter qu’on va personnaliser par la suite pour contrôler l’accès de l’action avec $this->denyAccessUnlessGranted(‘back_user_permute_enabled’, $users).

On boucle sur les utilisateurs et on permute la valeur enabled.

On flush et on redirige sur la page de recherche.

// src/Controller/Back/UserController
// ...
    /**
     * @Route("/permute/enabled", name="back_user_permute_enabled", methods="GET")
     */
    public function permuteEnabled(Request $request): Response
    {    
        $users = $this->userManager->getUsers();
        $this->denyAccessUnlessGranted('back_user_permute_enabled', $users);
        foreach ($users as $user) {
            $permute = $user->getEnabled() ? false : true;
            $user->setEnabled($permute);
        }
        $this->getDoctrine()->getManager()->flush();
        return $this->redirectToRoute('back_user_search');
    }
// ...

Explication et personnalisation du UserVoter

Pour utiliser les voters, vous devez comprendre comment Symfony travaille avec eux :

Tous les voters sont appelés chaque fois que vous utilisez dans un contrôleur la méthode denyAccessUnlessGranted().

Les voters sont très utiles si la condition d’accès à l’action du contrôleur est dynamique; c’est-à-dire que vous n’avez pas seulement à vérifier si l’utilisateur courant a un rôle en particulier.

Vous pourriez par exemple autoriser un utilisateur à modifier un projet seulement s’il fait partie de l’équipe de celui-ci et qu’il a le rôle manager.

En fin de compte, Symfony prend les réponses de tous les voters et prend la décision finale (autoriser ou refuser l’accès à la ressource) selon la stratégie définie dans l’application.

Dans notre cas, nous utilisons une clé pour préciser quel voter doit prendre la décision.

Chacun des voters vérifie s’il a la clé dans sa liste et renvoie faux sinon.

Pour que l’utilisateur puisse être autorisé, il faut au moins un voter qui renvoie vrai.

Pour que la clé soit unique, on utilise le nom de la route de l’action.

Dans l’action permuteEnabled on utilise :

    $this->denyAccessUnlessGranted('back_user_permute_enabled', $users);

Le premier paramètre étant la clé et le deuxième le sujet.

Le sujet permet de vérifier des conditions pour donner l’autorisation ou pas.

Il faut donc ajouter une constante dans le UserVoter avec la clé ‘back_user_permute_enabled’.

// src/Security/Voter/Back/UserVoter
// ...
    const PERMUTE_ENABLED = 'back_user_permute_enabled';
// ...

Et il faut l’ajouter dans la méthode supports :

// src/Security/Voter/Back/UserVoter
// ...
    protected function supports($attribute, $subject)
    {        
        return in_array($attribute, [
            self::SEARCH,
            self::CREATE,
            self::READ,
            self::UPDATE,
            self::DELETE,
            self::PERMUTE_ENABLED,
        ]);
    }
// ...

Dans la méthode voteOnAttribute :

  • On peut renvoyer faux si c’est un utilisateur déconnecté;
  • On peut renvoyer vrai, si c’est par exemple un super admin, mais dans notre cas on doit empêcher la suppression du super admin;
  • Et ensuite on appelle la bonne méthode associée à l’action en cours;
  • Si le code après le switch s’exécute c’est qu’il y a un problème donc on déclenche une exception.
// src/Security/Voter/Back/UserVoter
// ...
    protected function voteOnAttribute($attribute, $subject, TokenInterface $token)
    {
        $user = $token->getUser();
        // if the user is anonymous, do not grant access
        if (!$user instanceof UserInterface) {
            return false;
        }

        // ... (check conditions and return true to grant permission) ...
        switch ($attribute) {
            case self::SEARCH:
                return $this->canSearch($subject, $user);
            case self::CREATE:
                return $this->canCreate($user);
            case self::READ:
                return $this->canRead($subject, $user);
            case self::UPDATE:
                return $this->canUpdate($subject, $user);
            case self::DELETE:
                return $this->canDelete($subject, $user);
            case self::PERMUTE_ENABLED:
                return $this->canPermuteEnabled($subject, $user);
        }
        throw new \LogicException('This code should not be reached!');
    }
// ...

Et finalement, les méthodes associées à chacune des actions du contrôleur :

  • Seulement les utilisateurs avec le rôle ROLE_USER_ADMIN peuvent rechercher, créer, lire, modifier et supprimer les utilisateurs;
  • On ne peut pas modifier, supprimer, activer ou désactiver un super admin;
// src/Security/Voter/Back/UserVoter
// ...
    private function canSearch(array $data, User $user)
    {
        if (!$this->security->isGranted('ROLE_USER_ADMIN')) {
            return false;
        }
        return true;
    }

    private function canCreate(User $user)
    {
        if (!$this->security->isGranted('ROLE_USER_ADMIN')) {
            return false;
        }
        return true;
    }

    private function canRead(User $subject, User $user)
    {
        if (!$this->security->isGranted('ROLE_USER_ADMIN')) {
            return false;
        }
        return true;
    }

    private function canUpdate(User $subject, User $user)
    {
        if (!$this->security->isGranted('ROLE_USER_ADMIN')) {
            return false;
        }
        if ($subject->hasRole("ROLE_SUPER_ADMIN")) {
           return false;
        }
        return true;
    }

    private function canDelete(array $subject, User $user)
    {
        if (!$this->security->isGranted('ROLE_USER_ADMIN')) {
            return false;
        }
        foreach ($subject as $user) {
            if ($user->hasRole("ROLE_SUPER_ADMIN")) {
                return false;
            }
        }
        return true;
    }
    
    private function canPermuteEnabled(array $subject, User $user)
    {
        if (!$this->security->isGranted('ROLE_USER_ADMIN')) {
            return false;
        }
        foreach ($subject as $user) {
            if ($user->hasRole("ROLE_SUPER_ADMIN")) {
                return false;
            }
        }
        return true;
    }
// ...

Modification de la vue _list.html.twig

Voici les modifications de _list.html.twig résultant des personnalisations précédentes :

  • On ajoute un bouton pour explorer le compte d’un autre utilisateur et un bouton pour activer et désactiver un compte;
  • On n’affiche pas les boutons modifier, supprimer et explorer si l’utilisateur est un super admin;
  • Le bouton pour activer et désactiver n’est pas fonctionnel si l’utilisateur est un super admin;
-                            {% if can_update %}
+                            {% if can_update and not user.hasRole("ROLE_SUPER_ADMIN") %}
                                 <a href="{{ path('back_user_update', {'id': user.id}) }}" title="{{ 'button.update_title'|trans() }}"
                                     class="btn btn-warning" aria-label="{{ 'button.update_title'|trans() }}" role="button">
                                     <i class="fas fa-edit"></i>
                                 </a>
                             {% endif %}
-                            {% if can_delete %}
+                            {% if can_delete and not user.hasRole("ROLE_SUPER_ADMIN") %}
                                 <a href="#" class="btn btn-danger btn-delete" data-toggle="modal" data-target="#delete"
-                                    data-title="{{ user }}" role="button"
+                                    data-title="{{ user }}"  role="button"
                                     data-path="{{ path('back_user_delete', { 'ids': {0: user.id}}) }}"
                                     title="{{ 'button.delete_title'|trans() }}" aria-label="{{ 'button.delete_title'|trans() }}">
                                     <i class="fas fa-times"></i>
                                 </a>
                             {% endif %}
+                            {% if not user.hasRole("ROLE_SUPER_ADMIN") and is_granted("ROLE_ALLOWED_TO_SWITCH") %}
+                                {% set switch_path = path('front_home', { 'id': user.id, '_switch_user': user.email }) %}
+                                <a href="{{ switch_path }}"
+                                    class="btn btn-primary{{ is_granted('ROLE_PREVIOUS_ADMIN') ? ' disabled' : '' }}" role="button"
+                                    aria-disabled="{{ is_granted('ROLE_PREVIOUS_ADMIN') ? 'true' : 'false' }}"
+                                    title="{{ 'button.switch'|trans() }}" aria-label="{{ 'button.switch'|trans() }}">
+                                    <i class="fas fa-user"></i>
+                                </a>
+                            {% endif %}
+                            <a href="{{ path('back_user_permute_enabled', { 'ids': {0: user.id}}) }}" role="button"
+                                class="btn btn-primary{{ user.enabled ? ' active' : '' }}{{ user.hasRole("ROLE_SUPER_ADMIN") ? ' disabled'
+                                aria-pressed="{{ user.enabled ? 'true' : 'false' }}"
+                                aria-disabled="{{ user.hasRole("ROLE_SUPER_ADMIN") ? 'true' : 'false' }}">
+                                <span>{{ user.enabled ? 'button.disable'|trans : 'button.enable'|trans }}</span>
+                            </a>
+                        </td>

Supprimer plusieurs utilisateurs

Le devfusion/maker-bundle a généré un code permettant de sélectionner plusieurs utilisateurs à la fois et de les supprimer.

Nous allons ajouter une erreur de validation du formulaire si un des utilisateurs sélectionnés a le rôle super admin.

Nous pouvons faire ça facilement dans le UserManager :

     public function validationDelete($users)
     {
-        /*foreach($users as $user) {
-
-        }*/
+        foreach($users as $user) {
+            if ($user->hasRole("ROLE_SUPER_ADMIN")) {
+                return $this->translator->trans('user.error.cannot_delete_super_admin', [], 'back_messages');
+            }
+        }
+        return true;
+    }

Ajout d’un filtre par rôle

Nous avons déjà un filtre pour rechercher dans le nom, le prénom et le mail de l’utilisateur.

Par contre, pour l’instant, nous ne pouvons pas rechercher des utilisateurs selon leurs rôles.

Nous allons y remédier rapidement, car la structure du code généré par le devfusion/maker-bundle a été pensée pour être facilement modifiable.

Modification du UserFilterType.php

  • On injecte le service RoleHierarchy pour récupérer la liste des rôles;
  • On ajoute un ChoiceType afin de pouvoir sélectionner un rôle sur lequel filtrer;
+use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
+use Symfony\Component\Security\Core\Role\RoleHierarchyInterface;
+use Symfony\Component\Security\Core\Role\Role;

 class UserFilterType extends AbstractType
 {
+
+    /**
+     *
+     * @var RoleHierarchyInterface
+     */
+    private $roleHierarchy;
+
+    public function __construct(RoleHierarchyInterface $roleHierarchy)
+    {
+        $this->roleHierarchy = $roleHierarchy;
+    }
+
     public function buildForm(FormBuilderInterface $builder, array $options)
     {
+        $reachableRoleNames = $this->roleHierarchy->getReachableRoleNames([ 'ROLE_ADMIN', ]);
+        $roles = [];
+        foreach ($reachableRoleNames as $reachableRoleName) {
+            $roles[$reachableRoleName] = $reachableRoleName;
+        }

+            ->add('role', ChoiceType::class, [
+                'label' => false,
+                'placeholder' => 'user.label.roles',
+                'choices' => $roles,
+                'multiple' => false,
+                'expanded' => false,
+                'required' => false,
+            ])
     }
 }

Modification de la vue _filter.html.twig

Ajout du champ rôle et modification des rangées et des colonnes.

     <div class="card-body">
         {{ form_start(form_filter) }}
             <div class="row">
-                <div class="col-lg-4">
+                <div class="col-lg-6">
                     {{ form_row(form_filter.search) }}
                 </div>
-                <div class="col-lg-4">
+                <div class="col-lg-6">
+                    {{ form_row(form_filter.role) }}
+                </div>
+            </div>
+            <div class="row">
+                <div class="col-lg-6">
                     {{ form_row(form_filter.number_by_page) }}
                 </div>
-                <div class="col-lg-4">
+                <div class="col-lg-6">
                     <div class="form-group">
                         <button id="filter_submit" class="btn btn-primary btn-block">
                             <i class="fas fa-filter"></i> {{ 'button.filter'|trans() }}
                         </button>
                     </div>
                 </div>
             </div>
         {{ form_end(form_filter) }}
     </div>

Modification du UserRepository.php

+        if (null !== ($data['role'] ?? null)) {
+            $query
+                ->andWhere('u.roles LIKE :role')
+                ->setParameter('role', '%"'.$data['role'].'"%');
+        }
         return $query;

Modification du UserManager.php

Le code généré par le devfusion/maker-bundle sauvegarde le filtre en session.

De cette manière, lorsqu’on accède dans une sous page comme lire, modifier ou supprimer et qu’on revient à la page de recherche, la pagination et les données restent les mêmes.

Donc, nous allons ajouter le rôle sélectionné du filtre en session.

Dans la méthode getDefaultFormSearchData(), on récupère le rôle de la session et s’il n’existe pas on affecte null à la valeur :

+            'role' => $this->session->get('back_user_role', null),

Dans la méthode configFormFilter(FormInterface $form) :

Si le formulaire est soumis et valide, on ajoute en session la valeur du champ du formulaire.

+            $this->session->set('back_user_role', $form->get('role')->getData());

Activer et désactiver plusieurs utilisateurs à la fois

Le code généré par le devfusion/maker-bundle nous permet de sélectionner plusieurs utilisateurs à la fois et de lancer une action.

Pour l’instant, de la manière que nous avons configuré le scrud, seulement l’action supprimer est disponible.

Nous allons ajouter l’action pour activer ou désactiver plusieurs utilisateurs à la fois.

Modification du UserBatchType

N’oubliez pas d’ajouter dans les fichiers de traduction action.permute_enabled: Activer / Désactiver

                 'choices' => [
                     'action.delete' => 'delete',
+                    'action.permute_enabled' => 'permute_enabled',
                 ],

Modification du UserManager

Dans la méthode validationBatchForm(FormInterface $form) si on retourne une chaîne de caractère le UserBatchType n’est pas validé et la chaîne s’affiche en erreur.

Nous allons ajouter une case pour l’action permute_enabled :

         switch ($action) {
             case 'delete':
                 return $this->validationDelete($users);
+            case 'permute_enabled':
+                return $this->validationPermuteEnabled($users);
         }

Et la méthode de validation retournant un message d’erreur si un des utilisateurs a le rôle super admin :

+    public function validationPermuteEnabled($users)
+    {
+        foreach($users as $user) {
+            if ($user->hasRole("ROLE_SUPER_ADMIN")) {
+                return $this->translator->trans('user.error.cannot_permute_enabled_super_admin', [], 'back_messages');
+            }
+        }
+        return true;
+    }

La méthode dispatchBatchForm(FormInterface $form) du UserManager est exécutée une fois que le UserBatchType est validé.

Si cette méthode retourne faux, la page de recherche est affichée, sinon il y’a une redirection.

Dans le cas présent, on redirige sur l’action permuteEnabled que nous avons créée précédemment :

         switch ($action) {
             case 'delete':
                 return $this->urlGenerator->generate('back_user_delete', $this->getIds($users));
+            case 'permute_enabled':
+                return $this->urlGenerator->generate('back_user_permute_enabled', $this->getIds($users));
         }
         return false;
     }

En conclusion

Vous avez maintenant un projet de base contenant un back-office pouvant gérer des utilisateurs.

Il ne vous reste plus qu’à ajouter des fonctionnalités afin de répondre aux besoins que vous avez pour vos différents projets.

Vous pouvez retrouver le projet de base dans le repository Github dans la branche user-area-with-back-office.

Pour cloner le projet :

git clone --single-branch --branch user-area-with-back-office git@github.com:official-dev-fusion/dev-fusion-skeleton-user.git

En espérant que tout cela vous sera utile.

Si vous rencontrer des bogues, des erreurs ou si vous avez des commentaires, vous pouvez m’en faire part à l’adresse martin.gilbert@dev-fusion.com.