Implement email verification feature, add registration form, and update user entity for verification status

This commit is contained in:
2025-04-20 19:34:27 +02:00
parent c70f713f7e
commit 4bcab2724a
40 changed files with 1149 additions and 269 deletions

4
.env
View File

@@ -28,3 +28,7 @@ APP_SECRET=
# DATABASE_URL="mysql://app:!ChangeMe!@127.0.0.1:3306/app?serverVersion=10.11.2-MariaDB&charset=utf8mb4"
DATABASE_URL="postgresql://app:!ChangeMe!@127.0.0.1:5432/app?serverVersion=16&charset=utf8"
###< doctrine/doctrine-bundle ###
###> symfony/mailer ###
MAILER_DSN=null://null
###< symfony/mailer ###

View File

@@ -34,6 +34,8 @@ jobs:
*.cache-to=type=gha,scope=${{github.ref}},mode=max
- name: Start services
run: docker compose up --wait --no-build
- name: Coding Style
run: docker compose exec -T php vendor/bin/php-cs-fixer check --diff --show-progress=none
- name: Check HTTP reachability
run: curl -v --fail-with-body http://localhost
- name: Check HTTPS reachability

4
.gitignore vendored
View File

@@ -1,3 +1,5 @@
/frankenphp/data
### Generated by gibo (https://github.com/simonwhitaker/gibo)
### https://raw.github.com/github/gitignore/6eeebe6f49678aacd8311ce079842c971b3ebe96/Symfony.gitignore
@@ -32,7 +34,6 @@
/bin/*
!bin/console
!bin/symfony_requirements
/vendor/
# Assets and user uploads
/web/bundles/
@@ -40,7 +41,6 @@
# PHPUnit
/app/phpunit.xml
/phpunit.xml
# Build data
/build/

View File

@@ -131,7 +131,14 @@
<excludeFolder url="file://$MODULE_DIR$/vendor/doctrine/doctrine-fixtures-bundle" />
<excludeFolder url="file://$MODULE_DIR$/vendor/vincentlanglet/twig-cs-fixer" />
<excludeFolder url="file://$MODULE_DIR$/vendor/webmozart/assert" />
<excludeFolder url="file://$MODULE_DIR$/vendor/egulias/email-validator" />
<excludeFolder url="file://$MODULE_DIR$/vendor/masterminds/html5" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/browser-kit" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/dom-crawler" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/mailer" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/polyfill-php83" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/polyfill-php84" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfonycasts/verify-email-bundle" />
</content>
<orderEntry type="inheritedJdk" />
<orderEntry type="sourceFolder" forTests="false" />

View File

@@ -5,7 +5,7 @@
<tool tool_name="PHPUnit">
<cache>
<versions>
<info id="interpreter-c1266788-d465-407a-ac5d-1f67a9cf3e8a" version="11.5.2" />
<info id="interpreter-c1266788-d465-407a-ac5d-1f67a9cf3e8a" version="12.1.2" />
</versions>
</cache>
</tool>

10
.idea/php.xml generated
View File

@@ -162,6 +162,13 @@
<path value="$PROJECT_DIR$/vendor/webmozart/assert" />
<path value="$PROJECT_DIR$/vendor/vincentlanglet/twig-cs-fixer" />
<path value="$PROJECT_DIR$/vendor/symfony/polyfill-php84" />
<path value="$PROJECT_DIR$/vendor/symfony/browser-kit" />
<path value="$PROJECT_DIR$/vendor/symfony/dom-crawler" />
<path value="$PROJECT_DIR$/vendor/masterminds/html5" />
<path value="$PROJECT_DIR$/vendor/symfony/polyfill-php83" />
<path value="$PROJECT_DIR$/vendor/egulias/email-validator" />
<path value="$PROJECT_DIR$/vendor/symfony/mailer" />
<path value="$PROJECT_DIR$/vendor/symfonycasts/verify-email-bundle" />
</include_path>
</component>
<component name="PhpInterpreters">
@@ -252,8 +259,7 @@
</component>
<component name="PhpUnit">
<phpunit_settings>
<phpunit_by_interpreter interpreter_id="c1266788-d465-407a-ac5d-1f67a9cf3e8a" custom_loader_path="/app/vendor/autoload.php" phpunit_phar_path="" />
<PhpUnitSettings custom_loader_path="$PROJECT_DIR$/vendor/autoload.php" />
<phpunit_by_interpreter interpreter_id="c1266788-d465-407a-ac5d-1f67a9cf3e8a" configuration_file_path="phpunit.xml" custom_loader_path="/app/vendor/autoload.php" phpunit_phar_path="" use_configuration_file="true" />
</phpunit_settings>
</component>
<component name="Psalm">

View File

@@ -3,6 +3,7 @@
declare(strict_types=1);
use PhpCsFixer\Config;
use PhpCsFixer\Finder;
use PhpCsFixer\Runner\Parallel\ParallelConfigFactory;
$finder = (new Finder())
->in(__DIR__)
@@ -10,6 +11,7 @@ $finder = (new Finder())
;
return (new Config())
->setParallelConfig(ParallelConfigFactory::detect())
->setRules([
'@Symfony' => true,
'@Symfony:risky' => true,

View File

@@ -25,3 +25,10 @@ translations:
fix-cs:
docker compose exec php vendor/bin/php-cs-fixer fix
docker compose exec php vendor/bin/twig-cs-fixer fix
rector *args:
docker compose exec php vendor/bin/rector {{ args }}
phpstan *args:
docker compose exec php vendor/bin/phpstan analyse {{ args }}

View File

@@ -8,13 +8,12 @@ services:
- ./:/app
- ./frankenphp/Caddyfile:/etc/caddy/Caddyfile:ro
- ./frankenphp/conf.d/20-app.dev.ini:/usr/local/etc/php/app.conf.d/20-app.dev.ini:ro
# If you develop on Mac or Windows you can remove the vendor/ directory
# from the bind-mount for better performance by enabling the next line:
#- /app/vendor
- ./frankenphp/data:/data
environment:
MERCURE_EXTRA_DIRECTIVES: demo
# See https://xdebug.org/docs/all_settings#mode
XDEBUG_MODE: "${XDEBUG_MODE:-off}"
MAILER_DSN: "smtp://mailer:1025"
extra_hosts:
# Ensure that host.docker.internal is correctly defined on Linux
- host.docker.internal:host-gateway
@@ -27,4 +26,15 @@ services:
database:
ports:
- "5432:5432"
###< doctrine/doctrine-bundle ###
###< doctrine/doctrine-bundle ###
###> symfony/mailer ###
mailer:
image: axllent/mailpit
ports:
- "1025"
- "8025"
environment:
MP_SMTP_AUTH_ACCEPT_ANY: 1
MP_SMTP_AUTH_ALLOW_INSECURE: 1
###< symfony/mailer ###

View File

@@ -8,3 +8,17 @@ services:
APP_SECRET: ${APP_SECRET}
MERCURE_PUBLISHER_JWT_KEY: ${CADDY_MERCURE_JWT_SECRET}
MERCURE_SUBSCRIBER_JWT_KEY: ${CADDY_MERCURE_JWT_SECRET}
labels:
- "traefik.enable=true"
- "traefik.http.routers.php.rule=Host(`tijdvoordetest.nl`)"
- "traefik.http.routers.php.entrypoints=websecure"
- "traefik.http.routers.php.tls.certresolver=marijndoeve"
- "traefik.http.services.php.loadbalancer.server.port=80"
networks:
- web
- internal
networks:
web:
external: true
internal:
external: false

View File

@@ -31,12 +31,12 @@ services:
- target: 443
published: ${HTTP3_PORT:-443}
protocol: udp
# Mercure is installed as a Caddy module, prevent the Flex recipe from installing another service
###> symfony/mercure-bundle ###
###< symfony/mercure-bundle ###
###> doctrine/doctrine-bundle ###
# Mercure is installed as a Caddy module, prevent the Flex recipe from installing another service
###> symfony/mercure-bundle ###
###< symfony/mercure-bundle ###
###> doctrine/doctrine-bundle ###
database:
image: postgres:${POSTGRES_VERSION:-16}-alpine
environment:
@@ -45,22 +45,20 @@ services:
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-!ChangeMe!}
POSTGRES_USER: ${POSTGRES_USER:-app}
healthcheck:
test: ["CMD", "pg_isready", "-d", "${POSTGRES_DB:-app}", "-U", "${POSTGRES_USER:-app}"]
test: [ "CMD", "pg_isready", "-d", "${POSTGRES_DB:-app}", "-U", "${POSTGRES_USER:-app}" ]
timeout: 5s
retries: 5
start_period: 60s
volumes:
- database_data:/var/lib/postgresql/data:rw
# You may use a bind-mounted host directory instead, so that it is harder to accidentally remove the volume and lose all your data!
# - ./docker/db/data:/var/lib/postgresql/data:rw
###< doctrine/doctrine-bundle ###
###< doctrine/doctrine-bundle ###
volumes:
caddy_data:
caddy_config:
###> symfony/mercure-bundle ###
###< symfony/mercure-bundle ###
###> doctrine/doctrine-bundle ###
###> symfony/mercure-bundle ###
###< symfony/mercure-bundle ###
###> doctrine/doctrine-bundle ###
database_data:
###< doctrine/doctrine-bundle ###
###< doctrine/doctrine-bundle ###

View File

@@ -13,7 +13,7 @@
"doctrine/doctrine-bundle": "^2.14.0",
"doctrine/doctrine-migrations-bundle": "^3.4.1",
"doctrine/orm": "^3.3.2",
"easycorp/easyadmin-bundle": "^4.24.5",
"easycorp/easyadmin-bundle": "^4.24.6",
"runtime/frankenphp-symfony": "^0.2.0",
"symfony/asset": "7.2.*",
"symfony/console": "7.2.*",
@@ -21,24 +21,27 @@
"symfony/flex": "^2.5.0",
"symfony/form": "7.2.*",
"symfony/framework-bundle": "7.2.*",
"symfony/mailer": "7.2.*",
"symfony/runtime": "7.2.*",
"symfony/security-bundle": "7.2.*",
"symfony/twig-bundle": "7.2.*",
"symfony/uid": "7.2.*",
"symfony/yaml": "7.2.*",
"thecodingmachine/safe": "^3.0.2"
"symfonycasts/verify-email-bundle": "^1.17",
"thecodingmachine/safe": "^3.1.0"
},
"require-dev": {
"doctrine/doctrine-fixtures-bundle": "^4.1",
"friendsofphp/php-cs-fixer": "^3.75.0",
"phpstan/extension-installer": "^1.4.3",
"phpstan/phpstan": "^2.1.11",
"phpstan/phpstan": "^2.1.12",
"phpstan/phpstan-doctrine": "^2.0.2",
"phpstan/phpstan-phpunit": "^2.0.6",
"phpstan/phpstan-symfony": "^2.0.4",
"phpunit/phpunit": "^12.0.10",
"phpunit/phpunit": "^12.1.2",
"rector/rector": "^2.0.11",
"roave/security-advisories": "dev-latest",
"symfony/browser-kit": "7.2.*",
"symfony/maker-bundle": "^1.62.1",
"symfony/stopwatch": "7.2.*",
"symfony/web-profiler-bundle": "7.2.*",
@@ -73,8 +76,7 @@
"symfony/polyfill-php74": "*",
"symfony/polyfill-php80": "*",
"symfony/polyfill-php81": "*",
"symfony/polyfill-php82": "*",
"symfony/polyfill-php83": "*"
"symfony/polyfill-php82": "*"
},
"scripts": {
"auto-scripts": {

639
composer.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,7 @@
<?php
declare(strict_types=1);
use Doctrine\Bundle\DoctrineBundle\DoctrineBundle;
use Doctrine\Bundle\FixturesBundle\DoctrineFixturesBundle;
use Doctrine\Bundle\MigrationsBundle\DoctrineMigrationsBundle;
@@ -11,6 +12,7 @@ use Symfony\Bundle\SecurityBundle\SecurityBundle;
use Symfony\Bundle\TwigBundle\TwigBundle;
use Symfony\Bundle\WebProfilerBundle\WebProfilerBundle;
use Symfony\UX\TwigComponent\TwigComponentBundle;
use SymfonyCasts\Bundle\VerifyEmail\SymfonyCastsVerifyEmailBundle;
use Twig\Extra\TwigExtraBundle\TwigExtraBundle;
return [
@@ -25,4 +27,5 @@ return [
TwigComponentBundle::class => ['all' => true],
EasyAdminBundle::class => ['all' => true],
DoctrineFixturesBundle::class => ['dev' => true, 'test' => true],
SymfonyCastsVerifyEmailBundle::class => ['all' => true],
];

View File

@@ -0,0 +1,7 @@
framework:
mailer:
dsn: '%env(MAILER_DSN)%'
envelope:
sender: '%env(MAILER_SENDER)%'
headers:
From: 'Tijd voor de test <%env(MAILER_SENDER)%>'

View File

@@ -18,11 +18,12 @@ security:
lazy: true
provider: app_user_provider
form_login:
login_path: app_login
check_path: app_login
login_path: app_login_login
check_path: app_login_login
enable_csrf: true
default_target_path: app_backoffice_index
logout:
path: app_logout
path: app_login_logout
# where to redirect after logout
# target: app_any_route

View File

@@ -0,0 +1,30 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20250420111904 extends AbstractMigration
{
public function getDescription(): string
{
return 'Add is_verified column to user table';
}
public function up(Schema $schema): void
{
$this->addSql(<<<'SQL'
ALTER TABLE "user" ADD is_verified BOOLEAN NOT NULL
SQL);
}
public function down(Schema $schema): void
{
$this->addSql(<<<'SQL'
ALTER TABLE "user" DROP is_verified
SQL);
}
}

View File

@@ -0,0 +1,30 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20250420125040 extends AbstractMigration
{
public function getDescription(): string
{
return 'Drop preregister_candidates column from season table';
}
public function up(Schema $schema): void
{
$this->addSql(<<<'SQL'
ALTER TABLE season DROP preregister_candidates
SQL);
}
public function down(Schema $schema): void
{
$this->addSql(<<<'SQL'
ALTER TABLE season ADD preregister_candidates BOOLEAN NOT NULL DEFAULT true
SQL);
}
}

View File

@@ -26,6 +26,7 @@ return RectorConfig::configure()
doctrineCodeQuality: true,
symfonyCodeQuality: true,
)
->withComposerBased(twig: true, doctrine: true, phpunit: true)
->withAttributesSets(all: true)
->withComposerBased(twig: true, doctrine: true, phpunit: true, symfony: true)
->withAttributesSets()
;

View File

@@ -6,24 +6,35 @@ namespace App\Controller;
use App\Entity\Quiz;
use App\Entity\Season;
use App\Entity\User;
use App\Repository\CandidateRepository;
use App\Repository\SeasonRepository;
use App\Security\Voter\SeasonVoter;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Attribute\AsController;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
#[AsController]
#[IsGranted('ROLE_USER')]
final class BackofficeController extends AbstractController
{
public function __construct(
private readonly SeasonRepository $seasonRepository,
private readonly CandidateRepository $candidateRepository,
private readonly Security $security,
) {}
#[Route('/backoffice/', name: 'app_backoffice_index')]
public function index(): Response
{
$seasons = $this->seasonRepository->findAll();
$user = $this->getUser();
\assert($user instanceof User);
$seasons = $this->security->isGranted('ROLE_ADMIN')
? $this->seasonRepository->findAll()
: $this->seasonRepository->getSeasonsForUser($user);
return $this->render('backoffice/index.html.twig', [
'seasons' => $seasons,
@@ -31,6 +42,7 @@ final class BackofficeController extends AbstractController
}
#[Route('/backoffice/{seasonCode}', name: 'app_backoffice_season')]
#[IsGranted(SeasonVoter::EDIT, subject: 'season')]
public function season(Season $season): Response
{
return $this->render('backoffice/season.html.twig', [

View File

@@ -11,7 +11,7 @@ use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Authentication\AuthenticationUtils;
#[AsController]
class LoginController extends AbstractController
final class LoginController extends AbstractController
{
#[Route(path: '/login', name: 'app_login_login')]
public function login(AuthenticationUtils $authenticationUtils): Response
@@ -29,7 +29,7 @@ class LoginController extends AbstractController
}
#[Route(path: '/logout', name: 'app_login_logout')]
public function logout(): void
public function logout(): never
{
throw new \LogicException('This method can be blank - it will be intercepted by the logout key on your firewall.');
}

View File

@@ -8,10 +8,9 @@ use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
#[Route(path: '/backoffice/elimination')]
final class PrepareEliminationController extends AbstractController
{
#[Route('/prepare', name: 'app_prepare_elimination')]
#[Route('/backoffice/elimination/prepare', name: 'app_prepare_elimination')]
public function index(): Response
{
return $this->render('prepare_elimination/index.html.twig', [

View File

@@ -84,14 +84,9 @@ final class QuizController extends AbstractController
$candidate = $candidateRepository->getCandidateByHash($season, $nameHash);
if (!$candidate instanceof Candidate) {
if ($season->isPreregisterCandidates()) {
$this->addFlash(FlashType::Danger, 'Candidate not found');
$this->addFlash(FlashType::Danger, 'Candidate not found');
return $this->redirectToRoute('app_quiz_entername', ['seasonCode' => $season->getSeasonCode()]);
}
$candidate = new Candidate(Base64::base64UrlDecode($nameHash));
$candidateRepository->save($candidate);
return $this->redirectToRoute('app_quiz_entername', ['seasonCode' => $season->getSeasonCode()]);
}
if ('POST' === $request->getMethod()) {

View File

@@ -0,0 +1,93 @@
<?php
declare(strict_types=1);
namespace App\Controller;
use App\Entity\User;
use App\Form\RegistrationFormType;
use App\Repository\UserRepository;
use App\Security\EmailVerifier;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bridge\Twig\Mime\TemplatedEmail;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Contracts\Translation\TranslatorInterface;
use SymfonyCasts\Bundle\VerifyEmail\Exception\VerifyEmailExceptionInterface;
final class RegistrationController extends AbstractController
{
public function __construct(private readonly EmailVerifier $emailVerifier, private readonly TranslatorInterface $translator) {}
#[Route('/register', name: 'app_register')]
public function register(
Request $request,
UserPasswordHasherInterface $userPasswordHasher,
Security $security,
EntityManagerInterface $entityManager,
): Response {
$user = new User();
$form = $this->createForm(RegistrationFormType::class, $user);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
/** @var string $plainPassword */
$plainPassword = $form->get('plainPassword')->getData();
$user->setPassword($userPasswordHasher->hashPassword($user, $plainPassword));
$entityManager->persist($user);
$entityManager->flush();
// generate a signed url and email it to the user
$this->emailVerifier->sendEmailConfirmation('app_verify_email', $user,
(new TemplatedEmail())
->to((string) $user->getEmail())
->subject($this->translator->trans('Please Confirm your Email'))
->htmlTemplate('registration/confirmation_email.html.twig')
);
$response = $security->login($user, 'form_login', 'main');
\assert($response instanceof Response);
return $response;
}
return $this->render('registration/register.html.twig', [
'registrationForm' => $form,
]);
}
#[Route('/verify/email', name: 'app_verify_email')]
public function verifyUserEmail(Request $request, TranslatorInterface $translator, UserRepository $userRepository): Response
{
$id = $request->query->get('id');
if (null === $id) {
return $this->redirectToRoute('app_register');
}
$user = $userRepository->find($id);
if (null === $user) {
return $this->redirectToRoute('app_register');
}
// validate email confirmation link, sets User::isVerified=true and persists
try {
$this->emailVerifier->handleEmailConfirmation($request, $user);
} catch (VerifyEmailExceptionInterface $verifyEmailException) {
$this->addFlash('verify_email_error', $translator->trans($verifyEmailException->getReason(), [], 'VerifyEmailBundle'));
return $this->redirectToRoute('app_register');
}
$this->addFlash('success', 'Your email address has been verified.');
return $this->redirectToRoute('app_backoffice_index');
}
}

