From 96628cb452f3ef7e3dd8929d8eacae510a8d4610 Mon Sep 17 00:00:00 2001 From: Dendy Faist Date: Tue, 28 Oct 2025 08:54:55 +0100 Subject: [PATCH] feat: Create RegistrationToken system to manage creating new users --- composer.json | 1 + composer.lock | 159 +++++++++++++++++- config/packages/security.yaml | 5 + importmap.php | 14 +- migrations/Version20251028070614.php | 28 +++ migrations/Version20251028072231.php | 40 +++++ src/Controller/RegistrationController.php | 44 ++++- src/Entity/RegistrationToken.php | 78 +++++++++ src/Entity/User.php | 49 ++++-- .../RegistrationTokenRepository.php | 43 +++++ symfony.lock | 9 + templates/main/index.html.twig | 40 ++++- 12 files changed, 489 insertions(+), 21 deletions(-) create mode 100644 migrations/Version20251028070614.php create mode 100644 migrations/Version20251028072231.php create mode 100644 src/Entity/RegistrationToken.php create mode 100644 src/Repository/RegistrationTokenRepository.php diff --git a/composer.json b/composer.json index ecb08c3..9428327 100644 --- a/composer.json +++ b/composer.json @@ -38,6 +38,7 @@ "symfony/string": "7.3.*", "symfony/translation": "7.3.*", "symfony/twig-bundle": "7.3.*", + "symfony/uid": "7.3.*", "symfony/ux-turbo": "^2.31", "symfony/validator": "7.3.*", "symfony/web-link": "7.3.*", diff --git a/composer.lock b/composer.lock index f4af70d..b78e313 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "2d9b170b8d412dfe59ee73ed887b513c", + "content-hash": "243bd3d72c5d123c5b52c0a77a72f27b", "packages": [ { "name": "composer/semver", @@ -5391,6 +5391,89 @@ ], "time": "2025-06-24T13:30:11+00:00" }, + { + "name": "symfony/polyfill-uuid", + "version": "v1.33.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-uuid.git", + "reference": "21533be36c24be3f4b1669c4725c7d1d2bab4ae2" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-uuid/zipball/21533be36c24be3f4b1669c4725c7d1d2bab4ae2", + "reference": "21533be36c24be3f4b1669c4725c7d1d2bab4ae2", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "provide": { + "ext-uuid": "*" + }, + "suggest": { + "ext-uuid": "For best performance" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Uuid\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Grégoire Pineau", + "email": "lyrixx@lyrixx.info" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for uuid functions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "uuid" + ], + "support": { + "source": "https://github.com/symfony/polyfill-uuid/tree/v1.33.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2024-09-09T11:45:10+00:00" + }, { "name": "symfony/process", "version": "v7.3.4", @@ -7032,6 +7115,80 @@ ], "time": "2025-09-11T15:33:27+00:00" }, + { + "name": "symfony/uid", + "version": "v7.3.1", + "source": { + "type": "git", + "url": "https://github.com/symfony/uid.git", + "reference": "a69f69f3159b852651a6bf45a9fdd149520525bb" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/uid/zipball/a69f69f3159b852651a6bf45a9fdd149520525bb", + "reference": "a69f69f3159b852651a6bf45a9fdd149520525bb", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/polyfill-uuid": "^1.15" + }, + "require-dev": { + "symfony/console": "^6.4|^7.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Uid\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Grégoire Pineau", + "email": "lyrixx@lyrixx.info" + }, + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides an object-oriented API to generate and represent UIDs", + "homepage": "https://symfony.com", + "keywords": [ + "UID", + "ulid", + "uuid" + ], + "support": { + "source": "https://github.com/symfony/uid/tree/v7.3.1" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2025-06-27T19:55:54+00:00" + }, { "name": "symfony/ux-turbo", "version": "v2.31.0", diff --git a/config/packages/security.yaml b/config/packages/security.yaml index 7d3a54e..0d1fd58 100644 --- a/config/packages/security.yaml +++ b/config/packages/security.yaml @@ -39,6 +39,11 @@ security: - { path: ^/login, roles: } - { path: ^/, roles: ROLE_USER } + role_hierarchy: + ROLE_ADMIN: [ + ROLE_CREATE_REGISTRATION_TOKEN, + ] + when@test: security: password_hashers: diff --git a/importmap.php b/importmap.php index f98e68c..ea18145 100644 --- a/importmap.php +++ b/importmap.php @@ -16,6 +16,16 @@ return [ 'path' => './assets/app.js', 'entrypoint' => true, ], - - 'htmx.org' => ['version' => '2.0.8'], + 'htmx.org' => [ + 'version' => '2.0.8', + ], + '@hotwired/stimulus' => [ + 'version' => '3.2.2', + ], + '@symfony/stimulus-bundle' => [ + 'path' => './vendor/symfony/stimulus-bundle/assets/dist/loader.js', + ], + '@hotwired/turbo' => [ + 'version' => '7.3.0', + ], ]; diff --git a/migrations/Version20251028070614.php b/migrations/Version20251028070614.php new file mode 100644 index 0000000..d3ca294 --- /dev/null +++ b/migrations/Version20251028070614.php @@ -0,0 +1,28 @@ +addSql('CREATE TABLE registration_token (id BLOB NOT NULL --(DC2Type:uuid) + , user_id INTEGER DEFAULT NULL, PRIMARY KEY(id), CONSTRAINT FK_D09D01D3A76ED395 FOREIGN KEY (user_id) REFERENCES user (id) NOT DEFERRABLE INITIALLY IMMEDIATE)'); + $this->addSql('CREATE UNIQUE INDEX UNIQ_D09D01D3A76ED395 ON registration_token (user_id)'); + } + + public function down(Schema $schema): void + { + $this->addSql('DROP TABLE registration_token'); + } +} diff --git a/migrations/Version20251028072231.php b/migrations/Version20251028072231.php new file mode 100644 index 0000000..a3391f8 --- /dev/null +++ b/migrations/Version20251028072231.php @@ -0,0 +1,40 @@ +addSql('CREATE TEMPORARY TABLE __temp__registration_token AS SELECT id, user_id FROM registration_token'); + $this->addSql('DROP TABLE registration_token'); + $this->addSql('CREATE TABLE registration_token (id BLOB NOT NULL --(DC2Type:uuid) + , user_id INTEGER DEFAULT NULL, created_by_id INTEGER NOT NULL, created_at DATETIME NOT NULL --(DC2Type:datetime_immutable) + , PRIMARY KEY(id), CONSTRAINT FK_D09D01D3A76ED395 FOREIGN KEY (user_id) REFERENCES user (id) ON UPDATE NO ACTION ON DELETE NO ACTION NOT DEFERRABLE INITIALLY IMMEDIATE, CONSTRAINT FK_D09D01D3B03A8386 FOREIGN KEY (created_by_id) REFERENCES user (id) NOT DEFERRABLE INITIALLY IMMEDIATE)'); + $this->addSql('INSERT INTO registration_token (id, user_id) SELECT id, user_id FROM __temp__registration_token'); + $this->addSql('DROP TABLE __temp__registration_token'); + $this->addSql('CREATE UNIQUE INDEX UNIQ_D09D01D3A76ED395 ON registration_token (user_id)'); + $this->addSql('CREATE INDEX IDX_D09D01D3B03A8386 ON registration_token (created_by_id)'); + } + + public function down(Schema $schema): void + { + $this->addSql('CREATE TEMPORARY TABLE __temp__registration_token AS SELECT id, user_id FROM registration_token'); + $this->addSql('DROP TABLE registration_token'); + $this->addSql('CREATE TABLE registration_token (id BLOB NOT NULL --(DC2Type:uuid) + , user_id INTEGER DEFAULT NULL, PRIMARY KEY(id), CONSTRAINT FK_D09D01D3A76ED395 FOREIGN KEY (user_id) REFERENCES user (id) NOT DEFERRABLE INITIALLY IMMEDIATE)'); + $this->addSql('INSERT INTO registration_token (id, user_id) SELECT id, user_id FROM __temp__registration_token'); + $this->addSql('DROP TABLE __temp__registration_token'); + $this->addSql('CREATE UNIQUE INDEX UNIQ_D09D01D3A76ED395 ON registration_token (user_id)'); + } +} diff --git a/src/Controller/RegistrationController.php b/src/Controller/RegistrationController.php index 174187b..57914c8 100644 --- a/src/Controller/RegistrationController.php +++ b/src/Controller/RegistrationController.php @@ -2,34 +2,48 @@ namespace App\Controller; +use App\Entity\RegistrationToken; use App\Entity\User; use App\Form\RegistrationFormType; use Doctrine\ORM\EntityManagerInterface; -use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; +use Symfony\Bridge\Doctrine\Attribute\MapEntity; 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\Component\Security\Http\Attribute\IsGranted; -class RegistrationController extends AbstractController +class RegistrationController extends CustomAbstractController { - #[Route('/register', name: 'app_register')] - public function register(Request $request, UserPasswordHasherInterface $userPasswordHasher, Security $security, EntityManagerInterface $entityManager): Response - { + function __construct( + private EntityManagerInterface $em, + ) {} + + #[Route('/register/{id}', name: 'app_register')] + public function register( + RegistrationToken $token, + Request $request, + UserPasswordHasherInterface $userPasswordHasher, + Security $security, + ): Response { + if ($token->getUser() !== null) throw $this->createNotFoundException(); + $user = new User(); $form = $this->createForm(RegistrationFormType::class, $user); $form->handleRequest($request); if ($form->isSubmitted() && $form->isValid()) { + $token->setUser($user); + /** @var string $plainPassword */ $plainPassword = $form->get('plainPassword')->getData(); // encode the plain password $user->setPassword($userPasswordHasher->hashPassword($user, $plainPassword)); - $entityManager->persist($user); - $entityManager->flush(); + $this->em->persist($user); + $this->em->flush(); // do anything else you need here, like send an email return $security->login($user, 'form_login', 'main'); @@ -39,4 +53,20 @@ class RegistrationController extends AbstractController 'registrationForm' => $form, ]); } + + #[IsGranted('ROLE_CREATE_REGISTRATION_TOKEN')] + #[Route('/register/token/new', 'app_register_new_token', methods: 'POST')] + public function new_registration_token(): Response + { + $token = (new RegistrationToken) + ->setCreatedBy($this->getUser()); + $this->em->persist($token); + $this->em->flush(); + + return $this->renderBlock( + MainController::tmpl('index'), + 'fap_statistics_container', + [], + ); + } } diff --git a/src/Entity/RegistrationToken.php b/src/Entity/RegistrationToken.php new file mode 100644 index 0000000..c134cdd --- /dev/null +++ b/src/Entity/RegistrationToken.php @@ -0,0 +1,78 @@ +createdAt ??= new \DateTimeImmutable(); + } + + public function getId(): ?Uuid + { + return $this->id; + } + + public function getUser(): ?User + { + return $this->user; + } + + public function setUser(?User $user): static + { + $this->user = $user; + + return $this; + } + + public function getCreatedBy(): ?User + { + return $this->createdBy; + } + + public function setCreatedBy(?User $createdBy): static + { + $this->createdBy = $createdBy; + + return $this; + } + + public function getCreatedAt(): ?\DateTimeImmutable + { + return $this->createdAt; + } + + public function setCreatedAt(\DateTimeImmutable $createdAt): static + { + $this->createdAt = $createdAt; + + return $this; + } +} diff --git a/src/Entity/User.php b/src/Entity/User.php index b1ceaa7..3dd449b 100644 --- a/src/Entity/User.php +++ b/src/Entity/User.php @@ -23,27 +23,28 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface #[ORM\Column(length: 180)] private ?string $username = null; - /** - * @var list The user roles - */ + /** @var list The user roles */ #[ORM\Column] private array $roles = []; - /** - * @var string The hashed password - */ #[ORM\Column] private ?string $password = null; - /** - * @var Collection - */ + /** @var Collection */ #[ORM\OneToMany(targetEntity: Entry::class, mappedBy: 'user', orphanRemoval: true)] private Collection $entries; + #[ORM\OneToOne(mappedBy: 'user', cascade: ['persist', 'remove'])] + private ?RegistrationToken $registrationToken = null; + + /** @var Collection */ + #[ORM\OneToMany(targetEntity: RegistrationToken::class, mappedBy: 'createdBy')] + private Collection $createdRegistationTokens; + public function __construct() { - $this->entries = new ArrayCollection(); + $this->entries = new ArrayCollection(); + $this->createdRegistationTokens = new ArrayCollection(); } public function getId(): ?int @@ -156,4 +157,32 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface return $this; } + + public function getRegistrationToken(): ?RegistrationToken + { + return $this->registrationToken; + } + + public function setRegistrationToken(?RegistrationToken $registrationToken): static + { + // unset the owning side of the relation if necessary + if ($registrationToken === null && $this->registrationToken !== null) { + $this->registrationToken->setUser(null); + } + + // set the owning side of the relation if necessary + if ($registrationToken !== null && $registrationToken->getUser() !== $this) { + $registrationToken->setUser($this); + } + + $this->registrationToken = $registrationToken; + + return $this; + } + + /** @return Collection */ + public function getCreatedRegistrationTokens(): ?Collection + { + return $this->createdRegistationTokens; + } } diff --git a/src/Repository/RegistrationTokenRepository.php b/src/Repository/RegistrationTokenRepository.php new file mode 100644 index 0000000..44fcb36 --- /dev/null +++ b/src/Repository/RegistrationTokenRepository.php @@ -0,0 +1,43 @@ + + */ +class RegistrationTokenRepository extends ServiceEntityRepository +{ + public function __construct(ManagerRegistry $registry) + { + parent::__construct($registry, RegistrationToken::class); + } + + // /** + // * @return RegistrationToken[] Returns an array of RegistrationToken objects + // */ + // public function findByExampleField($value): array + // { + // return $this->createQueryBuilder('r') + // ->andWhere('r.exampleField = :val') + // ->setParameter('val', $value) + // ->orderBy('r.id', 'ASC') + // ->setMaxResults(10) + // ->getQuery() + // ->getResult() + // ; + // } + + // public function findOneBySomeField($value): ?RegistrationToken + // { + // return $this->createQueryBuilder('r') + // ->andWhere('r.exampleField = :val') + // ->setParameter('val', $value) + // ->getQuery() + // ->getOneOrNullResult() + // ; + // } +} diff --git a/symfony.lock b/symfony.lock index 20815be..e484d5c 100644 --- a/symfony.lock +++ b/symfony.lock @@ -270,6 +270,15 @@ "templates/base.html.twig" ] }, + "symfony/uid": { + "version": "7.3", + "recipe": { + "repo": "github.com/symfony/recipes", + "branch": "main", + "version": "7.0", + "ref": "0df5844274d871b37fc3816c57a768ffc60a43a5" + } + }, "symfony/ux-turbo": { "version": "2.31", "recipe": { diff --git a/templates/main/index.html.twig b/templates/main/index.html.twig index c922b0c..8a75d04 100644 --- a/templates/main/index.html.twig +++ b/templates/main/index.html.twig @@ -23,8 +23,9 @@
total: {{ app.user.entries|length }}
+
- {% for entry in app.user.entries %} + {% for entry in app.user.entries %}
{{ entry.dateAt|date('Y-m-d H:i:s') }} {% endfor %}
+ + {% if is_granted('ROLE_CREATE_REGISTRATION_TOKEN') %} +
+ +
+ {% endif %} +
{% endblock %} @@ -45,5 +74,14 @@
logout + {% if is_granted('ROLE_CREATE_REGISTRATION_TOKEN') %} + + create invite + + {% endif %}
{% endblock body %}