feat: add EasyAdmin CRUD controllers, security controller and templates

CRUD controllers for Article, ArticleType, AttributeDefinition,
ArticleTypeAttribute, AIPipelineJob, Order, Customer, Invoice, User
and LogEntry. SecurityController handles login/logout; TotpSetupController
manages 2FA enrollment. API controllers for pipeline and orders.

Admin dashboard template and Twig base layout included.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Simon Kuehn 2026-05-17 22:44:03 +00:00
parent 487c7f8da1
commit f310643064
17 changed files with 864 additions and 0 deletions

View file

@ -0,0 +1,53 @@
<?php
declare(strict_types=1);
namespace App\Infrastructure\Http\Controller\Admin;
use App\Domain\Pipeline\AIPipelineJob;
use App\Domain\Pipeline\AIPipelineJobStatus;
use App\Domain\Pipeline\AIPipelineJobType;
use EasyCorp\Bundle\EasyAdminBundle\Config\Action;
use EasyCorp\Bundle\EasyAdminBundle\Config\Actions;
use EasyCorp\Bundle\EasyAdminBundle\Config\Crud;
use EasyCorp\Bundle\EasyAdminBundle\Controller\AbstractCrudController;
use EasyCorp\Bundle\EasyAdminBundle\Field\ChoiceField;
use EasyCorp\Bundle\EasyAdminBundle\Field\IdField;
use EasyCorp\Bundle\EasyAdminBundle\Field\IntegerField;
/** @extends AbstractCrudController<AIPipelineJob> */
final class AIPipelineJobCrudController extends AbstractCrudController
{
public static function getEntityFqcn(): string
{
return AIPipelineJob::class;
}
public function configureCrud(Crud $crud): Crud
{
return $crud->setEntityLabelInSingular('AI Pipeline Job')->setEntityLabelInPlural('AI Pipeline Jobs');
}
public function configureActions(Actions $actions): Actions
{
return $actions->disable(Action::NEW, Action::EDIT, Action::DELETE);
}
public function configureFields(string $pageName): iterable
{
yield IdField::new('id')->hideOnForm();
yield ChoiceField::new('type')->setChoices(
array_combine(
array_map(static fn ($t) => $t->value, AIPipelineJobType::cases()),
AIPipelineJobType::cases(),
)
);
yield ChoiceField::new('status')->setChoices(
array_combine(
array_map(static fn ($s) => $s->value, AIPipelineJobStatus::cases()),
AIPipelineJobStatus::cases(),
)
);
yield IntegerField::new('attemptCount', 'Attempts');
}
}

View file