View File

@@ -21,7 +21,6 @@ class KrtekFixtures extends Fixture
$season->setName('Krtek Weekend')
->setSeasonCode('krtek')
->setPreregisterCandidates(true)
->addCandidate(new Candidate('Claudia'))
->addCandidate(new Candidate('Eelco'))
->addCandidate(new Candidate('Elise'))

View File

@@ -27,9 +27,6 @@ class Season
#[ORM\Column(length: 5)]
private string $seasonCode;
#[ORM\Column]
private bool $preregisterCandidates;
/** @var Collection<int, Quiz> */
#[ORM\OneToMany(targetEntity: Quiz::class, mappedBy: 'season', cascade: ['persist'], orphanRemoval: true)]
private Collection $quizzes;
@@ -81,18 +78,6 @@ class Season
return $this;
}
public function isPreregisterCandidates(): bool
{
return $this->preregisterCandidates;
}
public function setPreregisterCandidates(bool $preregisterCandidates): static
{
$this->preregisterCandidates = $preregisterCandidates;
return $this;
}
/** @return Collection<int, Quiz> */
public function getQuizzes(): Collection
{
@@ -158,4 +143,9 @@ class Season
return $this;
}
public function isOwner(User $user): bool
{
return $this->owners->contains($user);
}
}

View File

