From f3106430644dd6a997b0ec6d7e6036880ef4f0a4 Mon Sep 17 00:00:00 2001 From: Simon Kuehn Date: Sun, 17 May 2026 22:44:03 +0000 Subject: [PATCH] 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 --- .../Admin/AIPipelineJobCrudController.php | 53 +++++++ .../Admin/ArticleCrudController.php | 90 +++++++++++ .../ArticleTypeAttributeCrudController.php | 48 ++++++ .../Admin/CustomerCrudController.php | 46 ++++++ .../Admin/InvoiceCrudController.php | 50 ++++++ .../Admin/LogEntryCrudController.php | 45 ++++++ .../Controller/Admin/OrderCrudController.php | 74 +++++++++ .../Controller/Admin/UserCrudController.php | 40 +++++ .../Controller/Api/AIPipelineController.php | 144 ++++++++++++++++++ .../Http/Controller/Api/OrderController.php | 83 ++++++++++ .../Http/Controller/SecurityController.php | 31 ++++ .../Http/Controller/TotpSetupController.php | 72 +++++++++ templates/admin/dashboard.html.twig | 8 + templates/base.html.twig | 23 +++ templates/security/2fa.html.twig | 21 +++ templates/totp/manage.html.twig | 22 +++ templates/totp/setup.html.twig | 14 ++ 17 files changed, 864 insertions(+) create mode 100644 src/Infrastructure/Http/Controller/Admin/AIPipelineJobCrudController.php create mode 100644 src/Infrastructure/Http/Controller/Admin/ArticleCrudController.php create mode 100644 src/Infrastructure/Http/Controller/Admin/ArticleTypeAttributeCrudController.php create mode 100644 src/Infrastructure/Http/Controller/Admin/CustomerCrudController.php create mode 100644 src/Infrastructure/Http/Controller/Admin/InvoiceCrudController.php create mode 100644 src/Infrastructure/Http/Controller/Admin/LogEntryCrudController.php create mode 100644 src/Infrastructure/Http/Controller/Admin/OrderCrudController.php create mode 100644 src/Infrastructure/Http/Controller/Admin/UserCrudController.php create mode 100644 src/Infrastructure/Http/Controller/Api/AIPipelineController.php create mode 100644 src/Infrastructure/Http/Controller/Api/OrderController.php create mode 100644 src/Infrastructure/Http/Controller/SecurityController.php create mode 100644 src/Infrastructure/Http/Controller/TotpSetupController.php create mode 100644 templates/admin/dashboard.html.twig create mode 100644 templates/base.html.twig create mode 100644 templates/security/2fa.html.twig create mode 100644 templates/totp/manage.html.twig create mode 100644 templates/totp/setup.html.twig diff --git a/src/Infrastructure/Http/Controller/Admin/AIPipelineJobCrudController.php b/src/Infrastructure/Http/Controller/Admin/AIPipelineJobCrudController.php new file mode 100644 index 0000000..c48de09 --- /dev/null +++ b/src/Infrastructure/Http/Controller/Admin/AIPipelineJobCrudController.php @@ -0,0 +1,53 @@ + */ +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'); + } +} diff --git a/src/Infrastructure/Http/Controller/Admin/ArticleCrudController.php b/src/Infrastructure/Http/Controller/Admin/ArticleCrudController.php new file mode 100644 index 0000000..d49c033 --- /dev/null +++ b/src/Infrastructure/Http/Controller/Admin/ArticleCrudController.php @@ -0,0 +1,90 @@ + */ +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, + ]); + } +} diff --git a/src/Infrastructure/Http/Controller/Admin/ArticleTypeAttributeCrudController.php b/src/Infrastructure/Http/Controller/Admin/ArticleTypeAttributeCrudController.php new file mode 100644 index 0000000..319af87 --- /dev/null +++ b/src/Infrastructure/Http/Controller/Admin/ArticleTypeAttributeCrudController.php @@ -0,0 +1,48 @@ + */ +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'); + } +} diff --git a/src/Infrastructure/Http/Controller/Admin/CustomerCrudController.php b/src/Infrastructure/Http/Controller/Admin/CustomerCrudController.php new file mode 100644 index 0000000..c031644 --- /dev/null +++ b/src/Infrastructure/Http/Controller/Admin/CustomerCrudController.php @@ -0,0 +1,46 @@ + */ +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(); + } +} diff --git a/src/Infrastructure/Http/Controller/Admin/InvoiceCrudController.php b/src/Infrastructure/Http/Controller/Admin/InvoiceCrudController.php new file mode 100644 index 0000000..e5de021 --- /dev/null +++ b/src/Infrastructure/Http/Controller/Admin/InvoiceCrudController.php @@ -0,0 +1,50 @@ + */ +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(); + } +} diff --git a/src/Infrastructure/Http/Controller/Admin/LogEntryCrudController.php b/src/Infrastructure/Http/Controller/Admin/LogEntryCrudController.php new file mode 100644 index 0000000..b33af38 --- /dev/null +++ b/src/Infrastructure/Http/Controller/Admin/LogEntryCrudController.php @@ -0,0 +1,45 @@ + */ +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'); + } +} diff --git a/src/Infrastructure/Http/Controller/Admin/OrderCrudController.php b/src/Infrastructure/Http/Controller/Admin/OrderCrudController.php new file mode 100644 index 0000000..524c100 --- /dev/null +++ b/src/Infrastructure/Http/Controller/Admin/OrderCrudController.php @@ -0,0 +1,74 @@ + */ +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', + ])); + } +} diff --git a/src/Infrastructure/Http/Controller/Admin/UserCrudController.php b/src/Infrastructure/Http/Controller/Admin/UserCrudController.php new file mode 100644 index 0000000..b3644e3 --- /dev/null +++ b/src/Infrastructure/Http/Controller/Admin/UserCrudController.php @@ -0,0 +1,40 @@ + */ +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'); + } +} diff --git a/src/Infrastructure/Http/Controller/Api/AIPipelineController.php b/src/Infrastructure/Http/Controller/Api/AIPipelineController.php new file mode 100644 index 0000000..d7cba0c --- /dev/null +++ b/src/Infrastructure/Http/Controller/Api/AIPipelineController.php @@ -0,0 +1,144 @@ +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); + } +} diff --git a/src/Infrastructure/Http/Controller/Api/OrderController.php b/src/Infrastructure/Http/Controller/Api/OrderController.php new file mode 100644 index 0000000..bfc273e --- /dev/null +++ b/src/Infrastructure/Http/Controller/Api/OrderController.php @@ -0,0 +1,83 @@ +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 $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, + ]); + } +} diff --git a/src/Infrastructure/Http/Controller/SecurityController.php b/src/Infrastructure/Http/Controller/SecurityController.php new file mode 100644 index 0000000..5d48fd3 --- /dev/null +++ b/src/Infrastructure/Http/Controller/SecurityController.php @@ -0,0 +1,31 @@ +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.'); + } +} diff --git a/src/Infrastructure/Http/Controller/TotpSetupController.php b/src/Infrastructure/Http/Controller/TotpSetupController.php new file mode 100644 index 0000000..170505d --- /dev/null +++ b/src/Infrastructure/Http/Controller/TotpSetupController.php @@ -0,0 +1,72 @@ +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'); + } +} diff --git a/templates/admin/dashboard.html.twig b/templates/admin/dashboard.html.twig new file mode 100644 index 0000000..0e8ac09 --- /dev/null +++ b/templates/admin/dashboard.html.twig @@ -0,0 +1,8 @@ +{% extends '@EasyAdmin/page/content.html.twig' %} + +{% block page_title %}Dashboard{% endblock %} + +{% block main %} +