@ -0,0 +1,90 @@
<?php
declare(strict_types=1);
namespace App\Infrastructure\Http\Controller\Admin;
use App\Application\Article\ArticleService;
use App\Domain\Article\Article;
use App\Domain\Article\ArticleCondition;
use App\Domain\Article\ArticleStatus;
use EasyCorp\Bundle\EasyAdminBundle\Config\Action;
use EasyCorp\Bundle\EasyAdminBundle\Config\Actions;
use EasyCorp\Bundle\EasyAdminBundle\Config\Crud;
use EasyCorp\Bundle\EasyAdminBundle\Controller\AbstractCrudController;
use EasyCorp\Bundle\EasyAdminBundle\Field\AssociationField;
use EasyCorp\Bundle\EasyAdminBundle\Field\ChoiceField;
use EasyCorp\Bundle\EasyAdminBundle\Field\IdField;
use EasyCorp\Bundle\EasyAdminBundle\Field\MoneyField;
use EasyCorp\Bundle\EasyAdminBundle\Field\TextField;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Uid\Uuid;
/** @extends AbstractCrudController<Article> */
final class ArticleCrudController extends AbstractCrudController
{
public function __construct(private readonly ArticleService $articleService)
{
}
public static function getEntityFqcn(): string
{
return Article::class;
}
public function configureCrud(Crud $crud): Crud
{
return $crud->setEntityLabelInSingular('Article')->setEntityLabelInPlural('Articles');
}
public function configureActions(Actions $actions): Actions
{
$activate = Action::new('activate', 'Activate')
->linkToRoute('admin_article_activate', static fn (Article $a) => ['id' => $a->getId()->toRfc4122()])
->displayIf(static fn (Article $a) => ArticleStatus::Draft === $a->getStatus());
return $actions
->add(Crud::PAGE_INDEX, $activate)
->disable(Action::NEW, Action::EDIT, Action::DELETE);
}
public function configureFields(string $pageName): iterable
{
yield IdField::new('id')->hideOnForm();
yield TextField::new('sku');
yield TextField::new('inventoryNumber', 'Inventory #');
yield AssociationField::new('articleType');
yield ChoiceField::new('status')->setChoices(
array_combine(
array_map(static fn ($s) => $s->value, ArticleStatus::cases()),
ArticleStatus::cases(),
)
);
yield ChoiceField::new('condition')->setChoices(
array_combine(
array_map(static fn ($c) => $c->value, ArticleCondition::cases()),
ArticleCondition::cases(),
)
);
yield MoneyField::new('listingPrice')->setCurrency('EUR')->setRequired(false);
}
#[Route('/admin/articles/{id}/activate', name: 'admin_article_activate')]
public function activateArticle(string $id, Request $request): RedirectResponse
{
$result = $this->articleService->activate(Uuid::fromString($id));
if ([] !== $result['missing']) {
$this->addFlash('warning', 'Cannot activate: missing attributes: '.implode(', ', $result['missing']));
} else {
$this->addFlash('success', 'Article activated.');
}
return $this->redirectToRoute('easyadmin', [
'crudAction' => 'index',
'crudControllerFqcn' => self::class,
]);
}
}

View file

@ -0,0 +1,48 @@
<?php
declare(strict_types=1);
namespace App\Infrastructure\Http\Controller\Admin;
use App\Domain\Article\ArticleType;
use App\Domain\Article\ArticleTypeAttribute;
use App\Domain\Article\AttributeDefinition;
use EasyCorp\Bundle\EasyAdminBundle\Config\Crud;
use EasyCorp\Bundle\EasyAdminBundle\Controller\AbstractCrudController;
use EasyCorp\Bundle\EasyAdminBundle\Field\AssociationField;
use EasyCorp\Bundle\EasyAdminBundle\Field\BooleanField;
use EasyCorp\Bundle\EasyAdminBundle\Field\IdField;
/** @extends AbstractCrudController<ArticleTypeAttribute> */
final class ArticleTypeAttributeCrudController extends AbstractCrudController
{
public static function getEntityFqcn(): string
{
return ArticleTypeAttribute::class;
}
public function configureCrud(Crud $crud): Crud
{
return $crud
->setEntityLabelInSingular('Attribute Assignment')
->setEntityLabelInPlural('Attribute Assignments')
->setDefaultSort(['articleType' => 'ASC']);
}
public function createEntity(string $entityFqcn): ArticleTypeAttribute
{
// Temporary placeholders — the form will overwrite via setters
$type = new ArticleType('');
$def = new AttributeDefinition('', \App\Domain\Article\AttributeType::String);
return new ArticleTypeAttribute($type, $def);
}
public function configureFields(string $pageName): iterable
{
yield IdField::new('id')->hideOnForm();
yield AssociationField::new('articleType', 'Article Type')->autocomplete();
yield AssociationField::new('attributeDefinition', 'Attribute')->autocomplete();
yield BooleanField::new('required', 'Required');
}
}

View file

