Admin Account Management & Domain-Restricted Registration - Implementation Plan
For Claude: REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
Goal: Embed account management (change email, change password) into the admin backend, and restrict registration to pre-approved email domains managed via admin CRUD.
Architecture: Two independent features. Feature 1 adds an AdminAccountController that reuses existing account forms/services but renders in the admin layout. Feature 2 adds an AllowedDomain entity with admin CRUD and a custom Symfony validator constraint on registration.
Tech Stack: Symfony 8.0, PHP 8.4, Doctrine ORM, Twig, PHPUnit
Feature 1: Admin Account Management
Task 1: Admin Account Controller
Files:
- Create: src/Controller/Admin/AccountController.php
- Test: tests/Controller/Admin/AccountControllerTest.php
Step 1: Write the failing test
Create tests/Controller/Admin/AccountControllerTest.php:
<?php
declare(strict_types=1);
namespace App\Tests\Controller\Admin;
use App\Entity\User;
use App\Repository\UserRepository;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
class AccountControllerTest extends WebTestCase
{
public function testAccountPageRequiresAdmin(): void
{
$client = static::createClient();
$client->request('GET', '/admin/account');
$this->assertResponseRedirects('/login');
}
}
Step 2: Run test to verify it fails
Run: bin/phpunit tests/Controller/Admin/AccountControllerTest.php -v
Expected: FAIL (route not found)
Step 3: Write the controller
Create src/Controller/Admin/AccountController.php:
<?php
declare(strict_types=1);
namespace App\Controller\Admin;
use App\Entity\User;
use App\Form\AccountChangePasswordFormType;
use App\Form\ChangeEmailFormType;
use App\Repository\UserRepository;
use App\Service\EmailVerificationService;
use Symfony\Bridge\Twig\Mime\TemplatedEmail;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\Form\FormError;
use Symfony\Component\Form\FormInterface;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Mailer\MailerInterface;
use Symfony\Component\Mime\Address;
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
use Symfony\Component\Security\Http\Attribute\IsGranted;
#[IsGranted('ROLE_ADMIN')]
#[Route('/admin/account')]
class AccountController extends AbstractController
{
public function __construct(
private readonly EmailVerificationService $emailVerificationService,
private readonly UserPasswordHasherInterface $passwordHasher,
private readonly UserRepository $userRepository,
private readonly MailerInterface $mailer,
private readonly UrlGeneratorInterface $urlGenerator,
#[Autowire('%env(MAIL_FROM)%')]
private readonly string $mailFrom,
) {
}
#[Route('', name: 'admin_account', methods: ['GET'])]
public function index(): Response
{
/** @var User $user */
$user = $this->getUser();
return $this->renderAccountPage($user);
}
#[Route('/change-email', name: 'admin_account_change_email', methods: ['POST'])]
public function changeEmail(Request $request): Response
{
/** @var User $user */
$user = $this->getUser();
$form = $this->createForm(ChangeEmailFormType::class);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
$newEmail = $form->get('email')->getData();
if ($newEmail === $user->getEmail()) {
$this->addFlash('error', 'This is already your current email address.');
return $this->redirectToRoute('admin_account');
}
if (null !== $this->userRepository->findByEmail($newEmail)) {
$this->addFlash('error', 'This email address is already in use.');
return $this->redirectToRoute('admin_account');
}
$this->emailVerificationService->initiatePendingEmailChange($user, $newEmail);
$verificationUrl = $this->urlGenerator->generate('admin_account_verify_email', [
'token' => $user->getVerificationToken(),
], UrlGeneratorInterface::ABSOLUTE_URL);
$email = (new TemplatedEmail())
->from($this->mailFrom)
->to(new Address($newEmail))
->subject('Verify your new email address')
->htmlTemplate('email/email_change_verification.html.twig')
->context([
'verificationUrl' => $verificationUrl,
'tokenLifetimeHours' => 24,
]);
$this->mailer->send($email);
$this->addFlash('success', sprintf('A verification link has been sent to %s. Your email won\'t change until you confirm.', $newEmail));
return $this->redirectToRoute('admin_account');
}
return $this->renderAccountPage($user, changeEmailForm: $form);
}
#[Route('/verify-email/{token}', name: 'admin_account_verify_email', methods: ['GET'])]
public function verifyEmail(string $token): Response
{
try {
$this->emailVerificationService->verifyEmailChangeToken($token);
$this->addFlash('success', 'Your email has been updated.');
} catch (\Exception $e) {
$this->addFlash('error', $e->getMessage());
}
return $this->redirectToRoute('admin_account');
}
#[Route('/cancel-email-change', name: 'admin_account_cancel_email_change', methods: ['POST'])]
public function cancelEmailChange(Request $request): Response
{
/** @var User $user */
$user = $this->getUser();
if (!$this->isCsrfTokenValid('cancel-email-change', $request->getPayload()->getString('_token'))) {
$this->addFlash('error', 'Invalid CSRF token.');
return $this->redirectToRoute('admin_account');
}
$this->emailVerificationService->cancelPendingEmailChange($user);
$this->addFlash('success', 'Email change cancelled.');
return $this->redirectToRoute('admin_account');
}
#[Route('/change-password', name: 'admin_account_change_password', methods: ['POST'])]
public function changePassword(Request $request): Response
{
/** @var User $user */
$user = $this->getUser();
$form = $this->createForm(AccountChangePasswordFormType::class);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
$currentPassword = $form->get('currentPassword')->getData();
if (!$this->passwordHasher->isPasswordValid($user, $currentPassword)) {
$form->get('currentPassword')->addError(new FormError('The current password is incorrect.'));
return $this->renderAccountPage($user, changePasswordForm: $form);
}
$user->setPassword($this->passwordHasher->hashPassword(
$user,
$form->get('plainPassword')->getData()
));
$this->userRepository->save($user);
$this->addFlash('success', 'Your password has been updated.');
return $this->redirectToRoute('admin_account');
}
return $this->renderAccountPage($user, changePasswordForm: $form);
}
private function renderAccountPage(
User $user,
?FormInterface $changeEmailForm = null,
?FormInterface $changePasswordForm = null,
): Response {
return $this->render('admin/account/index.html.twig', [
'changeEmailForm' => $changeEmailForm ?? $this->createForm(ChangeEmailFormType::class, ['email' => $user->getEmail()]),
'changePasswordForm' => $changePasswordForm ?? $this->createForm(AccountChangePasswordFormType::class),
]);
}
}
Step 4: Run test to verify it passes
Run: bin/phpunit tests/Controller/Admin/AccountControllerTest.php -v
Expected: PASS
Step 5: Commit
git add src/Controller/Admin/AccountController.php tests/Controller/Admin/AccountControllerTest.php
git commit -m "Add admin account controller with email/password management"
Task 2: Admin Account Template
Files:
- Create: templates/admin/account/index.html.twig
Step 1: Create the template
Create templates/admin/account/index.html.twig:
{% extends 'admin/base.html.twig' %}
{% block admin_title %}My Account{% endblock %}
{% block admin_breadcrumb %}
<span class="admin-breadcrumb-separator">/</span>
<span>My Account</span>
{% endblock %}
{% block admin_content %}
{% import 'admin/components/flash.html.twig' as flash %}
{{ flash.render_flash_messages() }}
<div class="admin-page-header">
<h1 class="admin-page-title">My Account</h1>
</div>
{# Change Email #}
<div class="admin-card">
<div class="admin-card-header">
<h2 class="admin-card-title">Email Address</h2>
</div>
<div class="admin-card-body">
{% if app.user.pendingEmail %}
<div class="admin-flash admin-flash-info">
<span>ℹ</span>
<div>
Pending email change to <strong>{{ app.user.pendingEmail }}</strong>. Check your inbox.
<form method="post" action="{{ path('admin_account_cancel_email_change') }}" style="display: inline; margin-left: 10px;">
<input type="hidden" name="_token" value="{{ csrf_token('cancel-email-change') }}">
<button type="submit" class="admin-btn admin-btn-sm admin-btn-secondary">Cancel</button>
</form>
</div>
</div>
{% else %}
{{ form_start(changeEmailForm, {action: path('admin_account_change_email'), attr: {class: 'admin-form'}}) }}
<div class="admin-form-group">
{{ form_label(changeEmailForm.email, 'Email Address', {'label_attr': {'class': 'admin-form-label'}}) }}
{{ form_widget(changeEmailForm.email, {'attr': {'class': 'admin-form-input'}}) }}
{% if changeEmailForm.email.vars.errors|length > 0 %}
<div class="admin-form-error">{{ form_errors(changeEmailForm.email) }}</div>
{% endif %}
</div>
<div class="admin-form-actions">
<button type="submit" class="admin-btn admin-btn-primary">Change Email</button>
</div>
{{ form_end(changeEmailForm) }}
{% endif %}
</div>
</div>
{# Change Password #}
<div class="admin-card">
<div class="admin-card-header">
<h2 class="admin-card-title">Password</h2>
</div>
<div class="admin-card-body">
{{ form_start(changePasswordForm, {action: path('admin_account_change_password'), attr: {class: 'admin-form'}}) }}
<div class="admin-form-group">
{{ form_label(changePasswordForm.currentPassword, null, {'label_attr': {'class': 'admin-form-label'}}) }}
{{ form_widget(changePasswordForm.currentPassword, {'attr': {'class': 'admin-form-input'}}) }}
{% if changePasswordForm.currentPassword.vars.errors|length > 0 %}
<div class="admin-form-error">{{ form_errors(changePasswordForm.currentPassword) }}</div>
{% endif %}
</div>
<div class="admin-form-group">
{{ form_label(changePasswordForm.plainPassword.first, null, {'label_attr': {'class': 'admin-form-label'}}) }}
{{ form_widget(changePasswordForm.plainPassword.first, {'attr': {'class': 'admin-form-input'}}) }}
{% if changePasswordForm.plainPassword.first.vars.errors|length > 0 %}
<div class="admin-form-error">{{ form_errors(changePasswordForm.plainPassword.first) }}</div>
{% endif %}
</div>
<div class="admin-form-group">
{{ form_label(changePasswordForm.plainPassword.second, null, {'label_attr': {'class': 'admin-form-label'}}) }}
{{ form_widget(changePasswordForm.plainPassword.second, {'attr': {'class': 'admin-form-input'}}) }}
{% if changePasswordForm.plainPassword.second.vars.errors|length > 0 %}
<div class="admin-form-error">{{ form_errors(changePasswordForm.plainPassword.second) }}</div>
{% endif %}
</div>
<div class="admin-form-actions">
<button type="submit" class="admin-btn admin-btn-primary">Change Password</button>
</div>
{{ form_end(changePasswordForm) }}
</div>
</div>
{% endblock %}
Step 2: Verify visually
Run: php -d variables_order=EGPCS bin/console cache:clear
Navigate to /admin/account in the browser and verify the page renders correctly.
Step 3: Commit
git add templates/admin/account/index.html.twig
git commit -m "Add admin account template with email and password forms"
Task 3: Admin Layout - User Dropdown & Sidebar Link
Files:
- Modify: templates/admin/base.html.twig
Step 1: Add sidebar link
In templates/admin/base.html.twig, add a "My Account" link in the sidebar under the "Main" nav section, after the Users link (around line 730):
<a href="{{ path('admin_account') }}" class="admin-nav-link {{ app.request.attributes.get('_route') starts with 'admin_account' ? 'active' : '' }}">
<span class="admin-nav-icon">⚙</span>
My Account
</a>
Step 2: Add user dropdown in header
In templates/admin/base.html.twig, modify the admin-header-actions div (around line 780) to add user info and logout link after the {% block admin_actions %} block:
<div class="admin-header-actions">
{% block admin_actions %}{% endblock %}
<span style="color: var(--admin-secondary); font-size: 14px;">{{ app.user.email }}</span>
<a href="{{ path('admin_account') }}" class="admin-btn admin-btn-sm admin-btn-secondary">My Account</a>
<form action="{{ path('app_logout') }}" method="post" style="display: inline;">
<input type="hidden" name="_token" value="{{ csrf_token('logout') }}">
<button type="submit" class="admin-btn admin-btn-sm admin-btn-secondary">Log out</button>
</form>
</div>
Step 3: Verify visually
Clear cache and verify the sidebar link and header dropdown appear correctly in the admin panel.
Step 4: Commit
git add templates/admin/base.html.twig
git commit -m "Add My Account link and user info to admin sidebar and header"
Feature 2: Domain-Restricted Registration
Task 4: AllowedDomain Entity
Files:
- Create: src/Entity/AllowedDomain.php
- Create: src/Repository/AllowedDomainRepository.php
- Test: tests/Unit/Entity/AllowedDomainTest.php
Step 1: Write the failing test
Create tests/Unit/Entity/AllowedDomainTest.php:
<?php
declare(strict_types=1);
namespace App\Tests\Unit\Entity;
use App\Entity\AllowedDomain;
use PHPUnit\Framework\TestCase;
class AllowedDomainTest extends TestCase
{
public function testNewAllowedDomainHasCorrectDefaults(): void
{
$domain = new AllowedDomain();
self::assertNull($domain->getId());
self::assertNull($domain->getDomain());
self::assertNull($domain->getCreatedAt());
}
public function testSetDomain(): void
{
$domain = new AllowedDomain();
$result = $domain->setDomain('atraxion.com');
self::assertSame($domain, $result);
self::assertSame('atraxion.com', $domain->getDomain());
}
public function testSetDomainNormalizesToLowercase(): void
{
$domain = new AllowedDomain();
$domain->setDomain('Atraxion.COM');
self::assertSame('atraxion.com', $domain->getDomain());
}
}
Step 2: Run test to verify it fails
Run: bin/phpunit tests/Unit/Entity/AllowedDomainTest.php -v
Expected: FAIL (class not found)
Step 3: Create the entity
Create src/Entity/AllowedDomain.php:
<?php
declare(strict_types=1);
namespace App\Entity;
use App\Repository\AllowedDomainRepository;
use Doctrine\ORM\Mapping as ORM;
#[ORM\Entity(repositoryClass: AllowedDomainRepository::class)]
#[ORM\Table(name: 'allowed_domain')]
#[ORM\HasLifecycleCallbacks]
class AllowedDomain
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
private ?int $id = null;
#[ORM\Column(length: 255, unique: true)]
private ?string $domain = null;
#[ORM\Column]
private ?\DateTimeImmutable $createdAt = null;
public function getId(): ?int
{
return $this->id;
}
public function getDomain(): ?string
{
return $this->domain;
}
public function setDomain(string $domain): static
{
$this->domain = strtolower($domain);
return $this;
}
public function getCreatedAt(): ?\DateTimeImmutable
{
return $this->createdAt;
}
public function setCreatedAt(\DateTimeImmutable $createdAt): static
{
$this->createdAt = $createdAt;
return $this;
}
#[ORM\PrePersist]
public function setCreatedAtValue(): void
{
$this->createdAt = new \DateTimeImmutable();
}
}
Create src/Repository/AllowedDomainRepository.php:
<?php
declare(strict_types=1);
namespace App\Repository;
use App\Entity\AllowedDomain;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<AllowedDomain>
*/
class AllowedDomainRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, AllowedDomain::class);
}
public function isDomainAllowed(string $domain): bool
{
$totalCount = $this->count([]);
if (0 === $totalCount) {
return true;
}
return null !== $this->findOneBy(['domain' => strtolower($domain)]);
}
}
Step 4: Run test to verify it passes
Run: bin/phpunit tests/Unit/Entity/AllowedDomainTest.php -v
Expected: PASS
Step 5: Generate migration
Run: php -d variables_order=EGPCS bin/console doctrine:migrations:diff
Review the generated migration file.
Step 6: Commit
git add src/Entity/AllowedDomain.php src/Repository/AllowedDomainRepository.php tests/Unit/Entity/AllowedDomainTest.php migrations/
git commit -m "Add AllowedDomain entity with repository"
Task 5: AllowedEmailDomain Validator Constraint
Files:
- Create: src/Validator/AllowedEmailDomain.php
- Create: src/Validator/AllowedEmailDomainValidator.php
- Test: tests/Unit/Validator/AllowedEmailDomainValidatorTest.php
Step 1: Write the failing test
Create tests/Unit/Validator/AllowedEmailDomainValidatorTest.php:
<?php
declare(strict_types=1);
namespace App\Tests\Unit\Validator;
use App\Repository\AllowedDomainRepository;
use App\Validator\AllowedEmailDomain;
use App\Validator\AllowedEmailDomainValidator;
use PHPUnit\Framework\TestCase;
use Symfony\Component\Validator\Context\ExecutionContextInterface;
use Symfony\Component\Validator\Violation\ConstraintViolationBuilderInterface;
class AllowedEmailDomainValidatorTest extends TestCase
{
private AllowedDomainRepository $repository;
private ExecutionContextInterface $context;
private AllowedEmailDomainValidator $validator;
protected function setUp(): void
{
$this->repository = $this->createMock(AllowedDomainRepository::class);
$this->context = $this->createMock(ExecutionContextInterface::class);
$this->validator = new AllowedEmailDomainValidator($this->repository);
$this->validator->initialize($this->context);
}
public function testNullValueIsValid(): void
{
$this->context->expects(self::never())->method('buildViolation');
$this->validator->validate(null, new AllowedEmailDomain());
}
public function testEmptyStringIsValid(): void
{
$this->context->expects(self::never())->method('buildViolation');
$this->validator->validate('', new AllowedEmailDomain());
}
public function testAllowedDomainPasses(): void
{
$this->repository
->method('isDomainAllowed')
->with('atraxion.com')
->willReturn(true);
$this->context->expects(self::never())->method('buildViolation');
$this->validator->validate('user@atraxion.com', new AllowedEmailDomain());
}
public function testDisallowedDomainFails(): void
{
$this->repository
->method('isDomainAllowed')
->with('evil.com')
->willReturn(false);
$violationBuilder = $this->createMock(ConstraintViolationBuilderInterface::class);
$violationBuilder->expects(self::once())->method('addViolation');
$this->context
->expects(self::once())
->method('buildViolation')
->with('Registration is restricted to approved email domains.')
->willReturn($violationBuilder);
$this->validator->validate('hacker@evil.com', new AllowedEmailDomain());
}
public function testEmptyAllowedDomainsTableAllowsAll(): void
{
$this->repository
->method('isDomainAllowed')
->willReturn(true);
$this->context->expects(self::never())->method('buildViolation');
$this->validator->validate('anyone@anywhere.com', new AllowedEmailDomain());
}
}
Step 2: Run test to verify it fails
Run: bin/phpunit tests/Unit/Validator/AllowedEmailDomainValidatorTest.php -v
Expected: FAIL (classes not found)
Step 3: Create the constraint and validator
Create src/Validator/AllowedEmailDomain.php:
<?php
declare(strict_types=1);
namespace App\Validator;
use Symfony\Component\Validator\Constraint;
#[\Attribute(\Attribute::TARGET_PROPERTY | \Attribute::TARGET_METHOD)]
class AllowedEmailDomain extends Constraint
{
public string $message = 'Registration is restricted to approved email domains.';
}
Create src/Validator/AllowedEmailDomainValidator.php:
<?php
declare(strict_types=1);
namespace App\Validator;
use App\Repository\AllowedDomainRepository;
use Symfony\Component\Validator\Constraint;
use Symfony\Component\Validator\ConstraintValidator;
use Symfony\Component\Validator\Exception\UnexpectedTypeException;
class AllowedEmailDomainValidator extends ConstraintValidator
{
public function __construct(
private readonly AllowedDomainRepository $allowedDomainRepository,
) {
}
public function validate(mixed $value, Constraint $constraint): void
{
if (!$constraint instanceof AllowedEmailDomain) {
throw new UnexpectedTypeException($constraint, AllowedEmailDomain::class);
}
if (null === $value || '' === $value) {
return;
}
$domain = substr((string) $value, strrpos((string) $value, '@') + 1);
if (!$this->allowedDomainRepository->isDomainAllowed($domain)) {
$this->context->buildViolation($constraint->message)->addViolation();
}
}
}
Step 4: Run test to verify it passes
Run: bin/phpunit tests/Unit/Validator/AllowedEmailDomainValidatorTest.php -v
Expected: PASS
Step 5: Commit
git add src/Validator/AllowedEmailDomain.php src/Validator/AllowedEmailDomainValidator.php tests/Unit/Validator/AllowedEmailDomainValidatorTest.php
git commit -m "Add AllowedEmailDomain validator constraint"
Task 6: Wire Validator into Registration Form
Files:
- Modify: src/Form/RegistrationFormType.php
Step 1: Add the constraint to the email field
In src/Form/RegistrationFormType.php, add AllowedEmailDomain to the constraints array on the email field:
Add import: use App\Validator\AllowedEmailDomain;
Modify the email field constraints to:
'constraints' => [
new NotBlank(message: 'Please enter your email address.'),
new Email(message: 'Please enter a valid email address.'),
new AllowedEmailDomain(),
],
Step 2: Run full test suite to verify nothing breaks
Run: bin/phpunit -v
Expected: All tests PASS
Step 3: Commit
git add src/Form/RegistrationFormType.php
git commit -m "Wire AllowedEmailDomain validator into registration form"
Task 7: AllowedDomain Admin CRUD Controller
Files:
- Create: src/Controller/Admin/AllowedDomainController.php
- Create: src/Form/Admin/AllowedDomainFormType.php
Step 1: Create the form type
Create src/Form/Admin/AllowedDomainFormType.php:
<?php
declare(strict_types=1);
namespace App\Form\Admin;
use App\Entity\AllowedDomain;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
use Symfony\Component\Validator\Constraints\NotBlank;
use Symfony\Component\Validator\Constraints\Regex;
/**
* @extends AbstractType<AllowedDomain>
*/
class AllowedDomainFormType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder
->add('domain', TextType::class, [
'label' => 'Domain',
'constraints' => [
new NotBlank(message: 'Please enter a domain.'),
new Regex(
pattern: '/^[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?)*\.[a-zA-Z]{2,}$/',
message: 'Please enter a valid domain (e.g., example.com).',
),
],
'attr' => [
'class' => 'admin-form-input',
'placeholder' => 'example.com',
],
'help' => 'Enter the domain name without @ or protocol (e.g., atraxion.com).',
]);
}
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults([
'data_class' => AllowedDomain::class,
]);
}
}
Step 2: Create the controller
Create src/Controller/Admin/AllowedDomainController.php:
<?php
declare(strict_types=1);
namespace App\Controller\Admin;
use App\Entity\AllowedDomain;
use App\Form\Admin\AllowedDomainFormType;
use App\Service\Admin\AdminPaginationService;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
/**
* @extends AbstractAdminController<AllowedDomain>
*/
#[IsGranted('ROLE_ADMIN')]
#[Route('/admin/allowed-domains')]
class AllowedDomainController extends AbstractAdminController
{
public function __construct(
EntityManagerInterface $entityManager,
AdminPaginationService $paginationService,
) {
parent::__construct($entityManager, $paginationService);
}
protected function getEntityClass(): string
{
return AllowedDomain::class;
}
protected function getFormType(): ?string
{
return AllowedDomainFormType::class;
}
protected function getEntityLabel(): string
{
return 'Allowed Domain';
}
protected function getRoutePrefix(): string
{
return 'admin_allowed_domains';
}
protected function getTemplateDirectory(): string
{
return 'admin/allowed_domains';
}
protected function getSearchFields(): array
{
return ['domain'];
}
#[Route('', name: 'admin_allowed_domains', methods: ['GET'])]
#[Route('', name: 'admin_allowed_domains_index', methods: ['GET'])]
public function index(Request $request): Response
{
$result = $this->listEntities($request);
return $this->renderIndex($request, $result);
}
#[Route('/new', name: 'admin_allowed_domains_new', methods: ['GET', 'POST'])]
public function new(Request $request): Response
{
return $this->handleCreateForm($request);
}
#[Route('/{id}/delete', name: 'admin_allowed_domains_delete', requirements: ['id' => '\d+'], methods: ['POST'])]
public function delete(Request $request, int $id): Response
{
$entity = $this->findEntityOr404($id);
return $this->handleDelete($request, $entity);
}
}
Step 3: Run PHPStan and CS fixer
Run: vendor/bin/phpstan analyse src/Controller/Admin/AllowedDomainController.php src/Form/Admin/AllowedDomainFormType.php
Run: vendor/bin/php-cs-fixer fix
Step 4: Commit
git add src/Controller/Admin/AllowedDomainController.php src/Form/Admin/AllowedDomainFormType.php
git commit -m "Add AllowedDomain admin CRUD controller and form"
Task 8: AllowedDomain Admin Templates
Files:
- Create: templates/admin/allowed_domains/index.html.twig
- Create: templates/admin/allowed_domains/new.html.twig
Step 1: Create index template
Create templates/admin/allowed_domains/index.html.twig:
{% extends 'admin/base.html.twig' %}
{% block admin_title %}Allowed Domains{% endblock %}
{% block admin_breadcrumb %}
<span class="admin-breadcrumb-separator">/</span>
<span>Allowed Domains</span>
{% endblock %}
{% block admin_actions %}
<a href="{{ path('admin_allowed_domains_new') }}" class="admin-btn admin-btn-primary">
+ Add Domain
</a>
{% endblock %}
{% block admin_content %}
{% import 'admin/components/flash.html.twig' as flash %}
{% import 'admin/components/pagination.html.twig' as pagination %}
{{ flash.render_flash_messages() }}
<div class="admin-page-header">
<h1 class="admin-page-title">{{ entity_label }}s</h1>
</div>
<div class="admin-flash admin-flash-info" style="margin-bottom: 20px;">
<span>ℹ</span>
<div>
When domains are configured, only users with email addresses matching these domains can register.
If no domains are configured, registration is open to everyone.
</div>
</div>
{# Search and Filters #}
<div class="admin-filters">
<form method="get" action="{{ path(route_prefix ~ '_index') }}" class="admin-filter-group" style="flex: 1;">
<label class="admin-filter-label" for="search">Search</label>
<div style="display: flex; gap: 10px;">
<input type="text" name="q" id="search" value="{{ search }}" class="admin-filter-input" placeholder="Search by domain...">
<button type="submit" class="admin-btn admin-btn-primary">Search</button>
{% if search %}
<a href="{{ path(route_prefix ~ '_index') }}" class="admin-btn admin-btn-secondary">Clear</a>
{% endif %}
</div>
</form>
</div>
{# Table #}
{% if result.items is empty %}
<div class="admin-card">
<div class="admin-empty-state">
<div class="admin-empty-state-icon">🌐</div>
<div class="admin-empty-state-title">No allowed domains configured</div>
<p>Registration is currently open to all email domains.</p>
<a href="{{ path('admin_allowed_domains_new') }}" class="admin-btn admin-btn-primary" style="margin-top: 15px;">
+ Add Domain
</a>
</div>
</div>
{% else %}
<div class="admin-card">
<div class="admin-card-body" style="padding: 0; overflow-x: auto;">
<table class="admin-table">
<thead>
<tr>
<th>ID</th>
<th>Domain</th>
<th>Created At</th>
<th style="width: 1%; white-space: nowrap;">Actions</th>
</tr>
</thead>
<tbody>
{% for domain in result.items %}
<tr>
<td>{{ domain.id }}</td>
<td>{{ domain.domain }}</td>
<td>{{ domain.createdAt|date('Y-m-d H:i') }}</td>
<td>
<div class="admin-table-actions">
<form action="{{ path('admin_allowed_domains_delete', {id: domain.id}) }}" method="post" style="display: inline;" onsubmit="return confirm('Are you sure you want to remove this domain? Users with this email domain will no longer be able to register.');">
<input type="hidden" name="_token" value="{{ csrf_token('delete' ~ domain.id) }}">
<button type="submit" class="admin-btn admin-btn-sm admin-btn-danger" title="Delete">
🗑 Remove
</button>
</form>
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{{ pagination.render_pagination(result, route_prefix ~ '_index', {q: search}) }}
{% endif %}
{% endblock %}
Step 2: Create new template
Create templates/admin/allowed_domains/new.html.twig:
{% extends 'admin/base.html.twig' %}
{% block admin_title %}Add {{ entity_label }}{% endblock %}
{% block admin_breadcrumb %}
<span class="admin-breadcrumb-separator">/</span>
<a href="{{ path(route_prefix ~ '_index') }}">Allowed Domains</a>
<span class="admin-breadcrumb-separator">/</span>
<span>Add New</span>
{% endblock %}
{% block admin_actions %}
<a href="{{ path(route_prefix ~ '_index') }}" class="admin-btn admin-btn-secondary">
← Back to List
</a>
{% endblock %}
{% block admin_content %}
{% import 'admin/components/flash.html.twig' as flash %}
{{ flash.render_flash_messages() }}
<div class="admin-page-header">
<h1 class="admin-page-title">Add {{ entity_label }}</h1>
</div>
<div class="admin-card">
<div class="admin-card-header">
<h2 class="admin-card-title">Domain Information</h2>
</div>
<div class="admin-card-body">
{{ form_start(form, {'attr': {'class': 'admin-form'}}) }}
<div class="admin-form-group">
{{ form_label(form.domain, null, {'label_attr': {'class': 'admin-form-label'}}) }}
{{ form_widget(form.domain) }}
{% if form.domain.vars.help %}
<div class="admin-form-help">{{ form.domain.vars.help }}</div>
{% endif %}
{% if form.domain.vars.errors|length > 0 %}
<div class="admin-form-error">{{ form_errors(form.domain) }}</div>
{% endif %}
</div>
<div class="admin-form-actions">
<button type="submit" class="admin-btn admin-btn-primary">
+ Add Domain
</button>
<a href="{{ path(route_prefix ~ '_index') }}" class="admin-btn admin-btn-secondary">
Cancel
</a>
</div>
{{ form_end(form) }}
</div>
</div>
{% endblock %}
Step 3: Commit
git add templates/admin/allowed_domains/index.html.twig templates/admin/allowed_domains/new.html.twig
git commit -m "Add AllowedDomain admin templates"
Task 9: Add Sidebar Link for Allowed Domains
Files:
- Modify: templates/admin/base.html.twig
Step 1: Add sidebar link
In templates/admin/base.html.twig, add an "Allowed Domains" link in the sidebar under the "Main" nav section, after the "My Account" link added in Task 3:
<a href="{{ path('admin_allowed_domains') }}" class="admin-nav-link {{ app.request.attributes.get('_route') starts with 'admin_allowed_domains' ? 'active' : '' }}">
<span class="admin-nav-icon">🌐</span>
Allowed Domains
</a>
Step 2: Commit
Task 10: Run Database Migration & Full Test Suite
Step 1: Run migrations
Run: php -d variables_order=EGPCS bin/console doctrine:migrations:migrate
Step 2: Run full test suite
Run: bin/phpunit -v
Expected: All tests PASS
Step 3: Run static analysis
Run: vendor/bin/phpstan analyse
Expected: No errors
Step 4: Run code style fixer
Run: vendor/bin/php-cs-fixer fix --dry-run --diff
If any issues: vendor/bin/php-cs-fixer fix
Step 5: Commit any fixes
Task 11: AllowedDomainRepository Unit Test
Files:
- Test: tests/Unit/Repository/AllowedDomainRepositoryTest.php
Step 1: Write the test
Note: Since AllowedDomainRepository::isDomainAllowed hits the database, test the logic separately. We can test the repository's isDomainAllowed method via a functional/integration approach, or keep it simple and trust the unit test of the validator covers the flow.
If you want a pure unit test, test the validator more thoroughly (already done in Task 5).
Skip this task if the validator tests are sufficient. The validator test already covers the "allowed" vs "denied" vs "empty table" scenarios.
Summary
| Task | What | Files |
|---|---|---|
| 1 | Admin Account Controller | src/Controller/Admin/AccountController.php, test |
| 2 | Admin Account Template | templates/admin/account/index.html.twig |
| 3 | Sidebar + Header links | templates/admin/base.html.twig |
| 4 | AllowedDomain Entity | src/Entity/AllowedDomain.php, repository, test |
| 5 | Validator Constraint | src/Validator/AllowedEmailDomain*.php, test |
| 6 | Wire into Registration | src/Form/RegistrationFormType.php |
| 7 | Admin CRUD Controller | src/Controller/Admin/AllowedDomainController.php, form |
| 8 | Admin CRUD Templates | templates/admin/allowed_domains/*.html.twig |
| 9 | Sidebar link | templates/admin/base.html.twig |
| 10 | Migration & verification | Run migrations, tests, phpstan, cs-fixer |