From f915bba96606f765cb950173d5712726a87ffef8 Mon Sep 17 00:00:00 2001 From: Simon Kuehn Date: Sun, 17 May 2026 20:15:13 +0000 Subject: [PATCH] feat: admin panel, Mistral client, attribute management, API key command MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix EasyAdmin 5 routing: #[AdminDashboard] attribute + easyadmin.routes loader - Fix login: _username/_password field names, CSRF stateless token config, sessions directory, Opcache reload after cache:clear - Add MistralClient behind OllamaClientInterface — switchable via services.yaml alias - Add Attribute CRUD with EnumType form + ChoiceField display (enum-safe rendering) - Add Article Type CRUD with AssociationField for attribute assignments - Add app:api-keys:create console command (bcrypt-hashed, never stored as plaintext) - Add redis ext to Docker image + symfony/redis-messenger, start workers - Translate all UI strings to English - Add tests: MistralClient, ApiKey, CreateApiKeyCommand, StringArrayType, ArticleTypeCrudController, AttributeDefinitionCrudController (82 tests total) - Update design doc: tech stack, AI backend switching guide, ops section Co-Authored-By: Claude Sonnet 4.6 --- composer.json | 8 + composer.lock | 2078 ++++++++++++++++- config/packages/csrf.yaml | 9 + config/packages/monolog.yaml | 52 + config/packages/scheb_2fa.yaml | 8 + config/packages/security.yaml | 48 +- config/packages/translation.yaml | 5 + config/packages/twig.yaml | 6 + config/packages/twig_component.yaml | 5 + config/routes.yaml | 1 + config/routes/easyadmin.yaml | 6 +- config/routes/scheb_2fa.yaml | 7 + docker/app/Dockerfile | 6 +- .../2026-05-13-superseller3000-design.md | 479 ++++ src/Domain/Article/AttributeDefinition.php | 5 + src/Domain/Auth/ApiKey.php | 11 +- .../Console/CreateApiKeyCommand.php | 67 + .../Admin/ArticleTypeCrudController.php | 48 + .../AttributeDefinitionCrudController.php | 62 + .../Controller/Admin/DashboardController.php | 19 +- .../Controller/ChangePasswordController.php | 10 +- .../Http/Form/StringArrayType.php | 37 + symfony.lock | 129 + templates/security/change_password.html.twig | 14 +- templates/security/login.html.twig | 2 +- tests/Unit/Domain/Auth/ApiKeyTest.php | 98 + .../Infrastructure/AI/MistralClientTest.php | 123 + .../Admin/ArticleTypeCrudControllerTest.php | 41 + .../AttributeDefinitionCrudControllerTest.php | 81 + .../Console/CreateApiKeyCommandTest.php | 115 + .../Form/StringArrayTypeTest.php | 51 + 31 files changed, 3514 insertions(+), 117 deletions(-) create mode 100644 config/packages/csrf.yaml create mode 100644 config/packages/monolog.yaml create mode 100644 config/packages/scheb_2fa.yaml create mode 100644 config/packages/translation.yaml create mode 100644 config/packages/twig.yaml create mode 100644 config/packages/twig_component.yaml create mode 100644 config/routes/scheb_2fa.yaml create mode 100644 docs/superpowers/specs/2026-05-13-superseller3000-design.md create mode 100644 src/Infrastructure/Console/CreateApiKeyCommand.php create mode 100644 src/Infrastructure/Http/Controller/Admin/ArticleTypeCrudController.php create mode 100644 src/Infrastructure/Http/Controller/Admin/AttributeDefinitionCrudController.php create mode 100644 src/Infrastructure/Http/Form/StringArrayType.php create mode 100644 tests/Unit/Domain/Auth/ApiKeyTest.php create mode 100644 tests/Unit/Infrastructure/AI/MistralClientTest.php create mode 100644 tests/Unit/Infrastructure/Admin/ArticleTypeCrudControllerTest.php create mode 100644 tests/Unit/Infrastructure/Admin/AttributeDefinitionCrudControllerTest.php create mode 100644 tests/Unit/Infrastructure/Console/CreateApiKeyCommandTest.php create mode 100644 tests/Unit/Infrastructure/Form/StringArrayTypeTest.php diff --git a/composer.json b/composer.json index e0637ba..a024ed0 100644 --- a/composer.json +++ b/composer.json @@ -10,6 +10,12 @@ "doctrine/doctrine-bundle": "*", "doctrine/doctrine-migrations-bundle": "*", "doctrine/orm": "*", + "easycorp/easyadmin-bundle": "*", + "endroid/qr-code": "*", + "endroid/qr-code-bundle": "*", + "scheb/2fa-backup-code": "*", + "scheb/2fa-bundle": "*", + "scheb/2fa-totp": "*", "symfony/console": "8.0.*", "symfony/dotenv": "8.0.*", "symfony/flex": "^2", @@ -17,6 +23,8 @@ "symfony/http-client": "8.0.*", "symfony/mailer": "8.0.*", "symfony/messenger": "8.0.*", + "symfony/monolog-bundle": "^4.0", + "symfony/redis-messenger": "8.0.*", "symfony/runtime": "8.0.*", "symfony/security-bundle": "8.0.*", "symfony/serializer": "8.0.*", diff --git a/composer.lock b/composer.lock index c91d44c..fb2d064 100644 --- a/composer.lock +++ b/composer.lock @@ -4,8 +4,113 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "dbf73d90a5c13c3bfbbb78e56a672559", + "content-hash": "d301ecbb32180f89d93af20af66ef70a", "packages": [ + { + "name": "bacon/bacon-qr-code", + "version": "v3.1.1", + "source": { + "type": "git", + "url": "https://github.com/Bacon/BaconQrCode.git", + "reference": "4da2233e72eeecd9be3b62e0dc2cc9ed8e2e31c2" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Bacon/BaconQrCode/zipball/4da2233e72eeecd9be3b62e0dc2cc9ed8e2e31c2", + "reference": "4da2233e72eeecd9be3b62e0dc2cc9ed8e2e31c2", + "shasum": "" + }, + "require": { + "dasprid/enum": "^1.0.3", + "ext-iconv": "*", + "php": "^8.1" + }, + "require-dev": { + "phly/keep-a-changelog": "^2.12", + "phpunit/phpunit": "^10.5.11 || ^11.0.4", + "spatie/phpunit-snapshot-assertions": "^5.1.5", + "spatie/pixelmatch-php": "^1.2.0", + "squizlabs/php_codesniffer": "^3.9" + }, + "suggest": { + "ext-imagick": "to generate QR code images" + }, + "type": "library", + "autoload": { + "psr-4": { + "BaconQrCode\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-2-Clause" + ], + "authors": [ + { + "name": "Ben Scholzen 'DASPRiD'", + "email": "mail@dasprids.de", + "homepage": "https://dasprids.de/", + "role": "Developer" + } + ], + "description": "BaconQrCode is a QR code generator for PHP.", + "homepage": "https://github.com/Bacon/BaconQrCode", + "support": { + "issues": "https://github.com/Bacon/BaconQrCode/issues", + "source": "https://github.com/Bacon/BaconQrCode/tree/v3.1.1" + }, + "time": "2026-04-05T21:06:35+00:00" + }, + { + "name": "dasprid/enum", + "version": "1.0.7", + "source": { + "type": "git", + "url": "https://github.com/DASPRiD/Enum.git", + "reference": "b5874fa9ed0043116c72162ec7f4fb50e02e7cce" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/DASPRiD/Enum/zipball/b5874fa9ed0043116c72162ec7f4fb50e02e7cce", + "reference": "b5874fa9ed0043116c72162ec7f4fb50e02e7cce", + "shasum": "" + }, + "require": { + "php": ">=7.1 <9.0" + }, + "require-dev": { + "phpunit/phpunit": "^7 || ^8 || ^9 || ^10 || ^11", + "squizlabs/php_codesniffer": "*" + }, + "type": "library", + "autoload": { + "psr-4": { + "DASPRiD\\Enum\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-2-Clause" + ], + "authors": [ + { + "name": "Ben Scholzen 'DASPRiD'", + "email": "mail@dasprids.de", + "homepage": "https://dasprids.de/", + "role": "Developer" + } + ], + "description": "PHP 7.1 enum implementation", + "keywords": [ + "enum", + "map" + ], + "support": { + "issues": "https://github.com/DASPRiD/Enum/issues", + "source": "https://github.com/DASPRiD/Enum/tree/1.0.7" + }, + "time": "2025-09-16T12:23:56+00:00" + }, { "name": "doctrine/collections", "version": "2.6.0", @@ -1120,6 +1225,113 @@ }, "time": "2026-02-08T16:21:46+00:00" }, + { + "name": "easycorp/easyadmin-bundle", + "version": "v5.0.8", + "source": { + "type": "git", + "url": "https://github.com/EasyCorp/EasyAdminBundle.git", + "reference": "0feafc61cd7a2b990c2d7280fd7a450696a7fe92" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/EasyCorp/EasyAdminBundle/zipball/0feafc61cd7a2b990c2d7280fd7a450696a7fe92", + "reference": "0feafc61cd7a2b990c2d7280fd7a450696a7fe92", + "shasum": "" + }, + "require": { + "doctrine/dbal": "^3.10|^4.4", + "doctrine/doctrine-bundle": "^2.18|^3.2", + "doctrine/orm": "^2.20|^3.6", + "php": ">=8.2", + "symfony/asset": "^6.4|^7.0|^8.0", + "symfony/cache": "^6.4.33|^7.0|^8.0", + "symfony/config": "^6.4.32|^7.0|^8.0", + "symfony/dependency-injection": "^6.4.32|^7.0|^8.0", + "symfony/deprecation-contracts": "^3.0", + "symfony/doctrine-bridge": "^6.4.32|^7.0|^8.0", + "symfony/event-dispatcher": "^6.4.32|^7.0|^8.0", + "symfony/filesystem": "^6.4.30|^7.0|^8.0", + "symfony/form": "^6.4.32|^7.0|^8.0", + "symfony/framework-bundle": "^6.4.33|^7.0|^8.0", + "symfony/http-foundation": "^6.4.33|^7.0|^8.0", + "symfony/http-kernel": "^6.4.33|^7.0|^8.0", + "symfony/intl": "^6.4.32|^7.0|^8.0", + "symfony/property-access": "^6.4.32|^7.0|^8.0", + "symfony/security-bundle": "^6.4.32|^7.0|^8.0", + "symfony/string": "^6.4.30|^7.0|^8.0", + "symfony/translation": "^6.4.32|^7.0|^8.0", + "symfony/twig-bridge": "^6.4.32|^7.1.9|^7.2|^8.0", + "symfony/twig-bundle": "^6.4.32|^7.0|^8.0", + "symfony/uid": "^6.4.32|^7.0|^8.0", + "symfony/ux-twig-component": "^2.32|^3.0", + "symfony/validator": "^6.4.33|^7.0|^8.0", + "twig/extra-bundle": "^3.23", + "twig/html-extra": "^3.23", + "twig/twig": "^3.23" + }, + "require-dev": { + "dama/doctrine-test-bundle": "^8.2", + "doctrine/doctrine-fixtures-bundle": "^3.4|3.5.x-dev|^4.0", + "league/flysystem": "^3.10", + "moneyphp/money": "^4.8", + "phpstan/extension-installer": "^1.4", + "phpstan/phpstan": "^2.0", + "phpstan/phpstan-phpunit": "^2.0", + "phpstan/phpstan-strict-rules": "^2.0", + "phpstan/phpstan-symfony": "^2.0", + "psr/log": "^1.0|^2.0|^3.0", + "symfony/browser-kit": "^6.4.32|^7.0|^8.0", + "symfony/css-selector": "^6.4.24|^7.0|^8.0", + "symfony/debug-bundle": "^6.4.27|^7.0|^8.0", + "symfony/dom-crawler": "^6.4.32|^7.0|^8.0", + "symfony/expression-language": "^6.4.32|^7.0|^8.0", + "symfony/phpunit-bridge": "^6.4.26|^7.0|^8.0", + "symfony/process": "^6.4.33|^7.0|^8.0", + "symfony/web-link": "^6.4.32|^7.0|^8.0", + "vincentlanglet/twig-cs-fixer": "^3.10", + "zenstruck/foundry": "^2.3" + }, + "type": "symfony-bundle", + "extra": { + "branch-alias": { + "dev-master": "5.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "EasyCorp\\Bundle\\EasyAdminBundle\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Project Contributors", + "homepage": "https://github.com/EasyCorp/EasyAdminBundle/graphs/contributors" + } + ], + "description": "Admin generator for Symfony applications", + "homepage": "https://github.com/EasyCorp/EasyAdminBundle", + "keywords": [ + "admin", + "backend", + "generator" + ], + "support": { + "issues": "https://github.com/EasyCorp/EasyAdminBundle/issues", + "source": "https://github.com/EasyCorp/EasyAdminBundle/tree/v5.0.8" + }, + "funding": [ + { + "url": "https://github.com/javiereguiluz", + "type": "github" + } + ], + "time": "2026-05-13T17:18:33+00:00" + }, { "name": "egulias/email-validator", "version": "4.0.4", @@ -1187,6 +1399,319 @@ ], "time": "2025-03-06T22:45:56+00:00" }, + { + "name": "endroid/qr-code", + "version": "6.1.3", + "source": { + "type": "git", + "url": "https://github.com/endroid/qr-code.git", + "reference": "5fa534856ed95649d67c0eab0cabc03ab1d8e0e2" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/endroid/qr-code/zipball/5fa534856ed95649d67c0eab0cabc03ab1d8e0e2", + "reference": "5fa534856ed95649d67c0eab0cabc03ab1d8e0e2", + "shasum": "" + }, + "require": { + "bacon/bacon-qr-code": "^3.0", + "php": "^8.4" + }, + "require-dev": { + "endroid/quality": "dev-main", + "ext-gd": "*", + "khanamiryan/qrcode-detector-decoder": "^2.0.3", + "setasign/fpdf": "^1.8.2" + }, + "suggest": { + "ext-gd": "Enables you to write PNG images", + "khanamiryan/qrcode-detector-decoder": "Enables you to use the image validator", + "roave/security-advisories": "Makes sure package versions with known security issues are not installed", + "setasign/fpdf": "Enables you to use the PDF writer" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "6.x-dev" + } + }, + "autoload": { + "psr-4": { + "Endroid\\QrCode\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Jeroen van den Enden", + "email": "info@endroid.nl" + } + ], + "description": "Endroid QR Code", + "homepage": "https://github.com/endroid/qr-code", + "keywords": [ + "code", + "endroid", + "php", + "qr", + "qrcode" + ], + "support": { + "issues": "https://github.com/endroid/qr-code/issues", + "source": "https://github.com/endroid/qr-code/tree/6.1.3" + }, + "funding": [ + { + "url": "https://github.com/endroid", + "type": "github" + } + ], + "time": "2026-02-05T07:01:58+00:00" + }, + { + "name": "endroid/qr-code-bundle", + "version": "7.0.0", + "source": { + "type": "git", + "url": "https://github.com/endroid/qr-code-bundle.git", + "reference": "6e4c1a8a44f0e3ebfbf50d3eb543b3192ec8e353" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/endroid/qr-code-bundle/zipball/6e4c1a8a44f0e3ebfbf50d3eb543b3192ec8e353", + "reference": "6e4c1a8a44f0e3ebfbf50d3eb543b3192ec8e353", + "shasum": "" + }, + "require": { + "endroid/qr-code": "^6.1.0", + "php": "^8.4", + "symfony/framework-bundle": "^6.4||^7.4||^8.0", + "symfony/twig-bundle": "^6.4||^7.4||^8.0" + }, + "require-dev": { + "endroid/quality": "dev-main" + }, + "suggest": { + "roave/security-advisories": "Avoids installation of package versions with vulnerabilities" + }, + "type": "symfony-bundle", + "extra": { + "branch-alias": { + "dev-main": "7.x-dev" + } + }, + "autoload": { + "psr-4": { + "Endroid\\QrCodeBundle\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Jeroen van den Enden", + "email": "info@endroid.nl" + } + ], + "description": "Endroid QR Code Bundle", + "homepage": "https://github.com/endroid/qr-code-bundle", + "keywords": [ + "bundle", + "code", + "endroid", + "php", + "qr", + "symfony" + ], + "support": { + "issues": "https://github.com/endroid/qr-code-bundle/issues", + "source": "https://github.com/endroid/qr-code-bundle/tree/7.0.0" + }, + "funding": [ + { + "url": "https://github.com/endroid", + "type": "github" + } + ], + "time": "2026-04-22T18:13:09+00:00" + }, + { + "name": "monolog/monolog", + "version": "3.10.0", + "source": { + "type": "git", + "url": "https://github.com/Seldaek/monolog.git", + "reference": "b321dd6749f0bf7189444158a3ce785cc16d69b0" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Seldaek/monolog/zipball/b321dd6749f0bf7189444158a3ce785cc16d69b0", + "reference": "b321dd6749f0bf7189444158a3ce785cc16d69b0", + "shasum": "" + }, + "require": { + "php": ">=8.1", + "psr/log": "^2.0 || ^3.0" + }, + "provide": { + "psr/log-implementation": "3.0.0" + }, + "require-dev": { + "aws/aws-sdk-php": "^3.0", + "doctrine/couchdb": "~1.0@dev", + "elasticsearch/elasticsearch": "^7 || ^8", + "ext-json": "*", + "graylog2/gelf-php": "^1.4.2 || ^2.0", + "guzzlehttp/guzzle": "^7.4.5", + "guzzlehttp/psr7": "^2.2", + "mongodb/mongodb": "^1.8 || ^2.0", + "php-amqplib/php-amqplib": "~2.4 || ^3", + "php-console/php-console": "^3.1.8", + "phpstan/phpstan": "^2", + "phpstan/phpstan-deprecation-rules": "^2", + "phpstan/phpstan-strict-rules": "^2", + "phpunit/phpunit": "^10.5.17 || ^11.0.7", + "predis/predis": "^1.1 || ^2", + "rollbar/rollbar": "^4.0", + "ruflin/elastica": "^7 || ^8", + "symfony/mailer": "^5.4 || ^6", + "symfony/mime": "^5.4 || ^6" + }, + "suggest": { + "aws/aws-sdk-php": "Allow sending log messages to AWS services like DynamoDB", + "doctrine/couchdb": "Allow sending log messages to a CouchDB server", + "elasticsearch/elasticsearch": "Allow sending log messages to an Elasticsearch server via official client", + "ext-amqp": "Allow sending log messages to an AMQP server (1.0+ required)", + "ext-curl": "Required to send log messages using the IFTTTHandler, the LogglyHandler, the SendGridHandler, the SlackWebhookHandler or the TelegramBotHandler", + "ext-mbstring": "Allow to work properly with unicode symbols", + "ext-mongodb": "Allow sending log messages to a MongoDB server (via driver)", + "ext-openssl": "Required to send log messages using SSL", + "ext-sockets": "Allow sending log messages to a Syslog server (via UDP driver)", + "graylog2/gelf-php": "Allow sending log messages to a GrayLog2 server", + "mongodb/mongodb": "Allow sending log messages to a MongoDB server (via library)", + "php-amqplib/php-amqplib": "Allow sending log messages to an AMQP server using php-amqplib", + "rollbar/rollbar": "Allow sending log messages to Rollbar", + "ruflin/elastica": "Allow sending log messages to an Elastic Search server" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Monolog\\": "src/Monolog" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Jordi Boggiano", + "email": "j.boggiano@seld.be", + "homepage": "https://seld.be" + } + ], + "description": "Sends your logs to files, sockets, inboxes, databases and various web services", + "homepage": "https://github.com/Seldaek/monolog", + "keywords": [ + "log", + "logging", + "psr-3" + ], + "support": { + "issues": "https://github.com/Seldaek/monolog/issues", + "source": "https://github.com/Seldaek/monolog/tree/3.10.0" + }, + "funding": [ + { + "url": "https://github.com/Seldaek", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/monolog/monolog", + "type": "tidelift" + } + ], + "time": "2026-01-02T08:56:05+00:00" + }, + { + "name": "paragonie/constant_time_encoding", + "version": "v3.1.3", + "source": { + "type": "git", + "url": "https://github.com/paragonie/constant_time_encoding.git", + "reference": "d5b01a39b3415c2cd581d3bd3a3575c1ebbd8e77" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/paragonie/constant_time_encoding/zipball/d5b01a39b3415c2cd581d3bd3a3575c1ebbd8e77", + "reference": "d5b01a39b3415c2cd581d3bd3a3575c1ebbd8e77", + "shasum": "" + }, + "require": { + "php": "^8" + }, + "require-dev": { + "infection/infection": "^0", + "nikic/php-fuzzer": "^0", + "phpunit/phpunit": "^9|^10|^11", + "vimeo/psalm": "^4|^5|^6" + }, + "type": "library", + "autoload": { + "psr-4": { + "ParagonIE\\ConstantTime\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Paragon Initiative Enterprises", + "email": "security@paragonie.com", + "homepage": "https://paragonie.com", + "role": "Maintainer" + }, + { + "name": "Steve 'Sc00bz' Thomas", + "email": "steve@tobtu.com", + "homepage": "https://www.tobtu.com", + "role": "Original Developer" + } + ], + "description": "Constant-time Implementations of RFC 4648 Encoding (Base-64, Base-32, Base-16)", + "keywords": [ + "base16", + "base32", + "base32_decode", + "base32_encode", + "base64", + "base64_decode", + "base64_encode", + "bin2hex", + "encoding", + "hex", + "hex2bin", + "rfc4648" + ], + "support": { + "email": "info@paragonie.com", + "issues": "https://github.com/paragonie/constant_time_encoding/issues", + "source": "https://github.com/paragonie/constant_time_encoding" + }, + "time": "2025-09-24T15:06:41+00:00" + }, { "name": "psr/cache", "version": "3.0.0", @@ -1437,6 +1962,316 @@ }, "time": "2024-09-11T13:17:53+00:00" }, + { + "name": "scheb/2fa-backup-code", + "version": "v8.5.0", + "source": { + "type": "git", + "url": "https://github.com/scheb/2fa-backup-code.git", + "reference": "cf4251fcc24f4a39d1307d8bbfabecfbd21ed57b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/scheb/2fa-backup-code/zipball/cf4251fcc24f4a39d1307d8bbfabecfbd21ed57b", + "reference": "cf4251fcc24f4a39d1307d8bbfabecfbd21ed57b", + "shasum": "" + }, + "require": { + "php": "~8.4.0 || ~8.5.0", + "scheb/2fa-bundle": "self.version" + }, + "type": "library", + "autoload": { + "psr-4": { + "Scheb\\TwoFactorBundle\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Christian Scheb", + "email": "me@christianscheb.de" + } + ], + "description": "Extends scheb/2fa-bundle with backup codes support", + "homepage": "https://github.com/scheb/2fa", + "keywords": [ + "2fa", + "Authentication", + "backup-codes", + "symfony", + "two-factor", + "two-step" + ], + "support": { + "source": "https://github.com/scheb/2fa-backup-code/tree/v8.5.0" + }, + "time": "2026-01-24T13:26:10+00:00" + }, + { + "name": "scheb/2fa-bundle", + "version": "v8.5.0", + "source": { + "type": "git", + "url": "https://github.com/scheb/2fa-bundle.git", + "reference": "ae26ae91723685e3a8622f2f3b9119016de23e20" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/scheb/2fa-bundle/zipball/ae26ae91723685e3a8622f2f3b9119016de23e20", + "reference": "ae26ae91723685e3a8622f2f3b9119016de23e20", + "shasum": "" + }, + "require": { + "ext-json": "*", + "php": "~8.4.0 || ~8.5.0", + "symfony/config": "^7.4 || ^8.0", + "symfony/dependency-injection": "^7.4 || ^8.0", + "symfony/event-dispatcher": "^7.4 || ^8.0", + "symfony/framework-bundle": "^7.4 || ^8.0", + "symfony/http-foundation": "^7.4 || ^8.0", + "symfony/http-kernel": "^7.4 || ^8.0", + "symfony/property-access": "^7.4 || ^8.0", + "symfony/security-bundle": "^7.4 || ^8.0", + "symfony/service-contracts": "^2.5|^3", + "symfony/twig-bundle": "^7.4 || ^8.0" + }, + "conflict": { + "scheb/two-factor-bundle": "*" + }, + "suggest": { + "scheb/2fa-backup-code": "Emergency codes when you have no access to other methods", + "scheb/2fa-email": "Send codes by email", + "scheb/2fa-google-authenticator": "Google Authenticator support", + "scheb/2fa-totp": "Temporary one-time password (TOTP) support (Google Authenticator compatible)", + "scheb/2fa-trusted-device": "Trusted devices support" + }, + "type": "symfony-bundle", + "autoload": { + "psr-4": { + "Scheb\\TwoFactorBundle\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Christian Scheb", + "email": "me@christianscheb.de" + } + ], + "description": "A generic interface to implement two-factor authentication in Symfony applications", + "homepage": "https://github.com/scheb/2fa", + "keywords": [ + "2fa", + "Authentication", + "symfony", + "two-factor", + "two-step" + ], + "support": { + "source": "https://github.com/scheb/2fa-bundle/tree/v8.5.0" + }, + "time": "2026-03-24T18:33:45+00:00" + }, + { + "name": "scheb/2fa-totp", + "version": "v8.5.0", + "source": { + "type": "git", + "url": "https://github.com/scheb/2fa-totp.git", + "reference": "ca7562c6b6f9e5bb30cadcc98123327c2540e18f" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/scheb/2fa-totp/zipball/ca7562c6b6f9e5bb30cadcc98123327c2540e18f", + "reference": "ca7562c6b6f9e5bb30cadcc98123327c2540e18f", + "shasum": "" + }, + "require": { + "php": "~8.4.0 || ~8.5.0", + "scheb/2fa-bundle": "self.version", + "spomky-labs/otphp": "^11.4" + }, + "suggest": { + "symfony/validator": "Needed if you want to use the TOTP validator constraint" + }, + "type": "library", + "autoload": { + "psr-4": { + "Scheb\\TwoFactorBundle\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Christian Scheb", + "email": "me@christianscheb.de" + } + ], + "description": "Extends scheb/2fa-bundle with two-factor authentication using TOTP", + "homepage": "https://github.com/scheb/2fa", + "keywords": [ + "2fa", + "Authentication", + "symfony", + "totp", + "two-factor", + "two-step" + ], + "support": { + "source": "https://github.com/scheb/2fa-totp/tree/v8.5.0" + }, + "time": "2026-01-24T13:27:55+00:00" + }, + { + "name": "spomky-labs/otphp", + "version": "11.4.2", + "source": { + "type": "git", + "url": "https://github.com/Spomky-Labs/otphp.git", + "reference": "2a1b503fd1c1a5c751ab3c5cd37f2d2d26ab74ad" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Spomky-Labs/otphp/zipball/2a1b503fd1c1a5c751ab3c5cd37f2d2d26ab74ad", + "reference": "2a1b503fd1c1a5c751ab3c5cd37f2d2d26ab74ad", + "shasum": "" + }, + "require": { + "paragonie/constant_time_encoding": "^2.0 || ^3.0", + "php": ">=8.1", + "psr/clock": "^1.0", + "symfony/deprecation-contracts": "^3.2" + }, + "require-dev": { + "symfony/error-handler": "^6.4|^7.0|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "OTPHP\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Florent Morselli", + "homepage": "https://github.com/Spomky" + }, + { + "name": "All contributors", + "homepage": "https://github.com/Spomky-Labs/otphp/contributors" + } + ], + "description": "A PHP library for generating one time passwords according to RFC 4226 (HOTP Algorithm) and the RFC 6238 (TOTP Algorithm) and compatible with Google Authenticator", + "homepage": "https://github.com/Spomky-Labs/otphp", + "keywords": [ + "FreeOTP", + "RFC 4226", + "RFC 6238", + "google authenticator", + "hotp", + "otp", + "totp" + ], + "support": { + "issues": "https://github.com/Spomky-Labs/otphp/issues", + "source": "https://github.com/Spomky-Labs/otphp/tree/11.4.2" + }, + "funding": [ + { + "url": "https://github.com/Spomky", + "type": "github" + }, + { + "url": "https://www.patreon.com/FlorentMorselli", + "type": "patreon" + } + ], + "time": "2026-01-23T10:53:01+00:00" + }, + { + "name": "symfony/asset", + "version": "v8.0.8", + "source": { + "type": "git", + "url": "https://github.com/symfony/asset.git", + "reference": "72eca261f3af1bef741c48bb2c91a4e619dca03a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/asset/zipball/72eca261f3af1bef741c48bb2c91a4e619dca03a", + "reference": "72eca261f3af1bef741c48bb2c91a4e619dca03a", + "shasum": "" + }, + "require": { + "php": ">=8.4" + }, + "require-dev": { + "symfony/http-client": "^7.4|^8.0", + "symfony/http-foundation": "^7.4|^8.0", + "symfony/http-kernel": "^7.4|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Asset\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Manages URL generation and versioning of web assets such as CSS stylesheets, JavaScript files and image files", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/asset/tree/v8.0.8" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-03-30T15:14:47+00:00" + }, { "name": "symfony/cache", "version": "v8.0.10", @@ -2646,6 +3481,101 @@ ], "time": "2025-11-16T09:38:19+00:00" }, + { + "name": "symfony/form", + "version": "v8.0.9", + "source": { + "type": "git", + "url": "https://github.com/symfony/form.git", + "reference": "dd9f73dd3b92e657c97aeeca1f47e981c635ea91" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/form/zipball/dd9f73dd3b92e657c97aeeca1f47e981c635ea91", + "reference": "dd9f73dd3b92e657c97aeeca1f47e981c635ea91", + "shasum": "" + }, + "require": { + "php": ">=8.4", + "symfony/event-dispatcher": "^7.4|^8.0", + "symfony/options-resolver": "^7.4|^8.0", + "symfony/polyfill-ctype": "^1.8", + "symfony/polyfill-intl-icu": "^1.21", + "symfony/polyfill-mbstring": "^1.0", + "symfony/property-access": "^7.4|^8.0", + "symfony/service-contracts": "^2.5|^3" + }, + "conflict": { + "symfony/intl": "<7.4", + "symfony/translation-contracts": "<2.5", + "symfony/validator": "<7.4" + }, + "require-dev": { + "doctrine/collections": "^1.0|^2.0", + "symfony/clock": "^7.4|^8.0", + "symfony/config": "^7.4|^8.0", + "symfony/console": "^7.4|^8.0", + "symfony/dependency-injection": "^7.4|^8.0", + "symfony/expression-language": "^7.4|^8.0", + "symfony/html-sanitizer": "^7.4|^8.0", + "symfony/http-foundation": "^7.4|^8.0", + "symfony/http-kernel": "^7.4|^8.0", + "symfony/intl": "^7.4|^8.0", + "symfony/security-core": "^7.4|^8.0", + "symfony/security-csrf": "^7.4|^8.0", + "symfony/translation": "^7.4|^8.0", + "symfony/uid": "^7.4|^8.0", + "symfony/validator": "^7.4|^8.0", + "symfony/var-dumper": "^7.4|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Form\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Allows to easily create, process and reuse HTML forms", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/form/tree/v8.0.9" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-04-29T15:02:55+00:00" + }, { "name": "symfony/framework-bundle", "version": "v8.0.11", @@ -3149,6 +4079,95 @@ ], "time": "2026-05-13T18:07:14+00:00" }, + { + "name": "symfony/intl", + "version": "v8.0.8", + "source": { + "type": "git", + "url": "https://github.com/symfony/intl.git", + "reference": "604a1dbbd67471e885e93274379cadd80dc33535" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/intl/zipball/604a1dbbd67471e885e93274379cadd80dc33535", + "reference": "604a1dbbd67471e885e93274379cadd80dc33535", + "shasum": "" + }, + "require": { + "php": ">=8.4" + }, + "conflict": { + "symfony/string": "<7.4" + }, + "require-dev": { + "symfony/filesystem": "^7.4|^8.0", + "symfony/var-exporter": "^7.4|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Intl\\": "" + }, + "exclude-from-classmap": [ + "/Tests/", + "/Resources/data/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Bernhard Schussek", + "email": "bschussek@gmail.com" + }, + { + "name": "Eriksen Costa", + "email": "eriksen.costa@infranology.com.br" + }, + { + "name": "Igor Wiedler", + "email": "igor@wiedler.ch" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides access to the localization data of the ICU library", + "homepage": "https://symfony.com", + "keywords": [ + "i18n", + "icu", + "internationalization", + "intl", + "l10n", + "localization" + ], + "support": { + "source": "https://github.com/symfony/intl/tree/v8.0.8" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-03-30T15:14:47+00:00" + }, { "name": "symfony/mailer", "version": "v8.0.8", @@ -3405,6 +4424,233 @@ ], "time": "2026-04-29T15:02:55+00:00" }, + { + "name": "symfony/monolog-bridge", + "version": "v8.0.9", + "source": { + "type": "git", + "url": "https://github.com/symfony/monolog-bridge.git", + "reference": "4b7249b1520773ad325e99231b08443017729297" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/monolog-bridge/zipball/4b7249b1520773ad325e99231b08443017729297", + "reference": "4b7249b1520773ad325e99231b08443017729297", + "shasum": "" + }, + "require": { + "monolog/monolog": "^3", + "php": ">=8.4", + "symfony/http-kernel": "^7.4|^8.0", + "symfony/service-contracts": "^2.5|^3" + }, + "require-dev": { + "symfony/console": "^7.4|^8.0", + "symfony/http-client": "^7.4|^8.0", + "symfony/mailer": "^7.4|^8.0", + "symfony/messenger": "^7.4|^8.0", + "symfony/mime": "^7.4|^8.0", + "symfony/security-core": "^7.4|^8.0", + "symfony/var-dumper": "^7.4|^8.0" + }, + "type": "symfony-bridge", + "autoload": { + "psr-4": { + "Symfony\\Bridge\\Monolog\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides integration for Monolog with various Symfony components", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/monolog-bridge/tree/v8.0.9" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-04-29T15:02:55+00:00" + }, + { + "name": "symfony/monolog-bundle", + "version": "v4.0.2", + "source": { + "type": "git", + "url": "https://github.com/symfony/monolog-bundle.git", + "reference": "c012c6aba13129eb02aa7dd61e66e720911d8598" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/monolog-bundle/zipball/c012c6aba13129eb02aa7dd61e66e720911d8598", + "reference": "c012c6aba13129eb02aa7dd61e66e720911d8598", + "shasum": "" + }, + "require": { + "composer-runtime-api": "^2.0", + "monolog/monolog": "^3.5", + "php": ">=8.2", + "symfony/config": "^7.3 || ^8.0", + "symfony/dependency-injection": "^7.3 || ^8.0", + "symfony/http-kernel": "^7.3 || ^8.0", + "symfony/monolog-bridge": "^7.3 || ^8.0", + "symfony/polyfill-php84": "^1.30" + }, + "require-dev": { + "phpunit/phpunit": "^11.5.41 || ^12.3", + "symfony/console": "^7.3 || ^8.0", + "symfony/yaml": "^7.3 || ^8.0" + }, + "type": "symfony-bundle", + "autoload": { + "psr-4": { + "Symfony\\Bundle\\MonologBundle\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony MonologBundle", + "homepage": "https://symfony.com", + "keywords": [ + "log", + "logging" + ], + "support": { + "issues": "https://github.com/symfony/monolog-bundle/issues", + "source": "https://github.com/symfony/monolog-bundle/tree/v4.0.2" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-04-02T18:27:21+00:00" + }, + { + "name": "symfony/options-resolver", + "version": "v8.0.8", + "source": { + "type": "git", + "url": "https://github.com/symfony/options-resolver.git", + "reference": "b48bce0a70b914f6953dafbd10474df232ed4de8" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/options-resolver/zipball/b48bce0a70b914f6953dafbd10474df232ed4de8", + "reference": "b48bce0a70b914f6953dafbd10474df232ed4de8", + "shasum": "" + }, + "require": { + "php": ">=8.4", + "symfony/deprecation-contracts": "^2.5|^3" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\OptionsResolver\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides an improved replacement for the array_replace PHP function", + "homepage": "https://symfony.com", + "keywords": [ + "config", + "configuration", + "options" + ], + "support": { + "source": "https://github.com/symfony/options-resolver/tree/v8.0.8" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-03-30T15:14:47+00:00" + }, { "name": "symfony/password-hasher", "version": "v8.0.8", @@ -3560,6 +4806,94 @@ ], "time": "2026-04-26T13:13:48+00:00" }, + { + "name": "symfony/polyfill-intl-icu", + "version": "v1.37.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-intl-icu.git", + "reference": "3510b63d07376b04e57e27e82607d468bb134f78" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-intl-icu/zipball/3510b63d07376b04e57e27e82607d468bb134f78", + "reference": "3510b63d07376b04e57e27e82607d468bb134f78", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "suggest": { + "ext-intl": "For best performance and support of other locales than \"en\"" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Intl\\Icu\\": "" + }, + "classmap": [ + "Resources/stubs" + ], + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for intl's ICU-related data and classes", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "icu", + "intl", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-intl-icu/tree/v1.37.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-04-10T16:50:15+00:00" + }, { "name": "symfony/polyfill-intl-idn", "version": "v1.37.0", @@ -4147,6 +5481,82 @@ ], "time": "2026-03-30T15:14:47+00:00" }, + { + "name": "symfony/redis-messenger", + "version": "v8.0.8", + "source": { + "type": "git", + "url": "https://github.com/symfony/redis-messenger.git", + "reference": "d3e882f7b9ce1ca93e529de7cc79f55fad9af4a7" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/redis-messenger/zipball/d3e882f7b9ce1ca93e529de7cc79f55fad9af4a7", + "reference": "d3e882f7b9ce1ca93e529de7cc79f55fad9af4a7", + "shasum": "" + }, + "require": { + "ext-redis": "*", + "php": ">=8.4", + "symfony/messenger": "^7.4|^8.0" + }, + "conflict": { + "ext-redis": "<6.1", + "ext-relay": "<0.12" + }, + "require-dev": { + "symfony/property-access": "^7.4|^8.0", + "symfony/serializer": "^7.4|^8.0", + "symfony/var-dumper": "^7.4|^8.0" + }, + "type": "symfony-messenger-bridge", + "autoload": { + "psr-4": { + "Symfony\\Component\\Messenger\\Bridge\\Redis\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony Redis extension Messenger Bridge", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/redis-messenger/tree/v8.0.8" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-03-17T14:00:10+00:00" + }, { "name": "symfony/routing", "version": "v8.0.9", @@ -4991,6 +6401,99 @@ ], "time": "2026-05-13T12:07:53+00:00" }, + { + "name": "symfony/translation", + "version": "v8.0.10", + "source": { + "type": "git", + "url": "https://github.com/symfony/translation.git", + "reference": "f63e9342e12646a57c91ef8a366a4f9d8e557b67" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/translation/zipball/f63e9342e12646a57c91ef8a366a4f9d8e557b67", + "reference": "f63e9342e12646a57c91ef8a366a4f9d8e557b67", + "shasum": "" + }, + "require": { + "php": ">=8.4", + "symfony/polyfill-mbstring": "^1.0", + "symfony/translation-contracts": "^3.6.1" + }, + "conflict": { + "nikic/php-parser": "<5.0", + "symfony/http-client-contracts": "<2.5", + "symfony/service-contracts": "<2.5" + }, + "provide": { + "symfony/translation-implementation": "2.3|3.0" + }, + "require-dev": { + "nikic/php-parser": "^5.0", + "psr/log": "^1|^2|^3", + "symfony/config": "^7.4|^8.0", + "symfony/console": "^7.4|^8.0", + "symfony/dependency-injection": "^7.4|^8.0", + "symfony/finder": "^7.4|^8.0", + "symfony/http-client-contracts": "^2.5|^3.0", + "symfony/http-kernel": "^7.4|^8.0", + "symfony/intl": "^7.4|^8.0", + "symfony/polyfill-intl-icu": "^1.21", + "symfony/routing": "^7.4|^8.0", + "symfony/service-contracts": "^2.5|^3", + "symfony/yaml": "^7.4|^8.0" + }, + "type": "library", + "autoload": { + "files": [ + "Resources/functions.php" + ], + "psr-4": { + "Symfony\\Component\\Translation\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides tools to internationalize your application", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/translation/tree/v8.0.10" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-05-06T11:30:54+00:00" + }, { "name": "symfony/translation-contracts", "version": "v3.7.0", @@ -5073,6 +6576,198 @@ ], "time": "2026-01-05T13:30:16+00:00" }, + { + "name": "symfony/twig-bridge", + "version": "v8.0.8", + "source": { + "type": "git", + "url": "https://github.com/symfony/twig-bridge.git", + "reference": "a892d0b7f3d5d51b35895467e48aafbd1f2612a0" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/twig-bridge/zipball/a892d0b7f3d5d51b35895467e48aafbd1f2612a0", + "reference": "a892d0b7f3d5d51b35895467e48aafbd1f2612a0", + "shasum": "" + }, + "require": { + "php": ">=8.4", + "symfony/translation-contracts": "^2.5|^3", + "twig/twig": "^3.21" + }, + "conflict": { + "phpdocumentor/reflection-docblock": "<5.2|>=7", + "phpdocumentor/type-resolver": "<1.5.1", + "symfony/form": "<7.4.4|>8.0,<8.0.4", + "symfony/mime": "<7.4.8|>8.0,<8.0.8" + }, + "require-dev": { + "egulias/email-validator": "^2.1.10|^3|^4", + "league/html-to-markdown": "^5.0", + "phpdocumentor/reflection-docblock": "^5.2|^6.0", + "symfony/asset": "^7.4|^8.0", + "symfony/asset-mapper": "^7.4|^8.0", + "symfony/console": "^7.4|^8.0", + "symfony/dependency-injection": "^7.4|^8.0", + "symfony/emoji": "^7.4|^8.0", + "symfony/expression-language": "^7.4|^8.0", + "symfony/finder": "^7.4|^8.0", + "symfony/form": "^7.4.4|^8.0.4", + "symfony/html-sanitizer": "^7.4|^8.0", + "symfony/http-foundation": "^7.4|^8.0", + "symfony/http-kernel": "^7.4|^8.0", + "symfony/intl": "^7.4|^8.0", + "symfony/mime": "^7.4.8|^8.0.8", + "symfony/polyfill-intl-icu": "^1.0", + "symfony/property-info": "^7.4|^8.0", + "symfony/routing": "^7.4|^8.0", + "symfony/security-acl": "^2.8|^3.0", + "symfony/security-core": "^7.4|^8.0", + "symfony/security-csrf": "^7.4|^8.0", + "symfony/security-http": "^7.4|^8.0", + "symfony/serializer": "^7.4|^8.0", + "symfony/stopwatch": "^7.4|^8.0", + "symfony/translation": "^7.4|^8.0", + "symfony/validator": "^7.4|^8.0", + "symfony/web-link": "^7.4|^8.0", + "symfony/workflow": "^7.4|^8.0", + "symfony/yaml": "^7.4|^8.0", + "twig/cssinliner-extra": "^3", + "twig/inky-extra": "^3", + "twig/markdown-extra": "^3" + }, + "type": "symfony-bridge", + "autoload": { + "psr-4": { + "Symfony\\Bridge\\Twig\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides integration for Twig with various Symfony components", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/twig-bridge/tree/v8.0.8" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-03-30T15:14:47+00:00" + }, + { + "name": "symfony/twig-bundle", + "version": "v8.0.8", + "source": { + "type": "git", + "url": "https://github.com/symfony/twig-bundle.git", + "reference": "f83767b78e2580ca9fe9a2cf6fcff19cd5389bc1" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/twig-bundle/zipball/f83767b78e2580ca9fe9a2cf6fcff19cd5389bc1", + "reference": "f83767b78e2580ca9fe9a2cf6fcff19cd5389bc1", + "shasum": "" + }, + "require": { + "composer-runtime-api": ">=2.1", + "php": ">=8.4", + "symfony/config": "^7.4|^8.0", + "symfony/dependency-injection": "^7.4|^8.0", + "symfony/http-foundation": "^7.4|^8.0", + "symfony/http-kernel": "^7.4|^8.0", + "symfony/twig-bridge": "^7.4|^8.0" + }, + "require-dev": { + "symfony/asset": "^7.4|^8.0", + "symfony/expression-language": "^7.4|^8.0", + "symfony/finder": "^7.4|^8.0", + "symfony/form": "^7.4|^8.0", + "symfony/framework-bundle": "^7.4|^8.0", + "symfony/routing": "^7.4|^8.0", + "symfony/runtime": "^7.4|^8.0", + "symfony/stopwatch": "^7.4|^8.0", + "symfony/translation": "^7.4|^8.0", + "symfony/web-link": "^7.4|^8.0", + "symfony/yaml": "^7.4|^8.0" + }, + "type": "symfony-bundle", + "autoload": { + "psr-4": { + "Symfony\\Bundle\\TwigBundle\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides a tight integration of Twig into the Symfony full-stack framework", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/twig-bundle/tree/v8.0.8" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-03-30T15:14:47+00:00" + }, { "name": "symfony/type-info", "version": "v8.0.9", @@ -5233,6 +6928,94 @@ ], "time": "2026-04-30T16:10:06+00:00" }, + { + "name": "symfony/ux-twig-component", + "version": "v3.0.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/ux-twig-component.git", + "reference": "dea344e320238234c94f63b152d65821c80cd103" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/ux-twig-component/zipball/dea344e320238234c94f63b152d65821c80cd103", + "reference": "dea344e320238234c94f63b152d65821c80cd103", + "shasum": "" + }, + "require": { + "php": ">=8.4", + "symfony/dependency-injection": "^7.4|^8.0", + "symfony/deprecation-contracts": "^2.2|^3.0", + "symfony/event-dispatcher": "^7.4|^8.0", + "symfony/property-access": "^7.4|^8.0", + "twig/twig": "^3.10.3" + }, + "conflict": { + "symfony/config": "<6.4" + }, + "require-dev": { + "phpunit/phpunit": "^11.1|^12.0", + "symfony/console": "^7.4|^8.0", + "symfony/css-selector": "^7.4|^8.0", + "symfony/dom-crawler": "^7.4|^8.0", + "symfony/framework-bundle": "^7.4|^8.0", + "symfony/stimulus-bundle": "^2.9.1|^3.0", + "symfony/twig-bundle": "^7.4|^8.0", + "twig/extra-bundle": "^3.10.3", + "twig/html-extra": "^3.10.3" + }, + "type": "symfony-bundle", + "extra": { + "thanks": { + "url": "https://github.com/symfony/ux", + "name": "symfony/ux" + } + }, + "autoload": { + "psr-4": { + "Symfony\\UX\\TwigComponent\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Twig components for Symfony", + "homepage": "https://symfony.com", + "keywords": [ + "components", + "symfony-ux", + "twig" + ], + "support": { + "source": "https://github.com/symfony/ux-twig-component/tree/v3.0.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-04-09T22:56:44+00:00" + }, { "name": "symfony/validator", "version": "v8.0.10", @@ -5569,6 +7352,228 @@ } ], "time": "2026-05-13T12:07:53+00:00" + }, + { + "name": "twig/extra-bundle", + "version": "v3.24.0", + "source": { + "type": "git", + "url": "https://github.com/twigphp/twig-extra-bundle.git", + "reference": "6a621fcb1f28aa9ea7b34a99047ae0cdf5b834c9" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/twigphp/twig-extra-bundle/zipball/6a621fcb1f28aa9ea7b34a99047ae0cdf5b834c9", + "reference": "6a621fcb1f28aa9ea7b34a99047ae0cdf5b834c9", + "shasum": "" + }, + "require": { + "php": ">=8.1.0", + "symfony/framework-bundle": "^5.4|^6.4|^7.0|^8.0", + "symfony/twig-bundle": "^5.4|^6.4|^7.0|^8.0", + "twig/twig": "^3.2|^4.0" + }, + "require-dev": { + "league/commonmark": "^2.7", + "symfony/phpunit-bridge": "^6.4|^7.0", + "twig/cache-extra": "^3.0", + "twig/cssinliner-extra": "^3.0", + "twig/html-extra": "^3.0", + "twig/inky-extra": "^3.0", + "twig/intl-extra": "^3.0", + "twig/markdown-extra": "^3.0", + "twig/string-extra": "^3.0" + }, + "type": "symfony-bundle", + "autoload": { + "psr-4": { + "Twig\\Extra\\TwigExtraBundle\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com", + "homepage": "http://fabien.potencier.org", + "role": "Lead Developer" + } + ], + "description": "A Symfony bundle for extra Twig extensions", + "homepage": "https://twig.symfony.com", + "keywords": [ + "bundle", + "extra", + "twig" + ], + "support": { + "source": "https://github.com/twigphp/twig-extra-bundle/tree/v3.24.0" + }, + "funding": [ + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/twig/twig", + "type": "tidelift" + } + ], + "time": "2026-02-07T08:07:38+00:00" + }, + { + "name": "twig/html-extra", + "version": "v3.24.0", + "source": { + "type": "git", + "url": "https://github.com/twigphp/html-extra.git", + "reference": "313900fb98b371b006a55b1a29241a192634be13" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/twigphp/html-extra/zipball/313900fb98b371b006a55b1a29241a192634be13", + "reference": "313900fb98b371b006a55b1a29241a192634be13", + "shasum": "" + }, + "require": { + "php": ">=8.1.0", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/mime": "^5.4|^6.4|^7.0|^8.0", + "twig/twig": "^3.13|^4.0" + }, + "require-dev": { + "symfony/phpunit-bridge": "^6.4|^7.0" + }, + "type": "library", + "autoload": { + "files": [ + "Resources/functions.php" + ], + "psr-4": { + "Twig\\Extra\\Html\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com", + "homepage": "http://fabien.potencier.org", + "role": "Lead Developer" + } + ], + "description": "A Twig extension for HTML", + "homepage": "https://twig.symfony.com", + "keywords": [ + "html", + "twig" + ], + "support": { + "source": "https://github.com/twigphp/html-extra/tree/v3.24.0" + }, + "funding": [ + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/twig/twig", + "type": "tidelift" + } + ], + "time": "2026-03-17T07:24:08+00:00" + }, + { + "name": "twig/twig", + "version": "v3.24.0", + "source": { + "type": "git", + "url": "https://github.com/twigphp/Twig.git", + "reference": "a6769aefb305efef849dc25c9fd1653358c148f0" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/twigphp/Twig/zipball/a6769aefb305efef849dc25c9fd1653358c148f0", + "reference": "a6769aefb305efef849dc25c9fd1653358c148f0", + "shasum": "" + }, + "require": { + "php": ">=8.1.0", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/polyfill-ctype": "^1.8", + "symfony/polyfill-mbstring": "^1.3" + }, + "require-dev": { + "php-cs-fixer/shim": "^3.0@stable", + "phpstan/phpstan": "^2.0@stable", + "psr/container": "^1.0|^2.0", + "symfony/phpunit-bridge": "^5.4.9|^6.4|^7.0" + }, + "type": "library", + "autoload": { + "files": [ + "src/Resources/core.php", + "src/Resources/debug.php", + "src/Resources/escaper.php", + "src/Resources/string_loader.php" + ], + "psr-4": { + "Twig\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com", + "homepage": "http://fabien.potencier.org", + "role": "Lead Developer" + }, + { + "name": "Twig Team", + "role": "Contributors" + }, + { + "name": "Armin Ronacher", + "email": "armin.ronacher@active-4.com", + "role": "Project Founder" + } + ], + "description": "Twig, the flexible, fast, and secure template language for PHP", + "homepage": "https://twig.symfony.com", + "keywords": [ + "templating" + ], + "support": { + "issues": "https://github.com/twigphp/Twig/issues", + "source": "https://github.com/twigphp/Twig/tree/v3.24.0" + }, + "funding": [ + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/twig/twig", + "type": "tidelift" + } + ], + "time": "2026-03-17T21:31:11+00:00" } ], "packages-dev": [ @@ -9653,77 +11658,6 @@ ], "time": "2024-10-20T05:08:20+00:00" }, - { - "name": "symfony/options-resolver", - "version": "v8.0.8", - "source": { - "type": "git", - "url": "https://github.com/symfony/options-resolver.git", - "reference": "b48bce0a70b914f6953dafbd10474df232ed4de8" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/symfony/options-resolver/zipball/b48bce0a70b914f6953dafbd10474df232ed4de8", - "reference": "b48bce0a70b914f6953dafbd10474df232ed4de8", - "shasum": "" - }, - "require": { - "php": ">=8.4", - "symfony/deprecation-contracts": "^2.5|^3" - }, - "type": "library", - "autoload": { - "psr-4": { - "Symfony\\Component\\OptionsResolver\\": "" - }, - "exclude-from-classmap": [ - "/Tests/" - ] - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Fabien Potencier", - "email": "fabien@symfony.com" - }, - { - "name": "Symfony Community", - "homepage": "https://symfony.com/contributors" - } - ], - "description": "Provides an improved replacement for the array_replace PHP function", - "homepage": "https://symfony.com", - "keywords": [ - "config", - "configuration", - "options" - ], - "support": { - "source": "https://github.com/symfony/options-resolver/tree/v8.0.8" - }, - "funding": [ - { - "url": "https://symfony.com/sponsor", - "type": "custom" - }, - { - "url": "https://github.com/fabpot", - "type": "github" - }, - { - "url": "https://github.com/nicolas-grekas", - "type": "github" - }, - { - "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", - "type": "tidelift" - } - ], - "time": "2026-03-30T15:14:47+00:00" - }, { "name": "symfony/process", "version": "v8.0.11", diff --git a/config/packages/csrf.yaml b/config/packages/csrf.yaml new file mode 100644 index 0000000..1bdf70c --- /dev/null +++ b/config/packages/csrf.yaml @@ -0,0 +1,9 @@ +# Enable stateless CSRF protection for forms and logins/logouts +framework: + form: + csrf_protection: + token_id: submit + + csrf_protection: + stateless_token_ids: + - submit diff --git a/config/packages/monolog.yaml b/config/packages/monolog.yaml new file mode 100644 index 0000000..b308cf2 --- /dev/null +++ b/config/packages/monolog.yaml @@ -0,0 +1,52 @@ +monolog: + channels: + - deprecation + +when@dev: + monolog: + handlers: + main: + type: stream + path: '%kernel.logs_dir%/%kernel.environment%.log' + level: debug + channels: ['!event'] + console: + type: console + process_psr_3_messages: false + channels: ['!event', '!doctrine', '!console'] + +when@prod: + monolog: + handlers: + main: + type: fingers_crossed + action_level: error + handler: nested + excluded_http_codes: [404, 405] + buffer_size: 50 + nested: + type: stream + path: php://stderr + level: debug + formatter: monolog.formatter.json + database: + type: service + id: App\Infrastructure\Logging\DatabaseLogHandler + level: warning + channels: ['!doctrine'] + deprecation: + type: stream + channels: [deprecation] + path: php://stderr + +when@test: + monolog: + handlers: + main: + type: fingers_crossed + action_level: error + handler: nested + nested: + type: stream + path: '%kernel.logs_dir%/%kernel.environment%.log' + level: debug diff --git a/config/packages/scheb_2fa.yaml b/config/packages/scheb_2fa.yaml new file mode 100644 index 0000000..d8238ca --- /dev/null +++ b/config/packages/scheb_2fa.yaml @@ -0,0 +1,8 @@ +scheb_two_factor: + security_tokens: + - Symfony\Component\Security\Http\Authenticator\Token\PostAuthenticationToken + + totp: + enabled: true + issuer: SuperSeller3000 + template: security/2fa.html.twig diff --git a/config/packages/security.yaml b/config/packages/security.yaml index 8964044..82ad4e4 100644 --- a/config/packages/security.yaml +++ b/config/packages/security.yaml @@ -1,39 +1,49 @@ security: - # https://symfony.com/doc/current/security.html#registering-the-user-hashing-passwords password_hashers: Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface: 'auto' - # https://symfony.com/doc/current/security.html#loading-the-user-the-user-provider providers: - users_in_memory: { memory: null } + app_user_provider: + id: App\Infrastructure\Security\UserProvider firewalls: dev: - # Ensure dev tools and static assets are always allowed pattern: ^/(_profiler|_wdt|assets|build)/ security: false + + api: + pattern: ^/api/ + stateless: true + custom_authenticators: + - App\Infrastructure\Security\ApiKeyAuthenticator + main: lazy: true - provider: users_in_memory + provider: app_user_provider + form_login: + login_path: app_login + check_path: app_login + enable_csrf: true + default_target_path: easyadmin + logout: + path: app_logout + target: app_login + two_factor: + auth_form_path: 2fa_login + check_path: 2fa_login_check + enable_csrf: true - # Activate different ways to authenticate: - # https://symfony.com/doc/current/security.html#the-firewall - - # https://symfony.com/doc/current/security/impersonating_user.html - # switch_user: true - - # Note: Only the *first* matching rule is applied access_control: - # - { path: ^/admin, roles: ROLE_ADMIN } - # - { path: ^/profile, roles: ROLE_USER } + - { path: ^/login, roles: PUBLIC_ACCESS } + - { path: ^/2fa, roles: IS_AUTHENTICATED_2FA_IN_PROGRESS } + - { path: ^/admin, roles: ROLE_USER } + - { path: ^/totp, roles: ROLE_USER } when@test: security: password_hashers: - # Password hashers are resource-intensive by design to ensure security. - # In tests, it's safe to reduce their cost to improve performance. Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface: algorithm: auto - cost: 4 # Lowest possible value for bcrypt - time_cost: 3 # Lowest possible value for argon - memory_cost: 10 # Lowest possible value for argon + cost: 4 + time_cost: 3 + memory_cost: 10 diff --git a/config/packages/translation.yaml b/config/packages/translation.yaml new file mode 100644 index 0000000..490bfc2 --- /dev/null +++ b/config/packages/translation.yaml @@ -0,0 +1,5 @@ +framework: + default_locale: en + translator: + default_path: '%kernel.project_dir%/translations' + providers: diff --git a/config/packages/twig.yaml b/config/packages/twig.yaml new file mode 100644 index 0000000..3f795d9 --- /dev/null +++ b/config/packages/twig.yaml @@ -0,0 +1,6 @@ +twig: + file_name_pattern: '*.twig' + +when@test: + twig: + strict_variables: true diff --git a/config/packages/twig_component.yaml b/config/packages/twig_component.yaml new file mode 100644 index 0000000..fd17ac6 --- /dev/null +++ b/config/packages/twig_component.yaml @@ -0,0 +1,5 @@ +twig_component: + anonymous_template_directory: 'components/' + defaults: + # Namespace & directory for components + App\Twig\Components\: 'components/' diff --git a/config/routes.yaml b/config/routes.yaml index 086ebd7..6e3b88f 100644 --- a/config/routes.yaml +++ b/config/routes.yaml @@ -11,4 +11,5 @@ controllers: resource: path: ../src/Infrastructure/Http/Controller/ namespace: App\Infrastructure\Http\Controller + exclude: '../src/Infrastructure/Http/Controller/Admin' type: attribute diff --git a/config/routes/easyadmin.yaml b/config/routes/easyadmin.yaml index f853be1..083ca15 100644 --- a/config/routes/easyadmin.yaml +++ b/config/routes/easyadmin.yaml @@ -1,3 +1,3 @@ -_security_logout: - resource: security.route_loader.logout - type: service +easyadmin: + resource: . + type: easyadmin.routes diff --git a/config/routes/scheb_2fa.yaml b/config/routes/scheb_2fa.yaml new file mode 100644 index 0000000..9a8ca66 --- /dev/null +++ b/config/routes/scheb_2fa.yaml @@ -0,0 +1,7 @@ +2fa_login: + path: /2fa + defaults: + _controller: "scheb_two_factor.form_controller::form" + +2fa_login_check: + path: /2fa_check diff --git a/docker/app/Dockerfile b/docker/app/Dockerfile index 51c87e3..bee1675 100644 --- a/docker/app/Dockerfile +++ b/docker/app/Dockerfile @@ -6,11 +6,15 @@ RUN apk add --no-cache \ libzip-dev \ unzip \ git \ + $PHPIZE_DEPS \ && docker-php-ext-install \ pdo_pgsql \ intl \ zip \ - opcache + opcache \ + && pecl install redis \ + && docker-php-ext-enable redis \ + && apk del $PHPIZE_DEPS COPY --from=composer:2 /usr/bin/composer /usr/bin/composer diff --git a/docs/superpowers/specs/2026-05-13-superseller3000-design.md b/docs/superpowers/specs/2026-05-13-superseller3000-design.md new file mode 100644 index 0000000..9242900 --- /dev/null +++ b/docs/superpowers/specs/2026-05-13-superseller3000-design.md @@ -0,0 +1,479 @@ +# SuperSeller3000 — Design-Dokument + +**Datum:** 2026-05-13 · **Zuletzt aktualisiert:** 2026-05-17 +**Status:** In Betrieb (ss3k.schaunwama.de) +**Kontext:** Middleware zur Artikelverwaltung, KI-gestützten Erfassung und Multi-Plattform-Verkauf von refurbished IT-Hardware. + +--- + +## 1. Projektziel & Kontext + +SuperSeller3000 ist die **führende Schaltzentrale** für den Verkauf von refurbished IT-Hardware. Alle externen Systeme (eBay, Frappe ERP) sind Werkzeuge — die Middleware ist Master. + +**Betrieb heute:** Dropshipping. Lieferant erhält Rechnung per Mail und versendet manuell. Eigener Versand folgt später. +**Team:** 2–3 Mitarbeiter +**Artikel:** ~500 heute, Ziel ~5.000 +**Plattformen:** eBay (initial), weitere folgen (Amazon, Kaufland, …) + +--- + +## 2. Architektur + +### 2.1 Hexagonale Architektur (Ports & Adapters) + +``` +src/ + Domain/ # Reines PHP — Article, ArticleType, Order, Customer … + # Keine Framework-Imports, vollständig unit-testbar + Application/ # UseCases, Commands, Handlers + # Orchestriert Domain über Interfaces (Ports) + Infrastructure/ + Channel/ # EbayAdapter, FrappeErpAdapter, [FutureAdapter] + AI/ # OllamaClient, MistralClient (beide hinter OllamaClientInterface) + # OllamaVisionAgent, SpecsResearchAgent, JsonCodingAgent, EbayTextAgent + Persistence/ # Doctrine Repositories (PostgreSQL) + Logging/ # DatabaseLogHandler, ArchiveCommand + Http/ # Symfony Controller, Webhook-Listener, EasyAdmin + Queue/ # Symfony Messenger, Redis Transport + Storage/ # StorageManager +``` + +Jeder externe Dienst (eBay, Frappe ERP, Ollama, SMTP) implementiert ein Interface aus dem Application-Layer. Neue Plattform = neue Adapter-Klasse, Domain bleibt unberührt. + +### 2.2 Tech-Stack + +| Bereich | Technologie | +|---|---| +| Sprache / Framework | PHP 8.4 · Symfony 8.4 | +| ORM | Doctrine ORM | +| Queue | Symfony Messenger + Redis Transport (`symfony/redis-messenger`) | +| Mailer | Symfony Mailer (SMTP) | +| Tests | PHPUnit 11 + Pest | +| Datenbank | PostgreSQL 17 (eine Instanz, drei Schemas: `app`, `logs`, `logs_archive`) | +| Cache / Queue | Redis 7 | +| AI | **Ollama** (lokal, SSH-Tunnel + autossh) **oder Mistral Cloud API** — per Alias in `services.yaml` umschaltbar; Modelle via `AI_TEXT_MODEL` / `AI_VISION_MODEL` env vars konfigurierbar | +| Admin-Panel | EasyAdmin 5 (`#[AdminDashboard]` Attribut, `type: easyadmin.routes` Loader) | +| Auth | Symfony Security + scheb/two-factor-bundle (TOTP) | +| API-Auth | API-Key (eigene Entity, bcrypt-Hash) + Symfony Voters; Keys via `app:api-keys:create` anlegen | +| Reverse Proxy | Caddy 2 (Auto-HTTPS via Let's Encrypt, `php_fastcgi app:9000`) — Domain: `ss3k.schaunwama.de` | +| Containerisierung | Docker Compose (VPS) — `app`, `caddy`, `postgres`, `redis`, `worker-ai`, `worker-orders`, `worker-channel`, `cron` | +| Versionskontrolle | Gitea (self-hosted) + Gitea Actions (CI) | +| Backups | pg_dump täglich, Docker-Volume-Backup, gitea dump | + +### 2.3 Async Queue-Architektur + +Drei isolierte Transports — ein ausgefallener Worker blockiert die anderen nie: + +``` +redis://...?queue_name=ai_pipeline # AI-Agents (Vision, Specs, JSON, Text) +redis://...?queue_name=orders # Order-Processing, CustomerResolver, Invoice +redis://...?queue_name=channel_sync # Listing-Publish, Bestand-Sync, Deaktivierung, Tracking +``` + +**Ollama-Ausfall:** AI-Pipeline-Jobs verbleiben in der Queue. Symfony Messenger wiederholt mit Backoff, nach 3 Versuchen → Failed Transport (persistent, nicht verloren). Operativer Betrieb (Verkäufe, Rechnungen, Bestand) läuft völlig unabhängig durch. Replay via `messenger:failed:retry` wenn Ollama wieder verfügbar. + +--- + +## 3. Domain-Modell + +### 3.1 Artikel & Mapping + +``` +ArticleType + id: UUID (stabil) name: string (änderbar) + → AttributeDefinition[] + +AttributeDefinition + id: UUID (stabil) name: string (änderbar) + type: string|int|float|bool|select|multi_select + unit: string? options: string[]? + +Platform + id: UUID type: string label: string config: jsonb + +ChannelField # Registry aller Plattform-Felder + id: UUID (stabil) platform_id + label: string (änderbar) path: string + # z.B. path = "ItemSpecifics[name=Prozessor]" für eBay + +ArticleTypePlatformConfig # 1× pro ArticleType × Platform + article_type_id platform_id + category_id: string # eBay-Kategorie-ID, ERP-Warengruppe etc. + → AttributeMapping[] + +AttributeMapping + attribute_definition_id channel_field_id + transformer: string? # z.B. "kg_to_g" +``` + +**Stabilitätsprinzip:** Alle Mappings referenzieren UUIDs, nie Strings. Name/Label eines Attributs oder ChannelFields kann jederzeit geändert werden ohne ein Mapping zu brechen. + +### 3.2 Artikel + +``` +Article + id: UUID article_type_id sku: string inventory_number: string + status: ingesting|draft|needs_review|active|listed|sold + stock: int + condition: new|like_new|good|acceptable + condition_notes: text? # Freitext, z.B. "leichte Kratzer Unterseite" + listing_price: decimal? # eBay-Verkaufspreis + serial_number: string? + ebay_listing_id: string? + ebay_title: string? # AI-generiert, editierbar vor Freigabe + ebay_description: text? # AI-generiert, editierbar vor Freigabe + → AttributeValue[] + → ArticlePhoto[] + +AttributeValue + article_id attribute_definition_id value: string + # Typ-Cast beim Lesen anhand AttributeDefinition.type + +ArticlePhoto + id: UUID article_id storage_path_id filename: string + is_main: bool sort_order: int + # Voller Pfad = StoragePath.base_path + '/' + filename + +StoragePath + id: UUID label: string base_path: string + quota_bytes: bigint priority: int is_active: bool +``` + +**StorageManager:** Wählt aktiven Pfad nach Priorität, prüft Quota vor jedem Write, fällt auf nächsten zurück. Migration: nur `base_path` ändern, alle Referenzen lösen automatisch korrekt auf. + +**Artikel-Status-Maschine:** +``` +ingesting → draft → active → listed → sold + ↓ + needs_review (AI-Fehler nach 3 Versuchen) +``` + +### 3.3 Bestellungen & Kunden + +``` +Customer + id: UUID name email address: jsonb + frappe_customer_id: string? + platform_ids: jsonb # {"ebay": "user123", "amazon": "buyer456"} + # Frappe-Seite: Custom Fields superseller_customer_id + platform-IDs + +Order + id: UUID article_id customer_id platform_id + platform_order_id: string # eBay Order ID + status: pending|processing|shipped|completed|failed + sale_price: decimal sale_date: datetime + tracking_number: string? carrier: string? + shipped_at: datetime? tracking_pushed_to_ebay_at: datetime? + → Invoice? + +Invoice + id: UUID order_id frappe_invoice_id: string + storage_path_id filename: string # PDF + created_at: datetime emailed_at: datetime? +``` + +### 3.4 AI-Pipeline + +``` +AIPipelineJob + id: UUID type: photo|pxe|text_gen + article_id: UUID? # gesetzt nach Draft-Anlage + status: queued|processing|completed|failed|needs_review + attempt_count: int # max. 3 + input_data: jsonb # Foto-Pfad oder PXE-Dump + output_data: jsonb # extrahierte Attribute + missing_fields: text[] # für Retry-Prompt-Kontext (PostgreSQL text[]) + error_message: string? + created_at: datetime completed_at: datetime? +``` + +### 3.5 Auth + +``` +User + id: UUID email password_hash + totp_secret: string? # 2FA (scheb/two-factor-bundle) + permissions: jsonb # article:view, order:edit, log:delete … + is_active: bool + +ApiKey + id: UUID user_id label: string + key_hash: string # bcrypt — nie Klartext in DB + permissions: jsonb # eigenes, unabhängiges Permission-Set + is_active: bool + last_used_at: datetime? expires_at: datetime? +``` + +**PermissionVoter:** Eine einzige Voter-Klasse prüft User- und ApiKey-Permissions einheitlich. API-Keys sind für Browser-Login ohne 2FA, da der Key selbst ein starkes Credential ist. + +--- + +## 4. Artikel-Erfassung: AI-Pipelines + +### 4.1 Pipeline A — Foto (Typenschild) + +``` +1. Foto-Upload → PhotoUploadMessage in Queue (redis://ai_pipeline) +2. OllamaVisionAgent — LLaVA liest Typenschild + Output: Modellbezeichnung + Seriennummer (nur was sichtbar) +3. SpecsResearchAgent — Web-Suche mit Modellbezeichnung → vollständige Specs (Freitext) + Web-Suche ist Pflicht (kein reines LLM-Wissen — zu unzuverlässig für Hardware) +4. JsonCodingAgent — strukturierter Ollama-Call: Specs-Text → JSON gegen ArticleType-Schema +5. ValidationGate — alle Pflichtfelder gesetzt? (Schema + eBay-Kategorie-Pflichtfelder) + ✓ → Schritt 6 + ✗ → Retry ab Schritt 4 mit missing_fields im Prompt (max. 3×) + → needs_review nach 3 Fehlversuchen +6. DraftArticleCreator — Article anlegen (status: draft), Inventurnummer vergeben +7. EbayTextAgent — Titel + Beschreibung aus Attributen generieren, am Artikel speichern +8. → Freigabe-Queue (manuell) +``` + +### 4.2 Pipeline B — PXE-Inventur + +``` +1. Gerät bootet per PXE → sendet lshw/dmidecode-Dump an API +2. API vergibt Inventurnummer → PxeInventoryMessage in Queue +3. JsonCodingAgent — PXE-Dump enthält vollständige Rohdaten → direkt JSON-Mapping + (SpecsResearchAgent entfällt) +4. ValidationGate — identisch zu Pipeline A +5. DraftArticleCreator — Inventurnummer bereits bekannt +6. EbayTextAgent — identisch +7. → Freigabe-Queue (manuell) + Mitarbeiter klebt Inventurnummer-Aufkleber auf Gerät während Pipeline läuft +``` + +### 4.3 Freigabe + +Mitarbeiter öffnet Draft im Admin-Panel → prüft Attribute, Fotos, Preis, `condition_notes`, eBay-Texte → gibt frei. +Bei Freigabe: `status → active` → `PublishToChannelMessage` → eBay-Listing anlegen + Frappe-ERP-Sync. + +--- + +## 5. Channel-Adapter + +Jede Plattform implementiert `ChannelAdapterInterface`: + +```php +interface ChannelAdapterInterface { + public function publishListing(Article $article): string; // → platform_listing_id + public function updateStock(Article $article, int $stock): void; + public function deactivateListing(Article $article): void; + public function pushTracking(Order $order): void; +} +``` + +Neue Plattform = neue Implementierung. Domain, Order-Flow und Inventory-Logik bleiben unverändert. + +**eBay-spezifisch:** eBay Taxonomy API wird beim Anlegen einer `ArticleTypePlatformConfig` abgefragt — Pflichtfelder der gewählten Kategorie werden als vorgeschlagene Mappings vorgeladen. + +--- + +## 6. Order-Flow + +### 6.1 eBay-Webhook-Registrierung + +eBay Notification API — kein Polling. Registrierte Events: +- `FIXED_PRICE_TRANSACTION` +- `AUCTION_CHECKOUT_COMPLETE` +- `MARKETPLACE_ACCOUNT_DELETION` (eBay-Pflicht) + +### 6.2 Ablauf + +``` +1. Webhook POST /webhooks/ebay → HMAC-Signatur prüfen → sofort 200 → OrderReceivedMessage +2. InventoryLock ATOMAR: UPDATE article SET stock = stock - 1 WHERE id = ? AND stock > 0 + 0 Zeilen → Überverkauf → Order: failed, Alert, manuell klären + Erfolg → weiter +3. CustomerResolver — Matching-Kaskade (siehe 6.3) +4. InvoiceCreator — Sales Invoice in Frappe ERP via REST anlegen + (Frappe ERP wird nur hier involviert — rein reaktiv) +5. InvoicePdfFetcher — PDF von Frappe abrufen → StorageManager → Invoice-Record +6. InvoiceMailer — PDF per SMTP an feste Lieferanten-Adresse → Invoice.emailed_at setzen +7. ListingSync — stock > 0: Bestände auf allen Plattformen aktualisieren + stock = 0: DeactivateListingsMessage pro Plattform in Queue + → Retry mit Exponential-Backoff bei API-Fehler + → Alert nach 5 Fehlversuchen (kritisch: kein Überverkauf) +8. Abschluss — Order: completed, Article: sold (wenn stock = 0) +``` + +**Tracking (Lieferant meldet zurück):** +`PATCH /orders/{id}/tracking` → `tracking_number` + `carrier` → `TrackingPushMessage` → alle Plattformen als versandt markieren → `tracking_pushed_to_ebay_at` setzen. + +### 6.3 Customer-Matching-Kaskade + +**Stufe 1 — Platform-ID (exakt):** +`platform_ids->>'ebay' = ?` → Match: direkt verwenden. + +**Stufe 2 — Adresse (exakt, cross-platform):** +`lowercase(name + straße + ort + plz)` — 100% exakter Vergleich, keine Fuzzy-Logik. +Match → selber Kunde; neue Platform-ID sofort in `platform_ids` eintragen (nächster Sale trifft Stufe 1). +Kein Match → neuer Kunde anlegen. + +**Regel:** `lowercase` ist die einzige Toleranz. "Kirchstr 1" ≠ "Kirchstraße 1" → zwei Kunden. Datenmischung ist schlimmer als ein Duplikat. Manuelle Zusammenführung folgt als spätere Admin-Funktion. + +**Neu-Anlage:** Frappe-ERP-Kunde erstellen mit Custom Fields (`superseller_customer_id`, `ebay_user_id`) → `frappe_customer_id` in Middleware zurückschreiben. + +--- + +## 7. Logging + +``` +Schema: logs.log_entry + id level channel message context: jsonb + message_search: tsvector # PostgreSQL GIN-Index → Fulltext-Suche + created_at + +Schema: logs_archive.log_entry # identische Struktur +``` + +**Monolog Handler:** Custom `DatabaseLogHandler` schreibt alle Levels in `logs.log_entry`. + +**Rotation (täglich per Cron):** +1. Einträge > 90 Tage mit `level > DEBUG` → `logs_archive.log_entry` kopieren +2. Alle Einträge > 90 Tage aus `logs.log_entry` löschen + +**Admin-Panel (EasyAdmin):** Filter auf Level/Channel/Zeitraum, Fulltext-Suche, ACL-gesteuert (view/delete). + +--- + +## 8. Sicherheit & Hardening + +### VPS & Netzwerk +- **UFW:** nur Port 80, 443 (Caddy) + SSH öffentlich +- **SSH:** Key-only-Auth, Root-Login deaktiviert, Fail2ban +- **Docker-Netz:** internes Bridge-Network — PostgreSQL, Redis nie direkt nach außen +- **Caddy:** HSTS, X-Content-Type-Options, X-Frame-Options per Default + +### Dienste +- **PostgreSQL:** App-User mit minimalen Rechten (kein Superuser) +- **Redis:** `requirepass` gesetzt, nur intern erreichbar +- **Ollama:** lokal, Zugriff nur via SSH-Tunnel (autossh für Persistenz + Auto-Reconnect) +- **Gitea:** Registrierung deaktiviert, nur Admin-Anlage, SSH-Key-Auth + +### Applikation +- **Secrets:** nie in Git — `.env.local` auf Server oder Docker Secrets; `.env` ohne Credentials +- **APP_ENV:** `prod`, Debug-Modus aus +- **API-Keys:** in DB als bcrypt-Hash, nie Klartext +- **eBay-Webhook:** HMAC-Signatur auf jedem Request prüfen vor Queue-Einspeisung +- **Rate Limiting:** Caddy oder Symfony RateLimiter auf API-Endpoints + Webhook-Listener +- **Frappe-ERP:** API-Token im Secret-Store, HTTPS-only, TLS-Zertifikat validieren +- **2FA:** scheb/two-factor-bundle (TOTP) für alle Browser-Logins, Backup-Codes + +### Backups (täglich) +- PostgreSQL: verschlüsselter `pg_dump` → externer Storage +- Docker-Volumes: Rechnungs-PDFs + Artikel-Fotos +- Gitea: `gitea dump` per Cron + +--- + +## 9. Deployment + +``` +docker-compose.yml + app # PHP 8.4-FPM + Symfony + caddy # Reverse Proxy + Auto-HTTPS + postgres # PostgreSQL 17 + redis # Queue + Cache + worker-ai # Symfony Messenger Worker (ai_pipeline) + worker-orders # Symfony Messenger Worker (orders) + worker-channel # Symfony Messenger Worker (channel_sync) + cron # Symfony Console Commands (Log-Rotation, Backups, pg_dump) + gitea # Source Control + CI + act-runner # Gitea Actions Runner +``` + +**Gitea Actions CI:** Auf jedem Push — PHPUnit/Pest, PHPStan, PHP CS Fixer. + +--- + +## 10. Qualität + +- **TDD:** Tests werden vor oder parallel zur Implementierung geschrieben, kein Code ohne Test +- **PHPStan:** Level max, im CI erzwungen +- **PHP CS Fixer:** einheitlicher Code-Style, im CI erzwungen +- **Doku:** wird laufend gepflegt (OpenAPI für die REST-API, ADRs für Architekturentscheidungen) +- **Domain vollständig unit-testbar:** keine Framework-Abhängigkeiten im Domain-Layer +- **Adapter isoliert testbar:** jeder Channel-Adapter, jeder AI-Agent hat eigene Tests gegen Mocks + +--- + +## 11. Betrieb & Setup + +### 11.1 Admin-Panel + +URL: `https://ss3k.schaunwama.de/admin` +Login: `https://ss3k.schaunwama.de/login` (Formular-Login + TOTP falls aktiviert) +Passwort ändern: `/account/password` + +**Ersten User anlegen:** +```bash +docker compose exec app php bin/console app:users:create --env=prod +``` + +**Artikel-Typen & Merkmale:** +1. Admin → **Attributes** → neues Merkmal anlegen (Name, Typ, Einheit, Optionen) +2. Admin → **Article Types** → neuen Typ anlegen → Merkmale per Autocomplete zuweisen + +### 11.2 API-Keys + +API-Keys werden über die Console generiert (nie über die UI — der Klartext-Key wird nur einmal angezeigt): + +```bash +docker compose exec app php bin/console app:api-keys:create --env=prod +``` + +Der Key wird als bcrypt-Hash gespeichert. Prefix (erste 8 Zeichen) dient als Lookup-Key. +Verwendung: `X-Api-Key: ` HTTP-Header. + +### 11.3 AI-Backend wechseln + +**Ollama (Standard, lokal):** +```yaml +# config/services.yaml +App\Infrastructure\AI\OllamaClientInterface: + alias: App\Infrastructure\AI\OllamaClient +``` +```dotenv +# .env +AI_TEXT_MODEL=${OLLAMA_TEXT_MODEL} +AI_VISION_MODEL=${OLLAMA_VISION_MODEL} +``` + +**Mistral Cloud API:** +```yaml +App\Infrastructure\AI\OllamaClientInterface: + alias: App\Infrastructure\AI\MistralClient +``` +```dotenv +AI_TEXT_MODEL=${MISTRAL_TEXT_MODEL} +AI_VISION_MODEL=${MISTRAL_VISION_MODEL} +MISTRAL_API_KEY=sk-... +``` +Vision erfordert Pixtral (`pixtral-12b-2409`). Nach Änderung: `cache:clear` + FPM reload. + +### 11.4 Pipeline starten + +Voraussetzungen: mind. 1 Article Type + mind. 1 API-Key vorhanden, Worker laufen. + +```bash +# Photo-Pipeline +curl -X POST https://ss3k.schaunwama.de/api/pipeline/photo-upload \ + -H "X-Api-Key: " \ + -F "articleTypeId=" \ + -F "photo=@/path/to/photo.jpg" + +# Job-Status abfragen +curl https://ss3k.schaunwama.de/api/pipeline/jobs/ \ + -H "X-Api-Key: " +``` + +--- + +## Offene Punkte / Spätere Erweiterungen + +- Eigener Versand (Versandlabel-Generierung, weiterer Workflow-Schritt) +- Weitere Verkaufsplattformen (Amazon, Kaufland) — nur neuer Channel-Adapter nötig +- SpecsResearchAgent: konkrete Web-Such-Strategie definieren (SerpAPI, direkte Hersteller-Seiten, o.ä.) +- Manuelle Kunden-Zusammenführung als Admin-Funktion +- eBay-Promotions / Preisanpassungen +- `ebay_title` / `ebay_description` sind aktuell eBay-spezifisch auf `Article` — bei weiteren Plattformen zu `platform_texts: jsonb` generalisieren diff --git a/src/Domain/Article/AttributeDefinition.php b/src/Domain/Article/AttributeDefinition.php index 7fc92c7..44600b5 100644 --- a/src/Domain/Article/AttributeDefinition.php +++ b/src/Domain/Article/AttributeDefinition.php @@ -35,6 +35,11 @@ class AttributeDefinition $this->type = $type; } + public function __toString(): string + { + return $this->name; + } + public function getId(): Uuid { return $this->id; diff --git a/src/Domain/Auth/ApiKey.php b/src/Domain/Auth/ApiKey.php index b7866c5..2080d3f 100644 --- a/src/Domain/Auth/ApiKey.php +++ b/src/Domain/Auth/ApiKey.php @@ -22,6 +22,9 @@ class ApiKey #[ORM\Column(type: 'string', length: 255)] private string $label; + #[ORM\Column(type: 'string', length: 8)] + private string $keyPrefix; + #[ORM\Column(type: 'string', length: 255, unique: true)] private string $keyHash; @@ -38,11 +41,12 @@ class ApiKey #[ORM\Column(type: 'datetime_immutable', nullable: true)] private ?\DateTimeImmutable $expiresAt = null; - public function __construct(User $user, string $label, string $keyHash) + public function __construct(User $user, string $label, string $keyPrefix, string $keyHash) { $this->id = Uuid::v7(); $this->user = $user; $this->label = $label; + $this->keyPrefix = $keyPrefix; $this->keyHash = $keyHash; } @@ -61,6 +65,11 @@ class ApiKey return $this->label; } + public function getKeyPrefix(): string + { + return $this->keyPrefix; + } + public function getKeyHash(): string { return $this->keyHash; diff --git a/src/Infrastructure/Console/CreateApiKeyCommand.php b/src/Infrastructure/Console/CreateApiKeyCommand.php new file mode 100644 index 0000000..cc2fd88 --- /dev/null +++ b/src/Infrastructure/Console/CreateApiKeyCommand.php @@ -0,0 +1,67 @@ +ask('User email'); + if (!\is_string($email) || '' === $email) { + $io->error('Email is required.'); + + return Command::FAILURE; + } + + $user = $this->userRepository->findByEmail($email); + if (null === $user) { + $io->error(\sprintf('No user found with email "%s".', $email)); + + return Command::FAILURE; + } + + $label = $io->ask('Label for this key (e.g. "dev laptop", "pipeline script")'); + if (!\is_string($label) || '' === $label) { + $io->error('Label is required.'); + + return Command::FAILURE; + } + + $rawKey = bin2hex(random_bytes(24)); + $prefix = substr($rawKey, 0, 8); + $keyHash = password_hash($rawKey, PASSWORD_BCRYPT); + + $apiKey = new ApiKey($user, $label, $prefix, $keyHash); + $this->apiKeyRepository->save($apiKey); + + $io->success('API key created. Store it now — it will not be shown again.'); + $io->table(['Field', 'Value'], [ + ['Key ID', $apiKey->getId()->toRfc4122()], + ['Label', $label], + ['API Key', $rawKey], + ]); + + return Command::SUCCESS; + } +} diff --git a/src/Infrastructure/Http/Controller/Admin/ArticleTypeCrudController.php b/src/Infrastructure/Http/Controller/Admin/ArticleTypeCrudController.php new file mode 100644 index 0000000..5f8c2c8 --- /dev/null +++ b/src/Infrastructure/Http/Controller/Admin/ArticleTypeCrudController.php @@ -0,0 +1,48 @@ + */ +final class ArticleTypeCrudController extends AbstractCrudController +{ + public static function getEntityFqcn(): string + { + return ArticleType::class; + } + + public function configureCrud(Crud $crud): Crud + { + return $crud->setEntityLabelInSingular('Article Type')->setEntityLabelInPlural('Article Types'); + } + + public function createEntity(string $entityFqcn): ArticleType + { + return new ArticleType(''); + } + + public function configureActions(Actions $actions): Actions + { + return $actions; + } + + public function configureFields(string $pageName): iterable + { + yield IdField::new('id')->hideOnForm(); + yield TextField::new('name', 'Name'); + yield AssociationField::new('attributeDefinitions', 'Attributes') + ->setFormTypeOptions(['by_reference' => false]) + ->autocomplete() + ->hideOnIndex(); + } +} diff --git a/src/Infrastructure/Http/Controller/Admin/AttributeDefinitionCrudController.php b/src/Infrastructure/Http/Controller/Admin/AttributeDefinitionCrudController.php new file mode 100644 index 0000000..1a49554 --- /dev/null +++ b/src/Infrastructure/Http/Controller/Admin/AttributeDefinitionCrudController.php @@ -0,0 +1,62 @@ + */ +final class AttributeDefinitionCrudController extends AbstractCrudController +{ + public static function getEntityFqcn(): string + { + return AttributeDefinition::class; + } + + public function configureCrud(Crud $crud): Crud + { + return $crud + ->setEntityLabelInSingular('Attribute') + ->setEntityLabelInPlural('Attributes'); + } + + public function createEntity(string $entityFqcn): AttributeDefinition + { + return new AttributeDefinition('', AttributeType::String); + } + + public function configureFields(string $pageName): iterable + { + yield IdField::new('id')->hideOnForm(); + yield TextField::new('name', 'Name'); + + // choice.html.twig (used by ChoiceField) only renders formattedValue — safe for enums. + // text.html.twig also renders field.value in the title attr, which chokes on enum objects. + if (\in_array($pageName, [Crud::PAGE_NEW, Crud::PAGE_EDIT], true)) { + yield Field::new('type', 'Type') + ->setFormType(EnumType::class) + ->setFormTypeOptions(['class' => AttributeType::class]); + } else { + yield ChoiceField::new('type', 'Type') + ->setChoices([]) + ->formatValue(static fn (mixed $v): string => $v instanceof AttributeType ? $v->value : (string) $v); + } + + yield TextField::new('unit', 'Unit')->setRequired(false)->hideOnIndex(); + yield Field::new('options', 'Options (one per line)') + ->setFormType(StringArrayType::class) + ->setRequired(false) + ->hideOnIndex() + ->setHelp('Only relevant for type select / multi_select.'); + } +} diff --git a/src/Infrastructure/Http/Controller/Admin/DashboardController.php b/src/Infrastructure/Http/Controller/Admin/DashboardController.php index adb81b0..86bedfa 100644 --- a/src/Infrastructure/Http/Controller/Admin/DashboardController.php +++ b/src/Infrastructure/Http/Controller/Admin/DashboardController.php @@ -4,19 +4,19 @@ declare(strict_types=1); namespace App\Infrastructure\Http\Controller\Admin; +use EasyCorp\Bundle\EasyAdminBundle\Attribute\AdminDashboard; use EasyCorp\Bundle\EasyAdminBundle\Config\Dashboard; use EasyCorp\Bundle\EasyAdminBundle\Config\MenuItem; use EasyCorp\Bundle\EasyAdminBundle\Config\UserMenu; use EasyCorp\Bundle\EasyAdminBundle\Controller\AbstractDashboardController; use Symfony\Component\HttpFoundation\Response; -use Symfony\Component\Routing\Attribute\Route; use Symfony\Component\Security\Core\User\UserInterface; use Symfony\Component\Security\Http\Attribute\IsGranted; +#[AdminDashboard(routePath: '/admin', routeName: 'easyadmin')] #[IsGranted('ROLE_USER')] final class DashboardController extends AbstractDashboardController { - #[Route('/admin', name: 'easyadmin')] public function index(): Response { return $this->render('admin/dashboard.html.twig'); @@ -26,13 +26,15 @@ final class DashboardController extends AbstractDashboardController { return parent::configureUserMenu($user) ->addMenuItems([ - MenuItem::linkToRoute('Passwort ändern', 'fa fa-key', 'app_change_password'), + MenuItem::linkToRoute('Change Password', 'fa fa-key', 'app_change_password'), ]); } public function configureDashboard(): Dashboard { - return Dashboard::new()->setTitle('SuperSeller3000'); + return Dashboard::new() + ->setTitle('SuperSeller3000') + ->setFaviconPath('favicon.ico'); } public function configureMenuItems(): iterable @@ -40,12 +42,13 @@ final class DashboardController extends AbstractDashboardController yield MenuItem::linkToDashboard('Dashboard', 'fa fa-home'); yield MenuItem::linkTo(ArticleCrudController::class, 'Articles', 'fa fa-box'); yield MenuItem::linkTo(ArticleTypeCrudController::class, 'Article Types', 'fa fa-tags'); + yield MenuItem::linkTo(AttributeDefinitionCrudController::class, 'Attributes', 'fa fa-list-check'); yield MenuItem::linkTo(AIPipelineJobCrudController::class, 'AI Pipeline Jobs', 'fa fa-robot'); yield MenuItem::linkTo(UserCrudController::class, 'Users', 'fa fa-users'); yield MenuItem::linkTo(LogEntryCrudController::class, 'Logs', 'fa fa-list'); - yield MenuItem::section('Verkauf'); - yield MenuItem::linkTo(OrderCrudController::class, 'Bestellungen', 'fa fa-shopping-cart'); - yield MenuItem::linkTo(CustomerCrudController::class, 'Kunden', 'fa fa-users'); - yield MenuItem::linkTo(InvoiceCrudController::class, 'Rechnungen', 'fa fa-file-invoice'); + yield MenuItem::section('Sales'); + yield MenuItem::linkTo(OrderCrudController::class, 'Orders', 'fa fa-shopping-cart'); + yield MenuItem::linkTo(CustomerCrudController::class, 'Customers', 'fa fa-users'); + yield MenuItem::linkTo(InvoiceCrudController::class, 'Invoices', 'fa fa-file-invoice'); } } diff --git a/src/Infrastructure/Http/Controller/ChangePasswordController.php b/src/Infrastructure/Http/Controller/ChangePasswordController.php index 7881554..24d9a31 100644 --- a/src/Infrastructure/Http/Controller/ChangePasswordController.php +++ b/src/Infrastructure/Http/Controller/ChangePasswordController.php @@ -33,23 +33,23 @@ final class ChangePasswordController extends AbstractController if ($request->isMethod('POST')) { $token = $request->request->getString('_csrf_token'); if (!$this->isCsrfTokenValid('change_password', $token)) { - $error = 'Ungültiges Formular-Token. Bitte erneut versuchen.'; + $error = 'Invalid form token. Please try again.'; } else { $current = $request->request->getString('current_password'); $new = $request->request->getString('new_password'); $confirm = $request->request->getString('confirm_password'); if (!$this->hasher->isPasswordValid($user, $current)) { - $error = 'Das aktuelle Passwort ist falsch.'; + $error = 'Current password is incorrect.'; } elseif (mb_strlen($new) < 8) { - $error = 'Das neue Passwort muss mindestens 8 Zeichen lang sein.'; + $error = 'New password must be at least 8 characters.'; } elseif ($new !== $confirm) { - $error = 'Die neuen Passwörter stimmen nicht überein.'; + $error = 'Passwords do not match.'; } else { $user->setPasswordHash($this->hasher->hashPassword($user, $new)); $this->users->save($user); - $this->addFlash('success', 'Passwort erfolgreich geändert.'); + $this->addFlash('success', 'Password changed successfully.'); return $this->redirectToRoute('app_change_password'); } diff --git a/src/Infrastructure/Http/Form/StringArrayType.php b/src/Infrastructure/Http/Form/StringArrayType.php new file mode 100644 index 0000000..2ab8e95 --- /dev/null +++ b/src/Infrastructure/Http/Form/StringArrayType.php @@ -0,0 +1,37 @@ +addModelTransformer(new CallbackTransformer( + fn (?array $v) => implode("\n", $v ?? []), + fn (mixed $v) => '' === ($v ?? '') ? null : array_values( + array_filter(array_map('trim', explode("\n", (string) $v))) + ), + )); + } + + public function configureOptions(OptionsResolver $resolver): void + { + parent::configureOptions($resolver); + // EasyAdmin injects CollectionType options when it detects a json column; + // declare them as defined so they are accepted without error. + $resolver->setDefined(['allow_add', 'allow_delete', 'delete_empty', 'entry_options', 'entry_type']); + } + + public function getParent(): string + { + return TextareaType::class; + } +} diff --git a/symfony.lock b/symfony.lock index 7ccd7f8..02b4dd1 100644 --- a/symfony.lock +++ b/symfony.lock @@ -35,6 +35,70 @@ "migrations/.gitignore" ] }, + "easycorp/easyadmin-bundle": { + "version": "5.0", + "recipe": { + "repo": "github.com/symfony/recipes", + "branch": "main", + "version": "4.14", + "ref": "13b3e524d1038a6861ad6c7fc64e31b644ba0c54" + }, + "files": [ + "config/routes/easyadmin.yaml" + ] + }, + "endroid/qr-code-bundle": { + "version": "7.0.0" + }, + "friendsofphp/php-cs-fixer": { + "version": "3.95", + "recipe": { + "repo": "github.com/symfony/recipes", + "branch": "main", + "version": "3.39", + "ref": "97aaf9026490db73b86c23d49e5774bc89d2b232" + }, + "files": [ + ".php-cs-fixer.dist.php" + ] + }, + "phpstan/phpstan": { + "version": "2.1", + "recipe": { + "repo": "github.com/symfony/recipes-contrib", + "branch": "main", + "version": "1.0", + "ref": "5e490cc197fb6bb1ae22e5abbc531ddc633b6767" + } + }, + "phpunit/phpunit": { + "version": "11.5", + "recipe": { + "repo": "github.com/symfony/recipes", + "branch": "main", + "version": "11.1", + "ref": "ca0bc067abfb40a8de1b2561b96cbfc2b833c314" + }, + "files": [ + ".env.test", + "phpunit.dist.xml", + "tests/bootstrap.php", + "bin/phpunit" + ] + }, + "scheb/2fa-bundle": { + "version": "8.5", + "recipe": { + "repo": "github.com/symfony/recipes", + "branch": "main", + "version": "6.0", + "ref": "1e6f68089146853a790b5da9946fc5974f6fcd49" + }, + "files": [ + "config/packages/scheb_2fa.yaml", + "config/routes/scheb_2fa.yaml" + ] + }, "symfony/console": { "version": "8.0", "recipe": { @@ -60,6 +124,18 @@ ".env.dev" ] }, + "symfony/form": { + "version": "8.0", + "recipe": { + "repo": "github.com/symfony/recipes", + "branch": "main", + "version": "7.2", + "ref": "7d86a6723f4a623f59e2bf966b6aad2fc461d36b" + }, + "files": [ + "config/packages/csrf.yaml" + ] + }, "symfony/framework-bundle": { "version": "8.0", "recipe": { @@ -104,6 +180,18 @@ "config/packages/messenger.yaml" ] }, + "symfony/monolog-bundle": { + "version": "4.0", + "recipe": { + "repo": "github.com/symfony/recipes", + "branch": "main", + "version": "3.7", + "ref": "1b9efb10c54cb51c713a9391c9300ff8bceda459" + }, + "files": [ + "config/packages/monolog.yaml" + ] + }, "symfony/property-info": { "version": "8.0", "recipe": { @@ -142,6 +230,32 @@ "config/routes/security.yaml" ] }, + "symfony/translation": { + "version": "8.0", + "recipe": { + "repo": "github.com/symfony/recipes", + "branch": "main", + "version": "6.3", + "ref": "620a1b84865ceb2ba304c8f8bf2a185fbf32a843" + }, + "files": [ + "config/packages/translation.yaml", + "translations/.gitignore" + ] + }, + "symfony/twig-bundle": { + "version": "8.0", + "recipe": { + "repo": "github.com/symfony/recipes", + "branch": "main", + "version": "6.4", + "ref": "f250159ebe99153d0c640a3e7742876fc7453f2c" + }, + "files": [ + "config/packages/twig.yaml", + "templates/base.html.twig" + ] + }, "symfony/uid": { "version": "8.0", "recipe": { @@ -151,6 +265,18 @@ "ref": "0df5844274d871b37fc3816c57a768ffc60a43a5" } }, + "symfony/ux-twig-component": { + "version": "3.0", + "recipe": { + "repo": "github.com/symfony/recipes", + "branch": "main", + "version": "2.13", + "ref": "f367ae2a1faf01c503de2171f1ec22567febeead" + }, + "files": [ + "config/packages/twig_component.yaml" + ] + }, "symfony/validator": { "version": "8.0", "recipe": { @@ -162,5 +288,8 @@ "files": [ "config/packages/validator.yaml" ] + }, + "twig/extra-bundle": { + "version": "v3.24.0" } } diff --git a/templates/security/change_password.html.twig b/templates/security/change_password.html.twig index bcbc7b7..b4f3b95 100644 --- a/templates/security/change_password.html.twig +++ b/templates/security/change_password.html.twig @@ -1,10 +1,10 @@ {% extends 'base.html.twig' %} -{% block title %}Passwort ändern{% endblock %} +{% block title %}Change Password{% endblock %} {% block body %}
-