@ -0,0 +1,46 @@
<?php
declare(strict_types=1);
namespace App\Infrastructure\Http\Controller\Admin;
use App\Domain\Order\Customer;
use EasyCorp\Bundle\EasyAdminBundle\Config\Action;
use EasyCorp\Bundle\EasyAdminBundle\Config\Actions;
use EasyCorp\Bundle\EasyAdminBundle\Config\Crud;
use EasyCorp\Bundle\EasyAdminBundle\Controller\AbstractCrudController;
use EasyCorp\Bundle\EasyAdminBundle\Field\IdField;
use EasyCorp\Bundle\EasyAdminBundle\Field\TextField;
/** @extends AbstractCrudController<Customer> */
final class CustomerCrudController extends AbstractCrudController
{
public static function getEntityFqcn(): string
{
return Customer::class;
}
public function configureCrud(Crud $crud): Crud
{
return $crud
->setEntityLabelInSingular('Kunde')
->setEntityLabelInPlural('Kunden')
->setDefaultSort(['name' => 'ASC'])
->showEntityActionsInlined();
}
public function configureActions(Actions $actions): Actions
{
return $actions
->disable(Action::NEW, Action::DELETE)
->add(Crud::PAGE_INDEX, Action::DETAIL);
}
public function configureFields(string $pageName): iterable
{
yield IdField::new('id')->hideOnForm();
yield TextField::new('name', 'Name');
yield TextField::new('email', 'E-Mail');
yield TextField::new('frappeCustomerId', 'Frappe-ID')->onlyOnDetail();
}
}

View file

@ -0,0 +1,50 @@
<?php
declare(strict_types=1);
namespace App\Infrastructure\Http\Controller\Admin;
use App\Domain\Order\Invoice;
use EasyCorp\Bundle\EasyAdminBundle\Config\Action;
use EasyCorp\Bundle\EasyAdminBundle\Config\Actions;
use EasyCorp\Bundle\EasyAdminBundle\Config\Crud;
use EasyCorp\Bundle\EasyAdminBundle\Controller\AbstractCrudController;
use EasyCorp\Bundle\EasyAdminBundle\Field\AssociationField;
use EasyCorp\Bundle\EasyAdminBundle\Field\DateTimeField;
use EasyCorp\Bundle\EasyAdminBundle\Field\IdField;
use EasyCorp\Bundle\EasyAdminBundle\Field\TextField;
/** @extends AbstractCrudController<Invoice> */
final class InvoiceCrudController extends AbstractCrudController
{
public static function getEntityFqcn(): string
{
return Invoice::class;
}
public function configureCrud(Crud $crud): Crud
{
return $crud
->setEntityLabelInSingular('Rechnung')
->setEntityLabelInPlural('Rechnungen')
->setDefaultSort(['createdAt' => 'DESC'])
->showEntityActionsInlined();
}
public function configureActions(Actions $actions): Actions
{
return $actions
->disable(Action::NEW, Action::EDIT, Action::DELETE)
->add(Crud::PAGE_INDEX, Action::DETAIL);
}
public function configureFields(string $pageName): iterable
{
yield IdField::new('id')->hideOnForm();
yield TextField::new('frappeInvoiceId', 'Frappe-Rechnungsnr.');
yield AssociationField::new('order', 'Bestellung');
yield DateTimeField::new('createdAt', 'Erstellt am');
yield DateTimeField::new('emailedAt', 'Per E-Mail versendet');
yield TextField::new('filename', 'PDF-Datei')->onlyOnDetail();
}
}

View file

@ -0,0 +1,45 @@
<?php
declare(strict_types=1);
namespace App\Infrastructure\Http\Controller\Admin;
use App\Domain\Log\LogEntry;
use EasyCorp\Bundle\EasyAdminBundle\Config\Action;
use EasyCorp\Bundle\EasyAdminBundle\Config\Actions;
use EasyCorp\Bundle\EasyAdminBundle\Config\Crud;
use EasyCorp\Bundle\EasyAdminBundle\Controller\AbstractCrudController;
use EasyCorp\Bundle\EasyAdminBundle\Field\DateTimeField;
use EasyCorp\Bundle\EasyAdminBundle\Field\IntegerField;
use EasyCorp\Bundle\EasyAdminBundle\Field\TextField;
/** @extends AbstractCrudController<LogEntry> */
final class LogEntryCrudController extends AbstractCrudController
{
public static function getEntityFqcn(): string
{
return LogEntry::class;
}
public function configureCrud(Crud $crud): Crud
{
return $crud
->setEntityLabelInSingular('Log Entry')
->setEntityLabelInPlural('Log Entries')
->setDefaultSort(['loggedAt' => 'DESC']);
}
public function configureActions(Actions $actions): Actions
{
return $actions->disable(Action::NEW, Action::EDIT, Action::DELETE);
}
public function configureFields(string $pageName): iterable
{
yield DateTimeField::new('loggedAt', 'Time');
yield TextField::new('channel');
yield IntegerField::new('level');
yield TextField::new('levelName', 'Level');
yield TextField::new('message');
}
}

