add user CRUD

This commit is contained in:
Simon Vieille 2021-03-17 17:17:43 +01:00
parent 1c545bfb63
commit 7497232a5a
16 changed files with 633 additions and 40 deletions

View file

@ -45,5 +45,6 @@ security:
- { path: ^/login, roles: IS_AUTHENTICATED_ANONYMOUSLY }
- { path: ^/resetting, roles: IS_AUTHENTICATED_ANONYMOUSLY }
- { path: ^/2fa, role: IS_AUTHENTICATED_2FA_IN_PROGRESS }
- { path: ^/admin/user, roles: ROLE_ADMIN }
- { path: ^/admin, roles: ROLE_USER }
- { path: ^/_internal, roles: IS_AUTHENTICATED_ANONYMOUSLY }

View file

@ -98,7 +98,7 @@ class AuthController extends AbstractController
EntityManager $entityManager
): Response {
if ($this->getUser()) {
return $this->redirectToRoute('index');
return $this->redirectToRoute('admin_dashboard_index');
}
$account = $repository->findOneByConfirmationToken($token);

View file

@ -92,6 +92,7 @@ class CategoryAdminController extends AdminController
{
$posts = $postQuery->create()
->orderBy('.publishedAt', 'DESC')
->orderBy('.createdAt', 'DESC')
->inCategory($entity)
->paginate(1, 10)
;

View file

@ -0,0 +1,160 @@
<?php
namespace App\Controller\User;
use App\Controller\Admin\AdminController;
use App\Entity\User as Entity;
use App\Factory\UserFactory as EntityFactory;
use App\Form\UserType as EntityType;
use App\Manager\EntityManager;
use App\Repository\UserRepositoryQuery;
use App\Repository\UserRepositoryQuery as RepositoryQuery;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route;
use App\Repository\Blog\PostRepositoryQuery;
use Symfony\Component\Security\Csrf\TokenGenerator\TokenGeneratorInterface;
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
use App\Event\Account\PasswordRequestEvent;
use Symfony\Component\Security\Core\Encoder\UserPasswordEncoderInterface;
/**
* @Route("/admin/user")
*/
class UserAdminController extends AdminController
{
/**
* @Route("/{page}", name="admin_user_index", requirements={"page": "\d+"})
*/
public function index(int $page = 1, RepositoryQuery $query, Request $request): Response
{
$pager = $query->paginate($page);
return $this->render('user/user_admin/index.html.twig', [
'pager' => $pager,
]);
}
/**
* @Route("/new", name="admin_user_new")
*/
public function new(
EntityFactory $factory,
EntityManager $entityManager,
UserPasswordEncoderInterface $encoder,
Request $request
): Response
{
$entity = $factory->create($this->getUser());
$form = $this->createForm(EntityType::class, $entity);
if ($request->isMethod('POST')) {
$form->handleRequest($request);
if ($form->isValid()) {
$entityManager->create($entity);
$this->addFlash('success', 'Donnée enregistrée.');
return $this->redirectToRoute('admin_user_edit', [
'entity' => $entity->getId(),
]);
}
$this->addFlash('warning', 'Le formulaire est invalide.');
}
return $this->render('user/user_admin/new.html.twig', [
'form' => $form->createView(),
'entity' => $entity,
]);
}
/**
* @Route("/edit/{entity}", name="admin_user_edit")
*/
public function edit(Entity $entity, EntityManager $entityManager, Request $request): Response
{
$form = $this->createForm(EntityType::class, $entity);
if ($request->isMethod('POST')) {
$form->handleRequest($request);
if ($form->isValid()) {
$entityManager->update($entity);
$this->addFlash('success', 'Donnée enregistrée.');
return $this->redirectToRoute('admin_user_edit', [
'entity' => $entity->getId(),
]);
}
$this->addFlash('warning', 'Le formulaire est invalide.');
}
return $this->render('user/user_admin/edit.html.twig', [
'form' => $form->createView(),
'entity' => $entity,
]);
}
/**
* @Route("/show/{entity}", name="admin_user_show")
*/
public function show(Entity $entity, PostRepositoryQuery $postQuery): Response
{
$posts = $postQuery->create()
->orderBy('.publishedAt', 'DESC')
->orderBy('.createdAt', 'DESC')
->filterByAuthor($entity)
->paginate(1, 10)
;
return $this->render('user/user_admin/show.html.twig', [
'entity' => $entity,
'posts' => $posts,
]);
}
/**
* @Route("/resetting_request/{entity}", name="admin_user_resetting_request", methods={"POST"})
*/
public function requestResetting(
Entity $entity,
EntityManager $entityManager,
TokenGeneratorInterface $tokenGenerator,
EventDispatcherInterface $eventDispatcher,
Request $request
): Response
{
if ($this->isCsrfTokenValid('resetting_request'.$entity->getId(), $request->request->get('_token'))) {
$entity->setConfirmationToken($tokenGenerator->generateToken());
$entity->setPasswordRequestedAt(new \DateTime('now'));
$entityManager->update($entity);
$eventDispatcher->dispatch(new PasswordRequestEvent($entity), PasswordRequestEvent::EVENT);
$this->addFlash('success', 'Demande envoyée.');
}
return $this->redirectToRoute('admin_user_edit', [
'entity' => $entity->getId(),
]);
}
/**
* @Route("/delete/{entity}", name="admin_user_delete", methods={"DELETE"})
*/
public function delete(Entity $entity, EntityManager $entityManager, Request $request): Response
{
if ($this->isCsrfTokenValid('delete'.$entity->getId(), $request->request->get('_token'))) {
$entityManager->delete($entity);
$this->addFlash('success', 'Données supprimées.');
}
return $this->redirectToRoute('admin_user_index');
}
public function getSection(): string
{
return 'user';
}
}

View file

@ -71,6 +71,7 @@ class Post implements EntityInterface
/**
* @ORM\ManyToOne(targetEntity=User::class, inversedBy="posts")
* @ORM\JoinColumn(nullable=true, onDelete="SET NULL")
*/
private $author;

View file

@ -14,7 +14,6 @@ use Symfony\Component\Security\Core\User\UserInterface;
/**
* @ORM\Entity(repositoryClass=UserRepository::class)
* @ORM\HasLifecycleCallbacks
* @ORM\Table(name="`user`")
*/
class User implements UserInterface, TwoFactorInterface, EntityInterface
{
@ -72,6 +71,11 @@ class User implements UserInterface, TwoFactorInterface, EntityInterface
*/
private $posts;
/**
* @ORM\Column(type="boolean", options={"default"=0})
*/
private $isWriter;
public function __construct()
{
$this->posts = new ArrayCollection();
@ -109,9 +113,9 @@ class User implements UserInterface, TwoFactorInterface, EntityInterface
*/
public function getRoles(): array
{
$roles = $this->roles;
// guarantee every user at least has ROLE_USER
$roles[] = 'ROLE_USER';
if ($this->getIsWriter()) {
$roles[] = 'ROLE_WRITER';
}
if ($this->getIsAdmin()) {
$roles[] = 'ROLE_ADMIN';
@ -276,4 +280,16 @@ class User implements UserInterface, TwoFactorInterface, EntityInterface
return $this;
}
public function getIsWriter(): ?bool
{
return $this->isWriter;
}
public function setIsWriter(bool $isWriter): self
{
$this->isWriter = $isWriter;
return $this;
}
}

View file

@ -0,0 +1,36 @@
<?php
namespace App\Factory;
use App\Entity\User;
use Symfony\Component\Security\Core\Encoder\UserPasswordEncoderInterface;
use Symfony\Component\Security\Csrf\TokenGenerator\TokenGeneratorInterface;
/**
* class UserFactory.
*
* @author Simon Vieille <simon@deblan.fr>
*/
class UserFactory
{
protected TokenGeneratorInterface $tokenGenerator;
protected UserPasswordEncoderInterface $encoder;
public function __construct(TokenGeneratorInterface $tokenGenerator, UserPasswordEncoderInterface $encoder)
{
$this->tokenGenerator = $tokenGenerator;
$this->encoder = $encoder;
}
public function create(): User
{
$entity = new User();
$entity->setPassword($this->encoder->encodePassword(
$entity,
$this->tokenGenerator->generateToken()
));
return $entity;
}
}

92
src/Form/UserType.php Normal file
View file

@ -0,0 +1,92 @@
<?php
namespace App\Form;
use App\Entity\User;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
use Symfony\Component\Validator\Constraints\Email;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\Extension\Core\Type\CheckboxType;
use Symfony\Component\Form\Extension\Core\Type\EmailType;
class UserType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options)
{
$builder->add(
'email',
EmailType::class,
[
'label' => 'E-mail',
'required' => true,
'attr' => [
],
'constraints' => [
new Email(),
],
]
);
$builder->add(
'displayName',
TextType::class,
[
'label' => 'Nom complet',
'required' => true,
'attr' => [
],
'constraints' => [
],
]
);
$builder->add(
'displayName',
TextType::class,
[
'label' => 'Nom complet',
'required' => true,
'attr' => [
],
'constraints' => [
],
]
);
$builder->add(
'isAdmin',
CheckboxType::class,
[
'label' => 'Administrateur⋅trice',
'required' => false,
'attr' => [
],
'constraints' => [
],
]
);
$builder->add(
'isWriter',
CheckboxType::class,
[
'label' => 'Rédacteur⋅trice',
'required' => false,
'attr' => [
],
'constraints' => [
],
]
);
}
public function configureOptions(OptionsResolver $resolver)
{
$resolver->setDefaults([
'data_class' => User::class,
]);
}
}

View file

@ -5,6 +5,7 @@ namespace App\Repository\Blog;
use App\Entity\Blog\Category;
use App\Repository\RepositoryQuery;
use Knp\Component\Pager\PaginatorInterface;
use App\Entity\User;
/**
* class PostRepositoryQuery.
@ -30,4 +31,14 @@ class PostRepositoryQuery extends RepositoryQuery
return $this;
}
public function filterByAuthor(User $user)
{
$this
->andWhere('.author = :author')
->setParameter(':author', $user->getId())
;
return $this;
}
}

View file

@ -50,49 +50,53 @@
</li>
</ul>
<h6 class="sidebar-heading justify-content-between align-items-center px-3 mt-4 mb-1 text-muted">
<span>Contenu</span>
</h6>
{% if is_granted('ROLE_WRITER') %}
<h6 class="sidebar-heading justify-content-between align-items-center px-3 mt-4 mb-1 text-muted">
<span>Contenu</span>
</h6>
<ul class="nav flex-column">
<li class="nav-item">
<a class="nav-link {{ macros.active_class('page', section) }}" href="{{ path('admin_dashboard_index') }}">
<span class="fa fa-file-alt"></span>
<ul class="nav flex-column">
<li class="nav-item">
<a class="nav-link {{ macros.active_class('page', section) }}" href="{{ path('admin_dashboard_index') }}">
<span class="fa fa-file-alt"></span>
<span class="nav-item-label">Pages</span>
</a>
</li>
<span class="nav-item-label">Pages</span>
</a>
</li>
<li class="nav-item">
<a class="nav-link {{ macros.active_class('blog_post', section) }}" href="{{ path('admin_blog_post_index') }}">
<span class="fa fa-pen"></span>
<li class="nav-item">
<a class="nav-link {{ macros.active_class('blog_post', section) }}" href="{{ path('admin_blog_post_index') }}">
<span class="fa fa-pen"></span>
<span class="nav-item-label">Articles</span>
</a>
</li>
<span class="nav-item-label">Articles</span>
</a>
</li>
<li class="nav-item">
<a class="nav-link {{ macros.active_class('blog_category', section) }}" href="{{ path('admin_blog_category_index') }}">
<span class="fa fa-puzzle-piece"></span>
<li class="nav-item">
<a class="nav-link {{ macros.active_class('blog_category', section) }}" href="{{ path('admin_blog_category_index') }}">
<span class="fa fa-puzzle-piece"></span>
<span class="nav-item-label">Catégories</span>
</a>
</li>
</ul>
<span class="nav-item-label">Catégories</span>
</a>
</li>
</ul>
{% endif %}
<h6 class="sidebar-heading justify-content-between align-items-center px-3 mt-4 mb-1 text-muted">
<span>Administration</span>
</h6>
{% if is_granted('ROLE_ADMIN') %}
<h6 class="sidebar-heading justify-content-between align-items-center px-3 mt-4 mb-1 text-muted">
<span>Administration</span>
</h6>
<ul class="nav flex-column">
<li class="nav-item">
<a class="nav-link {{ macros.active_class('user', section) }}" href="{{ path('admin_dashboard_index') }}">
<span class="fa fa-user"></span>
<ul class="nav flex-column">
<li class="nav-item">
<a class="nav-link {{ macros.active_class('user', section) }}" href="{{ path('admin_user_index') }}">
<span class="fa fa-user"></span>
<span class="nav-item-label">Utilisateurs</span>
</a>
</li>
</ul>
<span class="nav-item-label">Utilisateurs</span>
</a>
</li>
</ul>
{% endif %}
</div>
</nav>
<div class="col-11 col-md-10 ml-sm-auto col-lg-10 body">

View file

@ -55,7 +55,8 @@
{% set categories = categories|merge(['<a href="' ~ url ~ '">' ~ category.title ~ '</a>']) %}
{% endfor %}
Dans {{ categories|join(', ')|raw }} par <a href="">{{ item.author.displayName }}</a>
Dans {{ categories|join(', ')|raw }}
par <a href="">{{ item.author ? item.author.displayName : '-' }}</a>
</td>
<td class="col-2">
<span class="btn btn-sm btn-light">

View file

@ -0,0 +1,12 @@
<div class="row">
<div class="col-12 p-3">
<div class="row">
{% for item in ['displayName', 'email', 'isAdmin', 'isWriter'] %}
<div class="col-12">
{{ form_row(form[item]) }}
</div>
{% endfor %}
</div>
</div>
</div>

View file

@ -0,0 +1,64 @@
{% extends 'admin/layout.html.twig' %}
{% block body %}
<div class="bg-light pl-5 pr-4 pt-5 pb-5">
<div class="d-flex">
<div class="mr-auto w-50">
<h1 class="display-5">{{ entity.displayName }}</h1>
</div>
<div class="ml-auto">
<div class="btn-group">
<a href="{{ path('admin_user_index') }}" class="btn btn-light">
<span class="fa fa-list pr-1"></span>
Retour à la liste
</a>
<a href="{{ path('admin_user_show', {entity: entity.id}) }}" class="btn btn-secondary">
<span class="fa fa-eye pr-1"></span>
Voir
</a>
<button type="submit" form="form-main" class="btn btn-primary">
<span class="fa fa-save pr-1"></span>
Enregistrer
</button>
<button type="button" class="btn btn-white dropdown-toggle dropdown-toggle-hide-after" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
<span class="font-weight-bold">
⋅⋅⋅
</span>
</button>
<div class="dropdown-menu dropdown-menu-right">
<button type="submit" form="form-resetting-request" class="dropdown-item">
Envoye un mail de récupération
</button>
<button type="submit" form="form-delete" class="dropdown-item">
Supprimer
</button>
</div>
</div>
</div>
</div>
</div>
<form action="{{ app.request.uri }}" method="post" id="form-main" enctype="multipart/form-data">
<div class="tab-content">
<div class="tab-pane active">
<div class="tab-form">
{{ include('user/user_admin/_form.html.twig') }}
</div>
</div>
</div>
{{ form_rest(form) }}
</form>
<form method="post" action="{{ path('admin_user_delete', {entity: entity.id}) }}" id="form-delete" data-form-confirm>
<input type="hidden" name="_method" value="DELETE">
<input type="hidden" name="_token" value="{{ csrf_token('delete' ~ entity.id) }}">
</form>
<form method="post" action="{{ path('admin_user_resetting_request', {entity: entity.id}) }}" id="form-resetting-request" data-form-confirm>
<input type="hidden" name="_token" value="{{ csrf_token('resetting_request' ~ entity.id) }}">
</form>
{% endblock %}

View file

@ -0,0 +1,71 @@
{% extends 'admin/layout.html.twig' %}
{% block body %}
<div class="bg-light pl-5 pr-4 pt-5 {% if pager.getPaginationData.pageCount < 2 %}pb-5{% endif %}">
<div class="d-flex">
<div class="mr-auto w-50">
<h1 class="display-5">Utilisateurs</h1>
</div>
<div class="ml-auto">
<div class="btn-group">
<a href="{{ path('admin_user_new') }}" class="btn btn-primary">
<span class="fa fa-plus pr-1"></span>
Nouveau
</a>
</div>
</div>
</div>
{{ knp_pagination_render(pager) }}
</div>
<table class="table" data-table-fixed>
<thead class="thead-light">
<tr>
<th class="col-10">Utilisateurs</th>
<th class="col-2 text-right">Actions</th>
</tr>
</thead>
<tbody>
{% for item in pager %}
{% set edit = path('admin_user_edit', {entity: item.id}) %}
{% set show = path('admin_user_show', {entity: item.id}) %}
<tr data-dblclick="{{ edit }}">
<td class="col-10">
<a href="{{ show }}" class="font-weight-bold text-body d-block">
{{ item.displayName }}
</a>
{{ item.email }}
</td>
<td class="col-2 text-right">
<a href="{{ edit }}" class="btn btn-sm btn-primary mr-1">
<span class="fa fa-edit"></span>
</a>
<button type="submit" form="form-delete-{{ item.id }}" class="btn btn-sm btn-danger">
<span class="fa fa-trash"></span>
</button>
<form method="post" action="{{ path('admin_user_delete', {entity: item.id}) }}" id="form-delete-{{ item.id }}" data-form-confirm>
<input type="hidden" name="_method" value="DELETE">
<input type="hidden" name="_token" value="{{ csrf_token('delete' ~ item.id) }}">
</form>
</td>
</tr>
{% else %}
<tr>
<td class="col-12 text-center p-4 text-black-50">
<div class="display-1">
<span class="fa fa-search"></span>
</div>
<div class="display-5 mt-3">
Aucun résultat
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endblock %}

View file

@ -0,0 +1,39 @@
{% extends 'admin/layout.html.twig' %}
{% block body %}
<div class="bg-light pl-5 pr-4 pt-5 pb-5">
<div class="d-flex">
<div class="mr-auto w-50">
<h1 class="display-5">Nouveau⋅elle utilisateur⋅tice</h1>
</div>
<div class="ml-auto">
<div class="btn-group">
<a href="{{ path('admin_user_index') }}" class="btn btn-light">
<span class="fa fa-list pr-1"></span>
Retour à la liste
</a>
<button type="submit" form="form-main" class="btn btn-primary">
<span class="fa fa-save pr-1"></span>
Enregistrer
</button>
</div>
</div>
</div>
</div>
<form action="{{ app.request.uri }}" method="post" id="form-main" enctype="multipart/form-data">
<div class="tab-content">
<div class="tab-pane active">
<div class="tab-form">
{{ include('user/user_admin/_form.html.twig') }}
</div>
</div>
</div>
{{ form_rest(form) }}
</form>
{% endblock %}

View file

@ -0,0 +1,84 @@
{% extends 'admin/layout.html.twig' %}
{% block body %}
<div class="bg-light pl-5 pr-4 pt-5 pb-5">
<div class="d-flex">
<div class="mr-auto w-50">
<h1 class="display-5">{{ entity.displayName }}</h1>
</div>
<div class="ml-auto">
<div class="btn-group">
<a href="{{ path('admin_user_index') }}" class="btn btn-secondary">
<span class="fa fa-list pr-1"></span>
Retour à la liste
</a>
<a href="{{ path('admin_user_edit', {entity: entity.id}) }}" class="btn btn-primary">
<span class="fa fa-edit pr-1"></span>
Éditer
</a>
</div>
</div>
</div>
</div>
<div class="row">
<div class="col-12 p-3">
<ul class="list-group">
<li class="list-group-item">
<span class="font-weight-bold pb-2 d-block">Titre</span>
{{ entity.displayName }}
</li>
<li class="list-group-item">
<span class="font-weight-bold pb-2 d-block">Sous-titre</span>
{{ entity.email }}
</li>
<li class="list-group-item">
<span class="font-weight-bold pb-2 d-block">Permissions</span>
{{ entity.roles|join(', ')|replace({
ROLE_USER: 'Utilisateur⋅trice',
ROLE_WRITER: 'Rédacteur⋅trice',
ROLE_ADMIN: 'Administrateur⋅trice',
}) }}
</li>
</ul>
</div>
<div class="col-12">
<table class="table">
<thead class="thead-light">
<tr>
<th>Derniers articles</th>
</tr>
</thead>
<tbody>
{% for item in posts %}
<tr>
<td>
<a href="{{ path('admin_blog_post_show', {entity: item.id}) }}" class="text-body">
{{ item.title }}
</a>
</td>
</tr>
{% else %}
<tr>
<td class="text-center p-4 text-black-50">
<div class="display-1">
<span class="fa fa-search"></span>
</div>
<div class="display-5 mt-3">
Aucun résultat
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% endblock %}