SuperSeller3000 Admin

+

Welcome, {{ app.user ? app.user.email : 'guest' }}.

+{% endblock %} diff --git a/templates/base.html.twig b/templates/base.html.twig new file mode 100644 index 0000000..c6fd7ad --- /dev/null +++ b/templates/base.html.twig @@ -0,0 +1,23 @@ + + + + + {% block title %}Welcome!{% endblock %} + + {% block stylesheets %} + {% endblock %} + + {% block javascripts %} + {% endblock %} + + {% set frankenphpHotReload = app.request.server.get('FRANKENPHP_HOT_RELOAD') %} + {% if frankenphpHotReload %} + + + + {% endif %} + + + {% block body %}{% endblock %} + + diff --git a/templates/security/2fa.html.twig b/templates/security/2fa.html.twig new file mode 100644 index 0000000..1b97c99 --- /dev/null +++ b/templates/security/2fa.html.twig @@ -0,0 +1,21 @@ +{% extends 'base.html.twig' %} + +{% block title %}Two-Factor Authentication{% endblock %} + +{% block body %} +
+

Two-Factor Authentication

+ {% if authenticationError %} +
{{ authenticationError.messageKey|trans(authenticationError.messageData, 'security') }}
+ {% endif %} +

Enter the 6-digit code from your authenticator app.

+
+
+
+ +
+ + +
+
+{% endblock %} diff --git a/templates/totp/manage.html.twig b/templates/totp/manage.html.twig new file mode 100644 index 0000000..f9ae2bf --- /dev/null +++ b/templates/totp/manage.html.twig @@ -0,0 +1,22 @@ +{% extends 'base.html.twig' %} + +{% block title %}Two-Factor Authentication{% endblock %} + +{% block body %} +
+

Two-Factor Authentication

+ {% for message in app.flashes('success') %} +
{{ message }}
+ {% endfor %} + {% if totp_enabled %} +

Two-factor authentication is enabled.

+
+ + +
+ {% else %} +

Two-factor authentication is disabled.

+ Enable 2FA + {% endif %} +
+{% endblock %} diff --git a/templates/totp/setup.html.twig b/templates/totp/setup.html.twig new file mode 100644 index 0000000..b7d91ec --- /dev/null +++ b/templates/totp/setup.html.twig @@ -0,0 +1,14 @@ +{% extends 'base.html.twig' %} + +{% block title %}Set Up Two-Factor Authentication{% endblock %} + +{% block body %} +
+

Set Up Two-Factor Authentication

+

Scan this QR code with your authenticator app (Google Authenticator, Authy, etc.):

+ {{ qr_code_image_data_uri(qr_code_url) }} +

Or enter this secret manually: {{ secret }}

+

After scanning, verify your setup by logging in again.

+ Back +
+{% endblock %}