View file

@ -0,0 +1,74 @@
<?php
declare(strict_types=1);
namespace App\Infrastructure\Http\Controller\Admin;
use App\Domain\Order\Order;
use App\Domain\Order\OrderStatus;
use EasyCorp\Bundle\EasyAdminBundle\Config\Action;
use EasyCorp\Bundle\EasyAdminBundle\Config\Actions;
use EasyCorp\Bundle\EasyAdminBundle\Config\Crud;
use EasyCorp\Bundle\EasyAdminBundle\Config\Filters;
use EasyCorp\Bundle\EasyAdminBundle\Controller\AbstractCrudController;
use EasyCorp\Bundle\EasyAdminBundle\Field\AssociationField;
use EasyCorp\Bundle\EasyAdminBundle\Field\ChoiceField;
use EasyCorp\Bundle\EasyAdminBundle\Field\DateTimeField;
use EasyCorp\Bundle\EasyAdminBundle\Field\IdField;
use EasyCorp\Bundle\EasyAdminBundle\Field\MoneyField;
use EasyCorp\Bundle\EasyAdminBundle\Field\TextField;
use EasyCorp\Bundle\EasyAdminBundle\Filter\ChoiceFilter;
/** @extends AbstractCrudController<Order> */
final class OrderCrudController extends AbstractCrudController
{
public static function getEntityFqcn(): string
{
return Order::class;
}
public function configureCrud(Crud $crud): Crud
{
return $crud
->setEntityLabelInSingular('Bestellung')
->setEntityLabelInPlural('Bestellungen')
->setDefaultSort(['saleDate' => 'DESC'])
->showEntityActionsInlined();
}
public function configureActions(Actions $actions): Actions
{
return $actions
->disable(Action::NEW, Action::DELETE)
->add(Crud::PAGE_INDEX, Action::DETAIL);
}
public function configureFields(string $pageName): iterable
{
yield IdField::new('id')->hideOnForm();
yield TextField::new('platformOrderId', 'Plattform-Bestellnr.');
yield AssociationField::new('article', 'Artikel');
yield AssociationField::new('customer', 'Käufer');
yield ChoiceField::new('status', 'Status')
->setChoices(array_combine(
array_map(static fn (OrderStatus $s) => ucfirst($s->value), OrderStatus::cases()),
array_map(static fn (OrderStatus $s) => $s->value, OrderStatus::cases()),
));
yield MoneyField::new('salePrice', 'Verkaufspreis')->setCurrency('EUR');
yield DateTimeField::new('saleDate', 'Verkaufsdatum');
yield TextField::new('trackingNumber', 'Sendungsnummer')->onlyOnDetail();
yield TextField::new('carrier', 'Versanddienstleister')->onlyOnDetail();
yield DateTimeField::new('shippedAt', 'Versanddatum')->onlyOnDetail();
}
public function configureFilters(Filters $filters): Filters
{
return $filters->add(ChoiceFilter::new('status')->setChoices([
'Ausstehend' => 'pending',
'In Bearbeitung' => 'processing',
'Versandt' => 'shipped',
'Abgeschlossen' => 'completed',
'Fehlgeschlagen' => 'failed',
]));
}
}

View file