@@ -10,6 +10,7 @@ use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Bridge\Doctrine\IdGenerator\UuidGenerator;
use Symfony\Bridge\Doctrine\Types\UuidType;
use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity;
use Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface;
use Symfony\Component\Security\Core\User\UserInterface;
use Symfony\Component\Uid\Uuid;
@@ -17,6 +18,7 @@ use Symfony\Component\Uid\Uuid;
#[ORM\Entity(repositoryClass: UserRepository::class)]
#[ORM\Table(name: '`user`')]
#[ORM\UniqueConstraint(name: 'UNIQ_IDENTIFIER_EMAIL', fields: ['email'])]
#[UniqueEntity(fields: ['email'], message: 'There is already an account with this email')]
class User implements UserInterface, PasswordAuthenticatedUserInterface
{
#[ORM\Id]
@@ -40,6 +42,9 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
#[ORM\ManyToMany(targetEntity: Season::class, mappedBy: 'owners')]
private Collection $seasons;
#[ORM\Column]
private bool $isVerified = false;
public function __construct()
{
$this->seasons = new ArrayCollection();
@@ -140,4 +145,21 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
return $this;
}
public function isVerified(): bool
{
return $this->isVerified;
}
public function setIsVerified(bool $isVerified): static
{
$this->isVerified = $isVerified;
return $this;
}
public function isAdmin(): bool
{
return \in_array('ROLE_ADMIN', $this->getRoles(), true);
}
}

