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:
parent
487c7f8da1
commit
f310643064
17 changed files with 864 additions and 0 deletions
|
|
@ -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');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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',
|
||||||
|
]));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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');
|
||||||
|
}
|
||||||
|
}
|
||||||
144
src/Infrastructure/Http/Controller/Api/AIPipelineController.php
Normal file
144
src/Infrastructure/Http/Controller/Api/AIPipelineController.php
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
83
src/Infrastructure/Http/Controller/Api/OrderController.php
Normal file
83
src/Infrastructure/Http/Controller/Api/OrderController.php
Normal 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,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
31
src/Infrastructure/Http/Controller/SecurityController.php
Normal file
31
src/Infrastructure/Http/Controller/SecurityController.php
Normal 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.');
|
||||||
|
}
|
||||||
|
}
|
||||||
72
src/Infrastructure/Http/Controller/TotpSetupController.php
Normal file
72
src/Infrastructure/Http/Controller/TotpSetupController.php
Normal 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');
|
||||||
|
}
|
||||||
|
}
|
||||||
8
templates/admin/dashboard.html.twig
Normal file
8
templates/admin/dashboard.html.twig
Normal 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
23
templates/base.html.twig
Normal 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>
|
||||||
21
templates/security/2fa.html.twig
Normal file
21
templates/security/2fa.html.twig
Normal 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 %}
|
||||||
22
templates/totp/manage.html.twig
Normal file
22
templates/totp/manage.html.twig
Normal 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 %}
|
||||||
14
templates/totp/setup.html.twig
Normal file
14
templates/totp/setup.html.twig
Normal 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 %}
|
||||||
Loading…
Reference in a new issue