Passwort ändern

+

Change Password

{% for message in app.flashes('success') %}
{{ message }}
@@ -16,21 +16,21 @@
-
+
-
+
-
+
- - Zurück zum Dashboard + + Back to Dashboard
diff --git a/templates/security/login.html.twig b/templates/security/login.html.twig index ceaee66..3576356 100644 --- a/templates/security/login.html.twig +++ b/templates/security/login.html.twig @@ -15,7 +15,7 @@

- +
diff --git a/tests/Unit/Domain/Auth/ApiKeyTest.php b/tests/Unit/Domain/Auth/ApiKeyTest.php new file mode 100644 index 0000000..0d701b8 --- /dev/null +++ b/tests/Unit/Domain/Auth/ApiKeyTest.php @@ -0,0 +1,98 @@ +user = new User('test@example.com', 'hash'); + } + + public function testConstructorSetsFields(): void + { + $key = new ApiKey($this->user, 'dev laptop', 'abcd1234', 'hashed-key'); + + self::assertSame('dev laptop', $key->getLabel()); + self::assertSame('abcd1234', $key->getKeyPrefix()); + self::assertSame('hashed-key', $key->getKeyHash()); + self::assertSame($this->user, $key->getUser()); + } + + public function testIsActiveByDefault(): void + { + $key = new ApiKey($this->user, 'label', 'abcd1234', 'hash'); + + self::assertTrue($key->isActive()); + } + + public function testCanBeDeactivated(): void + { + $key = new ApiKey($this->user, 'label', 'abcd1234', 'hash'); + $key->setIsActive(false); + + self::assertFalse($key->isActive()); + } + + public function testIsNotExpiredWithNoExpiry(): void + { + $key = new ApiKey($this->user, 'label', 'abcd1234', 'hash'); + + self::assertFalse($key->isExpired()); + } + + public function testIsExpiredWhenExpiryIsPast(): void + { + $key = new ApiKey($this->user, 'label', 'abcd1234', 'hash'); + $key->setExpiresAt(new \DateTimeImmutable('-1 hour')); + + self::assertTrue($key->isExpired()); + } + + public function testIsNotExpiredWhenExpiryIsFuture(): void + { + $key = new ApiKey($this->user, 'label', 'abcd1234', 'hash'); + $key->setExpiresAt(new \DateTimeImmutable('+1 hour')); + + self::assertFalse($key->isExpired()); + } + + public function testMarkUsedSetsLastUsedAt(): void + { + $key = new ApiKey($this->user, 'label', 'abcd1234', 'hash'); + self::assertNull($key->getLastUsedAt()); + + $key->markUsed(); + + self::assertNotNull($key->getLastUsedAt()); + } + + public function testGrantAndCheckPermission(): void + { + $key = new ApiKey($this->user, 'label', 'abcd1234', 'hash'); + $key->grantPermission('articles.write'); + + self::assertTrue($key->hasPermission('articles.write')); + self::assertFalse($key->hasPermission('orders.delete')); + } + + public function testRawKeyVerifiesAgainstStoredHash(): void + { + $rawKey = bin2hex(random_bytes(24)); + $hash = password_hash($rawKey, PASSWORD_BCRYPT); + $prefix = substr($rawKey, 0, 8); + + $key = new ApiKey($this->user, 'label', $prefix, $hash); + + self::assertTrue(password_verify($rawKey, $key->getKeyHash())); + self::assertSame($prefix, $key->getKeyPrefix()); + } +} diff --git a/tests/Unit/Infrastructure/AI/MistralClientTest.php b/tests/Unit/Infrastructure/AI/MistralClientTest.php new file mode 100644 index 0000000..524bf2c --- /dev/null +++ b/tests/Unit/Infrastructure/AI/MistralClientTest.php @@ -0,0 +1,123 @@ + [['message' => ['content' => 'Dell Latitude 5520']]], + ]); + + $client = $this->makeClient(new MockResponse((string) $body)); + + self::assertSame('Dell Latitude 5520', $client->generate('mistral-large-latest', 'What is this?')); + } + + public function testGenerateSendsAuthHeader(): void + { + $body = json_encode([ + 'choices' => [['message' => ['content' => 'ok']]], + ]); + + $response = new MockResponse((string) $body); + $client = $this->makeClient($response); + $client->generate('mistral-large-latest', 'test'); + + self::assertStringContainsString( + 'Bearer test-api-key', + implode(', ', $response->getRequestOptions()['headers'] ?? []), + ); + } + + public function testGenerateWithImageReturnsParsedContent(): void + { + $tmpFile = tempnam(sys_get_temp_dir(), 'test_') . '.jpg'; + file_put_contents($tmpFile, 'fake-image-data'); + + $body = json_encode([ + 'choices' => [['message' => ['content' => 'MODEL: ThinkPad X1']]], + ]); + + $client = $this->makeClient(new MockResponse((string) $body)); + $result = $client->generateWithImage('pixtral-12b-2409', 'Read the nameplate', $tmpFile); + + unlink($tmpFile); + + self::assertSame('MODEL: ThinkPad X1', $result); + } + + public function testGenerateWithImageEncodesImageAsBase64(): void + { + $tmpFile = tempnam(sys_get_temp_dir(), 'test_') . '.png'; + $imageContent = 'fake-png-bytes'; + file_put_contents($tmpFile, $imageContent); + + $body = json_encode([ + 'choices' => [['message' => ['content' => 'ok']]], + ]); + + $response = new MockResponse((string) $body); + $client = $this->makeClient($response); + $client->generateWithImage('pixtral-12b-2409', 'describe', $tmpFile); + + unlink($tmpFile); + + $requestBody = json_decode($response->getRequestOptions()['body'], true); + $imageUrl = $requestBody['messages'][0]['content'][1]['image_url']['url']; + + self::assertStringStartsWith('data:image/png;base64,', $imageUrl); + self::assertStringContainsString(base64_encode($imageContent), $imageUrl); + } + + /** + * @dataProvider mimeTypeProvider + */ + public function testGuessMimeTypeFromExtension(string $filename, string $expectedMime): void + { + $tmpFile = tempnam(sys_get_temp_dir(), 'test_') . '.' . pathinfo($filename, PATHINFO_EXTENSION); + file_put_contents($tmpFile, 'x'); + + $body = json_encode(['choices' => [['message' => ['content' => 'ok']]]]); + $response = new MockResponse((string) $body); + $client = $this->makeClient($response); + $client->generateWithImage('pixtral-12b-2409', 'x', $tmpFile); + + unlink($tmpFile); + + $requestBody = json_decode($response->getRequestOptions()['body'], true); + $imageUrl = $requestBody['messages'][0]['content'][1]['image_url']['url']; + + self::assertStringStartsWith('data:' . $expectedMime . ';base64,', $imageUrl); + } + + /** @return array */ + public static function mimeTypeProvider(): array + { + return [ + 'jpeg' => ['photo.jpg', 'image/jpeg'], + 'jpeg uppercase' => ['photo.JPG', 'image/jpeg'], + 'jpeg ext' => ['photo.jpeg', 'image/jpeg'], + 'png' => ['photo.png', 'image/png'], + 'webp' => ['photo.webp', 'image/webp'], + 'unknown falls back to jpeg' => ['photo.bmp', 'image/jpeg'], + ]; + } +} diff --git a/tests/Unit/Infrastructure/Admin/ArticleTypeCrudControllerTest.php b/tests/Unit/Infrastructure/Admin/ArticleTypeCrudControllerTest.php new file mode 100644 index 0000000..5b0f7ad --- /dev/null +++ b/tests/Unit/Infrastructure/Admin/ArticleTypeCrudControllerTest.php @@ -0,0 +1,41 @@ +controller = new ArticleTypeCrudController(); + } + + public function testGetEntityFqcn(): void + { + self::assertSame(ArticleType::class, ArticleTypeCrudController::getEntityFqcn()); + } + + public function testCreateEntityReturnsArticleTypeWithEmptyName(): void + { + $entity = $this->controller->createEntity(ArticleType::class); + + self::assertInstanceOf(ArticleType::class, $entity); + self::assertSame('', $entity->getName()); + } + + public function testConfigureFieldsContainsNameAndAttributes(): void + { + $fields = iterator_to_array($this->controller->configureFields('new')); + $names = array_map(fn ($f) => $f->getAsDto()->getProperty(), $fields); + + self::assertContains('name', $names); + self::assertContains('attributeDefinitions', $names); + } +} diff --git a/tests/Unit/Infrastructure/Admin/AttributeDefinitionCrudControllerTest.php b/tests/Unit/Infrastructure/Admin/AttributeDefinitionCrudControllerTest.php new file mode 100644 index 0000000..01be542 --- /dev/null +++ b/tests/Unit/Infrastructure/Admin/AttributeDefinitionCrudControllerTest.php @@ -0,0 +1,81 @@ +controller = new AttributeDefinitionCrudController(); + } + + public function testGetEntityFqcn(): void + { + self::assertSame(AttributeDefinition::class, AttributeDefinitionCrudController::getEntityFqcn()); + } + + public function testCreateEntityReturnsAttributeDefinitionWithDefaults(): void + { + $entity = $this->controller->createEntity(AttributeDefinition::class); + + self::assertInstanceOf(AttributeDefinition::class, $entity); + self::assertSame('', $entity->getName()); + self::assertSame(AttributeType::String, $entity->getType()); + } + + public function testConfigureFieldsYieldsExpectedFieldNames(): void + { + $fields = iterator_to_array($this->controller->configureFields('new')); + $names = array_map(fn ($f) => $f->getAsDto()->getProperty(), $fields); + + self::assertContains('name', $names); + self::assertContains('type', $names); + self::assertContains('unit', $names); + self::assertContains('options', $names); + } + + public function testTypeFieldOnIndexUsesChoiceFieldWithFormatValue(): void + { + $fields = iterator_to_array($this->controller->configureFields('index')); + $typeField = null; + foreach ($fields as $field) { + if ('type' === $field->getAsDto()->getProperty()) { + $typeField = $field; + break; + } + } + + self::assertNotNull($typeField); + + // Must have formatValue so choice.html.twig can render it safely + $formatValue = $typeField->getAsDto()->getFormatValueCallable(); + self::assertNotNull($formatValue, 'formatValue must be set so index page can render the enum'); + self::assertSame('select', $formatValue(AttributeType::Select, null)); + self::assertSame('string', $formatValue(AttributeType::String, null)); + } + + public function testTypeFieldOnFormUsesEnumType(): void + { + $fields = iterator_to_array($this->controller->configureFields('new')); + $typeField = null; + foreach ($fields as $field) { + if ('type' === $field->getAsDto()->getProperty()) { + $typeField = $field; + break; + } + } + + self::assertNotNull($typeField); + self::assertSame(EnumType::class, $typeField->getAsDto()->getFormType()); + } +} diff --git a/tests/Unit/Infrastructure/Console/CreateApiKeyCommandTest.php b/tests/Unit/Infrastructure/Console/CreateApiKeyCommandTest.php new file mode 100644 index 0000000..fda97ba --- /dev/null +++ b/tests/Unit/Infrastructure/Console/CreateApiKeyCommandTest.php @@ -0,0 +1,115 @@ +users = $this->createMock(UserRepositoryInterface::class); + $this->apiKeys = $this->createMock(ApiKeyRepositoryInterface::class); + + $this->tester = new CommandTester(new CreateApiKeyCommand($this->users, $this->apiKeys)); + } + + public function testFailsWhenEmailIsEmpty(): void + { + $this->tester->setInputs(['', '']); + + $this->tester->execute([]); + + self::assertSame(1, $this->tester->getStatusCode()); + self::assertStringContainsString('Email is required', $this->tester->getDisplay()); + } + + public function testFailsWhenUserNotFound(): void + { + $this->users->method('findByEmail')->willReturn(null); + + $this->tester->setInputs(['unknown@example.com', '']); + + $this->tester->execute([]); + + self::assertSame(1, $this->tester->getStatusCode()); + self::assertStringContainsString('No user found', $this->tester->getDisplay()); + } + + public function testFailsWhenLabelIsEmpty(): void + { + $this->users->method('findByEmail')->willReturn(new User('test@example.com', 'hash')); + + $this->tester->setInputs(['test@example.com', '']); + + $this->tester->execute([]); + + self::assertSame(1, $this->tester->getStatusCode()); + self::assertStringContainsString('Label is required', $this->tester->getDisplay()); + } + + public function testCreatesKeyAndPrintsIt(): void + { + $user = new User('test@example.com', 'hash'); + $this->users->method('findByEmail')->willReturn($user); + + $savedKey = null; + $this->apiKeys->expects($this->once()) + ->method('save') + ->willReturnCallback(function (ApiKey $key) use (&$savedKey): void { + $savedKey = $key; + }); + + $this->tester->setInputs(['test@example.com', 'dev laptop']); + $this->tester->execute([]); + + self::assertSame(0, $this->tester->getStatusCode()); + + $display = $this->tester->getDisplay(); + self::assertStringContainsString('API key created', $display); + self::assertStringContainsString('dev laptop', $display); + + self::assertNotNull($savedKey); + self::assertSame('dev laptop', $savedKey->getLabel()); + self::assertSame(8, strlen($savedKey->getKeyPrefix())); + self::assertStringContainsString($savedKey->getKeyPrefix(), $display); + } + + public function testStoredHashVerifiesAgainstPrintedKey(): void + { + $user = new User('test@example.com', 'hash'); + $this->users->method('findByEmail')->willReturn($user); + + $savedKey = null; + $this->apiKeys->method('save') + ->willReturnCallback(function (ApiKey $key) use (&$savedKey): void { + $savedKey = $key; + }); + + $this->tester->setInputs(['test@example.com', 'ci-runner']); + $this->tester->execute([]); + + self::assertNotNull($savedKey); + + // SymfonyStyle table uses spaces, not pipes: " API Key " + preg_match('/API Key\s+([a-f0-9]{48})/', $this->tester->getDisplay(), $matches); + self::assertNotEmpty($matches, 'Raw key not found in output'); + + $rawKey = $matches[1]; + self::assertSame(substr($rawKey, 0, 8), $savedKey->getKeyPrefix()); + self::assertTrue(password_verify($rawKey, $savedKey->getKeyHash())); + } +} diff --git a/tests/Unit/Infrastructure/Form/StringArrayTypeTest.php b/tests/Unit/Infrastructure/Form/StringArrayTypeTest.php new file mode 100644 index 0000000..369cc73 --- /dev/null +++ b/tests/Unit/Infrastructure/Form/StringArrayTypeTest.php @@ -0,0 +1,51 @@ +factory->create(StringArrayType::class); + $form->setData(null); + + self::assertSame('', $form->getViewData()); + } + + public function testArrayIsJoinedWithNewlines(): void + { + $form = $this->factory->create(StringArrayType::class); + $form->setData(['4GB', '8GB', '16GB']); + + self::assertSame("4GB\n8GB\n16GB", $form->getViewData()); + } + + public function testSubmittedNewlineStringIsTransformedToArray(): void + { + $form = $this->factory->create(StringArrayType::class); + $form->submit("4GB\n8GB\n16GB"); + + self::assertSame(['4GB', '8GB', '16GB'], $form->getData()); + } + + public function testBlankLinesAreStripped(): void + { + $form = $this->factory->create(StringArrayType::class); + $form->submit("4GB\n\n8GB\n \n16GB"); + + self::assertSame(['4GB', '8GB', '16GB'], $form->getData()); + } + + public function testEmptySubmitReturnsNull(): void + { + $form = $this->factory->create(StringArrayType::class); + $form->submit(''); + + self::assertNull($form->getData()); + } +}