View File

@@ -0,0 +1,56 @@
<?php
declare(strict_types=1);
namespace App\Form;
use App\Entity\User;
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\Length;
use Symfony\Component\Validator\Constraints\NotBlank;
use Symfony\Contracts\Translation\TranslatorInterface;
/**
* @extends AbstractType<User>
*/
class RegistrationFormType extends AbstractType
{
public function __construct(private readonly TranslatorInterface $translator) {}
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder
->add('email', EmailType::class, [
'label' => $this->translator->trans('Email'),
'attr' => ['autocomplete' => 'email'],
])
->add('plainPassword', PasswordType::class, [
'label' => $this->translator->trans('Password'),
'mapped' => false,
'attr' => ['autocomplete' => 'new-password'],
'constraints' => [
new NotBlank([
'message' => 'Please enter a password',
]),
new Length([
'min' => 8,
'minMessage' => 'Your password should be at least {{ limit }} characters',
// max length allowed by Symfony for security reasons
'max' => 4096,
]),
],
])
;
}
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults([
'data_class' => User::class,
]);
}
}

View File

@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace App\Repository;
use App\Entity\Season;
use App\Entity\User;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
@@ -17,4 +18,15 @@ class SeasonRepository extends ServiceEntityRepository
{
parent::__construct($registry, Season::class);
}
/** @return list<Season> Returns an array of Season objects */
public function getSeasonsForUser(User $user): array
{
$qb = $this->createQueryBuilder('s')
->where(':user MEMBER OF s.owners')
->orderBy('s.name')
->setParameter('user', $user);
return $qb->getQuery()->getResult();
}
}