@ -0,0 +1,40 @@
<?php
declare(strict_types=1);
namespace App\Infrastructure\Http\Controller\Admin;
use App\Domain\Auth\User;
use EasyCorp\Bundle\EasyAdminBundle\Config\Action;
use EasyCorp\Bundle\EasyAdminBundle\Config\Actions;
use EasyCorp\Bundle\EasyAdminBundle\Config\Crud;
use EasyCorp\Bundle\EasyAdminBundle\Controller\AbstractCrudController;
use EasyCorp\Bundle\EasyAdminBundle\Field\BooleanField;
use EasyCorp\Bundle\EasyAdminBundle\Field\IdField;
use EasyCorp\Bundle\EasyAdminBundle\Field\TextField;
/** @extends AbstractCrudController<User> */
final class UserCrudController extends AbstractCrudController
{
public static function getEntityFqcn(): string
{
return User::class;
}
public function configureCrud(Crud $crud): Crud
{
return $crud->setEntityLabelInSingular('User')->setEntityLabelInPlural('Users');
}
public function configureActions(Actions $actions): Actions
{
return $actions->disable(Action::NEW, Action::DELETE);
}
public function configureFields(string $pageName): iterable
{
yield IdField::new('id')->hideOnForm();
yield TextField::new('email')->setFormTypeOption('disabled', true);
yield BooleanField::new('isActive');
}
}

View file

@ -0,0 +1,144 @@
<?php
declare(strict_types=1);
namespace App\Infrastructure\Http\Controller\Api;
use App\Application\Article\PhotoService;
use App\Domain\Article\ArticleCondition;
use App\Domain\Article\Repository\ArticleTypeRepositoryInterface;
use App\Domain\Pipeline\AIPipelineJob;
use App\Domain\Pipeline\AIPipelineJobType;
use App\Domain\Pipeline\Repository\AIPipelineJobRepositoryInterface;
use App\Infrastructure\Messenger\Message\EbayTextMessage;
use App\Infrastructure\Messenger\Message\PhotoUploadMessage;
use App\Infrastructure\Messenger\Message\PxeInventoryMessage;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\File\UploadedFile;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Messenger\MessageBusInterface;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Uid\Uuid;
#[Route('/api/pipeline', name: 'api_pipeline_')]
final class AIPipelineController extends AbstractController
{
public function __construct(
private readonly MessageBusInterface $bus,
private readonly AIPipelineJobRepositoryInterface $jobRepository,
private readonly ArticleTypeRepositoryInterface $articleTypeRepository,
private readonly PhotoService $photoService,
) {
}
#[Route('/photo-upload', name: 'photo_upload', methods: ['POST'])]
public function photoUpload(Request $request): JsonResponse
{
$articleTypeId = $request->request->getString('articleTypeId');
$file = $request->files->get('photo');
if ('' === $articleTypeId || !$file instanceof UploadedFile) {
return $this->json(['error' => 'articleTypeId and photo are required'], Response::HTTP_BAD_REQUEST);
}
if (null === $this->articleTypeRepository->findById(Uuid::fromString($articleTypeId))) {
return $this->json(['error' => 'ArticleType not found'], Response::HTTP_NOT_FOUND);
}
$allowedMimes = ['image/jpeg', 'image/png', 'image/webp'];
if (!\in_array($file->getMimeType(), $allowedMimes, true)) {
return $this->json(['error' => 'Only JPEG, PNG, WebP allowed'], Response::HTTP_BAD_REQUEST);
}
$stored = $this->photoService->uploadRaw($file->getPathname(), (string) $file->getClientOriginalName());
$storedPath = $stored->storagePath->getBasePath().'/'.$stored->filename;
$job = new AIPipelineJob(AIPipelineJobType::Photo, [
'articleTypeId' => $articleTypeId,
'storedPhotoPath' => $storedPath,
]);
$this->jobRepository->save($job);
$this->bus->dispatch(new PhotoUploadMessage(
jobId: $job->getId()->toRfc4122(),
articleTypeId: $articleTypeId,
storedPhotoPath: $storedPath,
originalFilename: (string) $file->getClientOriginalName(),
));
return $this->json([
'jobId' => $job->getId()->toRfc4122(),
'status' => $job->getStatus()->value,
], Response::HTTP_ACCEPTED);
}
#[Route('/pxe-inventory', name: 'pxe_inventory', methods: ['POST'])]
public function pxeInventory(Request $request): JsonResponse
{
$data = $request->toArray();
foreach (['articleTypeId', 'pxeDump', 'inventoryNumber', 'condition'] as $field) {
if (empty($data[$field])) {
return $this->json(['error' => "{$field} is required"], Response::HTTP_BAD_REQUEST);
}
}
if (null === ArticleCondition::tryFrom($data['condition'])) {
return $this->json(['error' => 'Invalid condition'], Response::HTTP_BAD_REQUEST);
}
$job = new AIPipelineJob(AIPipelineJobType::Pxe, [
'articleTypeId' => $data['articleTypeId'],
'inventoryNumber' => $data['inventoryNumber'],
]);
$this->jobRepository->save($job);
$this->bus->dispatch(new PxeInventoryMessage(
jobId: $job->getId()->toRfc4122(),
articleTypeId: $data['articleTypeId'],
pxeDump: $data['pxeDump'],
inventoryNumber: $data['inventoryNumber'],
condition: $data['condition'],
));
return $this->json([
'jobId' => $job->getId()->toRfc4122(),
'inventoryNumber' => $data['inventoryNumber'],
'status' => $job->getStatus()->value,
], Response::HTTP_ACCEPTED);
}
#[Route('/jobs/{jobId}', name: 'job_status', methods: ['GET'])]
public function jobStatus(string $jobId): JsonResponse
{
$job = $this->jobRepository->findById(Uuid::fromString($jobId));
if (null === $job) {
return $this->json(['error' => 'Job not found'], Response::HTTP_NOT_FOUND);
}
return $this->json([
'id' => $job->getId()->toRfc4122(),
'type' => $job->getType()->value,
'status' => $job->getStatus()->value,
'attemptCount' => $job->getAttemptCount(),
'articleId' => $job->getArticleId()?->toRfc4122(),
'missingFields' => $job->getMissingFields(),
'errorMessage' => $job->getErrorMessage(),
]);
}
#[Route('/articles/{id}/regenerate-texts', name: 'regenerate_texts', methods: ['POST'])]
public function regenerateTexts(string $id): JsonResponse
{
$job = new AIPipelineJob(AIPipelineJobType::TextGeneration, ['articleId' => $id]);
$this->jobRepository->save($job);
$this->bus->dispatch(new EbayTextMessage(
jobId: $job->getId()->toRfc4122(),
articleId: $id,
));
return $this->json(['jobId' => $job->getId()->toRfc4122()], Response::HTTP_ACCEPTED);
}
}

