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:
Frank
2026-07-11 23:23:38 +02:00
parent 2941cfca49
commit 886208c5c0
7 changed files with 244 additions and 0 deletions
+105
View File
@@ -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([]);
}
}
+3
View File
@@ -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>
+39
View File
@@ -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 %}
+1
View File
@@ -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
+1
View File
@@ -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