View File

@@ -22,7 +22,9 @@ class UserRepository extends ServiceEntityRepository implements PasswordUpgrader
parent::__construct($registry, User::class);
}
/** Used to upgrade (rehash) the user's password automatically over time. */
/** Used to upgrade (rehash) the user's password automatically over time.
* @param User $user
* */
public function upgradePassword(PasswordAuthenticatedUserInterface $user, string $newHashedPassword): void
{
$user->setPassword($newHashedPassword);

View File

@@ -0,0 +1,52 @@
<?php
declare(strict_types=1);
namespace App\Security;
use App\Entity\User;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bridge\Twig\Mime\TemplatedEmail;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Mailer\MailerInterface;
use SymfonyCasts\Bundle\VerifyEmail\Exception\VerifyEmailExceptionInterface;
use SymfonyCasts\Bundle\VerifyEmail\VerifyEmailHelperInterface;
class EmailVerifier
{
public function __construct(
private readonly VerifyEmailHelperInterface $verifyEmailHelper,
private readonly MailerInterface $mailer,
private readonly EntityManagerInterface $entityManager,
) {}
public function sendEmailConfirmation(string $verifyEmailRouteName, User $user, TemplatedEmail $email): void
{
$signatureComponents = $this->verifyEmailHelper->generateSignature(
$verifyEmailRouteName,
(string) $user->getId(),
(string) $user->getEmail(),
['id' => $user->getId()]
);
$context = $email->getContext();
$context['signedUrl'] = $signatureComponents->getSignedUrl();
$context['expiresAtMessageKey'] = $signatureComponents->getExpirationMessageKey();
$context['expiresAtMessageData'] = $signatureComponents->getExpirationMessageData();
$email->context($context);
$this->mailer->send($email);
}
/** @throws VerifyEmailExceptionInterface */
public function handleEmailConfirmation(Request $request, User $user): void
{
$this->verifyEmailHelper->validateEmailConfirmationFromRequest($request, (string) $user->getId(), (string) $user->getEmail());
$user->setIsVerified(true);
$this->entityManager->persist($user);
$this->entityManager->flush();
}
}

View File

@@ -0,0 +1,48 @@
<?php
declare(strict_types=1);
namespace App\Security\Voter;
use App\Entity\Season;
use App\Entity\User;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Authorization\Voter\Voter;
final class SeasonVoter extends Voter
{
public const string EDIT = 'SEASON_EDIT';
public const string DELETE = 'SEASON_DELETE';
protected function supports(string $attribute, mixed $subject): bool
{
return \in_array($attribute, [self::EDIT, self::DELETE], true)
&& $subject instanceof Season;
}
/** @param Season $subject */
protected function voteOnAttribute(string $attribute, mixed $subject, TokenInterface $token): bool
{
$user = $token->getUser();
if (!$user instanceof User) {
return false;
}
if ($user->isAdmin()) {
return true;
}
switch ($attribute) {
case self::EDIT:
case self::DELETE:
if ($subject->isOwner($user)) {
return true;
}
break;
}
return false;
}
}

View File

@@ -141,6 +141,18 @@
"src/Kernel.php"
]
},
"symfony/mailer": {
"version": "7.2",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
"version": "4.3",
"ref": "09051cfde49476e3c12cd3a0e44289ace1c75a4f"
},
"files": [
"config/packages/mailer.yaml"
]
},
"symfony/maker-bundle": {
"version": "1.61",
"recipe": {
@@ -248,6 +260,9 @@
"config/routes/web_profiler.yaml"
]
},
"symfonycasts/verify-email-bundle": {
"version": "v1.17.3"
},
"twig/extra-bundle": {
"version": "v3.18.0"
},