View file

@ -0,0 +1,83 @@
<?php
declare(strict_types=1);
namespace App\Infrastructure\Http\Controller\Api;
use App\Domain\Order\Repository\OrderRepositoryInterface;
use App\Infrastructure\Messenger\Message\TrackingPushMessage;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Messenger\MessageBusInterface;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Uid\Uuid;
#[Route('/api/orders', name: 'api_order_')]
final class OrderController extends AbstractController
{
public function __construct(
private readonly OrderRepositoryInterface $orders,
private readonly MessageBusInterface $bus,
) {
}
#[Route('/{id}', name: 'get', methods: ['GET'])]
public function get(string $id): JsonResponse
{
$order = $this->orders->findById(Uuid::fromString($id));
if (null === $order) {
return $this->json(['error' => 'Order not found'], Response::HTTP_NOT_FOUND);
}
return $this->json([
'id' => $order->getId()->toRfc4122(),
'platformOrderId' => $order->getPlatformOrderId(),
'status' => $order->getStatus()->value,
'salePrice' => $order->getSalePrice(),
'trackingNumber' => $order->getTrackingNumber(),
'carrier' => $order->getCarrier(),
'shippedAt' => $order->getShippedAt()?->format(\DateTimeInterface::ATOM),
]);
}
/**
* PATCH /api/orders/{id}/tracking
* Body: {"tracking_number": "1Z999...", "carrier": "DHL"}.
*/
#[Route('/{id}/tracking', name: 'patch_tracking', methods: ['PATCH'])]
public function patchTracking(string $id, Request $request): JsonResponse
{
$order = $this->orders->findById(Uuid::fromString($id));
if (null === $order) {
return $this->json(['error' => 'Order not found'], Response::HTTP_NOT_FOUND);
}
/** @var array<string, string> $body */
$body = json_decode($request->getContent(), true) ?? [];
$trackingNumber = trim($body['tracking_number'] ?? '');
$carrier = trim($body['carrier'] ?? '');
if ('' === $trackingNumber || '' === $carrier) {
return $this->json(['error' => 'tracking_number and carrier are required'], Response::HTTP_BAD_REQUEST);
}
$order->setTracking($trackingNumber, $carrier);
$this->orders->save($order);
$this->bus->dispatch(new TrackingPushMessage(
orderId: $order->getId()->toRfc4122(),
trackingNumber: $trackingNumber,
carrier: $carrier,
));
return $this->json([
'id' => $order->getId()->toRfc4122(),
'trackingNumber' => $trackingNumber,
'carrier' => $carrier,
'status' => $order->getStatus()->value,
]);
}
}

