Add profile page for changing password and email
New /profile route (nav link between Admin and Logout) with two independent forms, both requiring the current password before applying a change: - Change password: standard current/new/repeat flow, same password strength rules as registration. - Change email: re-checks the new address isn't already taken (avoids a 500 from the unique constraint), then marks the account unverified and re-sends the confirmation email via the existing EmailVerifier/verify-email flow, same as registration. The current session stays logged in, but the user needs to verify the new address before their next login (UserChecker already blocks unverified accounts).
This commit is contained in:
@@ -0,0 +1,105 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Tech\Controller;
|
||||||
|
|
||||||
|
use App\Tech\Entity\User;
|
||||||
|
use App\Tech\Form\ProfileChangeEmailFormType;
|
||||||
|
use App\Tech\Form\ProfileChangePasswordFormType;
|
||||||
|
use App\Tech\Repository\UserRepository;
|
||||||
|
use App\Tech\Service\EmailVerifier;
|
||||||
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
|
use Symfony\Bridge\Twig\Mime\TemplatedEmail;
|
||||||
|
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||||
|
use Symfony\Component\Form\FormError;
|
||||||
|
use Symfony\Component\HttpFoundation\Request;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
|
||||||
|
use Symfony\Component\Routing\Attribute\Route;
|
||||||
|
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||||
|
|
||||||
|
final class ProfileController extends AbstractController
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly EntityManagerInterface $entityManager,
|
||||||
|
private readonly UserPasswordHasherInterface $passwordHasher,
|
||||||
|
private readonly UserRepository $userRepository,
|
||||||
|
private readonly EmailVerifier $emailVerifier,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Route('/profile', name: 'app_profile', methods: ['GET', 'POST'])]
|
||||||
|
#[IsGranted('ROLE_USER')]
|
||||||
|
public function index(Request $request): Response
|
||||||
|
{
|
||||||
|
$user = $this->getUser();
|
||||||
|
if (!$user instanceof User) {
|
||||||
|
throw $this->createAccessDeniedException();
|
||||||
|
}
|
||||||
|
|
||||||
|
$passwordForm = $this->createForm(ProfileChangePasswordFormType::class);
|
||||||
|
$passwordForm->handleRequest($request);
|
||||||
|
|
||||||
|
if ($passwordForm->isSubmitted() && $passwordForm->isValid()) {
|
||||||
|
$currentPassword = (string) $passwordForm->get('currentPassword')->getData();
|
||||||
|
|
||||||
|
if (!$this->passwordHasher->isPasswordValid($user, $currentPassword)) {
|
||||||
|
$passwordForm->get('currentPassword')->addError(new FormError('Incorrect password.'));
|
||||||
|
} else {
|
||||||
|
$plainPassword = (string) $passwordForm->get('plainPassword')->getData();
|
||||||
|
$user->setPassword($this->passwordHasher->hashPassword($user, $plainPassword));
|
||||||
|
$this->entityManager->flush();
|
||||||
|
|
||||||
|
$this->addFlash('success', 'Your password has been updated.');
|
||||||
|
|
||||||
|
return $this->redirectToRoute('app_profile');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$emailForm = $this->createForm(ProfileChangeEmailFormType::class, ['email' => $user->getEmail()]);
|
||||||
|
$emailForm->handleRequest($request);
|
||||||
|
|
||||||
|
if ($emailForm->isSubmitted() && $emailForm->isValid()) {
|
||||||
|
$currentPassword = (string) $emailForm->get('currentPassword')->getData();
|
||||||
|
|
||||||
|
if (!$this->passwordHasher->isPasswordValid($user, $currentPassword)) {
|
||||||
|
$emailForm->get('currentPassword')->addError(new FormError('Incorrect password.'));
|
||||||
|
} else {
|
||||||
|
$newEmail = (string) $emailForm->get('email')->getData();
|
||||||
|
|
||||||
|
if ($newEmail === $user->getEmail()) {
|
||||||
|
$this->addFlash('info', 'That is already your email address.');
|
||||||
|
return $this->redirectToRoute('app_profile');
|
||||||
|
}
|
||||||
|
|
||||||
|
$existing = $this->userRepository->findOneBy(['email' => $newEmail]);
|
||||||
|
if ($existing) {
|
||||||
|
$emailForm->get('email')->addError(new FormError('This email address is already in use.'));
|
||||||
|
} else {
|
||||||
|
$user->setEmail($newEmail);
|
||||||
|
$user->setIsVerified(false);
|
||||||
|
$this->entityManager->flush();
|
||||||
|
|
||||||
|
$this->emailVerifier->sendEmailConfirmation('app_verify_email', $user,
|
||||||
|
(new TemplatedEmail())
|
||||||
|
->from($this->getParameter('mailer_from'))
|
||||||
|
->to($newEmail)
|
||||||
|
->subject('Please confirm your new email address')
|
||||||
|
->htmlTemplate('tech/registration/confirmation_email.html.twig')
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->addFlash('success', 'Your email address has been updated. You must verify it (check your inbox) before you can log in again.');
|
||||||
|
|
||||||
|
return $this->redirectToRoute('app_profile');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->render('tech/profile/index.html.twig', [
|
||||||
|
'user' => $user,
|
||||||
|
'passwordForm' => $passwordForm,
|
||||||
|
'emailForm' => $emailForm,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Tech\Form;
|
||||||
|
|
||||||
|
use Symfony\Component\Form\AbstractType;
|
||||||
|
use Symfony\Component\Form\Extension\Core\Type\EmailType;
|
||||||
|
use Symfony\Component\Form\Extension\Core\Type\PasswordType;
|
||||||
|
use Symfony\Component\Form\FormBuilderInterface;
|
||||||
|
use Symfony\Component\OptionsResolver\OptionsResolver;
|
||||||
|
use Symfony\Component\Validator\Constraints\Email;
|
||||||
|
use Symfony\Component\Validator\Constraints\NotBlank;
|
||||||
|
|
||||||
|
class ProfileChangeEmailFormType extends AbstractType
|
||||||
|
{
|
||||||
|
public function buildForm(FormBuilderInterface $builder, array $options): void
|
||||||
|
{
|
||||||
|
$builder
|
||||||
|
->add('email', EmailType::class, [
|
||||||
|
'label' => 'New email address',
|
||||||
|
'constraints' => [new NotBlank(), new Email()],
|
||||||
|
])
|
||||||
|
->add('currentPassword', PasswordType::class, [
|
||||||
|
'label' => 'Current password',
|
||||||
|
'attr' => ['autocomplete' => 'current-password'],
|
||||||
|
'constraints' => [
|
||||||
|
new NotBlank(message: 'Please enter your current password'),
|
||||||
|
],
|
||||||
|
])
|
||||||
|
;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function configureOptions(OptionsResolver $resolver): void
|
||||||
|
{
|
||||||
|
$resolver->setDefaults([]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Tech\Form;
|
||||||
|
|
||||||
|
use Symfony\Component\Form\AbstractType;
|
||||||
|
use Symfony\Component\Form\Extension\Core\Type\PasswordType;
|
||||||
|
use Symfony\Component\Form\Extension\Core\Type\RepeatedType;
|
||||||
|
use Symfony\Component\Form\FormBuilderInterface;
|
||||||
|
use Symfony\Component\OptionsResolver\OptionsResolver;
|
||||||
|
use Symfony\Component\Validator\Constraints\Length;
|
||||||
|
use Symfony\Component\Validator\Constraints\NotBlank;
|
||||||
|
use Symfony\Component\Validator\Constraints\NotCompromisedPassword;
|
||||||
|
use Symfony\Component\Validator\Constraints\PasswordStrength;
|
||||||
|
|
||||||
|
class ProfileChangePasswordFormType extends AbstractType
|
||||||
|
{
|
||||||
|
public function buildForm(FormBuilderInterface $builder, array $options): void
|
||||||
|
{
|
||||||
|
$builder
|
||||||
|
->add('currentPassword', PasswordType::class, [
|
||||||
|
'label' => 'Current password',
|
||||||
|
'attr' => ['autocomplete' => 'current-password'],
|
||||||
|
'constraints' => [
|
||||||
|
new NotBlank(message: 'Please enter your current password'),
|
||||||
|
],
|
||||||
|
])
|
||||||
|
->add('plainPassword', RepeatedType::class, [
|
||||||
|
'type' => PasswordType::class,
|
||||||
|
'options' => [
|
||||||
|
'attr' => [
|
||||||
|
'autocomplete' => 'new-password',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
'first_options' => [
|
||||||
|
'constraints' => [
|
||||||
|
new NotBlank(message: 'Please enter a password'),
|
||||||
|
new Length(
|
||||||
|
min: 12,
|
||||||
|
minMessage: 'Your password should be at least {{ limit }} characters',
|
||||||
|
max: 4096,
|
||||||
|
),
|
||||||
|
new PasswordStrength(),
|
||||||
|
new NotCompromisedPassword(),
|
||||||
|
],
|
||||||
|
'label' => 'New password',
|
||||||
|
],
|
||||||
|
'second_options' => [
|
||||||
|
'label' => 'Repeat new password',
|
||||||
|
],
|
||||||
|
'invalid_message' => 'The password fields must match.',
|
||||||
|
])
|
||||||
|
;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function configureOptions(OptionsResolver $resolver): void
|
||||||
|
{
|
||||||
|
$resolver->setDefaults([]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -26,6 +26,9 @@
|
|||||||
<a class="nav-link" href="{{ path('game_admin_dashboard') }}">{{ 'nav.admin'|trans }}</a>
|
<a class="nav-link" href="{{ path('game_admin_dashboard') }}">{{ 'nav.admin'|trans }}</a>
|
||||||
</li>
|
</li>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link" href="{{ path('app_profile') }}">{{ 'nav.profile'|trans }}</a>
|
||||||
|
</li>
|
||||||
<li class="nav-item">
|
<li class="nav-item">
|
||||||
<a class="nav-link" href="{{ path('app_logout') }}">{{ 'nav.logout'|trans }}</a>
|
<a class="nav-link" href="{{ path('app_logout') }}">{{ 'nav.logout'|trans }}</a>
|
||||||
</li>
|
</li>
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
{% extends 'layout/site.html.twig' %}
|
||||||
|
|
||||||
|
{% block title %}Profile{% endblock %}
|
||||||
|
|
||||||
|
{% block body %}
|
||||||
|
<h1 class="mb-4">Profile</h1>
|
||||||
|
|
||||||
|
<div class="row g-4">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<div class="card h-100">
|
||||||
|
<div class="card-header bg-secondary text-white">Change Email</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<p class="text-muted">Current email: {{ user.email }}</p>
|
||||||
|
{{ form_start(emailForm) }}
|
||||||
|
{{ form_row(emailForm.email) }}
|
||||||
|
{{ form_row(emailForm.currentPassword) }}
|
||||||
|
<div class="d-grid mt-3">
|
||||||
|
<button type="submit" class="btn btn-primary">Update Email</button>
|
||||||
|
</div>
|
||||||
|
{{ form_end(emailForm) }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<div class="card h-100">
|
||||||
|
<div class="card-header bg-secondary text-white">Change Password</div>
|
||||||
|
<div class="card-body">
|
||||||
|
{{ form_start(passwordForm) }}
|
||||||
|
{{ form_row(passwordForm.currentPassword) }}
|
||||||
|
{{ form_row(passwordForm.plainPassword) }}
|
||||||
|
<div class="d-grid mt-3">
|
||||||
|
<button type="submit" class="btn btn-primary">Update Password</button>
|
||||||
|
</div>
|
||||||
|
{{ form_end(passwordForm) }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -3,6 +3,7 @@ site.name: EscapePage
|
|||||||
nav.home: Home
|
nav.home: Home
|
||||||
nav.game: Game
|
nav.game: Game
|
||||||
nav.admin: Admin
|
nav.admin: Admin
|
||||||
|
nav.profile: Profile
|
||||||
nav.login: Login
|
nav.login: Login
|
||||||
nav.register: Register
|
nav.register: Register
|
||||||
nav.logout: Logout
|
nav.logout: Logout
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ site.name: EscapePage
|
|||||||
nav.home: Start
|
nav.home: Start
|
||||||
nav.game: Spel
|
nav.game: Spel
|
||||||
nav.admin: Beheer
|
nav.admin: Beheer
|
||||||
|
nav.profile: Profiel
|
||||||
nav.login: Inloggen
|
nav.login: Inloggen
|
||||||
nav.register: Registreren
|
nav.register: Registreren
|
||||||
nav.logout: Uitloggen
|
nav.logout: Uitloggen
|
||||||
|
|||||||
Reference in New Issue
Block a user