View File

@@ -28,7 +28,14 @@
{% endblock nav %}
<main>
<div class="container">
{# {% include "messages.html" %} #}
{% for label, messages in app.flashes() %}
{% for message in messages %}
<div class="alert alert-{{ label }} alert-dismissible " role="alert">
{{ message }}
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
</div>
{% endfor %}
{% endfor %}
{% block body %}
{% endblock body %}
</div>

View File

@@ -3,26 +3,33 @@
{% block title %}Hello BackofficeController!{% endblock %}
{% block body %}
<h2>{% trans %}Your Seasons{% endtrans %}</h2>
<h2>
{{ is_granted('ROLE_ADMIN') ? 'All Seasons'|trans : 'Your Seasons'|trans }}
</h2>
<table class="table table-hover">
<thead>
<tr>
<th scope="col">{% trans %}Name{% endtrans %}</th>
<th scope="col">{% trans %}Active Quiz{% endtrans %}</th>
<th scope="col">{% trans %}Season Code{% endtrans %}</th>
<th scope="col">{% trans %}Preregister?{% endtrans %}</th>
<th scope="col">{% trans %}Manage{% endtrans %}</th>
{% if is_granted('ROLE_ADMIN') %}
<th scope="col">{{ 'Owner(s)'|trans }}</th>
{% endif %}
<th scope="col">{{ 'Name'|trans }}</th>
<th scope="col">{{ 'Active Quiz'|trans }}</th>
<th scope="col">{{ 'Season Code'|trans }}</th>
<th scope="col">{{ 'Manage'|trans }}</th>
</tr>
</thead>
<tbody>
{% for season in seasons %}
<tr class="align-middle">
{% if is_granted('ROLE_ADMIN') %}
<td>{{ season.owners|map(o => o.email)|join(', ') }}</td>
{% endif %}
<td>{{ season.name }}</td>
<td>
{% if season.activeQuiz %}
{{ season.activeQuiz.name }}
{% else %}
{% trans %} No active quiz {% endtrans %}
{{ ' No active quiz '|trans }}
{% endif %}
</td>
<td>
@@ -30,14 +37,7 @@
{% else %}class="disabled" {% endif %}>{{ season.seasonCode }}</a>
</td>
<td>
<input class="form-check-input"
type="checkbox"
disabled
{% if season.preregisterCandidates %}checked{% endif %}
aria-label="Preregister Enabled">
</td>
<td>
<a href="{{ path('app_backoffice_season', {seasonCode: season.seasonCode}) }}">{% trans %}Manage{% endtrans %}</a>
<a href="{{ path('app_backoffice_season', {seasonCode: season.seasonCode}) }}">{{ 'Manage'|trans }}</a>
</td>
</tr>
{% else %}

View File

@@ -1,43 +1,39 @@
{% extends 'base.html.twig' %}
{% extends 'backoffice/base.html.twig' %}
{% block title %}Log in!{% endblock %}
{% block title %}Log in{% endblock %}
{% block body %}
<form method="post">
{% if error %}
<div class="alert alert-danger">{{ error.messageKey|trans(error.messageData, 'security') }}</div>
{% endif %}
{% if app.user %}
<div class="mb-3">
You are logged in as {{ app.user.userIdentifier }}, <a href="{{ path('app_logout') }}">Logout</a>
</div>
{% endif %}
<h1 class="h3 mb-3 font-weight-normal">Please sign in</h1>
<label for="username">Email</label>
<input type="email" value="{{ last_username }}" name="_username" id="username" class="form-control"
autocomplete="email" required autofocus>
<label for="password">Password</label>
<input type="password" name="_password" id="password" class="form-control" autocomplete="current-password"
required>
<input type="hidden" name="_csrf_token"
value="{{ csrf_token('authenticate') }}"
>
{#
Uncomment this section and add a remember_me option below your firewall to activate remember me functionality.
See https://symfony.com/doc/current/security/remember_me.html
<div class="checkbox mb-3">
<input type="checkbox" name="_remember_me" id="_remember_me">
<label for="_remember_me">Remember me</label>
{% if app.user %}
<div class="mb-3">
You are logged in as {{ app.user.userIdentifier }}, <a href="{{ path('app_login_logout') }}">Logout</a>
</div>
#}
{% else %}
<form method="post">
<h1 class="h3 mb-3 font-weight-normal">{{ 'Please sign in'|trans }}</h1>
<div class="mb-3">
<label for="username" class="form-label">{{ 'Email'|trans }}</label>
<input type="email" value="{{ last_username }}" name="_username" id="username" class="form-control"
autocomplete="email" required autofocus>
</div>
<div class="mb-3">
<label for="password" class="form-label">{{ 'Password'|trans }}</label>
<input type="password" name="_password" id="password" class="form-control"
autocomplete="current-password"
required>
</div>
<input type="hidden" name="_csrf_token"
value="{{ csrf_token('authenticate') }}"
>
<div class="mb-3 form-check">
<input type="checkbox" name="_remember_me" id="_remember_me" class="form-check-input">
<label for="_remember_me" class="form-check-label">{{ 'Remember me'|trans }}</label>
</div>
<button class="btn btn-lg btn-primary" type="submit">
Sign in
</button>
</form>
<button class="btn btn-lg btn-primary" type="submit">
{{ 'Sign in'|trans }}
</button>
<a href="{{ path('app_register') }}"
class="btn btn-link">{{ 'Create an account'|trans }}</a>
</form>
{% endif %}
{% endblock %}

View File

@@ -0,0 +1,11 @@
<h1>Hi! Please confirm your email!</h1>
<p>
Please confirm your email address by clicking the following link: <br><br>
<a href="{{ signedUrl|raw }}">Confirm my Email</a>.
This link will expire in {{ expiresAtMessageKey|trans(expiresAtMessageData, 'VerifyEmailBundle') }}.
</p>
<p>
Cheers!
</p>

View File

@@ -0,0 +1,17 @@
{% extends 'backoffice/base.html.twig' %}
{% block title %}{{ 'Register'|trans }}{% endblock %}
{% block body %}
<h3>{{ 'Register'|trans }}</h3>
{{ form_errors(registrationForm) }}
{{ form_start(registrationForm) }}
{{ form_row(registrationForm.email) }}
{{ form_row(registrationForm.plainPassword) }}
<button type="submit" class="btn btn-primary">Register</button>
<a href="{{ path('app_login_login') }}" class="btn btn-link">{{ 'Already have an account? Log in'|trans }}</a>
{{ form_end(registrationForm) }}
{% endblock %}

View File

@@ -1,85 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Tests;
use App\Entity\User;
use Symfony\Bundle\FrameworkBundle\KernelBrowser;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
final class LoginControllerTest extends WebTestCase
{
private KernelBrowser $client;
protected function setUp(): void
{
$this->client = static::createClient();
$container = static::getContainer();
$em = $container->get('doctrine.orm.entity_manager');
$userRepository = $em->getRepository(User::class);
// Remove any existing users from the test database
foreach ($userRepository->findAll() as $user) {
$em->remove($user);
}
$em->flush();
// Create a User fixture
/** @var UserPasswordHasherInterface $passwordHasher */
$passwordHasher = $container->get('security.user_password_hasher');
$user = (new User())->setEmail('email@example.com');
$user->setPassword($passwordHasher->hashPassword($user, 'password'));
$em->persist($user);
$em->flush();
}
public function testLogin(): void
{
// Denied - Can't login with invalid email address.
$this->client->request('GET', '/login');
$this->assertResponseIsSuccessful();
$this->client->submitForm('Sign in', [
'_username' => 'doesNotExist@example.com',
'_password' => 'password',
]);
$this->assertResponseRedirects('/login');
$this->client->followRedirect();
// Ensure we do not reveal if the user exists or not.
$this->assertSelectorTextContains('.alert-danger', 'Invalid credentials.');
// Denied - Can't login with invalid password.
$this->client->request('GET', '/login');
$this->assertResponseIsSuccessful();
$this->client->submitForm('Sign in', [
'_username' => 'email@example.com',
'_password' => 'bad-password',
]);
$this->assertResponseRedirects('/login');
$this->client->followRedirect();
// Ensure we do not reveal the user exists but the password is wrong.
$this->assertSelectorTextContains('.alert-danger', 'Invalid credentials.');
// Success - Login with valid credentials is allowed.
$this->client->submitForm('Sign in', [
'_username' => 'email@example.com',
'_password' => 'password',
]);
$this->assertResponseRedirects('/');
$this->client->followRedirect();
$this->assertSelectorNotExists('.alert-danger');
$this->assertResponseIsSuccessful();
}
}

View File

@@ -1,8 +1,12 @@
'Active Quiz': 'Actieve test'
'All Seasons': 'Alle seizoenen'
'Already have an account? Log in': 'Heb je al een account? Log in'
Candidate: Kandidaat
Candidates: Kandidaten
'Correct Answers': 'Goede antwoorden'
Corrections: Jokers
'Create an account': 'Maak een account aan'
Email: E-mail
'Enter your name': 'Voor je naam in'
'Load Prepared Elimination': 'Laad voorbereide eliminatie'
Manage: Beheren
@@ -10,15 +14,21 @@ Name: Naam
'No active quiz': 'Geen actieve test'
'No results': 'Geen resultaten'
'Number of dropouts:': 'Aantal afvallers:'
Owner(s): Eigenar(en)
Password: Wachtwoord
'Please Confirm your Email': messages
'Please sign in': 'Log in aub'
'Prepare Custom Elimination': 'Bereid aangepaste eliminatie voor'
'Preregister?': 'Voorregistreren?'
Questions: Vragen
Quiz: Test
Quizzes: Tests
Register: Registreren
'Remember me': 'Onthoud mij'
Score: Score
Season: Seizoen
'Season Code': Seizoenscode
Seasons: Seizoenen
'Sign in': 'Log in'
'Start Elimination': 'Start eliminatie'
'There are no answers for this question': 'Er zijn geen antwoorden voor deze vraag'
Time: Tijd