View file

@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
namespace App\Infrastructure\Http\Controller;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Authentication\AuthenticationUtils;
final class SecurityController extends AbstractController
{
#[Route('/login', name: 'app_login')]
public function login(AuthenticationUtils $authenticationUtils): Response
{
$error = $authenticationUtils->getLastAuthenticationError();
$lastUsername = $authenticationUtils->getLastUsername();
return $this->render('security/login.html.twig', [
'last_username' => $lastUsername,
'error' => $error,
]);
}
#[Route('/logout', name: 'app_logout')]
public function logout(): never
{
throw new \LogicException('This method is handled by the firewall logout listener.');
}
}

View file

@ -0,0 +1,72 @@
<?php
declare(strict_types=1);
namespace App\Infrastructure\Http\Controller;
use App\Domain\Auth\Repository\UserRepositoryInterface;
use App\Domain\Auth\User;
use Scheb\TwoFactorBundle\Security\TwoFactor\Provider\Totp\TotpAuthenticatorInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
#[Route('/totp', name: 'totp_')]
#[IsGranted('ROLE_USER')]
final class TotpSetupController extends AbstractController
{
public function __construct(
private readonly TotpAuthenticatorInterface $totpAuthenticator,
private readonly UserRepositoryInterface $userRepository,
) {
}
#[Route('/setup', name: 'setup', methods: ['GET'])]
public function setup(): Response
{
$user = $this->getUser();
\assert($user instanceof User);
if ($user->isTotpAuthenticationEnabled()) {
return $this->redirectToRoute('totp_manage');
}
$secret = $this->totpAuthenticator->generateSecret();
$user->setTotpSecret($secret);
$this->userRepository->save($user);
$qrCodeUrl = $this->totpAuthenticator->getQRContent($user);
return $this->render('totp/setup.html.twig', [
'secret' => $secret,
'qr_code_url' => $qrCodeUrl,
]);
}
#[Route('/manage', name: 'manage', methods: ['GET'])]
public function manage(): Response
{
$user = $this->getUser();
\assert($user instanceof User);
return $this->render('totp/manage.html.twig', [
'totp_enabled' => $user->isTotpAuthenticationEnabled(),
]);
}
#[Route('/disable', name: 'disable', methods: ['POST'])]
public function disable(): Response
{
$user = $this->getUser();
\assert($user instanceof User);
$user->setTotpSecret(null);
$this->userRepository->save($user);
$this->addFlash('success', 'Two-factor authentication has been disabled.');
return $this->redirectToRoute('totp_manage');
}
}

View file

@ -0,0 +1,8 @@
{% extends '@EasyAdmin/page/content.html.twig' %}
{% block page_title %}Dashboard{% endblock %}
{% block main %}
<h1>SuperSeller3000 Admin</h1>
<p>Welcome, {{ app.user ? app.user.email : 'guest' }}.</p>
{% endblock %}

23
templates/base.html.twig Normal file
View file

@ -0,0 +1,23 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>{% block title %}Welcome!{% endblock %}</title>
<link rel="icon" href="data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 128 128%22><text y=%221.2em%22 font-size=%2296%22>⚫️</text><text y=%221.3em%22 x=%220.2em%22 font-size=%2276%22 fill=%22%23fff%22>sf</text></svg>">
{% block stylesheets %}
{% endblock %}
{% block javascripts %}
{% endblock %}
{% set frankenphpHotReload = app.request.server.get('FRANKENPHP_HOT_RELOAD') %}
{% if frankenphpHotReload %}
<meta name="frankenphp-hot-reload:url" content="{{ frankenphpHotReload }}">
<script src="https://cdn.jsdelivr.net/npm/idiomorph"></script>
<script src="https://cdn.jsdelivr.net/npm/frankenphp-hot-reload/+esm" type="module"></script>
{% endif %}
</head>
<body>
{% block body %}{% endblock %}
</body>
</html>

View file

@ -0,0 +1,21 @@
{% extends 'base.html.twig' %}
{% block title %}Two-Factor Authentication{% endblock %}
{% block body %}
<div style="max-width:400px;margin:4rem auto;font-family:sans-serif">
<h1>Two-Factor Authentication</h1>
{% if authenticationError %}
<div style="color:red;margin-bottom:1rem">{{ authenticationError.messageKey|trans(authenticationError.messageData, 'security') }}</div>
{% endif %}
<p>Enter the 6-digit code from your authenticator app.</p>
<form method="post" action="{{ two_factor_form_path() }}">
<div style="margin-bottom:1rem">
<label for="code">Authentication Code</label><br>
<input id="code" type="text" name="{{ two_factor_code_parameter() }}" inputmode="numeric" autocomplete="one-time-code" required autofocus style="width:100%;padding:.5rem;letter-spacing:.2em;font-size:1.5rem">
</div>
<input type="hidden" name="_csrf_token" value="{{ csrf_token('2fa') }}">
<button type="submit" style="padding:.5rem 1rem">Verify</button>
</form>
</div>
{% endblock %}

View file

@ -0,0 +1,22 @@
{% extends 'base.html.twig' %}
{% block title %}Two-Factor Authentication{% endblock %}
{% block body %}
<div style="max-width:500px;margin:4rem auto;font-family:sans-serif">
<h1>Two-Factor Authentication</h1>
{% for message in app.flashes('success') %}
<div style="color:green;margin-bottom:1rem">{{ message }}</div>
{% endfor %}
{% if totp_enabled %}
<p>Two-factor authentication is <strong>enabled</strong>.</p>
<form method="post" action="{{ path('totp_disable') }}">
<input type="hidden" name="_csrf_token" value="{{ csrf_token('totp_disable') }}">
<button type="submit" style="padding:.5rem 1rem;background:red;color:white;border:none;cursor:pointer">Disable 2FA</button>
</form>
{% else %}
<p>Two-factor authentication is <strong>disabled</strong>.</p>
<a href="{{ path('totp_setup') }}" style="padding:.5rem 1rem;background:green;color:white;text-decoration:none">Enable 2FA</a>
{% endif %}
</div>
{% endblock %}

View file

@ -0,0 +1,14 @@
{% extends 'base.html.twig' %}
{% block title %}Set Up Two-Factor Authentication{% endblock %}
{% block body %}
<div style="max-width:500px;margin:4rem auto;font-family:sans-serif">
<h1>Set Up Two-Factor Authentication</h1>
<p>Scan this QR code with your authenticator app (Google Authenticator, Authy, etc.):</p>
{{ qr_code_image_data_uri(qr_code_url) }}
<p>Or enter this secret manually: <code>{{ secret }}</code></p>
<p>After scanning, verify your setup by logging in again.</p>
<a href="{{ path('totp_manage') }}">Back</a>
</div>
{% endblock %}