diff --git a/.php-cs-fixer.dist.php b/.php-cs-fixer.dist.php new file mode 100644 index 0000000..fb860c8 --- /dev/null +++ b/.php-cs-fixer.dist.php @@ -0,0 +1,19 @@ +in([ + __DIR__ . '/config', + __DIR__ . '/lib', + __DIR__ . '/pages', + __DIR__ . '/tests', + ]) + ->name('*.php'); + +return (new PhpCsFixer\Config()) + ->setRiskyAllowed(false) + ->setRules([ + '@PSR12' => true, + 'ordered_imports' => ['sort_algorithm' => 'alpha'], + 'no_unused_imports' => true, + ]) + ->setFinder($finder); diff --git a/CLAUDE.md b/CLAUDE.md index 72bbb7e..97ce2e9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -68,7 +68,11 @@ docker/ # Dockerfiles, Nginx configs, PHP configs 1. **App** (`lib/App/`): DI container bootstrap. Registers all service/repository factories. No business logic. 2. **Repository** (`lib/Repository/`): All SQL lives here. No HTTP, session, or rendering. -3. **Service** (`lib/Service/`): Business rules and validation. No HTML. Uses factory pattern (`*ServicesFactory`). +3. **Service** (`lib/Service/`): Business rules and validation. No HTML. Four internal sub-types: + - **Service** (`*Service.php`): Business logic + orchestration. Coordinates repositories and gateways. + - **Gateway** (`*Gateway.php`): Adapter for a single external dependency (repository, settings, HTTP API, scope). No orchestration flow. + - **Factory** (`*Factory.php`): Only place inside `lib/Service/` that may call `new` on services/gateways/repositories. Registers via DI container. + - **Policy** (`*Policy.php` in `lib/Service/Access/`): Authorization decisions per resource type. Returns `AuthorizationDecision`. No HTTP, no business logic. 4. **Action** (`pages/**/*.php`): Reads input, checks permissions + tenant scope, calls services, sets view vars. 5. **View** (`pages/**/*.phtml`): Pure rendering. **No DB calls.** HTML escaping via `e(...)`. 6. **Template** (`templates/`): Layouts and partials. diff --git a/README.md b/README.md index 94e2107..4c6d276 100644 --- a/README.md +++ b/README.md @@ -193,6 +193,20 @@ docker compose exec php vendor/bin/phpunit --bootstrap vendor/autoload.php tests docker compose exec php vendor/bin/phpstan analyse -c phpstan.neon --no-progress ``` +### Coding Style (PHP CS Fixer) + +CI-Check (ohne Dateien zu aendern): + +```bash +docker compose exec php composer cs:check +``` + +Lokal automatisch formatieren: + +```bash +docker compose exec php composer cs:fix +``` + ### Frontend-JS Smoke-Check (ohne Node) - Browser-DevTools öffnen (Console + Preserve log) diff --git a/composer.json b/composer.json index e3ec0dc..27c5386 100644 --- a/composer.json +++ b/composer.json @@ -22,7 +22,8 @@ "mintyphp/debugger": "*", "phpunit/phpunit": "*", "phpstan/phpstan": "^1.9", - "icanhazstring/composer-unused": "^0.9.6" + "icanhazstring/composer-unused": "^0.9.6", + "friendsofphp/php-cs-fixer": "^3.66" }, "autoload-dev": { "psr-4": { @@ -33,5 +34,9 @@ "psr-4": { "MintyPHP\\": "lib/" } + }, + "scripts": { + "cs:check": "php-cs-fixer fix --config=.php-cs-fixer.dist.php --dry-run --diff --verbose", + "cs:fix": "php-cs-fixer fix --config=.php-cs-fixer.dist.php --verbose" } } diff --git a/composer.lock b/composer.lock index 95bf95c..48df14e 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "5abdc17eb52371dc1f353dca0cb0b9ca", + "content-hash": "91c05b180a4c7a7b830ea9a02bf7dca4", "packages": [ { "name": "bacon/bacon-qr-code", @@ -1368,6 +1368,70 @@ } ], "packages-dev": [ + { + "name": "clue/ndjson-react", + "version": "v1.3.0", + "source": { + "type": "git", + "url": "https://github.com/clue/reactphp-ndjson.git", + "reference": "392dc165fce93b5bb5c637b67e59619223c931b0" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/clue/reactphp-ndjson/zipball/392dc165fce93b5bb5c637b67e59619223c931b0", + "reference": "392dc165fce93b5bb5c637b67e59619223c931b0", + "shasum": "" + }, + "require": { + "php": ">=5.3", + "react/stream": "^1.2" + }, + "require-dev": { + "phpunit/phpunit": "^9.5 || ^5.7 || ^4.8.35", + "react/event-loop": "^1.2" + }, + "type": "library", + "autoload": { + "psr-4": { + "Clue\\React\\NDJson\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Christian Lück", + "email": "christian@clue.engineering" + } + ], + "description": "Streaming newline-delimited JSON (NDJSON) parser and encoder for ReactPHP.", + "homepage": "https://github.com/clue/reactphp-ndjson", + "keywords": [ + "NDJSON", + "json", + "jsonlines", + "newline", + "reactphp", + "streaming" + ], + "support": { + "issues": "https://github.com/clue/reactphp-ndjson/issues", + "source": "https://github.com/clue/reactphp-ndjson/tree/v1.3.0" + }, + "funding": [ + { + "url": "https://clue.engineering/support", + "type": "custom" + }, + { + "url": "https://github.com/clue", + "type": "github" + } + ], + "time": "2022-12-23T10:58:28+00:00" + }, { "name": "composer-unused/contracts", "version": "0.3.0", @@ -1565,6 +1629,83 @@ ], "time": "2024-11-12T16:29:46+00:00" }, + { + "name": "composer/semver", + "version": "3.4.4", + "source": { + "type": "git", + "url": "https://github.com/composer/semver.git", + "reference": "198166618906cb2de69b95d7d47e5fa8aa1b2b95" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/composer/semver/zipball/198166618906cb2de69b95d7d47e5fa8aa1b2b95", + "reference": "198166618906cb2de69b95d7d47e5fa8aa1b2b95", + "shasum": "" + }, + "require": { + "php": "^5.3.2 || ^7.0 || ^8.0" + }, + "require-dev": { + "phpstan/phpstan": "^1.11", + "symfony/phpunit-bridge": "^3 || ^7" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Composer\\Semver\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nils Adermann", + "email": "naderman@naderman.de", + "homepage": "http://www.naderman.de" + }, + { + "name": "Jordi Boggiano", + "email": "j.boggiano@seld.be", + "homepage": "http://seld.be" + }, + { + "name": "Rob Bast", + "email": "rob.bast@gmail.com", + "homepage": "http://robbast.nl" + } + ], + "description": "Semver library that offers utilities, version constraint parsing and validation.", + "keywords": [ + "semantic", + "semver", + "validation", + "versioning" + ], + "support": { + "irc": "ircs://irc.libera.chat:6697/composer", + "issues": "https://github.com/composer/semver/issues", + "source": "https://github.com/composer/semver/tree/3.4.4" + }, + "funding": [ + { + "url": "https://packagist.com", + "type": "custom" + }, + { + "url": "https://github.com/composer", + "type": "github" + } + ], + "time": "2025-08-20T19:15:30+00:00" + }, { "name": "composer/xdebug-handler", "version": "3.0.5", @@ -1631,6 +1772,218 @@ ], "time": "2024-05-06T16:37:16+00:00" }, + { + "name": "evenement/evenement", + "version": "v3.0.2", + "source": { + "type": "git", + "url": "https://github.com/igorw/evenement.git", + "reference": "0a16b0d71ab13284339abb99d9d2bd813640efbc" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/igorw/evenement/zipball/0a16b0d71ab13284339abb99d9d2bd813640efbc", + "reference": "0a16b0d71ab13284339abb99d9d2bd813640efbc", + "shasum": "" + }, + "require": { + "php": ">=7.0" + }, + "require-dev": { + "phpunit/phpunit": "^9 || ^6" + }, + "type": "library", + "autoload": { + "psr-4": { + "Evenement\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Igor Wiedler", + "email": "igor@wiedler.ch" + } + ], + "description": "Événement is a very simple event dispatching library for PHP", + "keywords": [ + "event-dispatcher", + "event-emitter" + ], + "support": { + "issues": "https://github.com/igorw/evenement/issues", + "source": "https://github.com/igorw/evenement/tree/v3.0.2" + }, + "time": "2023-08-08T05:53:35+00:00" + }, + { + "name": "fidry/cpu-core-counter", + "version": "1.3.0", + "source": { + "type": "git", + "url": "https://github.com/theofidry/cpu-core-counter.git", + "reference": "db9508f7b1474469d9d3c53b86f817e344732678" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/theofidry/cpu-core-counter/zipball/db9508f7b1474469d9d3c53b86f817e344732678", + "reference": "db9508f7b1474469d9d3c53b86f817e344732678", + "shasum": "" + }, + "require": { + "php": "^7.2 || ^8.0" + }, + "require-dev": { + "fidry/makefile": "^0.2.0", + "fidry/php-cs-fixer-config": "^1.1.2", + "phpstan/extension-installer": "^1.2.0", + "phpstan/phpstan": "^2.0", + "phpstan/phpstan-deprecation-rules": "^2.0.0", + "phpstan/phpstan-phpunit": "^2.0", + "phpstan/phpstan-strict-rules": "^2.0", + "phpunit/phpunit": "^8.5.31 || ^9.5.26", + "webmozarts/strict-phpunit": "^7.5" + }, + "type": "library", + "autoload": { + "psr-4": { + "Fidry\\CpuCoreCounter\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Théo FIDRY", + "email": "theo.fidry@gmail.com" + } + ], + "description": "Tiny utility to get the number of CPU cores.", + "keywords": [ + "CPU", + "core" + ], + "support": { + "issues": "https://github.com/theofidry/cpu-core-counter/issues", + "source": "https://github.com/theofidry/cpu-core-counter/tree/1.3.0" + }, + "funding": [ + { + "url": "https://github.com/theofidry", + "type": "github" + } + ], + "time": "2025-08-14T07:29:31+00:00" + }, + { + "name": "friendsofphp/php-cs-fixer", + "version": "v3.94.2", + "source": { + "type": "git", + "url": "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer.git", + "reference": "7787ceff91365ba7d623ec410b8f429cdebb4f63" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/PHP-CS-Fixer/PHP-CS-Fixer/zipball/7787ceff91365ba7d623ec410b8f429cdebb4f63", + "reference": "7787ceff91365ba7d623ec410b8f429cdebb4f63", + "shasum": "" + }, + "require": { + "clue/ndjson-react": "^1.3", + "composer/semver": "^3.4", + "composer/xdebug-handler": "^3.0.5", + "ext-filter": "*", + "ext-hash": "*", + "ext-json": "*", + "ext-tokenizer": "*", + "fidry/cpu-core-counter": "^1.3", + "php": "^7.4 || ^8.0", + "react/child-process": "^0.6.6", + "react/event-loop": "^1.5", + "react/socket": "^1.16", + "react/stream": "^1.4", + "sebastian/diff": "^4.0.6 || ^5.1.1 || ^6.0.2 || ^7.0 || ^8.0", + "symfony/console": "^5.4.47 || ^6.4.24 || ^7.0 || ^8.0", + "symfony/event-dispatcher": "^5.4.45 || ^6.4.24 || ^7.0 || ^8.0", + "symfony/filesystem": "^5.4.45 || ^6.4.24 || ^7.0 || ^8.0", + "symfony/finder": "^5.4.45 || ^6.4.24 || ^7.0 || ^8.0", + "symfony/options-resolver": "^5.4.45 || ^6.4.24 || ^7.0 || ^8.0", + "symfony/polyfill-mbstring": "^1.33", + "symfony/polyfill-php80": "^1.33", + "symfony/polyfill-php81": "^1.33", + "symfony/polyfill-php84": "^1.33", + "symfony/process": "^5.4.47 || ^6.4.24 || ^7.2 || ^8.0", + "symfony/stopwatch": "^5.4.45 || ^6.4.24 || ^7.0 || ^8.0" + }, + "require-dev": { + "facile-it/paraunit": "^1.3.1 || ^2.7.1", + "infection/infection": "^0.32.3", + "justinrainbow/json-schema": "^6.6.4", + "keradus/cli-executor": "^2.3", + "mikey179/vfsstream": "^1.6.12", + "php-coveralls/php-coveralls": "^2.9.1", + "php-cs-fixer/phpunit-constraint-isidenticalstring": "^1.7", + "php-cs-fixer/phpunit-constraint-xmlmatchesxsd": "^1.7", + "phpunit/phpunit": "^9.6.34 || ^10.5.63 || ^11.5.51", + "symfony/polyfill-php85": "^1.33", + "symfony/var-dumper": "^5.4.48 || ^6.4.32 || ^7.4.4 || ^8.0.4", + "symfony/yaml": "^5.4.45 || ^6.4.30 || ^7.4.1 || ^8.0.1" + }, + "suggest": { + "ext-dom": "For handling output formats in XML", + "ext-mbstring": "For handling non-UTF8 characters." + }, + "bin": [ + "php-cs-fixer" + ], + "type": "application", + "autoload": { + "psr-4": { + "PhpCsFixer\\": "src/" + }, + "exclude-from-classmap": [ + "src/**/Internal/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Dariusz Rumiński", + "email": "dariusz.ruminski@gmail.com" + } + ], + "description": "A tool to automatically fix PHP code style", + "keywords": [ + "Static code analysis", + "fixer", + "standards", + "static analysis" + ], + "support": { + "issues": "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/issues", + "source": "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/tree/v3.94.2" + }, + "funding": [ + { + "url": "https://github.com/keradus", + "type": "github" + } + ], + "time": "2026-02-20T16:13:53+00:00" + }, { "name": "icanhazstring/composer-unused", "version": "0.9.6", @@ -2743,6 +3096,532 @@ }, "time": "2024-09-11T13:17:53+00:00" }, + { + "name": "react/cache", + "version": "v1.2.0", + "source": { + "type": "git", + "url": "https://github.com/reactphp/cache.git", + "reference": "d47c472b64aa5608225f47965a484b75c7817d5b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/reactphp/cache/zipball/d47c472b64aa5608225f47965a484b75c7817d5b", + "reference": "d47c472b64aa5608225f47965a484b75c7817d5b", + "shasum": "" + }, + "require": { + "php": ">=5.3.0", + "react/promise": "^3.0 || ^2.0 || ^1.1" + }, + "require-dev": { + "phpunit/phpunit": "^9.5 || ^5.7 || ^4.8.35" + }, + "type": "library", + "autoload": { + "psr-4": { + "React\\Cache\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Christian Lück", + "email": "christian@clue.engineering", + "homepage": "https://clue.engineering/" + }, + { + "name": "Cees-Jan Kiewiet", + "email": "reactphp@ceesjankiewiet.nl", + "homepage": "https://wyrihaximus.net/" + }, + { + "name": "Jan Sorgalla", + "email": "jsorgalla@gmail.com", + "homepage": "https://sorgalla.com/" + }, + { + "name": "Chris Boden", + "email": "cboden@gmail.com", + "homepage": "https://cboden.dev/" + } + ], + "description": "Async, Promise-based cache interface for ReactPHP", + "keywords": [ + "cache", + "caching", + "promise", + "reactphp" + ], + "support": { + "issues": "https://github.com/reactphp/cache/issues", + "source": "https://github.com/reactphp/cache/tree/v1.2.0" + }, + "funding": [ + { + "url": "https://opencollective.com/reactphp", + "type": "open_collective" + } + ], + "time": "2022-11-30T15:59:55+00:00" + }, + { + "name": "react/child-process", + "version": "v0.6.7", + "source": { + "type": "git", + "url": "https://github.com/reactphp/child-process.git", + "reference": "970f0e71945556422ee4570ccbabaedc3cf04ad3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/reactphp/child-process/zipball/970f0e71945556422ee4570ccbabaedc3cf04ad3", + "reference": "970f0e71945556422ee4570ccbabaedc3cf04ad3", + "shasum": "" + }, + "require": { + "evenement/evenement": "^3.0 || ^2.0 || ^1.0", + "php": ">=5.3.0", + "react/event-loop": "^1.2", + "react/stream": "^1.4" + }, + "require-dev": { + "phpunit/phpunit": "^9.6 || ^5.7 || ^4.8.36", + "react/socket": "^1.16", + "sebastian/environment": "^5.0 || ^3.0 || ^2.0 || ^1.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "React\\ChildProcess\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Christian Lück", + "email": "christian@clue.engineering", + "homepage": "https://clue.engineering/" + }, + { + "name": "Cees-Jan Kiewiet", + "email": "reactphp@ceesjankiewiet.nl", + "homepage": "https://wyrihaximus.net/" + }, + { + "name": "Jan Sorgalla", + "email": "jsorgalla@gmail.com", + "homepage": "https://sorgalla.com/" + }, + { + "name": "Chris Boden", + "email": "cboden@gmail.com", + "homepage": "https://cboden.dev/" + } + ], + "description": "Event-driven library for executing child processes with ReactPHP.", + "keywords": [ + "event-driven", + "process", + "reactphp" + ], + "support": { + "issues": "https://github.com/reactphp/child-process/issues", + "source": "https://github.com/reactphp/child-process/tree/v0.6.7" + }, + "funding": [ + { + "url": "https://opencollective.com/reactphp", + "type": "open_collective" + } + ], + "time": "2025-12-23T15:25:20+00:00" + }, + { + "name": "react/dns", + "version": "v1.14.0", + "source": { + "type": "git", + "url": "https://github.com/reactphp/dns.git", + "reference": "7562c05391f42701c1fccf189c8225fece1cd7c3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/reactphp/dns/zipball/7562c05391f42701c1fccf189c8225fece1cd7c3", + "reference": "7562c05391f42701c1fccf189c8225fece1cd7c3", + "shasum": "" + }, + "require": { + "php": ">=5.3.0", + "react/cache": "^1.0 || ^0.6 || ^0.5", + "react/event-loop": "^1.2", + "react/promise": "^3.2 || ^2.7 || ^1.2.1" + }, + "require-dev": { + "phpunit/phpunit": "^9.6 || ^5.7 || ^4.8.36", + "react/async": "^4.3 || ^3 || ^2", + "react/promise-timer": "^1.11" + }, + "type": "library", + "autoload": { + "psr-4": { + "React\\Dns\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Christian Lück", + "email": "christian@clue.engineering", + "homepage": "https://clue.engineering/" + }, + { + "name": "Cees-Jan Kiewiet", + "email": "reactphp@ceesjankiewiet.nl", + "homepage": "https://wyrihaximus.net/" + }, + { + "name": "Jan Sorgalla", + "email": "jsorgalla@gmail.com", + "homepage": "https://sorgalla.com/" + }, + { + "name": "Chris Boden", + "email": "cboden@gmail.com", + "homepage": "https://cboden.dev/" + } + ], + "description": "Async DNS resolver for ReactPHP", + "keywords": [ + "async", + "dns", + "dns-resolver", + "reactphp" + ], + "support": { + "issues": "https://github.com/reactphp/dns/issues", + "source": "https://github.com/reactphp/dns/tree/v1.14.0" + }, + "funding": [ + { + "url": "https://opencollective.com/reactphp", + "type": "open_collective" + } + ], + "time": "2025-11-18T19:34:28+00:00" + }, + { + "name": "react/event-loop", + "version": "v1.6.0", + "source": { + "type": "git", + "url": "https://github.com/reactphp/event-loop.git", + "reference": "ba276bda6083df7e0050fd9b33f66ad7a4ac747a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/reactphp/event-loop/zipball/ba276bda6083df7e0050fd9b33f66ad7a4ac747a", + "reference": "ba276bda6083df7e0050fd9b33f66ad7a4ac747a", + "shasum": "" + }, + "require": { + "php": ">=5.3.0" + }, + "require-dev": { + "phpunit/phpunit": "^9.6 || ^5.7 || ^4.8.36" + }, + "suggest": { + "ext-pcntl": "For signal handling support when using the StreamSelectLoop" + }, + "type": "library", + "autoload": { + "psr-4": { + "React\\EventLoop\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Christian Lück", + "email": "christian@clue.engineering", + "homepage": "https://clue.engineering/" + }, + { + "name": "Cees-Jan Kiewiet", + "email": "reactphp@ceesjankiewiet.nl", + "homepage": "https://wyrihaximus.net/" + }, + { + "name": "Jan Sorgalla", + "email": "jsorgalla@gmail.com", + "homepage": "https://sorgalla.com/" + }, + { + "name": "Chris Boden", + "email": "cboden@gmail.com", + "homepage": "https://cboden.dev/" + } + ], + "description": "ReactPHP's core reactor event loop that libraries can use for evented I/O.", + "keywords": [ + "asynchronous", + "event-loop" + ], + "support": { + "issues": "https://github.com/reactphp/event-loop/issues", + "source": "https://github.com/reactphp/event-loop/tree/v1.6.0" + }, + "funding": [ + { + "url": "https://opencollective.com/reactphp", + "type": "open_collective" + } + ], + "time": "2025-11-17T20:46:25+00:00" + }, + { + "name": "react/promise", + "version": "v3.3.0", + "source": { + "type": "git", + "url": "https://github.com/reactphp/promise.git", + "reference": "23444f53a813a3296c1368bb104793ce8d88f04a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/reactphp/promise/zipball/23444f53a813a3296c1368bb104793ce8d88f04a", + "reference": "23444f53a813a3296c1368bb104793ce8d88f04a", + "shasum": "" + }, + "require": { + "php": ">=7.1.0" + }, + "require-dev": { + "phpstan/phpstan": "1.12.28 || 1.4.10", + "phpunit/phpunit": "^9.6 || ^7.5" + }, + "type": "library", + "autoload": { + "files": [ + "src/functions_include.php" + ], + "psr-4": { + "React\\Promise\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Jan Sorgalla", + "email": "jsorgalla@gmail.com", + "homepage": "https://sorgalla.com/" + }, + { + "name": "Christian Lück", + "email": "christian@clue.engineering", + "homepage": "https://clue.engineering/" + }, + { + "name": "Cees-Jan Kiewiet", + "email": "reactphp@ceesjankiewiet.nl", + "homepage": "https://wyrihaximus.net/" + }, + { + "name": "Chris Boden", + "email": "cboden@gmail.com", + "homepage": "https://cboden.dev/" + } + ], + "description": "A lightweight implementation of CommonJS Promises/A for PHP", + "keywords": [ + "promise", + "promises" + ], + "support": { + "issues": "https://github.com/reactphp/promise/issues", + "source": "https://github.com/reactphp/promise/tree/v3.3.0" + }, + "funding": [ + { + "url": "https://opencollective.com/reactphp", + "type": "open_collective" + } + ], + "time": "2025-08-19T18:57:03+00:00" + }, + { + "name": "react/socket", + "version": "v1.17.0", + "source": { + "type": "git", + "url": "https://github.com/reactphp/socket.git", + "reference": "ef5b17b81f6f60504c539313f94f2d826c5faa08" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/reactphp/socket/zipball/ef5b17b81f6f60504c539313f94f2d826c5faa08", + "reference": "ef5b17b81f6f60504c539313f94f2d826c5faa08", + "shasum": "" + }, + "require": { + "evenement/evenement": "^3.0 || ^2.0 || ^1.0", + "php": ">=5.3.0", + "react/dns": "^1.13", + "react/event-loop": "^1.2", + "react/promise": "^3.2 || ^2.6 || ^1.2.1", + "react/stream": "^1.4" + }, + "require-dev": { + "phpunit/phpunit": "^9.6 || ^5.7 || ^4.8.36", + "react/async": "^4.3 || ^3.3 || ^2", + "react/promise-stream": "^1.4", + "react/promise-timer": "^1.11" + }, + "type": "library", + "autoload": { + "psr-4": { + "React\\Socket\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Christian Lück", + "email": "christian@clue.engineering", + "homepage": "https://clue.engineering/" + }, + { + "name": "Cees-Jan Kiewiet", + "email": "reactphp@ceesjankiewiet.nl", + "homepage": "https://wyrihaximus.net/" + }, + { + "name": "Jan Sorgalla", + "email": "jsorgalla@gmail.com", + "homepage": "https://sorgalla.com/" + }, + { + "name": "Chris Boden", + "email": "cboden@gmail.com", + "homepage": "https://cboden.dev/" + } + ], + "description": "Async, streaming plaintext TCP/IP and secure TLS socket server and client connections for ReactPHP", + "keywords": [ + "Connection", + "Socket", + "async", + "reactphp", + "stream" + ], + "support": { + "issues": "https://github.com/reactphp/socket/issues", + "source": "https://github.com/reactphp/socket/tree/v1.17.0" + }, + "funding": [ + { + "url": "https://opencollective.com/reactphp", + "type": "open_collective" + } + ], + "time": "2025-11-19T20:47:34+00:00" + }, + { + "name": "react/stream", + "version": "v1.4.0", + "source": { + "type": "git", + "url": "https://github.com/reactphp/stream.git", + "reference": "1e5b0acb8fe55143b5b426817155190eb6f5b18d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/reactphp/stream/zipball/1e5b0acb8fe55143b5b426817155190eb6f5b18d", + "reference": "1e5b0acb8fe55143b5b426817155190eb6f5b18d", + "shasum": "" + }, + "require": { + "evenement/evenement": "^3.0 || ^2.0 || ^1.0", + "php": ">=5.3.8", + "react/event-loop": "^1.2" + }, + "require-dev": { + "clue/stream-filter": "~1.2", + "phpunit/phpunit": "^9.6 || ^5.7 || ^4.8.36" + }, + "type": "library", + "autoload": { + "psr-4": { + "React\\Stream\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Christian Lück", + "email": "christian@clue.engineering", + "homepage": "https://clue.engineering/" + }, + { + "name": "Cees-Jan Kiewiet", + "email": "reactphp@ceesjankiewiet.nl", + "homepage": "https://wyrihaximus.net/" + }, + { + "name": "Jan Sorgalla", + "email": "jsorgalla@gmail.com", + "homepage": "https://sorgalla.com/" + }, + { + "name": "Chris Boden", + "email": "cboden@gmail.com", + "homepage": "https://cboden.dev/" + } + ], + "description": "Event-driven readable and writable streams for non-blocking I/O in ReactPHP", + "keywords": [ + "event-driven", + "io", + "non-blocking", + "pipe", + "reactphp", + "readable", + "stream", + "writable" + ], + "support": { + "issues": "https://github.com/reactphp/stream/issues", + "source": "https://github.com/reactphp/stream/tree/v1.4.0" + }, + "funding": [ + { + "url": "https://opencollective.com/reactphp", + "type": "open_collective" + } + ], + "time": "2024-06-11T12:45:25+00:00" + }, { "name": "sebastian/cli-parser", "version": "4.0.0", @@ -3857,6 +4736,167 @@ ], "time": "2026-01-27T16:18:07+00:00" }, + { + "name": "symfony/event-dispatcher", + "version": "v8.0.4", + "source": { + "type": "git", + "url": "https://github.com/symfony/event-dispatcher.git", + "reference": "99301401da182b6cfaa4700dbe9987bb75474b47" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/99301401da182b6cfaa4700dbe9987bb75474b47", + "reference": "99301401da182b6cfaa4700dbe9987bb75474b47", + "shasum": "" + }, + "require": { + "php": ">=8.4", + "symfony/event-dispatcher-contracts": "^2.5|^3" + }, + "conflict": { + "symfony/security-http": "<7.4", + "symfony/service-contracts": "<2.5" + }, + "provide": { + "psr/event-dispatcher-implementation": "1.0", + "symfony/event-dispatcher-implementation": "2.0|3.0" + }, + "require-dev": { + "psr/log": "^1|^2|^3", + "symfony/config": "^7.4|^8.0", + "symfony/dependency-injection": "^7.4|^8.0", + "symfony/error-handler": "^7.4|^8.0", + "symfony/expression-language": "^7.4|^8.0", + "symfony/framework-bundle": "^7.4|^8.0", + "symfony/http-foundation": "^7.4|^8.0", + "symfony/service-contracts": "^2.5|^3", + "symfony/stopwatch": "^7.4|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\EventDispatcher\\": "" + }, + "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 that allow your application components to communicate with each other by dispatching events and listening to them", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/event-dispatcher/tree/v8.0.4" + }, + "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-01-05T11:45:55+00:00" + }, + { + "name": "symfony/event-dispatcher-contracts", + "version": "v3.6.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/event-dispatcher-contracts.git", + "reference": "59eb412e93815df44f05f342958efa9f46b1e586" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/event-dispatcher-contracts/zipball/59eb412e93815df44f05f342958efa9f46b1e586", + "reference": "59eb412e93815df44f05f342958efa9f46b1e586", + "shasum": "" + }, + "require": { + "php": ">=8.1", + "psr/event-dispatcher": "^1" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/contracts", + "name": "symfony/contracts" + }, + "branch-alias": { + "dev-main": "3.6-dev" + } + }, + "autoload": { + "psr-4": { + "Symfony\\Contracts\\EventDispatcher\\": "" + } + }, + "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": "Generic abstractions related to dispatching event", + "homepage": "https://symfony.com", + "keywords": [ + "abstractions", + "contracts", + "decoupling", + "interfaces", + "interoperability", + "standards" + ], + "support": { + "source": "https://github.com/symfony/event-dispatcher-contracts/tree/v3.6.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2024-09-25T14:21:43+00:00" + }, { "name": "symfony/filesystem", "version": "v8.0.1", @@ -3995,6 +5035,77 @@ ], "time": "2026-01-26T15:08:38+00:00" }, + { + "name": "symfony/options-resolver", + "version": "v8.0.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/options-resolver.git", + "reference": "d2b592535ffa6600c265a3893a7f7fd2bad82dd7" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/options-resolver/zipball/d2b592535ffa6600c265a3893a7f7fd2bad82dd7", + "reference": "d2b592535ffa6600c265a3893a7f7fd2bad82dd7", + "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.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": "2025-11-12T15:55:31+00:00" + }, { "name": "symfony/polyfill-ctype", "version": "v1.33.0", @@ -4330,6 +5441,231 @@ ], "time": "2024-12-23T08:48:59+00:00" }, + { + "name": "symfony/polyfill-php81", + "version": "v1.33.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-php81.git", + "reference": "4a4cfc2d253c21a5ad0e53071df248ed48c6ce5c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-php81/zipball/4a4cfc2d253c21a5ad0e53071df248ed48c6ce5c", + "reference": "4a4cfc2d253c21a5ad0e53071df248ed48c6ce5c", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Php81\\": "" + }, + "classmap": [ + "Resources/stubs" + ] + }, + "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 backporting some PHP 8.1+ features to lower PHP versions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-php81/tree/v1.33.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": "2024-09-09T11:45:10+00:00" + }, + { + "name": "symfony/polyfill-php84", + "version": "v1.33.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-php84.git", + "reference": "d8ced4d875142b6a7426000426b8abc631d6b191" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-php84/zipball/d8ced4d875142b6a7426000426b8abc631d6b191", + "reference": "d8ced4d875142b6a7426000426b8abc631d6b191", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Php84\\": "" + }, + "classmap": [ + "Resources/stubs" + ] + }, + "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 backporting some PHP 8.4+ features to lower PHP versions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-php84/tree/v1.33.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": "2025-06-24T13:30:11+00:00" + }, + { + "name": "symfony/process", + "version": "v8.0.5", + "source": { + "type": "git", + "url": "https://github.com/symfony/process.git", + "reference": "b5f3aa6762e33fd95efbaa2ec4f4bc9fdd16d674" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/process/zipball/b5f3aa6762e33fd95efbaa2ec4f4bc9fdd16d674", + "reference": "b5f3aa6762e33fd95efbaa2ec4f4bc9fdd16d674", + "shasum": "" + }, + "require": { + "php": ">=8.4" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Process\\": "" + }, + "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": "Executes commands in sub-processes", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/process/tree/v8.0.5" + }, + "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-01-26T15:08:38+00:00" + }, { "name": "symfony/property-access", "version": "v8.0.4", @@ -4680,6 +6016,72 @@ ], "time": "2025-07-15T11:30:57+00:00" }, + { + "name": "symfony/stopwatch", + "version": "v8.0.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/stopwatch.git", + "reference": "67df1914c6ccd2d7b52f70d40cf2aea02159d942" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/stopwatch/zipball/67df1914c6ccd2d7b52f70d40cf2aea02159d942", + "reference": "67df1914c6ccd2d7b52f70d40cf2aea02159d942", + "shasum": "" + }, + "require": { + "php": ">=8.4", + "symfony/service-contracts": "^2.5|^3" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Stopwatch\\": "" + }, + "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 way to profile code", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/stopwatch/tree/v8.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": "2025-08-04T07:36:47+00:00" + }, { "name": "symfony/string", "version": "v8.0.4", diff --git a/config/routes.php b/config/routes.php index a5a3b9d..f8006ad 100644 --- a/config/routes.php +++ b/config/routes.php @@ -1,4 +1,5 @@ 'login', 'target' => 'auth/login', 'public' => true], ['path' => 'register', 'target' => 'auth/register', 'public' => true], diff --git a/config/settings.php b/config/settings.php index d80190d..dfb5d85 100644 --- a/config/settings.php +++ b/config/settings.php @@ -1,4 +1,5 @@ 'CoreCore', 'app_locale' => 'de', diff --git a/config/themes.php b/config/themes.php index 1b1a626..2d461b2 100644 --- a/config/themes.php +++ b/config/themes.php @@ -1,4 +1,5 @@ 'Light', 'dark' => 'Dark', diff --git a/docs/architektur.md b/docs/architektur.md index 6f87a0e..fb09ed6 100644 --- a/docs/architektur.md +++ b/docs/architektur.md @@ -13,6 +13,54 @@ Verbindliche Kurzregeln für Architekturentscheidungen. - Autorisierung läuft zentral über Policies. - Externe API-Verträge sind UUID-first. +## Schichtenübersicht + +```mermaid +flowchart TD + Browser([Browser / API-Client]) + + subgraph APP["lib/App/ — Composition Root"] + DI["AppContainer + Registrars"] + end + + subgraph SVC["lib/Service/"] + direction LR + FAC["Factory"] + SERVICE["Service"] + GW["Gateway"] + POL["Policy"] + end + + subgraph REPO["lib/Repository/"] + REP["Repository"] + end + + subgraph pages["pages/**"] + ACT["Action (*.php)"] + VIEW["View (*.phtml)"] + end + + subgraph TPL["templates/"] + LAY["Layouts & Partials"] + end + + DB[(MariaDB)] + + DI -->|"ruft zur Laufzeit"| FAC + FAC -->|"erzeugt"| SERVICE + FAC -->|"erzeugt"| GW + + Browser --> ACT + ACT -->|"Input · AuthZ · Response"| SERVICE + ACT --> POL + ACT --> VIEW + VIEW --> LAY + SERVICE --> GW + GW --> REP + SERVICE --> REP + REP --> DB +``` + ## Request-Flow 1. `web/index.php` bootstrappt Routing/Konfiguration und initialisiert den `AppContainer`. diff --git a/docs/frontend-javascript.md b/docs/frontend-javascript.md index 9ce16f8..19e9cc4 100644 --- a/docs/frontend-javascript.md +++ b/docs/frontend-javascript.md @@ -95,6 +95,45 @@ Hinweise: - `order` nur bekannte Sort-Key-Spalten - Bei Search-/Filter-Änderung wird auf Seite 1 zurückgesetzt. +### Row Interaction (Globaler Standard) + +Listen mit `rowDblClick.getUrl(...)` nutzen standardmäßig eine zugängliche Row-Interaktion: + +- sichtbare Action-Spalte rechts (`Open`/`Edit`) +- Doppelklick bleibt als Fallback aktiv +- `Enter` auf fokussierter Zeile navigiert zur selben URL + +Optionale Konfiguration über `createServerGrid(...)`: + +```js +createServerGrid({ + // ... + rowDblClick: { + getUrl: (rowData) => new URL(`admin/users/edit/${rowData?.cells?.[1]?.data}`, appBase).toString(), + }, + rowInteraction: { + enabled: true, + showActionButton: true, + keepDblClick: true, + enableEnterOnRow: true, + actionColumnLabel: 'Actions', + resolveActionLabel: (url) => (url.includes('/edit/') ? 'Edit' : 'Open'), + } +}); +``` + +Defaults: + +- `rowInteraction.enabled`: `true`, wenn `rowDblClick.getUrl` vorhanden ist +- `showActionButton`: `true` +- `keepDblClick`: `true` +- `enableEnterOnRow`: `true` +- `actionColumnLabel`: `language.actions.column` oder `'Actions'` + +Hinweis: + +- Falls eine Seite `actions.enabled: true` explizit setzt, wird keine zusätzliche automatische Row-Action-Spalte injiziert. + ### Filter UX 2.0 (Globaler Standard) Für Listen gilt standardmäßig: @@ -275,5 +314,5 @@ Prüfen im Browser: 1. DevTools öffnen (Console, optional Preserve log aktivieren). 2. Kernseiten laden (`/admin/users`, `/address-book`, Audit-Listen). -3. Filter setzen/zurücksetzen, Doppelklick-Navigation und Bulk-Aktionen testen. +3. Filter setzen/zurücksetzen, Row-Action-Button + Enter + Doppelklick sowie Bulk-Aktionen testen. 4. Erwartung: keine JavaScript-Errors und keine Unhandled Promise Rejections. diff --git a/docs/lib-standards.md b/docs/lib-standards.md index cf427d8..d6c865c 100644 --- a/docs/lib-standards.md +++ b/docs/lib-standards.md @@ -1,6 +1,6 @@ # lib-Standards -Letzte Aktualisierung: 2026-03-03 +Letzte Aktualisierung: 2026-03-05 Praktische Regeln nur für `lib/**`. Ergänzt `docs/architektur.md`. @@ -9,12 +9,17 @@ Praktische Regeln nur für `lib/**`. Ergänzt `docs/architektur.md`. - Verantwortlich für SQL, Filter, Paging und Mapping. - Keine HTTP-, Session-, Redirect- oder Policy-Logik. -## 2) Service-Regeln +## 2) Service-Schicht im Detail + +`lib/Service/` enthält vier klar getrennte Typen — jeder hat eine eigene Verantwortung: + +### 2a) Service (`*Service.php`) - Verantwortlich für Fachlogik und Orchestrierung. +- Koordiniert Repositories und Gateways; enthält Business-Regeln und Validierung. - Keine Superglobals (`$_POST`, `$_GET`, `$_SESSION`). - Keine Header- oder Redirect-Ausgabe. -- Keine direkten `DB::...`-Aufrufe im Service. +- Keine direkten `DB::...`-Aufrufe. - Abhängigkeiten per Constructor Injection. - DB-Locks und Transaktionen über Repository-Schicht (z. B. `DatabaseSessionRepository`). - Audit-Hooks für fachliche Schreibaktionen laufen in Services (`SystemAuditService`), nicht in Templates. @@ -22,6 +27,28 @@ Praktische Regeln nur für `lib/**`. Ergänzt `docs/architektur.md`. - Scheduler-Run-/Job-Ereignisse werden zentral im `SchedulerRunService` in `SystemAuditService` geschrieben. - CLI-System-Audit für zentrale Commands wird am Entrypoint (`bin/**`) geschrieben, mit minimaler Metadata (ohne Argumentwerte). +### 2b) Gateway (`*Gateway.php`) + +- Adapter-Schicht: kapselt eine einzelne externe Abhängigkeit für Services. +- Typische Abhängigkeiten: ein Repository, Settings-Zugriff, externe HTTP-APIs, Scope-Daten. +- Kein Fach-Orchestrierungsfluss — ein Gateway löst genau eine Infrastruktur-Frage. +- Beispiele: `AuthPermissionGateway` (Permission-Lookup), `UserSettingsGateway` (Settings-Zugriff), `OidcHttpGateway` (externe OIDC-API). +- Abhängigkeiten per Constructor Injection; keine direkten `new ...Repository()` außerhalb von Factories. + +### 2c) Factory (`*Factory.php` / `*ServicesFactory.php` / `*GatewayFactory.php`) + +- Einzige erlaubte Stelle für `new ...Service()`, `new ...Gateway()`, `new ...Repository()` innerhalb von `lib/Service/`. +- Erzeugt konfigurierte Service-/Gateway-Gruppen für den DI-Container. +- Keine Fachlogik; reine Kompositions-Helfer. +- Werden im Composition Root (`lib/App/registerContainer.php`) registriert. + +### 2d) Policy (`*Policy.php` / `*AuthorizationPolicy.php`) + +- Liegen in `lib/Service/Access/`. +- Treffen Authorization-Entscheidungen (Ability-Checks) für einen bestimmten Ressourcentyp. +- Geben `AuthorizationDecision` zurück; kein HTTP-Redirect, keine Fachlogik. +- Werden über `AccessPolicyFactory` + `AuthorizationService` aufgelöst — nie direkt in Actions instanziiert. + ## 3) Instanziierung - Composition Root ist `web/index.php` + `lib/App/registerContainer.php` diff --git a/i18n/default_de.json b/i18n/default_de.json index 9f12596..056924f 100644 --- a/i18n/default_de.json +++ b/i18n/default_de.json @@ -198,6 +198,7 @@ "Modified by": "Bearbeitet von", "Modified": "Aktualisiert", "No users found": "Keine Benutzer gefunden", + "Open": "Öffnen", "Yes": "Ja", "No": "Nein", "User created": "Benutzer erstellt", @@ -345,6 +346,7 @@ "Next": "Weiter", "Page %d of %d": "Seite %d von %d", "Page %d": "Seite %d", + "Rows per page": "Einträge pro Seite", "Showing": "Zeige", "of": "von", "to": "bis", diff --git a/i18n/default_en.json b/i18n/default_en.json index b024ab9..0e01e99 100644 --- a/i18n/default_en.json +++ b/i18n/default_en.json @@ -198,6 +198,7 @@ "Modified by": "Modified by", "Modified": "Modified", "No users found": "No users found", + "Open": "Open", "Yes": "Yes", "No": "No", "User created": "User created", @@ -345,6 +346,7 @@ "Next": "Next", "Page %d of %d": "Page %d of %d", "Page %d": "Page %d", + "Rows per page": "Rows per page", "Showing": "Showing", "of": "of", "to": "to", diff --git a/lib/App/Container/Registrars/ServiceFactoryRegistrar.php b/lib/App/Container/Registrars/ServiceFactoryRegistrar.php index 4d4e3a3..1be29cf 100644 --- a/lib/App/Container/Registrars/ServiceFactoryRegistrar.php +++ b/lib/App/Container/Registrars/ServiceFactoryRegistrar.php @@ -11,7 +11,6 @@ use MintyPHP\Service\Access\AccessPolicyFactory; use MintyPHP\Service\Access\AccessRepositoryFactory; use MintyPHP\Service\Access\AccessServicesFactory; use MintyPHP\Service\Access\PermissionGateway; -use MintyPHP\Service\Directory\DirectoryScopeGateway; use MintyPHP\Service\AddressBook\AddressBookServicesFactory; use MintyPHP\Service\Audit\AuditRepositoryFactory; use MintyPHP\Service\Audit\AuditServicesFactory; @@ -22,6 +21,7 @@ use MintyPHP\Service\Branding\BrandingServicesFactory; use MintyPHP\Service\CustomField\CustomFieldServicesFactory; use MintyPHP\Service\Directory\DirectoryGatewayFactory; use MintyPHP\Service\Directory\DirectoryRepositoryFactory; +use MintyPHP\Service\Directory\DirectoryScopeGateway; use MintyPHP\Service\Directory\DirectoryServicesFactory; use MintyPHP\Service\Import\ImportRepositoryFactory; use MintyPHP\Service\Import\ImportServicesFactory; @@ -75,7 +75,6 @@ final class ServiceFactoryRegistrar implements ContainerRegistrar $c->get(UserServicesFactory::class), $c->get(AccessServicesFactory::class), $c->get(SettingServicesFactory::class), - $c->get(UserRepositoryFactory::class), $c->get(DirectoryServicesFactory::class), $c->get(ImportRepositoryFactory::class) )); @@ -114,10 +113,10 @@ final class ServiceFactoryRegistrar implements ContainerRegistrar $c->get(DirectoryScopeGateway::class) )); $container->set(UserGatewayFactory::class, static fn (AppContainer $c): UserGatewayFactory => new UserGatewayFactory( - $c->get(UserRepositoryFactory::class), $c->get(AccessServicesFactory::class), $c->get(SettingServicesFactory::class), - $c->get(TenantScopeService::class) + $c->get(TenantScopeService::class), + $c->get(DirectoryRepositoryFactory::class), )); $container->set(UserServicesFactory::class, static fn (AppContainer $c): UserServicesFactory => new UserServicesFactory( $c->get(AuditServicesFactory::class), diff --git a/lib/App/Container/Registrars/SettingsRegistrar.php b/lib/App/Container/Registrars/SettingsRegistrar.php index e0f8aef..5cdd182 100644 --- a/lib/App/Container/Registrars/SettingsRegistrar.php +++ b/lib/App/Container/Registrars/SettingsRegistrar.php @@ -7,6 +7,7 @@ use MintyPHP\App\Container\ContainerRegistrar; use MintyPHP\Repository\Auth\ApiTokenRepository; use MintyPHP\Repository\Auth\RememberTokenRepository; use MintyPHP\Service\Access\RoleService; +use MintyPHP\Service\Audit\SystemAuditService; use MintyPHP\Service\Branding\BrandingFaviconService; use MintyPHP\Service\Branding\BrandingLogoService; use MintyPHP\Service\Branding\BrandingServicesFactory; @@ -18,7 +19,6 @@ use MintyPHP\Service\Settings\SettingService; use MintyPHP\Service\Settings\SettingServicesFactory; use MintyPHP\Service\Settings\ThemeConfigService; use MintyPHP\Service\Tenant\TenantService; -use MintyPHP\Service\Audit\SystemAuditService; final class SettingsRegistrar implements ContainerRegistrar { diff --git a/lib/App/Container/Registrars/UserRegistrar.php b/lib/App/Container/Registrars/UserRegistrar.php index cc58302..381627b 100644 --- a/lib/App/Container/Registrars/UserRegistrar.php +++ b/lib/App/Container/Registrars/UserRegistrar.php @@ -6,14 +6,15 @@ use MintyPHP\App\AppContainer; use MintyPHP\App\Container\ContainerRegistrar; use MintyPHP\Repository\Access\UserRoleRepository; use MintyPHP\Repository\Org\UserDepartmentRepository; +use MintyPHP\Repository\Tenant\UserTenantRepository; use MintyPHP\Repository\User\UserReadRepository; use MintyPHP\Repository\User\UserWriteRepository; -use MintyPHP\Repository\Tenant\UserTenantRepository; use MintyPHP\Service\Auth\TenantSsoService; use MintyPHP\Service\Branding\BrandingLogoService; use MintyPHP\Service\User\UserAccessPdfService; use MintyPHP\Service\User\UserAccessTemplateService; use MintyPHP\Service\User\UserAccountService; +use MintyPHP\Service\User\UserApiWriteInputMapper; use MintyPHP\Service\User\UserAssignmentService; use MintyPHP\Service\User\UserAvatarService; use MintyPHP\Service\User\UserDirectoryGateway; @@ -21,7 +22,6 @@ use MintyPHP\Service\User\UserLifecycleRestoreService; use MintyPHP\Service\User\UserLifecycleService; use MintyPHP\Service\User\UserPasswordPolicyService; use MintyPHP\Service\User\UserPasswordService; -use MintyPHP\Service\User\UserApiWriteInputMapper; use MintyPHP\Service\User\UserRepositoryFactory; use MintyPHP\Service\User\UserSavedFilterService; use MintyPHP\Service\User\UserScopeGateway; diff --git a/lib/Http/ApiBootstrap.php b/lib/Http/ApiBootstrap.php index 65f1397..4237466 100644 --- a/lib/Http/ApiBootstrap.php +++ b/lib/Http/ApiBootstrap.php @@ -2,7 +2,6 @@ namespace MintyPHP\Http; -use MintyPHP\Http\RequestContext; use MintyPHP\Service\Audit\ApiAuditService; use MintyPHP\Service\Security\RateLimiterService; use MintyPHP\Service\Settings\SettingGateway; diff --git a/lib/Http/ApiResponse.php b/lib/Http/ApiResponse.php index 025a7da..a4ed9c7 100644 --- a/lib/Http/ApiResponse.php +++ b/lib/Http/ApiResponse.php @@ -3,7 +3,6 @@ namespace MintyPHP\Http; use MintyPHP\Http\Input\FormErrors; -use MintyPHP\Http\RequestContext; use MintyPHP\Service\Access\AuthorizationService; use MintyPHP\Service\Audit\ApiAuditService; @@ -20,8 +19,7 @@ class ApiResponse callable $apiAuditServiceResolver, callable $authorizationServiceResolver, callable $apiSystemAuditReporterResolver - ): void - { + ): void { self::$apiAuditServiceResolver = $apiAuditServiceResolver; self::$authorizationServiceResolver = $authorizationServiceResolver; self::$apiSystemAuditReporterResolver = $apiSystemAuditReporterResolver; diff --git a/lib/Http/Request.php b/lib/Http/Request.php index a909455..c51f737 100644 --- a/lib/Http/Request.php +++ b/lib/Http/Request.php @@ -2,8 +2,8 @@ namespace MintyPHP\Http; -use MintyPHP\Router; use MintyPHP\I18n; +use MintyPHP\Router; class Request { diff --git a/lib/Repository/Audit/ImportAuditRunRepository.php b/lib/Repository/Audit/ImportAuditRunRepository.php index 261a4ce..f76a498 100644 --- a/lib/Repository/Audit/ImportAuditRunRepository.php +++ b/lib/Repository/Audit/ImportAuditRunRepository.php @@ -2,8 +2,8 @@ namespace MintyPHP\Repository\Audit; -use MintyPHP\Domain\Taxonomy\ImportAuditStatus; use MintyPHP\DB; +use MintyPHP\Domain\Taxonomy\ImportAuditStatus; use MintyPHP\Repository\Support\RepoQuery; class ImportAuditRunRepository implements ImportAuditRunRepositoryInterface diff --git a/lib/Repository/Audit/SystemAuditLogRepository.php b/lib/Repository/Audit/SystemAuditLogRepository.php index 9837ee3..26436fa 100644 --- a/lib/Repository/Audit/SystemAuditLogRepository.php +++ b/lib/Repository/Audit/SystemAuditLogRepository.php @@ -2,9 +2,9 @@ namespace MintyPHP\Repository\Audit; +use MintyPHP\DB; use MintyPHP\Domain\Taxonomy\SystemAuditChannel; use MintyPHP\Domain\Taxonomy\SystemAuditOutcome; -use MintyPHP\DB; use MintyPHP\Repository\Support\RepoQuery; class SystemAuditLogRepository implements SystemAuditLogRepositoryInterface diff --git a/lib/Repository/Audit/UserLifecycleAuditRepository.php b/lib/Repository/Audit/UserLifecycleAuditRepository.php index 1270d61..424ac29 100644 --- a/lib/Repository/Audit/UserLifecycleAuditRepository.php +++ b/lib/Repository/Audit/UserLifecycleAuditRepository.php @@ -2,10 +2,10 @@ namespace MintyPHP\Repository\Audit; +use MintyPHP\DB; use MintyPHP\Domain\Taxonomy\UserLifecycleAction; use MintyPHP\Domain\Taxonomy\UserLifecycleStatus; use MintyPHP\Domain\Taxonomy\UserLifecycleTriggerType; -use MintyPHP\DB; use MintyPHP\Repository\Support\RepoQuery; class UserLifecycleAuditRepository implements UserLifecycleAuditRepositoryInterface diff --git a/lib/Repository/Mail/MailLogRepository.php b/lib/Repository/Mail/MailLogRepository.php index 011f59b..da02ab3 100644 --- a/lib/Repository/Mail/MailLogRepository.php +++ b/lib/Repository/Mail/MailLogRepository.php @@ -2,8 +2,8 @@ namespace MintyPHP\Repository\Mail; -use MintyPHP\Domain\Taxonomy\MailLogStatus; use MintyPHP\DB; +use MintyPHP\Domain\Taxonomy\MailLogStatus; use MintyPHP\Repository\Support\RepoQuery; class MailLogRepository implements MailLogRepositoryInterface diff --git a/lib/Repository/Scheduler/ScheduledJobRepository.php b/lib/Repository/Scheduler/ScheduledJobRepository.php index 9d9727b..825831e 100644 --- a/lib/Repository/Scheduler/ScheduledJobRepository.php +++ b/lib/Repository/Scheduler/ScheduledJobRepository.php @@ -2,8 +2,8 @@ namespace MintyPHP\Repository\Scheduler; -use MintyPHP\Domain\Taxonomy\ScheduledJobStatus; use MintyPHP\DB; +use MintyPHP\Domain\Taxonomy\ScheduledJobStatus; use MintyPHP\Repository\Support\RepoQuery; class ScheduledJobRepository implements ScheduledJobRepositoryInterface diff --git a/lib/Repository/Scheduler/ScheduledJobRunRepository.php b/lib/Repository/Scheduler/ScheduledJobRunRepository.php index afaa67c..3069ada 100644 --- a/lib/Repository/Scheduler/ScheduledJobRunRepository.php +++ b/lib/Repository/Scheduler/ScheduledJobRunRepository.php @@ -2,9 +2,9 @@ namespace MintyPHP\Repository\Scheduler; +use MintyPHP\DB; use MintyPHP\Domain\Taxonomy\ScheduledJobRunStatus; use MintyPHP\Domain\Taxonomy\ScheduledJobTriggerType; -use MintyPHP\DB; use MintyPHP\Repository\Support\RepoQuery; class ScheduledJobRunRepository implements ScheduledJobRunRepositoryInterface diff --git a/lib/Repository/Scheduler/SchedulerRuntimeRepository.php b/lib/Repository/Scheduler/SchedulerRuntimeRepository.php index 165441a..d8fe7e7 100644 --- a/lib/Repository/Scheduler/SchedulerRuntimeRepository.php +++ b/lib/Repository/Scheduler/SchedulerRuntimeRepository.php @@ -2,8 +2,8 @@ namespace MintyPHP\Repository\Scheduler; -use MintyPHP\Domain\Taxonomy\SchedulerRuntimeResult; use MintyPHP\DB; +use MintyPHP\Domain\Taxonomy\SchedulerRuntimeResult; class SchedulerRuntimeRepository implements SchedulerRuntimeRepositoryInterface { diff --git a/lib/Repository/Stats/AdminStatsRepository.php b/lib/Repository/Stats/AdminStatsRepository.php index 47856f3..507d2dc 100644 --- a/lib/Repository/Stats/AdminStatsRepository.php +++ b/lib/Repository/Stats/AdminStatsRepository.php @@ -2,6 +2,7 @@ namespace MintyPHP\Repository\Stats; +use MintyPHP\DB; use MintyPHP\Domain\Taxonomy\ImportAuditStatus; use MintyPHP\Domain\Taxonomy\MailLogStatus; use MintyPHP\Domain\Taxonomy\ScheduledJobRunStatus; @@ -10,7 +11,6 @@ use MintyPHP\Domain\Taxonomy\SystemAuditOutcome; use MintyPHP\Domain\Taxonomy\TenantStatus; use MintyPHP\Domain\Taxonomy\UserLifecycleAction; use MintyPHP\Domain\Taxonomy\UserLifecycleStatus; -use MintyPHP\DB; class AdminStatsRepository implements AdminStatsRepositoryInterface { @@ -43,7 +43,7 @@ class AdminStatsRepository implements AdminStatsRepositoryInterface $inactiveDepartmentCount = (int) (DB::selectValue('select count(*) from departments where active = 0') ?? 0); $activeRoleCount = (int) (DB::selectValue('select count(*) from roles where active = 1') ?? 0); $inactiveRoleCount = (int) (DB::selectValue('select count(*) from roles where active = 0') ?? 0); - + $rolesWithoutPermissionsCount = (int) (DB::selectValue( 'select count(*) from roles r left join role_permissions rp on rp.role_id = r.id where r.active = 1 and rp.role_id is null' ) ?? 0); @@ -56,7 +56,7 @@ class AdminStatsRepository implements AdminStatsRepositoryInterface $usersWithoutTenantsCount = (int) (DB::selectValue( 'select count(*) from users u left join user_tenants ut on ut.user_id = u.id where u.active = 1 and ut.user_id is null' ) ?? 0); - + $flattenStatsRow = static function ($row): array { if (!is_array($row)) { return []; @@ -74,7 +74,7 @@ class AdminStatsRepository implements AdminStatsRepositoryInterface } return $flat; }; - + $ssoStatsAvailable = (int) (DB::selectValue( "select count(*) from information_schema.tables where table_schema = database() and table_name = 'tenant_auth_microsoft'" ) ?? 0) === 1; @@ -86,7 +86,7 @@ class AdminStatsRepository implements AdminStatsRepositoryInterface $ssoTenantRows = []; $usersMicrosoftOnlyRows = []; $usersMicrosoftSyncGapRows = []; - + if ($ssoStatsAvailable) { $ssoEnabledTenantCount = (int) (DB::selectValue( "select count(*) from tenants t " . @@ -103,7 +103,7 @@ class AdminStatsRepository implements AdminStatsRepositoryInterface if ($activeTenantCount > 0) { $ssoEnabledTenantPercent = round(((float) $ssoEnabledTenantCount * 100) / (float) $activeTenantCount, 1); } - + $usersMicrosoftOnlyCount = (int) (DB::selectValue( "select count(*) from users u " . "where u.active = 1 " . @@ -122,7 +122,7 @@ class AdminStatsRepository implements AdminStatsRepositoryInterface $tenantActiveStatus, $tenantActiveStatus ) ?? 0); - + $usersMicrosoftSyncGapsCount = (int) (DB::selectValue( "select count(*) from ( " . " select u.id, " . @@ -147,7 +147,7 @@ class AdminStatsRepository implements AdminStatsRepositoryInterface " or (x.need_mobile = 1 and x.mobile_value = '')", $tenantActiveStatus ) ?? 0); - + $ssoTenantRowsRaw = DB::select( "select " . " t.uuid as tenant_uuid, " . @@ -182,7 +182,7 @@ class AdminStatsRepository implements AdminStatsRepositoryInterface }, $ssoTenantRowsRaw ))) : []; - + $usersMicrosoftOnlyRowsRaw = DB::select( "select " . " u.uuid as user_uuid, " . @@ -217,7 +217,7 @@ class AdminStatsRepository implements AdminStatsRepositoryInterface }, $usersMicrosoftOnlyRowsRaw ))) : []; - + $usersMicrosoftSyncGapRowsRaw = DB::select( "select " . " x.user_uuid, x.user_display_name, x.user_email, " . @@ -278,7 +278,7 @@ class AdminStatsRepository implements AdminStatsRepositoryInterface $usersMicrosoftSyncGapRowsRaw ))) : []; } - + $customFieldStatsAvailable = (int) (DB::selectValue( "select count(*) from information_schema.tables " . "where table_schema = database() " . @@ -293,7 +293,7 @@ class AdminStatsRepository implements AdminStatsRepositoryInterface $customFieldsInactiveWithValuesHref = 'admin/tenants'; $customFieldsUnusedActiveHref = 'admin/tenants'; $customFieldIssueTenantRows = []; - + if ($customFieldStatsAvailable) { $loadCustomFieldIssueTenants = static function (string $query): array { $rows = DB::select($query); @@ -318,7 +318,7 @@ class AdminStatsRepository implements AdminStatsRepositoryInterface } return $items; }; - + $customFieldsSelectionWithoutOptionsCount = (int) (DB::selectValue( "select count(*) from ( " . "select d.id " . @@ -329,7 +329,7 @@ class AdminStatsRepository implements AdminStatsRepositoryInterface "having count(o.id) = 0 " . ') missing_options' ) ?? 0); - + $customFieldTenantsWithSelectionWithoutOptionsCount = (int) (DB::selectValue( "select count(distinct missing_options.tenant_id) from ( " . "select d.tenant_id " . @@ -340,13 +340,13 @@ class AdminStatsRepository implements AdminStatsRepositoryInterface "having count(o.id) = 0 " . ') missing_options' ) ?? 0); - + $customFieldsInactiveWithValuesCount = (int) (DB::selectValue( 'select count(distinct d.id) from tenant_custom_field_definitions d ' . 'inner join user_custom_field_values v on v.definition_id = d.id ' . 'where d.active = 0' ) ?? 0); - + $customFieldsUnusedActiveCount = (int) (DB::selectValue( "select count(*) from ( " . "select d.id " . @@ -357,7 +357,7 @@ class AdminStatsRepository implements AdminStatsRepositoryInterface "having count(v.id) = 0 " . ') unused_active' ) ?? 0); - + $selectionWithoutOptionsTenantUuid = (string) (DB::selectValue( "select t.uuid from tenants t " . "inner join ( " . @@ -372,7 +372,7 @@ class AdminStatsRepository implements AdminStatsRepositoryInterface ') issue on issue.tenant_id = t.id ' . 'limit 1' ) ?? ''); - + $inactiveWithValuesTenantUuid = (string) (DB::selectValue( "select t.uuid from tenants t " . "inner join ( " . @@ -386,7 +386,7 @@ class AdminStatsRepository implements AdminStatsRepositoryInterface ') issue on issue.tenant_id = t.id ' . 'limit 1' ) ?? ''); - + $unusedActiveTenantUuid = (string) (DB::selectValue( "select t.uuid from tenants t " . "inner join ( " . @@ -401,7 +401,7 @@ class AdminStatsRepository implements AdminStatsRepositoryInterface ') issue on issue.tenant_id = t.id ' . 'limit 1' ) ?? ''); - + if ($selectionWithoutOptionsTenantUuid !== '') { $customFieldsSelectionWithoutOptionsHref = 'admin/tenants/edit/' . $selectionWithoutOptionsTenantUuid . '?tab=custom_fields'; $customFieldTenantsWithSelectionWithoutOptionsHref = $customFieldsSelectionWithoutOptionsHref; @@ -412,7 +412,7 @@ class AdminStatsRepository implements AdminStatsRepositoryInterface if ($unusedActiveTenantUuid !== '') { $customFieldsUnusedActiveHref = 'admin/tenants/edit/' . $unusedActiveTenantUuid . '?tab=custom_fields'; } - + $selectionIssueTenants = $loadCustomFieldIssueTenants( "select t.uuid, t.description, count(*) as issue_count " . "from tenants t " . @@ -436,7 +436,7 @@ class AdminStatsRepository implements AdminStatsRepositoryInterface 'issue_count' => $row['issue_count'], ]; } - + $inactiveIssueTenants = $loadCustomFieldIssueTenants( "select t.uuid, t.description, count(distinct d.id) as issue_count " . "from tenants t " . @@ -454,7 +454,7 @@ class AdminStatsRepository implements AdminStatsRepositoryInterface 'issue_count' => $row['issue_count'], ]; } - + $unusedIssueTenants = $loadCustomFieldIssueTenants( "select t.uuid, t.description, count(*) as issue_count " . "from tenants t " . @@ -479,7 +479,7 @@ class AdminStatsRepository implements AdminStatsRepositoryInterface ]; } } - + $apiStatsAvailable = (int) (DB::selectValue( "select count(*) from information_schema.tables " . "where table_schema = database() and table_name in ('api_audit_log', 'user_api_tokens')" @@ -493,7 +493,7 @@ class AdminStatsRepository implements AdminStatsRepositoryInterface $apiTokensExpiring7dCount = 0; $apiTopErrorRows = []; $apiSlowEndpointRows = []; - + if ($apiStatsAvailable) { $apiRequests24hCount = (int) (DB::selectValue( 'select count(*) from api_audit_log where created_at >= (NOW() - INTERVAL 24 HOUR)' @@ -504,14 +504,14 @@ class AdminStatsRepository implements AdminStatsRepositoryInterface if ($apiRequestsPrev24hCount > 0) { $apiRequestTrendPercent = round((($apiRequests24hCount - $apiRequestsPrev24hCount) * 100) / $apiRequestsPrev24hCount, 1); } - + $apiErrors4xx24hCount = (int) (DB::selectValue( 'select count(*) from api_audit_log where created_at >= (NOW() - INTERVAL 24 HOUR) and status_code between 400 and 499' ) ?? 0); $apiErrors5xx24hCount = (int) (DB::selectValue( 'select count(*) from api_audit_log where created_at >= (NOW() - INTERVAL 24 HOUR) and status_code between 500 and 599' ) ?? 0); - + $durationCount = (int) (DB::selectValue( 'select count(*) from api_audit_log where created_at >= (NOW() - INTERVAL 24 HOUR) and status_code between 200 and 299 and duration_ms is not null' ) ?? 0); @@ -523,12 +523,12 @@ class AdminStatsRepository implements AdminStatsRepositoryInterface 'order by duration_ms asc limit ' . $offset . ', 1' ) ?? 0); } - + $apiTokensExpiring7dCount = (int) (DB::selectValue( 'select count(*) from user_api_tokens ' . 'where revoked_at is null and expires_at is not null and expires_at > NOW() and expires_at <= (NOW() + INTERVAL 7 DAY)' ) ?? 0); - + $apiTopErrorRowsRaw = DB::select( "select " . " coalesce(nullif(error_code, ''), concat('http_', status_code)) as error_label, " . @@ -554,7 +554,7 @@ class AdminStatsRepository implements AdminStatsRepositoryInterface }, $apiTopErrorRowsRaw ))) : []; - + $apiSlowEndpointRowsRaw = DB::select( "select " . " path, " . @@ -586,7 +586,7 @@ class AdminStatsRepository implements AdminStatsRepositoryInterface $apiSlowEndpointRowsRaw ))) : []; } - + $importStatsAvailable = (int) (DB::selectValue( "select count(*) from information_schema.tables " . "where table_schema = database() and table_name = 'import_audit_runs'" @@ -602,7 +602,7 @@ class AdminStatsRepository implements AdminStatsRepositoryInterface $importRecentRunRows = []; $importProfileRows = []; $importTopErrorCodeRows = []; - + if ($importStatsAvailable) { $importRuns7dCount = (int) (DB::selectValue( "select count(*) from import_audit_runs where started_at >= (NOW() - INTERVAL 7 DAY)" @@ -613,7 +613,7 @@ class AdminStatsRepository implements AdminStatsRepositoryInterface if ($importRunsPrev7dCount > 0) { $importRunTrendPercent = round((($importRuns7dCount - $importRunsPrev7dCount) * 100) / $importRunsPrev7dCount, 1); } - + $importRowsCreated7dCount = (int) (DB::selectValue( "select coalesce(sum(created_count), 0) from import_audit_runs where started_at >= (NOW() - INTERVAL 7 DAY)" ) ?? 0); @@ -635,7 +635,7 @@ class AdminStatsRepository implements AdminStatsRepositoryInterface if ($importRuns7dCount > 0) { $importSuccessRate7dPercent = round(($importSuccessRuns7dCount * 100) / $importRuns7dCount, 1); } - + $importRecentRunRowsRaw = DB::select( "select " . " import_audit_runs.id, " . @@ -680,7 +680,7 @@ class AdminStatsRepository implements AdminStatsRepositoryInterface }, $importRecentRunRowsRaw ))) : []; - + $importProfileRowsRaw = DB::select( "select " . " profile_key, " . @@ -715,7 +715,7 @@ class AdminStatsRepository implements AdminStatsRepositoryInterface }, $importProfileRowsRaw ))) : []; - + $importTopErrorRowsRaw = DB::select( "select error_codes_json from import_audit_runs " . "where started_at >= (NOW() - INTERVAL 7 DAY) " . @@ -1001,7 +1001,7 @@ class AdminStatsRepository implements AdminStatsRepositoryInterface $lifecycleRecentRiskRowsRaw ))) : []; } - + $scheduledStatsAvailable = (int) (DB::selectValue( "select count(*) from information_schema.tables " . "where table_schema = database() and table_name in ('scheduled_jobs', 'scheduled_job_runs', 'scheduler_runtime_status')" @@ -1014,7 +1014,7 @@ class AdminStatsRepository implements AdminStatsRepositoryInterface $schedulerRunnerLastResult = ''; $schedulerRunnerLastErrorCode = ''; $schedulerRunnerIsActive = false; - + if ($scheduledStatsAvailable) { $scheduledEnabledJobsCount = (int) (DB::selectValue( "select count(*) from scheduled_jobs where enabled = 1" @@ -1034,12 +1034,12 @@ class AdminStatsRepository implements AdminStatsRepositoryInterface $schedulerTriggerScheduler, $schedulerRunStatusFailed ) ?? 0); - + $schedulerRunnerIsActive = (int) (DB::selectValue( "select case when last_heartbeat_at >= (NOW() - INTERVAL 3 MINUTE) then 1 else 0 end " . "from scheduler_runtime_status where id = 1" ) ?? 0) === 1; - + $schedulerRuntimeRowRaw = DB::selectOne( "select last_heartbeat_at, last_result, last_error_code from scheduler_runtime_status where id = 1 limit 1" ); @@ -1048,7 +1048,7 @@ class AdminStatsRepository implements AdminStatsRepositoryInterface $schedulerRunnerLastResult = trim((string) ($schedulerRuntimeRow['last_result'] ?? '')); $schedulerRunnerLastErrorCode = trim((string) ($schedulerRuntimeRow['last_error_code'] ?? '')); } - + $usersUnverifiedCount = (int) (DB::selectValue( 'select count(*) from users where active = 1 and email_verified_at is null' ) ?? 0); @@ -1069,7 +1069,7 @@ class AdminStatsRepository implements AdminStatsRepositoryInterface static fn ($row) => $row['users'] ?? $row, $neverLoggedUsers ) : []; - + $mailLogFailedCount = (int) (DB::selectValue('select count(*) from mail_log where status = ?', $mailStatusFailed) ?? 0); $mailLogSentCount = (int) (DB::selectValue('select count(*) from mail_log where status = ?', $mailStatusSent) ?? 0); $mailLogLastSentAt = (string) (DB::selectValue('select max(sent_at) from mail_log where status = ?', $mailStatusSent) ?? ''); @@ -1081,7 +1081,7 @@ class AdminStatsRepository implements AdminStatsRepositoryInterface static fn ($row) => $row['mail_log'] ?? $row, $mailLogRecentFailed ) : []; - + $vars = get_defined_vars(); unset($vars['this']); diff --git a/lib/Repository/Tenant/TenantRepository.php b/lib/Repository/Tenant/TenantRepository.php index 7766fcd..e6796df 100644 --- a/lib/Repository/Tenant/TenantRepository.php +++ b/lib/Repository/Tenant/TenantRepository.php @@ -2,8 +2,8 @@ namespace MintyPHP\Repository\Tenant; -use MintyPHP\Domain\Taxonomy\TenantStatus; use MintyPHP\DB; +use MintyPHP\Domain\Taxonomy\TenantStatus; use MintyPHP\Repository\Support\RepoQuery; class TenantRepository implements TenantRepositoryInterface diff --git a/lib/Service/Access/AccessGatewayFactory.php b/lib/Service/Access/AccessGatewayFactory.php index df3c8d4..e244832 100644 --- a/lib/Service/Access/AccessGatewayFactory.php +++ b/lib/Service/Access/AccessGatewayFactory.php @@ -12,8 +12,7 @@ class AccessGatewayFactory public function __construct( private readonly AccessRepositoryFactory $accessRepositoryFactory, private readonly AuditServicesFactory $auditServicesFactory - ) - { + ) { } public function createPermissionService(): PermissionService diff --git a/lib/Service/Access/AuthorizationDecision.php b/lib/Service/Access/AuthorizationDecision.php index 0419eae..8b55e98 100644 --- a/lib/Service/Access/AuthorizationDecision.php +++ b/lib/Service/Access/AuthorizationDecision.php @@ -47,4 +47,3 @@ class AuthorizationDecision return array_key_exists($key, $this->attributes) ? $this->attributes[$key] : $default; } } - diff --git a/lib/Service/Access/AuthorizationPolicyInterface.php b/lib/Service/Access/AuthorizationPolicyInterface.php index 593e134..abeb4de 100644 --- a/lib/Service/Access/AuthorizationPolicyInterface.php +++ b/lib/Service/Access/AuthorizationPolicyInterface.php @@ -8,4 +8,3 @@ interface AuthorizationPolicyInterface public function authorize(string $ability, array $context = []): AuthorizationDecision; } - diff --git a/lib/Service/AddressBook/AddressBookDirectoryGateway.php b/lib/Service/AddressBook/AddressBookDirectoryGateway.php index 8090d9b..3f782e2 100644 --- a/lib/Service/AddressBook/AddressBookDirectoryGateway.php +++ b/lib/Service/AddressBook/AddressBookDirectoryGateway.php @@ -2,8 +2,8 @@ namespace MintyPHP\Service\AddressBook; -use MintyPHP\Service\Directory\DirectoryScopeGateway; use MintyPHP\Service\Access\RoleService; +use MintyPHP\Service\Directory\DirectoryScopeGateway; use MintyPHP\Service\Org\DepartmentService; use MintyPHP\Service\Tenant\TenantService; diff --git a/lib/Service/AddressBook/AddressBookServicesFactory.php b/lib/Service/AddressBook/AddressBookServicesFactory.php index 50b0f67..120a2b1 100644 --- a/lib/Service/AddressBook/AddressBookServicesFactory.php +++ b/lib/Service/AddressBook/AddressBookServicesFactory.php @@ -17,7 +17,8 @@ class AddressBookServicesFactory private readonly UserServicesFactory $userServicesFactory, private readonly DirectoryServicesFactory $directoryServicesFactory, private readonly CustomFieldServicesFactory $customFieldServicesFactory - ) {} + ) { + } public function createAddressBookService(): AddressBookService { diff --git a/lib/Service/Audit/AuditServicesFactory.php b/lib/Service/Audit/AuditServicesFactory.php index 8242257..108db5c 100644 --- a/lib/Service/Audit/AuditServicesFactory.php +++ b/lib/Service/Audit/AuditServicesFactory.php @@ -17,7 +17,8 @@ class AuditServicesFactory public function __construct( private readonly AuditRepositoryFactory $auditRepositoryFactory, private readonly SettingGateway $settingGateway - ) {} + ) { + } public function createApiAuditLogRepository(): ApiAuditLogRepositoryInterface { diff --git a/lib/Service/Audit/SystemAuditService.php b/lib/Service/Audit/SystemAuditService.php index 0bbd7f6..fffa9cb 100644 --- a/lib/Service/Audit/SystemAuditService.php +++ b/lib/Service/Audit/SystemAuditService.php @@ -4,8 +4,8 @@ namespace MintyPHP\Service\Audit; use MintyPHP\Domain\Taxonomy\SystemAuditChannel; use MintyPHP\Domain\Taxonomy\SystemAuditOutcome; -use MintyPHP\Http\RequestContext; use MintyPHP\Http\ApiAuth; +use MintyPHP\Http\RequestContext; use MintyPHP\Repository\Audit\SystemAuditLogRepositoryInterface; use MintyPHP\Repository\Support\RepoQuery; use MintyPHP\Service\Settings\SettingGateway; @@ -27,8 +27,7 @@ class SystemAuditService string $eventType, string $outcome = SystemAuditOutcome::Success->value, array $context = [] - ): ?int - { + ): ?int { if (!$this->isEnabled()) { return null; } diff --git a/lib/Service/Auth/AuthService.php b/lib/Service/Auth/AuthService.php index 0495319..83b8b9c 100644 --- a/lib/Service/Auth/AuthService.php +++ b/lib/Service/Auth/AuthService.php @@ -6,9 +6,9 @@ use MintyPHP\Auth; use MintyPHP\Repository\User\UserReadRepositoryInterface; use MintyPHP\Repository\User\UserWriteRepositoryInterface; use MintyPHP\Router; +use MintyPHP\Service\Audit\SystemAuditService; use MintyPHP\Service\User\UserAccountService; use MintyPHP\Service\User\UserTenantContextService; -use MintyPHP\Service\Audit\SystemAuditService; use MintyPHP\Session; use MintyPHP\Support\Flash; diff --git a/lib/Service/Auth/AuthServicesFactory.php b/lib/Service/Auth/AuthServicesFactory.php index 3dab552..f258cec 100644 --- a/lib/Service/Auth/AuthServicesFactory.php +++ b/lib/Service/Auth/AuthServicesFactory.php @@ -26,7 +26,8 @@ class AuthServicesFactory private readonly AuthRepositoryFactory $authRepositoryFactory, private readonly AuthGatewayFactory $authGatewayFactory, private readonly DatabaseSessionRepository $databaseSessionRepository - ) {} + ) { + } public function createApiTokenService(): ApiTokenService { diff --git a/lib/Service/Auth/SsoUserLinkService.php b/lib/Service/Auth/SsoUserLinkService.php index 81be22b..e61e0a4 100644 --- a/lib/Service/Auth/SsoUserLinkService.php +++ b/lib/Service/Auth/SsoUserLinkService.php @@ -6,7 +6,6 @@ use MintyPHP\Repository\Tenant\UserTenantRepositoryInterface; use MintyPHP\Repository\User\UserReadRepositoryInterface; use MintyPHP\Repository\User\UserWriteRepositoryInterface; use MintyPHP\Service\User\UserAssignmentService; -use MintyPHP\Service\User\UserAvatarService; class SsoUserLinkService { diff --git a/lib/Service/Content/PageService.php b/lib/Service/Content/PageService.php index d224fbf..6095f31 100644 --- a/lib/Service/Content/PageService.php +++ b/lib/Service/Content/PageService.php @@ -2,9 +2,9 @@ namespace MintyPHP\Service\Content; -use MintyPHP\Repository\Content\PageRepository; -use MintyPHP\Repository\Content\PageContentRepository; use MintyPHP\I18n; +use MintyPHP\Repository\Content\PageContentRepository; +use MintyPHP\Repository\Content\PageRepository; class PageService { @@ -59,8 +59,7 @@ class PageService string $content, int $currentUserId = 0, ?string $locale = null - ): array - { + ): array { $slug = trim($slug); if ($slug === '') { return ['ok' => false, 'status' => 404, 'error' => 'not_found']; diff --git a/lib/Service/Directory/DirectoryServicesFactory.php b/lib/Service/Directory/DirectoryServicesFactory.php index 94b16d8..cc5a9f4 100644 --- a/lib/Service/Directory/DirectoryServicesFactory.php +++ b/lib/Service/Directory/DirectoryServicesFactory.php @@ -22,7 +22,8 @@ class DirectoryServicesFactory private readonly AuditServicesFactory $auditServicesFactory, private readonly DirectoryRepositoryFactory $directoryRepositoryFactory, private readonly DirectoryGatewayFactory $directoryGatewayFactory - ) {} + ) { + } public function createTenantService(): TenantService { diff --git a/lib/Service/Image/ImageUploadTrait.php b/lib/Service/Image/ImageUploadTrait.php index c1b06b3..0c71e30 100644 --- a/lib/Service/Image/ImageUploadTrait.php +++ b/lib/Service/Image/ImageUploadTrait.php @@ -143,8 +143,12 @@ trait ImageUploadTrait $scale = min($width / $srcWidth, $height / $srcHeight); $dstWidth = (int) round($srcWidth * $scale); $dstHeight = (int) round($srcHeight * $scale); - if ($dstWidth < 1) $dstWidth = 1; - if ($dstHeight < 1) $dstHeight = 1; + if ($dstWidth < 1) { + $dstWidth = 1; + } + if ($dstHeight < 1) { + $dstHeight = 1; + } $dst = imagecreatetruecolor($dstWidth, $dstHeight); if ($ext === 'png' || $ext === 'webp') { diff --git a/lib/Service/Import/ImportService.php b/lib/Service/Import/ImportService.php index 74ac641..f1b61e3 100644 --- a/lib/Service/Import/ImportService.php +++ b/lib/Service/Import/ImportService.php @@ -2,8 +2,8 @@ namespace MintyPHP\Service\Import; -use MintyPHP\Service\Access\PermissionService; use MintyPHP\Service\Access\PermissionGateway; +use MintyPHP\Service\Access\PermissionService; use MintyPHP\Service\Audit\ImportAuditService; use MintyPHP\Service\Import\Profile\ImportProfileInterface; use MintyPHP\Service\Settings\SettingGateway; diff --git a/lib/Service/Import/ImportServicesFactory.php b/lib/Service/Import/ImportServicesFactory.php index 190fe40..8098791 100644 --- a/lib/Service/Import/ImportServicesFactory.php +++ b/lib/Service/Import/ImportServicesFactory.php @@ -13,7 +13,6 @@ use MintyPHP\Service\Import\Profile\UserImportGateway; use MintyPHP\Service\Import\Profile\UserImportProfile; use MintyPHP\Service\Settings\SettingGateway; use MintyPHP\Service\Settings\SettingServicesFactory; -use MintyPHP\Service\User\UserRepositoryFactory; use MintyPHP\Service\User\UserServicesFactory; class ImportServicesFactory @@ -30,10 +29,10 @@ class ImportServicesFactory private readonly UserServicesFactory $userServicesFactory, private readonly AccessServicesFactory $accessServicesFactory, private readonly SettingServicesFactory $settingServicesFactory, - private readonly UserRepositoryFactory $userRepositoryFactory, private readonly DirectoryServicesFactory $directoryServicesFactory, private readonly ImportRepositoryFactory $importRepositoryFactory - ) {} + ) { + } public function createImportService(): ImportService { @@ -45,7 +44,9 @@ class ImportServicesFactory 'users' => new UserImportProfile(new UserImportGateway( $this->userServicesFactory->createUserReadRepository(), $this->userServicesFactory->createUserAccountService(), - $this->userRepositoryFactory + $this->directoryServicesFactory->createTenantRepository(), + $this->directoryServicesFactory->createRoleRepository(), + $this->directoryServicesFactory->createDepartmentRepository(), )), 'departments' => new DepartmentImportProfile(new DepartmentImportGateway( $this->directoryServicesFactory->createDepartmentRepository(), diff --git a/lib/Service/Import/ImportStateStoreService.php b/lib/Service/Import/ImportStateStoreService.php index 63f4081..b8d0c22 100644 --- a/lib/Service/Import/ImportStateStoreService.php +++ b/lib/Service/Import/ImportStateStoreService.php @@ -60,4 +60,3 @@ class ImportStateStoreService unset($_SESSION[self::SESSION_KEY][$token]); } } - diff --git a/lib/Service/Import/Profile/UserImportGateway.php b/lib/Service/Import/Profile/UserImportGateway.php index 422f843..62239ba 100644 --- a/lib/Service/Import/Profile/UserImportGateway.php +++ b/lib/Service/Import/Profile/UserImportGateway.php @@ -7,14 +7,15 @@ use MintyPHP\Repository\Org\DepartmentRepositoryInterface; use MintyPHP\Repository\Tenant\TenantRepositoryInterface; use MintyPHP\Repository\User\UserReadRepositoryInterface; use MintyPHP\Service\User\UserAccountService; -use MintyPHP\Service\User\UserRepositoryFactory; class UserImportGateway { public function __construct( private readonly UserReadRepositoryInterface $userReadRepository, private readonly UserAccountService $userAccountService, - private readonly UserRepositoryFactory $userRepositoryFactory + private readonly TenantRepositoryInterface $tenantRepository, + private readonly RoleRepositoryInterface $roleRepository, + private readonly DepartmentRepositoryInterface $departmentRepository, ) { } @@ -25,32 +26,32 @@ class UserImportGateway public function findTenantByUuid(string $uuid): ?array { - return $this->tenantRepository()->findByUuid($uuid); + return $this->tenantRepository->findByUuid($uuid); } public function findTenantById(int $id): ?array { - return $this->tenantRepository()->find($id); + return $this->tenantRepository->find($id); } public function findRoleByUuid(string $uuid): ?array { - return $this->roleRepository()->findByUuid($uuid); + return $this->roleRepository->findByUuid($uuid); } public function findRoleById(int $id): ?array { - return $this->roleRepository()->find($id); + return $this->roleRepository->find($id); } public function findDepartmentByUuid(string $uuid): ?array { - return $this->departmentRepository()->findByUuid($uuid); + return $this->departmentRepository->findByUuid($uuid); } public function findDepartmentById(int $id): ?array { - return $this->departmentRepository()->find($id); + return $this->departmentRepository->find($id); } /** @@ -61,19 +62,4 @@ class UserImportGateway { return $this->userAccountService->createFromAdmin($input, $currentUserId); } - - private function tenantRepository(): TenantRepositoryInterface - { - return $this->userRepositoryFactory->createTenantRepository(); - } - - private function roleRepository(): RoleRepositoryInterface - { - return $this->userRepositoryFactory->createRoleRepository(); - } - - private function departmentRepository(): DepartmentRepositoryInterface - { - return $this->userRepositoryFactory->createDepartmentRepository(); - } } diff --git a/lib/Service/Mail/MailServicesFactory.php b/lib/Service/Mail/MailServicesFactory.php index 6f96b70..14e1e62 100644 --- a/lib/Service/Mail/MailServicesFactory.php +++ b/lib/Service/Mail/MailServicesFactory.php @@ -13,7 +13,8 @@ class MailServicesFactory public function __construct( private readonly MailRepositoryFactory $mailRepositoryFactory, private readonly SettingGateway $settingGateway - ) {} + ) { + } public function createMailLogRepository(): MailLogRepositoryInterface { diff --git a/lib/Service/Scheduler/ScheduledJobRegistry.php b/lib/Service/Scheduler/ScheduledJobRegistry.php index bca69a3..9a29a69 100644 --- a/lib/Service/Scheduler/ScheduledJobRegistry.php +++ b/lib/Service/Scheduler/ScheduledJobRegistry.php @@ -2,10 +2,10 @@ namespace MintyPHP\Service\Scheduler; +use MintyPHP\Service\Audit\SystemAuditService; use MintyPHP\Service\Scheduler\Handler\ScheduledJobHandlerInterface; use MintyPHP\Service\Scheduler\Handler\SystemAuditPurgeJobHandler; use MintyPHP\Service\Scheduler\Handler\UserLifecycleJobHandler; -use MintyPHP\Service\Audit\SystemAuditService; use MintyPHP\Service\User\UserLifecycleService; class ScheduledJobRegistry @@ -30,8 +30,7 @@ class ScheduledJobRegistry public function __construct( UserLifecycleService $userLifecycleService, SystemAuditService $systemAuditService - ) - { + ) { $this->handlers = [ self::USER_LIFECYCLE_RUN => new UserLifecycleJobHandler($userLifecycleService), self::SYSTEM_AUDIT_PURGE => new SystemAuditPurgeJobHandler($systemAuditService), diff --git a/lib/Service/Scheduler/SchedulerServicesFactory.php b/lib/Service/Scheduler/SchedulerServicesFactory.php index 93ddca2..6935615 100644 --- a/lib/Service/Scheduler/SchedulerServicesFactory.php +++ b/lib/Service/Scheduler/SchedulerServicesFactory.php @@ -23,7 +23,8 @@ class SchedulerServicesFactory private readonly AuditServicesFactory $auditServicesFactory, private readonly DatabaseSessionRepository $databaseSessionRepository, private readonly SchedulerRepositoryFactory $schedulerRepositoryFactory - ) {} + ) { + } public function createScheduledJobService(): ScheduledJobService { diff --git a/lib/Service/Security/SecurityServicesFactory.php b/lib/Service/Security/SecurityServicesFactory.php index 91383b8..ca82af2 100644 --- a/lib/Service/Security/SecurityServicesFactory.php +++ b/lib/Service/Security/SecurityServicesFactory.php @@ -10,7 +10,8 @@ class SecurityServicesFactory public function __construct( private readonly SecurityRepositoryFactory $securityRepositoryFactory - ) {} + ) { + } public function createRateLimitRepository(): RateLimitRepositoryInterface { diff --git a/lib/Service/Settings/AdminSettingsService.php b/lib/Service/Settings/AdminSettingsService.php index 96b44ac..80094ed 100644 --- a/lib/Service/Settings/AdminSettingsService.php +++ b/lib/Service/Settings/AdminSettingsService.php @@ -5,8 +5,8 @@ namespace MintyPHP\Service\Settings; use MintyPHP\Repository\Auth\ApiTokenRepository; use MintyPHP\Repository\Auth\RememberTokenRepository; use MintyPHP\Service\Access\RoleService; -use MintyPHP\Service\Org\DepartmentService; use MintyPHP\Service\Audit\SystemAuditService; +use MintyPHP\Service\Org\DepartmentService; use MintyPHP\Service\Tenant\TenantService; class AdminSettingsService diff --git a/lib/Service/Settings/SettingServicesFactory.php b/lib/Service/Settings/SettingServicesFactory.php index c9c6de7..82167e2 100644 --- a/lib/Service/Settings/SettingServicesFactory.php +++ b/lib/Service/Settings/SettingServicesFactory.php @@ -16,7 +16,8 @@ class SettingServicesFactory public function __construct( private readonly SettingRepositoryFactory $settingRepositoryFactory - ) {} + ) { + } public function createSettingRepository(): SettingRepositoryInterface { diff --git a/lib/Service/Tenant/TenantServicesFactory.php b/lib/Service/Tenant/TenantServicesFactory.php index 2820223..c83a477 100644 --- a/lib/Service/Tenant/TenantServicesFactory.php +++ b/lib/Service/Tenant/TenantServicesFactory.php @@ -16,7 +16,8 @@ class TenantServicesFactory public function __construct( private readonly PermissionGateway $permissionGateway, private readonly TenantRepositoryFactory $tenantRepositoryFactory - ) {} + ) { + } public function createTenantScopeService(): TenantScopeService { diff --git a/lib/Service/User/UserAccountService.php b/lib/Service/User/UserAccountService.php index 908d0ce..4b8e4d6 100644 --- a/lib/Service/User/UserAccountService.php +++ b/lib/Service/User/UserAccountService.php @@ -565,7 +565,7 @@ class UserAccountService $errors[] = t('Tenant not found'); } else { $userTenantIds = $this->userAssignmentService->buildAssignmentsForUser($userId)['tenants'] ?? []; - $userTenantIds = array_map(static fn(array $t): int => (int) ($t['id'] ?? 0), $userTenantIds); + $userTenantIds = array_map(static fn (array $t): int => (int) ($t['id'] ?? 0), $userTenantIds); $tenantId = (int) $tenant['id']; if (!in_array($tenantId, $userTenantIds, true)) { $errors[] = t('No access to this tenant'); diff --git a/lib/Service/User/UserDirectoryGateway.php b/lib/Service/User/UserDirectoryGateway.php index b029380..e7e16b4 100644 --- a/lib/Service/User/UserDirectoryGateway.php +++ b/lib/Service/User/UserDirectoryGateway.php @@ -9,72 +9,59 @@ use MintyPHP\Repository\Tenant\TenantRepositoryInterface; class UserDirectoryGateway { public function __construct( - private readonly UserRepositoryFactory $userRepositoryFactory + private readonly TenantRepositoryInterface $tenantRepository, + private readonly RoleRepositoryInterface $roleRepository, + private readonly DepartmentRepositoryInterface $departmentRepository, ) { } public function listTenantIds(): array { - return $this->tenantRepository()->listIds(); + return $this->tenantRepository->listIds(); } public function listTenantsByIds(array $tenantIds): array { - return $this->tenantRepository()->listByIds($tenantIds); + return $this->tenantRepository->listByIds($tenantIds); } public function findTenant(int $tenantId): ?array { - return $this->tenantRepository()->find($tenantId); + return $this->tenantRepository->find($tenantId); } public function findTenantByUuid(string $tenantUuid): ?array { - return $this->tenantRepository()->findByUuid($tenantUuid); + return $this->tenantRepository->findByUuid($tenantUuid); } public function listActiveTenantIdsByIds(array $tenantIds): array { - return $this->tenantRepository()->listActiveIdsByIds($tenantIds); + return $this->tenantRepository->listActiveIdsByIds($tenantIds); } public function listActiveRoleIds(): array { - return $this->roleRepository()->listActiveIds(); + return $this->roleRepository->listActiveIds(); } public function listRolesByIds(array $roleIds): array { - return $this->roleRepository()->listByIds($roleIds); + return $this->roleRepository->listByIds($roleIds); } public function listDepartmentsByIds(array $departmentIds, bool $includeInactive = true): array { - return $this->departmentRepository()->listByIds($departmentIds, $includeInactive); + return $this->departmentRepository->listByIds($departmentIds, $includeInactive); } public function listActiveDepartmentIdsByTenantIds(array $tenantIds): array { - return $this->departmentRepository()->listActiveIdsByTenantIds($tenantIds); + return $this->departmentRepository->listActiveIdsByTenantIds($tenantIds); } public function listDepartmentsByTenantIds(array $tenantIds): array { - return $this->departmentRepository()->listByTenantIds($tenantIds); - } - - private function tenantRepository(): TenantRepositoryInterface - { - return $this->userRepositoryFactory->createTenantRepository(); - } - - private function roleRepository(): RoleRepositoryInterface - { - return $this->userRepositoryFactory->createRoleRepository(); - } - - private function departmentRepository(): DepartmentRepositoryInterface - { - return $this->userRepositoryFactory->createDepartmentRepository(); + return $this->departmentRepository->listByTenantIds($tenantIds); } } diff --git a/lib/Service/User/UserGatewayFactory.php b/lib/Service/User/UserGatewayFactory.php index 6290158..81b0107 100644 --- a/lib/Service/User/UserGatewayFactory.php +++ b/lib/Service/User/UserGatewayFactory.php @@ -4,6 +4,7 @@ namespace MintyPHP\Service\User; use MintyPHP\Service\Access\AccessServicesFactory; use MintyPHP\Service\Access\PermissionGateway; +use MintyPHP\Service\Directory\DirectoryRepositoryFactory; use MintyPHP\Service\Settings\SettingGateway; use MintyPHP\Service\Settings\SettingServicesFactory; use MintyPHP\Service\Tenant\TenantScopeService; @@ -18,10 +19,10 @@ class UserGatewayFactory private ?UserPermissionGateway $userPermissionGateway = null; public function __construct( - private readonly UserRepositoryFactory $userRepositoryFactory, private readonly AccessServicesFactory $accessServicesFactory, private readonly SettingServicesFactory $settingServicesFactory, - private readonly TenantScopeService $tenantScopeService + private readonly TenantScopeService $tenantScopeService, + private readonly DirectoryRepositoryFactory $directoryRepositoryFactory, ) { } @@ -37,7 +38,11 @@ class UserGatewayFactory public function createUserDirectoryGateway(): UserDirectoryGateway { - return $this->userDirectoryGateway ??= new UserDirectoryGateway($this->userRepositoryFactory); + return $this->userDirectoryGateway ??= new UserDirectoryGateway( + $this->directoryRepositoryFactory->createTenantRepository(), + $this->directoryRepositoryFactory->createRoleRepository(), + $this->directoryRepositoryFactory->createDepartmentRepository(), + ); } public function createUserPermissionGateway(): UserPermissionGateway @@ -54,5 +59,4 @@ class UserGatewayFactory { return $this->settingGateway ??= $this->settingServicesFactory->createSettingGateway(); } - } diff --git a/lib/Service/User/UserRepositoryFactory.php b/lib/Service/User/UserRepositoryFactory.php index 94844bb..44f50a1 100644 --- a/lib/Service/User/UserRepositoryFactory.php +++ b/lib/Service/User/UserRepositoryFactory.php @@ -2,16 +2,10 @@ namespace MintyPHP\Service\User; -use MintyPHP\Repository\Access\RoleRepository; -use MintyPHP\Repository\Access\RoleRepositoryInterface; use MintyPHP\Repository\Access\UserRoleRepository; use MintyPHP\Repository\Access\UserRoleRepositoryInterface; -use MintyPHP\Repository\Org\DepartmentRepository; -use MintyPHP\Repository\Org\DepartmentRepositoryInterface; use MintyPHP\Repository\Org\UserDepartmentRepository; use MintyPHP\Repository\Org\UserDepartmentRepositoryInterface; -use MintyPHP\Repository\Tenant\TenantRepository; -use MintyPHP\Repository\Tenant\TenantRepositoryInterface; use MintyPHP\Repository\Tenant\UserTenantRepository; use MintyPHP\Repository\Tenant\UserTenantRepositoryInterface; use MintyPHP\Repository\User\UserListQueryRepository; @@ -29,9 +23,6 @@ class UserRepositoryFactory private ?UserWriteRepository $userWriteRepository = null; private ?UserListQueryRepository $userListQueryRepository = null; private ?UserSavedFilterRepository $userSavedFilterRepository = null; - private ?TenantRepository $tenantRepository = null; - private ?RoleRepository $roleRepository = null; - private ?DepartmentRepository $departmentRepository = null; private ?UserTenantRepository $userTenantRepository = null; private ?UserRoleRepository $userRoleRepository = null; private ?UserDepartmentRepository $userDepartmentRepository = null; @@ -56,21 +47,6 @@ class UserRepositoryFactory return $this->userSavedFilterRepository ??= new UserSavedFilterRepository(); } - public function createTenantRepository(): TenantRepositoryInterface - { - return $this->tenantRepository ??= new TenantRepository(); - } - - public function createRoleRepository(): RoleRepositoryInterface - { - return $this->roleRepository ??= new RoleRepository(); - } - - public function createDepartmentRepository(): DepartmentRepositoryInterface - { - return $this->departmentRepository ??= new DepartmentRepository(); - } - public function createUserTenantRepository(): UserTenantRepositoryInterface { return $this->userTenantRepository ??= new UserTenantRepository(); diff --git a/lib/Service/User/UserServicesFactory.php b/lib/Service/User/UserServicesFactory.php index 5f5f6da..f13f38a 100644 --- a/lib/Service/User/UserServicesFactory.php +++ b/lib/Service/User/UserServicesFactory.php @@ -31,7 +31,8 @@ class UserServicesFactory private readonly UserRepositoryFactory $userRepositoryFactory, private readonly UserGatewayFactory $userGatewayFactory, private readonly DatabaseSessionRepository $databaseSessionRepository - ) {} + ) { + } public function createUserAccountService(): UserAccountService { diff --git a/lib/Support/helpers/app.php b/lib/Support/helpers/app.php index 62d7dc6..a2294a4 100644 --- a/lib/Support/helpers/app.php +++ b/lib/Support/helpers/app.php @@ -409,7 +409,9 @@ function appFaviconUrl(string $file): string */ function sortByDescription(array &$items): void { - usort($items, static fn($a, $b) => + usort( + $items, + static fn ($a, $b) => strcasecmp((string) ($a['description'] ?? ''), (string) ($b['description'] ?? '')) ); } diff --git a/lib/Support/helpers/ui.php b/lib/Support/helpers/ui.php index 583ce5e..afa7c91 100644 --- a/lib/Support/helpers/ui.php +++ b/lib/Support/helpers/ui.php @@ -18,11 +18,17 @@ function gridLang(): array 'next' => t('Next'), 'navigate' => t('Page %d of %d'), 'page' => t('Page %d'), + 'rowsPerPage' => t('Rows per page'), 'showing' => t('Showing'), 'of' => t('of'), 'to' => t('to'), 'results' => t('results'), ], + 'actions' => [ + 'column' => t('Actions'), + 'open' => t('Open'), + 'edit' => t('Edit'), + ], 'loading' => t('Loading...'), 'noRecordsFound' => t('No records found'), 'error' => t('An error happened while fetching the data'), diff --git a/pages/account/profile().php b/pages/account/profile().php index 763f5a9..3cf895d 100644 --- a/pages/account/profile().php +++ b/pages/account/profile().php @@ -1,9 +1,9 @@ $row['id'] ?? null, - 'uuid' => $row['uuid'] ?? '', + 'uuid' => $tenantUuid, 'is_default' => $tenantId > 0 && $defaultTenantId === $tenantId, 'description' => $row['description'] ?? '', 'status' => $tenantStatus->value, @@ -92,6 +94,7 @@ $fetchRows = static function ( 'inactive_users' => $usersInactive, 'created' => dt($row['created'] ?? ''), 'modified' => dt($row['modified'] ?? ''), + 'has_avatar' => $tenantUuid !== '' && $tenantAvatarService->hasAvatar($tenantUuid), ]; } diff --git a/pages/admin/tenants/edit($id).php b/pages/admin/tenants/edit($id).php index 0e5a97a..9f54c72 100644 --- a/pages/admin/tenants/edit($id).php +++ b/pages/admin/tenants/edit($id).php @@ -6,6 +6,7 @@ use MintyPHP\Service\Access\TenantAuthorizationPolicy; use MintyPHP\Service\CustomField\TenantCustomFieldService; use MintyPHP\Support\Flash; use MintyPHP\Support\Guard; + Guard::requireLogin(); $request = requestInput(); diff --git a/pages/admin/tenants/index($slug).php b/pages/admin/tenants/index($slug).php index 65d2509..b285281 100644 --- a/pages/admin/tenants/index($slug).php +++ b/pages/admin/tenants/index($slug).php @@ -1,8 +1,8 @@ $filterChipMeta, 'gridLang' => $gridLang, 'labels' => [ + 'avatar' => t('Avatar'), 'description' => t('Description'), 'status' => t('Status'), 'theme' => t('Theme'), diff --git a/pages/admin/user-lifecycle-audit/index().php b/pages/admin/user-lifecycle-audit/index().php index 77260a4..9a928b5 100644 --- a/pages/admin/user-lifecycle-audit/index().php +++ b/pages/admin/user-lifecycle-audit/index().php @@ -4,8 +4,8 @@ use MintyPHP\Buffer; use MintyPHP\Domain\Taxonomy\UserLifecycleAction; use MintyPHP\Domain\Taxonomy\UserLifecycleStatus; use MintyPHP\Domain\Taxonomy\UserLifecycleTriggerType; -use MintyPHP\Service\Access\UiCapabilityMap; use MintyPHP\Service\Access\UiAccessService; +use MintyPHP\Service\Access\UiCapabilityMap; use MintyPHP\Service\Audit\UserLifecycleAuditService; use MintyPHP\Support\Guard; diff --git a/pages/admin/user-lifecycle-audit/purge().php b/pages/admin/user-lifecycle-audit/purge().php index 51cb21f..4a48c40 100644 --- a/pages/admin/user-lifecycle-audit/purge().php +++ b/pages/admin/user-lifecycle-audit/purge().php @@ -2,8 +2,8 @@ use MintyPHP\Router; use MintyPHP\Session; -use MintyPHP\Support\Guard; use MintyPHP\Support\Flash; +use MintyPHP\Support\Guard; Guard::requireLogin(); Guard::requireAbility(\MintyPHP\Service\Access\SettingsAuthorizationPolicy::ABILITY_ADMIN_SETTINGS_UPDATE); diff --git a/pages/admin/user-lifecycle-audit/view($id).php b/pages/admin/user-lifecycle-audit/view($id).php index 8fba43e..7787754 100644 --- a/pages/admin/user-lifecycle-audit/view($id).php +++ b/pages/admin/user-lifecycle-audit/view($id).php @@ -3,8 +3,8 @@ use MintyPHP\Buffer; use MintyPHP\Domain\Taxonomy\UserLifecycleAction; use MintyPHP\Router; -use MintyPHP\Service\Access\UiCapabilityMap; use MintyPHP\Service\Access\UiAccessService; +use MintyPHP\Service\Access\UiCapabilityMap; use MintyPHP\Support\Flash; use MintyPHP\Support\Guard; diff --git a/pages/admin/users/create().php b/pages/admin/users/create().php index aae0522..38ebf18 100644 --- a/pages/admin/users/create().php +++ b/pages/admin/users/create().php @@ -1,8 +1,8 @@ createUserReadRepository(); $user = $userReadRepository->findByEmail($email); if (!$user) { - $rateLimiter->registerFailure('api.auth.login.ip', $ip, 10, 600, 300); - $rateLimiter->registerFailure('api.auth.login.email_ip', $emailKey, 5, 900, 900); + $rateLimiter->registerFailure('api.auth.login.ip', $ip, 10, 600, 300); + $rateLimiter->registerFailure('api.auth.login.email_ip', $emailKey, 5, 900, 900); ApiResponse::error('invalid_credentials', 401); } // ---- Account checks ---- if (!(int) ($user['active'] ?? 0)) { - $rateLimiter->registerFailure('api.auth.login.ip', $ip, 10, 600, 300); - $rateLimiter->registerFailure('api.auth.login.email_ip', $emailKey, 5, 900, 900); + $rateLimiter->registerFailure('api.auth.login.ip', $ip, 10, 600, 300); + $rateLimiter->registerFailure('api.auth.login.email_ip', $emailKey, 5, 900, 900); ApiResponse::error('account_inactive', 401); } if (empty($user['email_verified_at'])) { - $rateLimiter->registerFailure('api.auth.login.ip', $ip, 10, 600, 300); - $rateLimiter->registerFailure('api.auth.login.email_ip', $emailKey, 5, 900, 900); + $rateLimiter->registerFailure('api.auth.login.ip', $ip, 10, 600, 300); + $rateLimiter->registerFailure('api.auth.login.email_ip', $emailKey, 5, 900, 900); ApiResponse::error('invalid_credentials', 401); } // ---- Password verification ---- if (!password_verify($password, (string) ($user['password'] ?? ''))) { - $rateLimiter->registerFailure('api.auth.login.ip', $ip, 10, 600, 300); - $rateLimiter->registerFailure('api.auth.login.email_ip', $emailKey, 5, 900, 900); + $rateLimiter->registerFailure('api.auth.login.ip', $ip, 10, 600, 300); + $rateLimiter->registerFailure('api.auth.login.email_ip', $emailKey, 5, 900, 900); ApiResponse::error('invalid_credentials', 401); } diff --git a/pages/api/v1/settings/index().php b/pages/api/v1/settings/index().php index 34116b0..954a1e5 100644 --- a/pages/api/v1/settings/index().php +++ b/pages/api/v1/settings/index().php @@ -1,7 +1,7 @@ static fn(?string $v) => $settingService->setAppTitle($v), - 'app_locale' => static fn(?string $v) => $settingService->setAppLocale($v), - 'app_theme' => static fn(?string $v) => $settingService->setAppTheme($v), - 'app_theme_user_allowed' => static fn($v) => $settingService->setUserThemeAllowed((bool) $v), - 'app_registration_enabled' => static fn($v) => $settingService->setRegistrationEnabled((bool) $v), - 'app_primary_color' => static fn(?string $v) => $settingService->setAppPrimaryColor($v), - 'api_token_default_ttl_days' => static fn($v) => $settingService->setApiTokenDefaultTtlDays($v !== null ? (int) $v : null), - 'api_token_max_ttl_days' => static fn($v) => $settingService->setApiTokenMaxTtlDays($v !== null ? (int) $v : null), - 'api_cors_allowed_origins' => static fn(?string $v) => $settingService->setApiCorsAllowedOrigins($v), - 'default_tenant_id' => static fn($v) => $settingService->setDefaultTenantId($v !== null ? (int) $v : null), - 'default_role_id' => static fn($v) => $settingService->setDefaultRoleId($v !== null ? (int) $v : null), - 'default_department_id' => static fn($v) => $settingService->setDefaultDepartmentId($v !== null ? (int) $v : null), - 'user_inactivity_deactivate_days' => static fn($v) => $settingService->setUserInactivityDeactivateDays($v !== null ? (int) $v : null), - 'user_inactivity_delete_days' => static fn($v) => $settingService->setUserInactivityDeleteDays($v !== null ? (int) $v : null), - 'system_audit_enabled' => static fn($v) => $settingService->setSystemAuditEnabled((bool) $v), - 'system_audit_retention_days' => static fn($v) => $settingService->setSystemAuditRetentionDays($v !== null ? (int) $v : null), - 'microsoft_shared_client_id' => static fn(?string $v) => $settingService->setMicrosoftSharedClientId($v), - 'microsoft_authority' => static fn(?string $v) => $settingService->setMicrosoftAuthority($v), - 'smtp_host' => static fn(?string $v) => $settingService->setSmtpHost($v), - 'smtp_port' => static fn($v) => $settingService->setSmtpPort($v !== null ? (int) $v : null), - 'smtp_user' => static fn(?string $v) => $settingService->setSmtpUser($v), - 'smtp_password' => static fn(?string $v) => $settingService->setSmtpPassword($v), - 'smtp_secure' => static fn(?string $v) => $settingService->setSmtpSecure($v), - 'smtp_from' => static fn(?string $v) => $settingService->setSmtpFrom($v), - 'smtp_from_name' => static fn(?string $v) => $settingService->setSmtpFromName($v), + 'app_title' => static fn (?string $v) => $settingService->setAppTitle($v), + 'app_locale' => static fn (?string $v) => $settingService->setAppLocale($v), + 'app_theme' => static fn (?string $v) => $settingService->setAppTheme($v), + 'app_theme_user_allowed' => static fn ($v) => $settingService->setUserThemeAllowed((bool) $v), + 'app_registration_enabled' => static fn ($v) => $settingService->setRegistrationEnabled((bool) $v), + 'app_primary_color' => static fn (?string $v) => $settingService->setAppPrimaryColor($v), + 'api_token_default_ttl_days' => static fn ($v) => $settingService->setApiTokenDefaultTtlDays($v !== null ? (int) $v : null), + 'api_token_max_ttl_days' => static fn ($v) => $settingService->setApiTokenMaxTtlDays($v !== null ? (int) $v : null), + 'api_cors_allowed_origins' => static fn (?string $v) => $settingService->setApiCorsAllowedOrigins($v), + 'default_tenant_id' => static fn ($v) => $settingService->setDefaultTenantId($v !== null ? (int) $v : null), + 'default_role_id' => static fn ($v) => $settingService->setDefaultRoleId($v !== null ? (int) $v : null), + 'default_department_id' => static fn ($v) => $settingService->setDefaultDepartmentId($v !== null ? (int) $v : null), + 'user_inactivity_deactivate_days' => static fn ($v) => $settingService->setUserInactivityDeactivateDays($v !== null ? (int) $v : null), + 'user_inactivity_delete_days' => static fn ($v) => $settingService->setUserInactivityDeleteDays($v !== null ? (int) $v : null), + 'system_audit_enabled' => static fn ($v) => $settingService->setSystemAuditEnabled((bool) $v), + 'system_audit_retention_days' => static fn ($v) => $settingService->setSystemAuditRetentionDays($v !== null ? (int) $v : null), + 'microsoft_shared_client_id' => static fn (?string $v) => $settingService->setMicrosoftSharedClientId($v), + 'microsoft_authority' => static fn (?string $v) => $settingService->setMicrosoftAuthority($v), + 'smtp_host' => static fn (?string $v) => $settingService->setSmtpHost($v), + 'smtp_port' => static fn ($v) => $settingService->setSmtpPort($v !== null ? (int) $v : null), + 'smtp_user' => static fn (?string $v) => $settingService->setSmtpUser($v), + 'smtp_password' => static fn (?string $v) => $settingService->setSmtpPassword($v), + 'smtp_secure' => static fn (?string $v) => $settingService->setSmtpSecure($v), + 'smtp_from' => static fn (?string $v) => $settingService->setSmtpFrom($v), + 'smtp_from_name' => static fn (?string $v) => $settingService->setSmtpFromName($v), ]; foreach ($input as $key => $value) { diff --git a/pages/api/v1/users/avatar($id).php b/pages/api/v1/users/avatar($id).php index c16c043..4e8b5bb 100644 --- a/pages/api/v1/users/avatar($id).php +++ b/pages/api/v1/users/avatar($id).php @@ -3,7 +3,6 @@ use MintyPHP\Http\ApiAuth; use MintyPHP\Http\ApiBootstrap; use MintyPHP\Http\ApiResponse; -use MintyPHP\Service\Access\AuthorizationDecision; use MintyPHP\Service\Access\UserAuthorizationPolicy; use MintyPHP\Service\User\UserAccountService; use MintyPHP\Service\User\UserAvatarService; diff --git a/pages/auth/login().php b/pages/auth/login().php index d2e8cdd..a57b5ee 100644 --- a/pages/auth/login().php +++ b/pages/auth/login().php @@ -9,11 +9,11 @@ use MintyPHP\Session; use MintyPHP\Support\Flash; if (!empty($_SESSION['user']['id'])) { - if (Flash::has()) { - Flash::keep(); - } - Router::redirect("admin"); - return; + if (Flash::has()) { + Flash::keep(); + } + Router::redirect("admin"); + return; } $tenantHint = trim((string) (requestInput()->queryAll()['tenant'] ?? requestInput()->bodyAll()['tenant_hint'] ?? '')); @@ -26,13 +26,13 @@ $selectedTenant = null; $selectedTenantMethods = ['local' => false, 'microsoft' => false, 'microsoft_reason' => '']; $errorBag = formErrors(); $setLoginWarning = static function (string $message) use ($errorBag): void { - $errorBag->addGlobal($message); + $errorBag->addGlobal($message); }; $setRateLimitWarning = static function (int $retryAfterSeconds) use ($setLoginWarning): void { - $retryAfterSeconds = max(1, $retryAfterSeconds); - http_response_code(429); - header('Retry-After: ' . $retryAfterSeconds); - $setLoginWarning(t('Too many login attempts. Please wait and try again.')); + $retryAfterSeconds = max(1, $retryAfterSeconds); + http_response_code(429); + header('Retry-After: ' . $retryAfterSeconds); + $setLoginWarning(t('Too many login attempts. Please wait and try again.')); }; $rateLimiterService = (app(SecurityServicesFactory::class))->createRateLimiterService(); @@ -55,271 +55,271 @@ $userReadRepository = $userServicesFactory->createUserReadRepository(); $userTenantContextService = $userServicesFactory->createUserTenantContextService(); $resolveLoginCandidates = static function (string $inputEmail) use ($userReadRepository, $userTenantContextService, $tenantSsoService, $tenantAvatarService): array { - $emailValue = strtolower(trim($inputEmail)); - if ($emailValue === '' || !filter_var($emailValue, FILTER_VALIDATE_EMAIL)) { - return ['ok' => false]; - } - - $user = $userReadRepository->findByEmail($emailValue); - if (!$user || (int) ($user['active'] ?? 0) !== 1) { - return ['ok' => false]; - } - - $userId = (int) ($user['id'] ?? 0); - if ($userId <= 0) { - return ['ok' => false]; - } - - $tenants = $userTenantContextService->getAssignedActiveTenants($userId); - if (!$tenants) { - return ['ok' => false]; - } - - $candidates = []; - $candidateById = []; - foreach ($tenants as $tenant) { - $tenantId = (int) ($tenant['id'] ?? 0); - if ($tenantId <= 0) { - continue; + $emailValue = strtolower(trim($inputEmail)); + if ($emailValue === '' || !filter_var($emailValue, FILTER_VALIDATE_EMAIL)) { + return ['ok' => false]; } - $tenantSlug = $tenantSsoService->tenantLoginSlug($tenant); - if ($tenantSlug === '') { - continue; + + $user = $userReadRepository->findByEmail($emailValue); + if (!$user || (int) ($user['active'] ?? 0) !== 1) { + return ['ok' => false]; } - $methods = $tenantSsoService->resolveTenantLoginMethods($tenantId); - $tenantUuid = (string) ($tenant['uuid'] ?? ''); - $hasAvatar = $tenantUuid !== '' && $tenantAvatarService->hasAvatar($tenantUuid); - $candidate = [ - 'id' => $tenantId, - 'uuid' => $tenantUuid, - 'description' => (string) ($tenant['description'] ?? ''), - 'slug' => $tenantSlug, - 'has_avatar' => $hasAvatar, - 'avatar_url' => $hasAvatar - ? lurl('auth/tenant-avatar-file?uuid=' . rawurlencode($tenantUuid) . '&size=256') - : '', - 'methods' => $methods, + + $userId = (int) ($user['id'] ?? 0); + if ($userId <= 0) { + return ['ok' => false]; + } + + $tenants = $userTenantContextService->getAssignedActiveTenants($userId); + if (!$tenants) { + return ['ok' => false]; + } + + $candidates = []; + $candidateById = []; + foreach ($tenants as $tenant) { + $tenantId = (int) ($tenant['id'] ?? 0); + if ($tenantId <= 0) { + continue; + } + $tenantSlug = $tenantSsoService->tenantLoginSlug($tenant); + if ($tenantSlug === '') { + continue; + } + $methods = $tenantSsoService->resolveTenantLoginMethods($tenantId); + $tenantUuid = (string) ($tenant['uuid'] ?? ''); + $hasAvatar = $tenantUuid !== '' && $tenantAvatarService->hasAvatar($tenantUuid); + $candidate = [ + 'id' => $tenantId, + 'uuid' => $tenantUuid, + 'description' => (string) ($tenant['description'] ?? ''), + 'slug' => $tenantSlug, + 'has_avatar' => $hasAvatar, + 'avatar_url' => $hasAvatar + ? lurl('auth/tenant-avatar-file?uuid=' . rawurlencode($tenantUuid) . '&size=256') + : '', + 'methods' => $methods, + ]; + $candidates[] = $candidate; + $candidateById[$tenantId] = $candidate; + } + + if (!$candidates) { + return ['ok' => false]; + } + + return [ + 'ok' => true, + 'email' => $emailValue, + 'user' => $user, + 'candidates' => $candidates, + 'candidate_by_id' => $candidateById, ]; - $candidates[] = $candidate; - $candidateById[$tenantId] = $candidate; - } - - if (!$candidates) { - return ['ok' => false]; - } - - return [ - 'ok' => true, - 'email' => $emailValue, - 'user' => $user, - 'candidates' => $candidates, - 'candidate_by_id' => $candidateById, - ]; }; $resolveSelectedTenantId = static function (array $candidateByIdValue, int $requestedTenantIdValue, string $tenantHintValue): int { - if ($requestedTenantIdValue > 0 && isset($candidateByIdValue[$requestedTenantIdValue])) { - return $requestedTenantIdValue; - } - - $hint = trim(strtolower($tenantHintValue)); - if ($hint !== '') { - foreach ($candidateByIdValue as $candidate) { - $candidateSlug = strtolower((string) ($candidate['slug'] ?? '')); - if ($candidateSlug !== '' && $candidateSlug === $hint) { - return (int) ($candidate['id'] ?? 0); - } + if ($requestedTenantIdValue > 0 && isset($candidateByIdValue[$requestedTenantIdValue])) { + return $requestedTenantIdValue; } - } - $first = reset($candidateByIdValue); - if (!is_array($first)) { - return 0; - } - return (int) ($first['id'] ?? 0); + $hint = trim(strtolower($tenantHintValue)); + if ($hint !== '') { + foreach ($candidateByIdValue as $candidate) { + $candidateSlug = strtolower((string) ($candidate['slug'] ?? '')); + if ($candidateSlug !== '' && $candidateSlug === $hint) { + return (int) ($candidate['id'] ?? 0); + } + } + } + + $first = reset($candidateByIdValue); + if (!is_array($first)) { + return 0; + } + return (int) ($first['id'] ?? 0); }; if (requestInput()->method() === 'POST') { - if (!Session::checkCsrfToken()) { - $errorBag->addGlobal(t('Form expired, please try again')); - $stage = 'resolve_email'; - } else { - $postedStage = trim((string) (requestInput()->bodyAll()['stage'] ?? 'resolve_email')); - $email = trim((string) (requestInput()->bodyAll()['email'] ?? '')); - $clientIp = trim((string) ($_SERVER['REMOTE_ADDR'] ?? '')); - if ($clientIp === '') { - $clientIp = 'unknown'; - } - - if ($postedStage === 'reset') { - $email = ''; - $stage = 'resolve_email'; - } else { - if ($postedStage === 'resolve_email') { - $resolveLimitResult = $rateLimiterService->isBlocked($loginResolveScope, $clientIp); - if (!($resolveLimitResult['allowed'] ?? true)) { - $setRateLimitWarning((int) ($resolveLimitResult['retry_after'] ?? 60)); - return; - } - } - - $resolved = $resolveLoginCandidates($email); - if (!$resolved['ok']) { + if (!Session::checkCsrfToken()) { + $errorBag->addGlobal(t('Form expired, please try again')); $stage = 'resolve_email'; - $setLoginWarning(t('We could not continue with these login details')); - if ($postedStage === 'resolve_email') { - $resolveFailureResult = $rateLimiterService->registerFailure( - $loginResolveScope, - $clientIp, - $loginResolveLimit, - $loginResolveWindow, - $loginResolveBlock - ); - if (!($resolveFailureResult['allowed'] ?? true)) { - $setRateLimitWarning((int) ($resolveFailureResult['retry_after'] ?? $loginResolveBlock)); - } + } else { + $postedStage = trim((string) (requestInput()->bodyAll()['stage'] ?? 'resolve_email')); + $email = trim((string) (requestInput()->bodyAll()['email'] ?? '')); + $clientIp = trim((string) ($_SERVER['REMOTE_ADDR'] ?? '')); + if ($clientIp === '') { + $clientIp = 'unknown'; } - } else { - $email = (string) $resolved['email']; - $tenantCandidates = $resolved['candidates']; - $tenantCandidateById = $resolved['candidate_by_id']; - $postedTenantId = (int) (requestInput()->bodyAll()['tenant_id'] ?? 0); - if (in_array($postedStage, ['choose_tenant', 'login_password'], true) - && ($postedTenantId <= 0 || !isset($tenantCandidateById[$postedTenantId]))) { - $stage = 'choose_tenant'; - $setLoginWarning(t('We could not continue with these login details')); - $selectedTenantId = $resolveSelectedTenantId( - $tenantCandidateById, - 0, - trim((string) (requestInput()->bodyAll()['tenant_hint'] ?? $tenantHint)) - ); - $selectedTenant = $tenantCandidateById[$selectedTenantId] ?? null; - $selectedTenantMethods = is_array($selectedTenant['methods'] ?? null) - ? $selectedTenant['methods'] - : $selectedTenantMethods; - } else { - $selectedTenantId = $resolveSelectedTenantId( - $tenantCandidateById, - $postedTenantId, - trim((string) (requestInput()->bodyAll()['tenant_hint'] ?? $tenantHint)) - ); - $selectedTenant = $tenantCandidateById[$selectedTenantId] ?? null; - if (!is_array($selectedTenant)) { + if ($postedStage === 'reset') { + $email = ''; $stage = 'resolve_email'; - $setLoginWarning(t('We could not continue with these login details')); - } else { - $selectedTenantMethods = $selectedTenant['methods']; - $selectedTenantSlug = trim((string) $selectedTenant['slug']); - $isMicrosoftOnlyTenant = empty($selectedTenantMethods['local']) - && !empty($selectedTenantMethods['microsoft']) - && $selectedTenantSlug !== ''; - - if ( - $postedStage === 'resolve_email' - && count($tenantCandidates) === 1 - && $isMicrosoftOnlyTenant - ) { - Router::redirect('auth/microsoft/start?tenant=' . rawurlencode($selectedTenantSlug)); - return; - } - - if ( - $postedStage === 'choose_tenant' - && $isMicrosoftOnlyTenant - ) { - Router::redirect('auth/microsoft/start?tenant=' . rawurlencode($selectedTenantSlug)); - return; - } - + } else { if ($postedStage === 'resolve_email') { - $stage = count($tenantCandidates) === 1 ? 'methods' : 'choose_tenant'; - } elseif ($postedStage === 'choose_tenant') { - $stage = 'methods'; - } elseif ($postedStage === 'login_password') { - $stage = 'methods'; - $passwordAttemptKey = strtolower(trim($email)) . '|' . $clientIp; - $passwordLimitResult = $rateLimiterService->isBlocked($loginPasswordScope, $passwordAttemptKey); - if (!($passwordLimitResult['allowed'] ?? true)) { - $setRateLimitWarning((int) ($passwordLimitResult['retry_after'] ?? 60)); - return; - } - - if (empty($selectedTenantMethods['local'])) { - $setLoginWarning(t('Password login is disabled for this tenant. Please use Microsoft login.')); - } else { - $password = (string) (requestInput()->bodyAll()['password'] ?? ''); - $remember = !empty(requestInput()->bodyAll()['remember']); - $result = $authService->login($email, $password); - if (!($result['ok'] ?? false)) { - if (!empty($result['needs_verification'])) { - $_SESSION['email_verification_email'] = $result['email'] ?? $email; - Flash::error(t('Please verify your email first'), 'verify-email', 'login_not_verified'); - Router::redirect('verify-email'); + $resolveLimitResult = $rateLimiterService->isBlocked($loginResolveScope, $clientIp); + if (!($resolveLimitResult['allowed'] ?? true)) { + $setRateLimitWarning((int) ($resolveLimitResult['retry_after'] ?? 60)); return; - } - $setLoginWarning(t((string) ($result['message'] ?? 'Email/password not valid'))); - $passwordFailureResult = $rateLimiterService->registerFailure( - $loginPasswordScope, - $passwordAttemptKey, - $loginPasswordLimit, - $loginPasswordWindow, - $loginPasswordBlock - ); - if (!($passwordFailureResult['allowed'] ?? true)) { - $setRateLimitWarning((int) ($passwordFailureResult['retry_after'] ?? $loginPasswordBlock)); - } - } else { - $userId = (int) ($_SESSION['user']['id'] ?? 0); - if ($userId <= 0 || !$authService->canLoginToTenant($userId, $selectedTenantId)) { - $authService->logout(); - $setLoginWarning(t('No access to this tenant')); - $passwordFailureResult = $rateLimiterService->registerFailure( - $loginPasswordScope, - $passwordAttemptKey, - $loginPasswordLimit, - $loginPasswordWindow, - $loginPasswordBlock - ); - if (!($passwordFailureResult['allowed'] ?? true)) { - $setRateLimitWarning((int) ($passwordFailureResult['retry_after'] ?? $loginPasswordBlock)); - } - } else { - $setTenant = $userTenantContextService->setCurrentTenant($userId, $selectedTenantId); - if (!($setTenant['ok'] ?? false)) { - $authService->logout(); - $setLoginWarning(t('No access to this tenant')); - $passwordFailureResult = $rateLimiterService->registerFailure( - $loginPasswordScope, - $passwordAttemptKey, - $loginPasswordLimit, - $loginPasswordWindow, - $loginPasswordBlock - ); - if (!($passwordFailureResult['allowed'] ?? true)) { - $setRateLimitWarning((int) ($passwordFailureResult['retry_after'] ?? $loginPasswordBlock)); - } - } else { - $rateLimiterService->reset($loginPasswordScope, $passwordAttemptKey); - $authService->loadTenantDataIntoSession($userId); - if ($remember) { - $rememberMeService->rememberUser($userId); - } - Router::redirect('admin'); - return; - } - } } - } - } else { - $stage = 'resolve_email'; - $setLoginWarning(t('We could not continue with these login details')); } - } + + $resolved = $resolveLoginCandidates($email); + if (!$resolved['ok']) { + $stage = 'resolve_email'; + $setLoginWarning(t('We could not continue with these login details')); + if ($postedStage === 'resolve_email') { + $resolveFailureResult = $rateLimiterService->registerFailure( + $loginResolveScope, + $clientIp, + $loginResolveLimit, + $loginResolveWindow, + $loginResolveBlock + ); + if (!($resolveFailureResult['allowed'] ?? true)) { + $setRateLimitWarning((int) ($resolveFailureResult['retry_after'] ?? $loginResolveBlock)); + } + } + } else { + $email = (string) $resolved['email']; + $tenantCandidates = $resolved['candidates']; + $tenantCandidateById = $resolved['candidate_by_id']; + $postedTenantId = (int) (requestInput()->bodyAll()['tenant_id'] ?? 0); + + if (in_array($postedStage, ['choose_tenant', 'login_password'], true) + && ($postedTenantId <= 0 || !isset($tenantCandidateById[$postedTenantId]))) { + $stage = 'choose_tenant'; + $setLoginWarning(t('We could not continue with these login details')); + $selectedTenantId = $resolveSelectedTenantId( + $tenantCandidateById, + 0, + trim((string) (requestInput()->bodyAll()['tenant_hint'] ?? $tenantHint)) + ); + $selectedTenant = $tenantCandidateById[$selectedTenantId] ?? null; + $selectedTenantMethods = is_array($selectedTenant['methods'] ?? null) + ? $selectedTenant['methods'] + : $selectedTenantMethods; + } else { + $selectedTenantId = $resolveSelectedTenantId( + $tenantCandidateById, + $postedTenantId, + trim((string) (requestInput()->bodyAll()['tenant_hint'] ?? $tenantHint)) + ); + $selectedTenant = $tenantCandidateById[$selectedTenantId] ?? null; + if (!is_array($selectedTenant)) { + $stage = 'resolve_email'; + $setLoginWarning(t('We could not continue with these login details')); + } else { + $selectedTenantMethods = $selectedTenant['methods']; + $selectedTenantSlug = trim((string) $selectedTenant['slug']); + $isMicrosoftOnlyTenant = empty($selectedTenantMethods['local']) + && !empty($selectedTenantMethods['microsoft']) + && $selectedTenantSlug !== ''; + + if ( + $postedStage === 'resolve_email' + && count($tenantCandidates) === 1 + && $isMicrosoftOnlyTenant + ) { + Router::redirect('auth/microsoft/start?tenant=' . rawurlencode($selectedTenantSlug)); + return; + } + + if ( + $postedStage === 'choose_tenant' + && $isMicrosoftOnlyTenant + ) { + Router::redirect('auth/microsoft/start?tenant=' . rawurlencode($selectedTenantSlug)); + return; + } + + if ($postedStage === 'resolve_email') { + $stage = count($tenantCandidates) === 1 ? 'methods' : 'choose_tenant'; + } elseif ($postedStage === 'choose_tenant') { + $stage = 'methods'; + } elseif ($postedStage === 'login_password') { + $stage = 'methods'; + $passwordAttemptKey = strtolower(trim($email)) . '|' . $clientIp; + $passwordLimitResult = $rateLimiterService->isBlocked($loginPasswordScope, $passwordAttemptKey); + if (!($passwordLimitResult['allowed'] ?? true)) { + $setRateLimitWarning((int) ($passwordLimitResult['retry_after'] ?? 60)); + return; + } + + if (empty($selectedTenantMethods['local'])) { + $setLoginWarning(t('Password login is disabled for this tenant. Please use Microsoft login.')); + } else { + $password = (string) (requestInput()->bodyAll()['password'] ?? ''); + $remember = !empty(requestInput()->bodyAll()['remember']); + $result = $authService->login($email, $password); + if (!($result['ok'] ?? false)) { + if (!empty($result['needs_verification'])) { + $_SESSION['email_verification_email'] = $result['email'] ?? $email; + Flash::error(t('Please verify your email first'), 'verify-email', 'login_not_verified'); + Router::redirect('verify-email'); + return; + } + $setLoginWarning(t((string) ($result['message'] ?? 'Email/password not valid'))); + $passwordFailureResult = $rateLimiterService->registerFailure( + $loginPasswordScope, + $passwordAttemptKey, + $loginPasswordLimit, + $loginPasswordWindow, + $loginPasswordBlock + ); + if (!($passwordFailureResult['allowed'] ?? true)) { + $setRateLimitWarning((int) ($passwordFailureResult['retry_after'] ?? $loginPasswordBlock)); + } + } else { + $userId = (int) ($_SESSION['user']['id'] ?? 0); + if ($userId <= 0 || !$authService->canLoginToTenant($userId, $selectedTenantId)) { + $authService->logout(); + $setLoginWarning(t('No access to this tenant')); + $passwordFailureResult = $rateLimiterService->registerFailure( + $loginPasswordScope, + $passwordAttemptKey, + $loginPasswordLimit, + $loginPasswordWindow, + $loginPasswordBlock + ); + if (!($passwordFailureResult['allowed'] ?? true)) { + $setRateLimitWarning((int) ($passwordFailureResult['retry_after'] ?? $loginPasswordBlock)); + } + } else { + $setTenant = $userTenantContextService->setCurrentTenant($userId, $selectedTenantId); + if (!($setTenant['ok'] ?? false)) { + $authService->logout(); + $setLoginWarning(t('No access to this tenant')); + $passwordFailureResult = $rateLimiterService->registerFailure( + $loginPasswordScope, + $passwordAttemptKey, + $loginPasswordLimit, + $loginPasswordWindow, + $loginPasswordBlock + ); + if (!($passwordFailureResult['allowed'] ?? true)) { + $setRateLimitWarning((int) ($passwordFailureResult['retry_after'] ?? $loginPasswordBlock)); + } + } else { + $rateLimiterService->reset($loginPasswordScope, $passwordAttemptKey); + $authService->loadTenantDataIntoSession($userId); + if ($remember) { + $rememberMeService->rememberUser($userId); + } + Router::redirect('admin'); + return; + } + } + } + } + } else { + $stage = 'resolve_email'; + $setLoginWarning(t('We could not continue with these login details')); + } + } + } + } } - } } - } } $errors = $errorBag->toFlatList(); diff --git a/pages/auth/register().php b/pages/auth/register().php index 5719993..da64e2d 100644 --- a/pages/auth/register().php +++ b/pages/auth/register().php @@ -8,34 +8,34 @@ use MintyPHP\Support\Flash; $request = requestInput(); $error = false; if (!allowRegistration()) { - Flash::error(t('Registration is currently disabled'), 'login', 'registration_disabled'); - Router::redirect('login'); + Flash::error(t('Registration is currently disabled'), 'login', 'registration_disabled'); + Router::redirect('login'); } if ($request->hasBody('email')) { - $authService = (app(AuthServicesFactory::class))->createAuthService(); - $firstName = $request->bodyString('first_name'); - $lastName = $request->bodyString('last_name'); - $email = $request->bodyString('email'); - $password = (string) $request->body('password', ''); - $password2 = (string) $request->body('password2', ''); + $authService = (app(AuthServicesFactory::class))->createAuthService(); + $firstName = $request->bodyString('first_name'); + $lastName = $request->bodyString('last_name'); + $email = $request->bodyString('email'); + $password = (string) $request->body('password', ''); + $password2 = (string) $request->body('password2', ''); - $result = $authService->register([ - 'first_name' => $firstName, - 'last_name' => $lastName, - 'email' => $email, - 'password' => $password, - 'password2' => $password2, - ]); + $result = $authService->register([ + 'first_name' => $firstName, + 'last_name' => $lastName, + 'email' => $email, + 'password' => $password, + 'password2' => $password2, + ]); - if (!($result['ok'] ?? false)) { - $error = $result['error'] ?? t('User can not be registered'); - } else { - // Store email in session for verify-email page - $_SESSION['email_verification_email'] = $result['email'] ?? $email; - Flash::success(t('Registration successful! Please check your email for the verification code.'), 'verify-email', 'registration_success'); - Router::redirect('verify-email'); - } + if (!($result['ok'] ?? false)) { + $error = $result['error'] ?? t('User can not be registered'); + } else { + // Store email in session for verify-email page + $_SESSION['email_verification_email'] = $result['email'] ?? $email; + Flash::success(t('Registration successful! Please check your email for the verification code.'), 'verify-email', 'registration_success'); + Router::redirect('verify-email'); + } } $userPasswordPolicyService = (app(UserServicesFactory::class))->createUserPasswordPolicyService(); diff --git a/pages/flash/dismiss($id).php b/pages/flash/dismiss($id).php index 0f181a4..a6b1f03 100644 --- a/pages/flash/dismiss($id).php +++ b/pages/flash/dismiss($id).php @@ -1,25 +1,25 @@ bodyAll()['return'] ?? ''); $errorBag = formErrors(); if (requestInput()->method() !== 'POST') { - Router::redirect($target); + Router::redirect($target); } if (!Session::checkCsrfToken()) { - $errorBag->addGlobal(t('Form expired, please try again')); - flashFormErrors($errorBag, null, 'flash_dismiss'); - Router::redirect($target); + $errorBag->addGlobal(t('Form expired, please try again')); + flashFormErrors($errorBag, null, 'flash_dismiss'); + Router::redirect($target); } $flashId = isset($id) ? trim((string) $id) : ''; if ($flashId !== '') { - Flash::dismiss($flashId); + Flash::dismiss($flashId); } Router::redirect($target); diff --git a/pages/index().php b/pages/index().php index 320f2ca..536715a 100644 --- a/pages/index().php +++ b/pages/index().php @@ -3,7 +3,7 @@ use MintyPHP\Router; if (isset($_SESSION['user'])) { - Router::redirect('admin'); + Router::redirect('admin'); } else { - Router::redirect('login'); + Router::redirect('login'); } diff --git a/pages/lang().php b/pages/lang().php index 899e876..4149720 100644 --- a/pages/lang().php +++ b/pages/lang().php @@ -9,24 +9,24 @@ use MintyPHP\Support\Flash; $rawLocale = (string) ($_REQUEST['locale'] ?? (requestInput()->queryAll()['lang'] ?? '')); $locale = strtolower(trim($rawLocale)); if ($locale !== '' && strpos($locale, '-') !== false) { - $locale = explode('-', $locale, 2)[0]; + $locale = explode('-', $locale, 2)[0]; } $locales = defined('APP_LOCALES') ? APP_LOCALES : [I18n::$defaultLocale]; if ($locale === '' || !in_array($locale, $locales, true)) { - Flash::error('Language not supported', null, 'lang_not_supported'); - Router::redirect(''); + Flash::error('Language not supported', null, 'lang_not_supported'); + Router::redirect(''); } I18n::$locale = $locale; if (isset($_SESSION['user']['id'])) { - $userId = (int) ($_SESSION['user']['id'] ?? 0); - if ($userId) { - (app(UserServicesFactory::class))->createUserAccountService()->setLocale($userId, $locale); - $_SESSION['user']['locale'] = $locale; - } + $userId = (int) ($_SESSION['user']['id'] ?? 0); + if ($userId) { + (app(UserServicesFactory::class))->createUserAccountService()->setLocale($userId, $locale); + $_SESSION['user']['locale'] = $locale; + } } else { - setcookie('locale', $locale, time() + 31536000, '/', '', false, true); + setcookie('locale', $locale, time() + 31536000, '/', '', false, true); } $target = Request::safeReturnTarget(requestInput()->queryAll()['return'] ?? ''); diff --git a/pages/page/copy-language().php b/pages/page/copy-language().php index a6452d7..3d383d2 100644 --- a/pages/page/copy-language().php +++ b/pages/page/copy-language().php @@ -1,11 +1,11 @@ assertStringContainsString('invalid_email', $errorJson); } } - diff --git a/tests/Service/Import/CsvReaderServiceTest.php b/tests/Service/Import/CsvReaderServiceTest.php index aaca166..2fa81bf 100644 --- a/tests/Service/Import/CsvReaderServiceTest.php +++ b/tests/Service/Import/CsvReaderServiceTest.php @@ -62,4 +62,3 @@ class CsvReaderServiceTest extends TestCase return $file; } } - diff --git a/tests/Service/Import/ImportServiceTest.php b/tests/Service/Import/ImportServiceTest.php index a9fa0ee..a73e679 100644 --- a/tests/Service/Import/ImportServiceTest.php +++ b/tests/Service/Import/ImportServiceTest.php @@ -2,8 +2,8 @@ namespace MintyPHP\Tests\Service\Import; -use MintyPHP\Service\Audit\ImportAuditService; use MintyPHP\Service\Access\PermissionGateway; +use MintyPHP\Service\Audit\ImportAuditService; use MintyPHP\Service\Import\CsvReaderService; use MintyPHP\Service\Import\ImportService; use MintyPHP\Service\Import\ImportStateStoreService; diff --git a/tests/Service/Import/ImportStateStoreServiceTest.php b/tests/Service/Import/ImportStateStoreServiceTest.php index 0244cb3..1b1fc9f 100644 --- a/tests/Service/Import/ImportStateStoreServiceTest.php +++ b/tests/Service/Import/ImportStateStoreServiceTest.php @@ -44,4 +44,3 @@ class ImportStateStoreServiceTest extends TestCase $this->assertNull($stateStore->getState('token_b')); } } - diff --git a/tests/Service/Scheduler/UserLifecycleJobHandlerTest.php b/tests/Service/Scheduler/UserLifecycleJobHandlerTest.php index 2107269..4ff1ba4 100644 --- a/tests/Service/Scheduler/UserLifecycleJobHandlerTest.php +++ b/tests/Service/Scheduler/UserLifecycleJobHandlerTest.php @@ -53,4 +53,3 @@ class UserLifecycleJobHandlerTest extends TestCase $this->assertSame('lock_not_acquired', $result['error_code']); } } - diff --git a/tools/codex-skills/core-guardrails/SKILL.md b/tools/codex-skills/core-guardrails/SKILL.md index b22da41..ece9e2e 100644 --- a/tools/codex-skills/core-guardrails/SKILL.md +++ b/tools/codex-skills/core-guardrails/SKILL.md @@ -14,7 +14,7 @@ Durchgaengig dieselben technischen Grenzen durchsetzen, damit Core robust, wartb 1. MUST Layering einhalten (`pages` -> `Service` -> `Repository`, Templates nur Rendering). 2. MUST Request/Input/Validation-Contract einhalten (`requestInput()`, `FormErrors`, API-Validation ueber `ApiResponse::validationFromFormErrors(...)`, kein `ApiResponse::readJsonBody(...)` in `pages/api/v1/**`). 3. MUST AuthZ/RBAC serverseitig durchsetzen und im UI nur ueber `$viewAuth` steuern. -4. MUST UI-Standards und Data-Contracts fuer Listen/Detailseiten nutzen (Wrapper, Partials, keine inline `confirm(...)`). +4. MUST UI-Standards und Data-Contracts fuer Listen/Detailseiten nutzen (Wrapper, Partials, keine inline `confirm(...)`, inklusive globaler Grid-Standards fuer rowInteraction/pageSize). 5. MUST Quality-Gates ausfuehren und Ergebnis transparent berichten. 6. MUST bei Konflikten den regelkonformen Weg waehlen oder den Konflikt als Blocker markieren. 7. MUST NOT Extension-Architektur erfinden; dieses Thema ist aktuell Out of Scope. diff --git a/tools/codex-skills/core-guardrails/references/boundaries-ui.md b/tools/codex-skills/core-guardrails/references/boundaries-ui.md index 234efd4..c842bb1 100644 --- a/tools/codex-skills/core-guardrails/references/boundaries-ui.md +++ b/tools/codex-skills/core-guardrails/references/boundaries-ui.md @@ -7,6 +7,8 @@ 3. MUST aktive Chips/Drawer-Verhalten ueber Standard-Contracts laufen lassen. 4. MUST NOT direkt `gridFiltersFromSchema(...)` oder `initListFilterExperience(...)` in Listen-Templates verwenden. 5. MUST NOT Legacy-Filter-Toggle-Markup (`data-toolbar-toggle`, `data-filter-overflow`, `data-filter-toggle`) neu einbauen. +6. MUST rowInteraction-Standard fuer Listen mit Row-Navigation beibehalten (sichtbare Action-Spalte + Enter + Doppelklick-Fallback). +7. MUST pageSize-Standard beibehalten (10/25/50/100, Toolbar rechts, URL > localStorage > default). ## Detailseiten-Standard diff --git a/tools/codex-skills/starterkit-grid-standards/SKILL.md b/tools/codex-skills/starterkit-grid-standards/SKILL.md new file mode 100644 index 0000000..501ccfe --- /dev/null +++ b/tools/codex-skills/starterkit-grid-standards/SKILL.md @@ -0,0 +1,38 @@ +--- +name: starterkit-grid-standards +description: Standardisierungs-Skill fuer GridJS-Listen im Starterkit. Verwenden, wenn Listen neu gebaut oder refactored werden sollen (rowInteraction, pageSize, filter drawer, URL-state, accessibility). +--- + +# Starterkit Grid Standards + +## Ziel + +GridJS-Listen projektweit einheitlich, zuganglich und wartbar halten. + +## Wann verwenden + +1. Neue Listen mit `initStandardListPage(...)` oder `createServerGrid(...)`. +2. Refactor von Legacy-Listen. +3. Review von UX/Accessibility in Listen. + +## Verbindliche Regeln + +1. MUST `initStandardListPage(...)` als Standard nutzen (nur begruendete Ausnahmen direkt mit `createServerGrid(...)`). +2. MUST `rowDblClick.getUrl(...)` fuer zeilenbezogene Navigation setzen, wenn Row-Navigation vorgesehen ist. +3. MUST globale `rowInteraction`-Defaults respektieren (Action-Spalte, Enter, Doppelklick-Fallback). +4. MUST globale `pageSize`-Standards respektieren (`10/25/50/100`, Toolbar-Placement, URL > storage > default). +5. MUST Filter-Drawer/Chips ueber den Standard-Contract nutzen, keine parallelen Custom-Patterns. +6. MUST NOT pro Seite eigene Row-Action-Hacks bauen, wenn der Factory-Contract ausreicht. + +## Vorgehen + +1. Ist-Liste gegen `references/list-checklist.md` pruefen. +2. Fehlende Standards zuerst zentral in der Factory loesen, danach nur minimale Seitenanpassungen. +3. UI/CSS nur als additive Erweiterung zu bestehenden Komponenten. +4. Relevante Gates und Browser-Smoke (Keyboard + Console) ausfuehren. + +## Referenzen + +- Checkliste: `references/list-checklist.md` +- Row Interaction: `references/row-interaction-standard.md` +- Page Size: `references/page-size-standard.md` diff --git a/tools/codex-skills/starterkit-grid-standards/agents/openai.yaml b/tools/codex-skills/starterkit-grid-standards/agents/openai.yaml new file mode 100644 index 0000000..929f3f9 --- /dev/null +++ b/tools/codex-skills/starterkit-grid-standards/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "Starterkit Grid Standards" + short_description: "Standardize GridJS lists and accessibility patterns" + default_prompt: "Use $starterkit-grid-standards to standardize a list page with rowInteraction, pageSize, and filter drawer contracts." diff --git a/tools/codex-skills/starterkit-grid-standards/references/list-checklist.md b/tools/codex-skills/starterkit-grid-standards/references/list-checklist.md new file mode 100644 index 0000000..c196787 --- /dev/null +++ b/tools/codex-skills/starterkit-grid-standards/references/list-checklist.md @@ -0,0 +1,30 @@ +# List Checklist + +## Bootstrap + +1. `initStandardListPage({ grid, filters, hooks })` verwendet. +2. `filterSchema` aus Server-Config uebergeben. +3. `urlSync` bewusst gesetzt. + +## Interaction + +1. `rowDblClick.getUrl(...)` liefert stabile Ziel-URL oder leer. +2. Keine Doppel-Action-Spalte (Factory auto-action vs explizite actions). +3. Enter/Pointer/Focus fuer oeffnbare Rows funktionieren. + +## Pagination + URL + +1. Page-size Optionen nur `10/25/50/100`. +2. URL Sync pruefen (`page`, `order`, `dir`, `limit`). +3. Default-Werte erzeugen saubere URL. + +## Filter UX + +1. Search sichtbar. +2. Drawer-Filter und Chips intakt. +3. Reset/Apply Verhalten reproduzierbar. + +## Regression + +1. Sortierung, Selection/Bulk, Doppelklick unveraendert. +2. Keine neuen Console Errors. diff --git a/tools/codex-skills/starterkit-grid-standards/references/page-size-standard.md b/tools/codex-skills/starterkit-grid-standards/references/page-size-standard.md new file mode 100644 index 0000000..2da7f2f --- /dev/null +++ b/tools/codex-skills/starterkit-grid-standards/references/page-size-standard.md @@ -0,0 +1,20 @@ +# Page Size Standard + +## Globale Defaults + +1. Optionen: `10`, `25`, `50`, `100`. +2. Default: `10`. +3. Prioritaet: `URL > localStorage > Default`. +4. Placement: obere `app-list-toolbar`, rechts ausgerichtet. + +## URL Contract + +1. Param: `limit`. +2. Bei Default-Limit optional clean URL (Param entfernen). +3. Bei Hard-Reload muss gesetztes URL-Limit reproduzierbar sein. + +## Verhalten + +1. Aenderung der Seitengroesse setzt Seite auf 1. +2. Sortierung/Filter bleiben erhalten. +3. LocalStorage-Key pro Tabelle (path + dataUrl + container). diff --git a/tools/codex-skills/starterkit-grid-standards/references/row-interaction-standard.md b/tools/codex-skills/starterkit-grid-standards/references/row-interaction-standard.md new file mode 100644 index 0000000..42285de --- /dev/null +++ b/tools/codex-skills/starterkit-grid-standards/references/row-interaction-standard.md @@ -0,0 +1,31 @@ +# Row Interaction Standard + +## Ziel + +Row-Navigation discoverable und keyboard-freundlich machen. + +## Standardverhalten + +1. Sichtbare Action-Spalte rechts (`Open`/`Edit`) fuer Listen mit `rowDblClick`. +2. Enter auf fokussierter Row loest dieselbe Navigation aus. +3. Doppelklick bleibt als Fallback fuer Power-User. +4. Nur Rows mit Ziel-URL erhalten `data-row-open-url`. + +## Contract + +```js +rowInteraction: { + enabled: true, + showActionButton: true, + keepDblClick: true, + enableEnterOnRow: true, + actionColumnLabel: 'Actions', + resolveActionLabel: (url, rowData) => (url.includes('/edit/') ? 'Edit' : 'Open') +} +``` + +## Guardrails + +1. Keine globale Tab-Stop-Flut auf allen Rows. +2. Fokusstil fuer Row und Action-Button sichtbar. +3. Explizite `actions.enabled: true` pro Seite darf Auto-Action uebersteuern. diff --git a/tools/codex-skills/starterkit-php-style-ci/SKILL.md b/tools/codex-skills/starterkit-php-style-ci/SKILL.md new file mode 100644 index 0000000..d3d47b9 --- /dev/null +++ b/tools/codex-skills/starterkit-php-style-ci/SKILL.md @@ -0,0 +1,37 @@ +--- +name: starterkit-php-style-ci +description: Standard-Skill fuer PHP Coding-Style im Starterkit (php-cs-fixer, dry-run in CI, lokaler fix/check workflow). Verwenden bei Style-Einfuehrung, CI-Checks oder grossen Format-Diffs. +--- + +# Starterkit PHP Style CI + +## Ziel + +Coding-Style pruefbar, reproduzierbar und regressionsarm machen. + +## Wann verwenden + +1. Einfuehrung oder Nachschaerfung von Style-Regeln. +2. CI-Dry-Run fuer Style-Checks. +3. Aufraeumen grosser Style-Diff-Wellen. + +## Verbindliche Regeln + +1. MUST `composer cs:check` fuer nicht-mutierende Pruefung nutzen. +2. MUST `composer cs:fix` nur bewusst und mit Diff-Kontrolle ausfuehren. +3. MUST Style-Check vor Merge gruen halten (lokal und optional CI). +4. MUST nach groesseren Fixes mindestens `phpunit` und `phpstan` laufen lassen. +5. MUST NOT manuell Einzelstellen formatieren, wenn der Fixer sie zentral loesen kann. + +## Workflow + +1. Baseline erfassen: `composer cs:check`. +2. Falls noetig fixen: `composer cs:fix`. +3. Diff fokussiert pruefen (Imports, Braces, Nebenwirkungen). +4. Qualitaetsgates laufen lassen. +5. CI-Dry-Run konfigurieren oder validieren. + +## Referenzen + +- Lokaler Workflow: `references/style-workflow.md` +- CI Dry Run: `references/ci-dry-run.md` diff --git a/tools/codex-skills/starterkit-php-style-ci/agents/openai.yaml b/tools/codex-skills/starterkit-php-style-ci/agents/openai.yaml new file mode 100644 index 0000000..c3e1eca --- /dev/null +++ b/tools/codex-skills/starterkit-php-style-ci/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "Starterkit PHP Style CI" + short_description: "Enforce PHP coding style with safe local and CI checks" + default_prompt: "Use $starterkit-php-style-ci to set up and run php-cs-fixer check/fix workflow with CI dry-run and guardrails." diff --git a/tools/codex-skills/starterkit-php-style-ci/references/ci-dry-run.md b/tools/codex-skills/starterkit-php-style-ci/references/ci-dry-run.md new file mode 100644 index 0000000..9c0a624 --- /dev/null +++ b/tools/codex-skills/starterkit-php-style-ci/references/ci-dry-run.md @@ -0,0 +1,21 @@ +# CI Dry Run + +## Ziel + +CI soll nur pruefen, nicht formatieren. + +## Minimaler Check + +```bash +composer cs:check +``` + +## Erwartung + +1. Exit Code 0: Style konform. +2. Exit Code != 0: Diff ausgeben, Entwickler fuehrt lokal `composer cs:fix` aus. + +## Hinweise + +1. Kein Auto-Commit aus CI. +2. Bei initialen Altlasten zuerst einmalige Bereinigung auf dediziertem Branch. diff --git a/tools/codex-skills/starterkit-php-style-ci/references/style-workflow.md b/tools/codex-skills/starterkit-php-style-ci/references/style-workflow.md new file mode 100644 index 0000000..d7d707f --- /dev/null +++ b/tools/codex-skills/starterkit-php-style-ci/references/style-workflow.md @@ -0,0 +1,25 @@ +# Local Style Workflow + +## Commands + +```bash +composer cs:check +composer cs:fix +``` + +## Praktikabler Ablauf + +1. Erst `cs:check` laufen lassen. +2. Danach `cs:fix`. +3. Nur relevante Diffs uebernehmen, grosse Misch-Diffs vermeiden. +4. Anschliessend: + +```bash +docker compose exec php vendor/bin/phpunit +docker compose exec php vendor/bin/phpstan analyse -c phpstan.neon --no-progress +``` + +## Review-Fokus + +1. `ordered_imports`, `braces_position`, `statement_indentation`. +2. Kein semantischer Drift bei anon classes, arrow functions, array maps. diff --git a/web/css/components/app-list-toolbar.css b/web/css/components/app-list-toolbar.css index 095e50e..62a41de 100644 --- a/web/css/components/app-list-toolbar.css +++ b/web/css/components/app-list-toolbar.css @@ -57,6 +57,20 @@ font-size: 11px !important; } + .app-list-toolbar .app-grid-page-size select { + min-width: 84px; + } + + .app-list-toolbar .app-grid-page-size { + margin-inline-start: auto !important; + } + + @media (max-width: 768px) { + .app-list-toolbar .app-grid-page-size { + margin-inline-start: 0 !important; + } + } + .app-list-toolbar .multi-select .multi-select-header diff --git a/web/css/vendor-overrides/gridjs.css b/web/css/vendor-overrides/gridjs.css index 783a10c..2bc0621 100644 --- a/web/css/vendor-overrides/gridjs.css +++ b/web/css/vendor-overrides/gridjs.css @@ -135,14 +135,29 @@ } .grid-avatar { - width: 36px; - height: 36px; - border-radius: 999px; + --grid-avatar-width: 36px; + --grid-avatar-height: 36px; + --grid-avatar-radius: 999px; + --grid-avatar-fit: cover; + --grid-avatar-padding: 0; + width: var(--grid-avatar-width); + height: var(--grid-avatar-height); + border-radius: var(--grid-avatar-radius); border: 1px solid var(--app-muted-border-color); background: var(--app-card-background-color); - object-fit: cover; + object-fit: var(--grid-avatar-fit); + padding: var(--grid-avatar-padding); display: inline-flex; flex-shrink: 0; + box-sizing: border-box; + } + + .grid-avatar-tenant { + --grid-avatar-width: 48px; + --grid-avatar-height: 28px; + --grid-avatar-radius: var(--app-border-radius); + --grid-avatar-fit: contain; + --grid-avatar-padding: 2px; } .grid-avatar-placeholder { @@ -154,6 +169,10 @@ background: var(--app-background-color); } + .grid-avatar-tenant.grid-avatar-placeholder { + font-size: 10px; + } + .grid-name-cell { display: inline-flex; align-items: center; @@ -198,7 +217,25 @@ font-size: small; } - tr.gridjs-tr { + .gridjs-container .grid-actions { + display: inline-flex; + align-items: center; + gap: 0.35rem; + } + + .gridjs-container .grid-actions-row-open { + justify-content: flex-end; + width: 100%; + } + + .gridjs-container .grid-actions .app-grid-row-open { + padding: 4px 10px; + font-size: 12px; + line-height: 1.2; + white-space: nowrap; + } + + tr.gridjs-tr[data-row-open-url] { cursor: pointer; } @@ -206,6 +243,19 @@ background: var(--app-table-row-stripped-background-color); } + tr.gridjs-tr[data-row-open-url]:focus-visible td { + background: var(--app-table-row-stripped-background-color); + box-shadow: inset 0 0 0 var(--app-outline-width) var(--app-primary-focus); + } + + tr.gridjs-tr[data-row-open-url]:focus-visible { + outline: none; + } + + .gridjs-container table td .grid-actions .app-grid-row-open:focus-visible { + box-shadow: 0 0 0 var(--app-outline-width) var(--app-primary-focus); + } + html[data-theme="dark"] button.gridjs-sort, html[data-theme="dark"] button.gridjs-sort-neutral { filter: invert(1); diff --git a/web/js/core/app-grid-factory.js b/web/js/core/app-grid-factory.js index bc49098..154a30f 100644 --- a/web/js/core/app-grid-factory.js +++ b/web/js/core/app-grid-factory.js @@ -3,6 +3,55 @@ import { gridFiltersFromSchema } from '../pages/app-list-utils.js'; import { initListFilterExperience } from '../pages/app-list-filter-experience.js'; import { buildChipsFromMeta, removeChipFromMetaState, clearMetaState } from '../pages/app-list-filter-state.js'; +const DEFAULT_PAGE_SIZE_OPTIONS = [10, 25, 50, 100]; +const PAGE_SIZE_STORAGE_PREFIX = 'grid:page-size'; + +const parsePositiveInt = (value) => { + const parsed = Number.parseInt(String(value ?? ''), 10); + if (!Number.isFinite(parsed) || parsed <= 0) { + return null; + } + return parsed; +}; + +const normalizePageSizeOptions = (options) => { + const source = Array.isArray(options) && options.length + ? options + : DEFAULT_PAGE_SIZE_OPTIONS; + const normalized = []; + source.forEach((option) => { + const parsed = parsePositiveInt(option); + if (parsed !== null && !normalized.includes(parsed)) { + normalized.push(parsed); + } + }); + return normalized.length ? normalized : [...DEFAULT_PAGE_SIZE_OPTIONS]; +}; + +const safeStorageGet = (key) => { + const storageKey = String(key || '').trim(); + if (storageKey === '') { + return null; + } + try { + return window.localStorage.getItem(storageKey); + } catch { + return null; + } +}; + +const safeStorageSet = (key, value) => { + const storageKey = String(key || '').trim(); + if (storageKey === '') { + return; + } + try { + window.localStorage.setItem(storageKey, value); + } catch { + // localStorage can be unavailable; fail safe. + } +}; + export function createServerGrid(options) { const { container, @@ -22,8 +71,10 @@ export function createServerGrid(options) { gridjs, rowDblClick = null, actions = null, + rowInteraction = null, rowDataset = null, - selection = null + selection = null, + pageSize = null } = options || {}; const normalizedFilterBindingMode = String(filterBindingMode || 'live').trim().toLowerCase() === 'manual' @@ -47,14 +98,18 @@ export function createServerGrid(options) { pageParam: 'page', sortOrderParam: 'order', sortDirParam: 'dir', + limitParam: 'limit', cleanFirstPage: true, + cleanDefaultLimit: true, syncPage: true, syncSort: true, + syncLimit: true, ...(urlState || {}) }; const pageParam = urlStateConfig.pageParam || 'page'; const sortOrderParam = urlStateConfig.sortOrderParam || 'order'; const sortDirParam = urlStateConfig.sortDirParam || 'dir'; + const limitParam = urlStateConfig.limitParam || 'limit'; const resolveUrl = (path) => new URL(path, appBase).toString(); const addParam = (url, key, value) => { const nextUrl = new URL(url); @@ -84,6 +139,68 @@ export function createServerGrid(options) { const normalized = String(rawValue ?? '').trim().toLowerCase(); return normalized === 'asc' || normalized === 'desc' ? normalized : ''; }; + const pageSizeConfig = pageSize && typeof pageSize === 'object' + ? pageSize + : {}; + const pageSizeEnabled = pageSizeConfig.enabled !== false; + const pageSizeOptions = normalizePageSizeOptions(pageSizeConfig.options); + const defaultLimit = pageSizeEnabled + ? ( + pageSizeOptions.includes(parsePositiveInt(paginationLimit)) + ? Number(parsePositiveInt(paginationLimit)) + : pageSizeOptions[0] + ) + : (parsePositiveInt(paginationLimit) ?? 10); + const syncLimit = pageSizeEnabled && urlStateConfig.syncLimit !== false; + const cleanDefaultLimit = urlStateConfig.cleanDefaultLimit !== false; + const dataUrlPath = (() => { + try { + return new URL(dataUrl, appBase).pathname || String(dataUrl || ''); + } catch { + return String(dataUrl || ''); + } + })(); + const containerKey = typeof container === 'string' + ? container + : (containerEl.id ? `#${containerEl.id}` : 'grid'); + const autoPageSizeStorageKey = [ + PAGE_SIZE_STORAGE_PREFIX, + encodeURIComponent(window.location.pathname || ''), + encodeURIComponent(dataUrlPath || ''), + encodeURIComponent(containerKey || '') + ].join(':'); + const pageSizeStorageKey = pageSizeEnabled + ? (String(pageSizeConfig.storageKey || autoPageSizeStorageKey).trim() || autoPageSizeStorageKey) + : ''; + const parseAllowedLimit = (value) => { + if (!pageSizeEnabled) { + return parsePositiveInt(value); + } + const parsed = parsePositiveInt(value); + if (parsed === null) { + return null; + } + return pageSizeOptions.includes(parsed) ? parsed : null; + }; + const pageSizeLabel = String( + pageSizeConfig.label + || language?.pagination?.rowsPerPage + || 'Rows per page' + ).trim() || 'Rows per page'; + + let currentLimit = defaultLimit; + const urlLimit = syncLimit ? parseAllowedLimit(params.get(limitParam)) : null; + if (urlLimit !== null) { + currentLimit = urlLimit; + } else if (pageSizeEnabled) { + const storedLimit = parseAllowedLimit(safeStorageGet(pageSizeStorageKey)); + if (storedLimit !== null) { + currentLimit = storedLimit; + } + } + if (pageSizeEnabled) { + safeStorageSet(pageSizeStorageKey, String(currentLimit)); + } let currentPage = urlStateConfig.syncPage ? parsePage(params.get(pageParam)) : 1; let currentSort = null; if (urlStateConfig.syncSort) { @@ -93,6 +210,36 @@ export function createServerGrid(options) { currentSort = { order: initialOrder, dir: initialDir }; } } + const rowInteractionConfig = rowInteraction && typeof rowInteraction === 'object' + ? rowInteraction + : {}; + const hasRowOpenHandler = Boolean(rowDblClick && typeof rowDblClick.getUrl === 'function'); + const rowInteractionEnabled = hasRowOpenHandler && rowInteractionConfig.enabled !== false; + const keepDblClick = rowInteractionEnabled && rowInteractionConfig.keepDblClick !== false; + const enableEnterOnRow = rowInteractionEnabled && rowInteractionConfig.enableEnterOnRow !== false; + const showRowActionButton = rowInteractionEnabled && rowInteractionConfig.showActionButton !== false; + const rowActionColumnLabel = String( + rowInteractionConfig.actionColumnLabel + || language?.actions?.column + || 'Actions' + ).trim() || 'Actions'; + const rowActionOpenLabel = String(language?.actions?.open || 'Open').trim() || 'Open'; + const rowActionEditLabel = String(language?.actions?.edit || 'Edit').trim() || 'Edit'; + const defaultResolveRowActionLabel = (url) => ( + /(^|\/)edit(\/|$|\?)/i.test(String(url || '').trim()) + ? rowActionEditLabel + : rowActionOpenLabel + ); + const resolveActionLabel = typeof rowInteractionConfig.resolveActionLabel === 'function' + ? rowInteractionConfig.resolveActionLabel + : defaultResolveRowActionLabel; + const resolveRowOpenUrl = (rowData, rowIndex = -1) => { + if (!rowInteractionEnabled) { + return ''; + } + const url = rowDblClick.getUrl(rowData, rowIndex); + return String(url || '').trim(); + }; const getInput = (input) => { if (!input) {return null;} @@ -223,6 +370,15 @@ export function createServerGrid(options) { } else { query.delete(pageParam); } + if (syncLimit) { + if (!cleanDefaultLimit || currentLimit !== defaultLimit) { + query.set(limitParam, String(currentLimit)); + } else { + query.delete(limitParam); + } + } else { + query.delete(limitParam); + } return query; }; @@ -244,6 +400,7 @@ export function createServerGrid(options) { url.searchParams.delete(sortOrderParam); url.searchParams.delete(sortDirParam); url.searchParams.delete(pageParam); + url.searchParams.delete(limitParam); const query = buildUrlParams(); query.forEach((value, key) => url.searchParams.set(key, value)); if (historyMode === 'push') { @@ -253,7 +410,7 @@ export function createServerGrid(options) { window.history.replaceState({}, '', url.toString()); }; - const actionConfig = actions && actions.enabled ? { + const explicitActionConfig = actions && actions.enabled ? { label: actions.label || 'Actions', index: Number.isInteger(actions.index) ? actions.index : null, items: Array.isArray(actions.items) ? actions.items : [], @@ -261,6 +418,26 @@ export function createServerGrid(options) { wrapperClass: actions.wrapperClass || 'grid-actions', onAction: typeof actions.onAction === 'function' ? actions.onAction : null } : null; + const rowOpenActionConfig = !explicitActionConfig && showRowActionButton ? { + label: rowActionColumnLabel, + index: null, + items: [{ + key: 'row-open', + type: 'link', + className: 'outline secondary app-grid-row-open', + label: (rowData, url) => { + const dynamicLabel = resolveActionLabel(url, rowData); + const normalizedLabel = String(dynamicLabel || '').trim(); + return normalizedLabel || defaultResolveRowActionLabel(url); + }, + href: ({ id }) => id + }], + // For row-open actions the URL itself is the stable row identifier. + getRowId: (rowData) => resolveRowOpenUrl(rowData), + wrapperClass: 'grid-actions grid-actions-row-open', + onAction: null + } : null; + const actionConfig = explicitActionConfig || rowOpenActionConfig; const selectionConfig = selection && selection.enabled ? { id: selection.id || 'selectRow', index: Number.isInteger(selection.index) ? selection.index : 0, @@ -402,18 +579,22 @@ export function createServerGrid(options) { } : { enabled: false }; const paginationConfig = { - limit: paginationLimit, + limit: currentLimit, page: Math.max(currentPage - 1, 0), // We control page resets explicitly (search/filter/reset actions) to keep deep-link pages stable. resetPageOnUpdate: false, server: { url: (prev, page, limit) => { const parsedPage = Number.parseInt(String(page ?? ''), 10); - const parsedLimit = Number.parseInt(String(limit ?? ''), 10); + const parsedLimit = parseAllowedLimit(limit); const nextPage = Number.isFinite(parsedPage) && parsedPage >= 0 ? parsedPage : 0; - const nextLimit = Number.isFinite(parsedLimit) && parsedLimit > 0 ? parsedLimit : paginationLimit; + const nextLimit = parsedLimit ?? currentLimit; + currentLimit = nextLimit; + if (pageSizeEnabled) { + safeStorageSet(pageSizeStorageKey, String(currentLimit)); + } currentPage = nextPage + 1; - if (urlSync && urlStateConfig.syncPage) { + if (urlSync && (urlStateConfig.syncPage || syncLimit)) { updateUrl(); } const offset = nextPage * nextLimit; @@ -476,22 +657,37 @@ export function createServerGrid(options) { } const applyRowDataset = () => { - if (typeof rowDataset !== 'function') {return;} + const shouldApplyCustomDataset = typeof rowDataset === 'function'; + if (!shouldApplyCustomDataset && !rowInteractionEnabled) {return;} const rowEls = containerEl.querySelectorAll('.gridjs-tbody .gridjs-tr'); const dataRows = grid.config.store.getState().data?.rows || []; rowEls.forEach((rowEl, index) => { const rowData = dataRows[index] || null; if (!rowData) {return;} - const attrs = rowDataset(rowData, index) || {}; - Object.keys(attrs).forEach((key) => { - const value = attrs[key]; - const attr = `data-${key}`; - if (value === null || typeof value === 'undefined' || value === '') { - rowEl.removeAttribute(attr); + if (rowInteractionEnabled) { + const rowOpenUrl = resolveRowOpenUrl(rowData, index); + if (rowOpenUrl !== '') { + rowEl.setAttribute('data-row-open-url', rowOpenUrl); + rowEl.setAttribute('tabindex', '-1'); } else { - rowEl.setAttribute(attr, String(value)); + rowEl.removeAttribute('data-row-open-url'); + if (rowEl.getAttribute('tabindex') === '-1') { + rowEl.removeAttribute('tabindex'); + } } - }); + } + if (shouldApplyCustomDataset) { + const attrs = rowDataset(rowData, index) || {}; + Object.keys(attrs).forEach((key) => { + const value = attrs[key]; + const attr = `data-${key}`; + if (value === null || typeof value === 'undefined' || value === '') { + rowEl.removeAttribute(attr); + } else { + rowEl.setAttribute(attr, String(value)); + } + }); + } }); }; @@ -530,7 +726,7 @@ export function createServerGrid(options) { updateHeaderState(); }; - if (typeof rowDataset === 'function') { + if (typeof rowDataset === 'function' || rowInteractionEnabled) { const observer = new MutationObserver(() => applyRowDataset()); observer.observe(containerEl, { childList: true, subtree: true }); applyRowDataset(); @@ -547,25 +743,71 @@ export function createServerGrid(options) { return { rowData, rowIndex }; }; - if (rowDblClick) { + const resolveRowOpenUrlByElement = (rowEl) => { + if (!rowInteractionEnabled || !rowEl) { + return ''; + } + const storedUrl = String(rowEl.getAttribute('data-row-open-url') || '').trim(); + if (storedUrl !== '') { + return storedUrl; + } + const rowInfo = getRowDataByElement(rowEl); + if (!rowInfo) { + return ''; + } + return resolveRowOpenUrl(rowInfo.rowData, rowInfo.rowIndex); + }; + + const navigateToRow = (rowEl) => { + const url = resolveRowOpenUrlByElement(rowEl); + if (url === '') { + return false; + } + window.location.href = url; + return true; + }; + + if (keepDblClick && rowDblClick) { const excludeSelector = rowDblClick.exclude || '.grid-actions, button, a, input, select, textarea'; containerEl.addEventListener('dblclick', (event) => { if (excludeSelector && event.target.closest(excludeSelector)) { return; } const rowEl = event.target.closest('.gridjs-tr'); - const rowInfo = getRowDataByElement(rowEl); - if (!rowInfo) { + navigateToRow(rowEl); + }); + } + + if (enableEnterOnRow) { + const interactiveSelector = '.grid-actions, button, a, input, select, textarea, [role="button"], [contenteditable="true"]'; + containerEl.addEventListener('click', (event) => { + const target = event.target instanceof Element ? event.target : null; + if (!target || target.closest(interactiveSelector)) { return; } - const { rowData, rowIndex } = rowInfo; - const getUrl = rowDblClick.getUrl; - if (typeof getUrl !== 'function') { + const rowEl = target.closest('.gridjs-tr[data-row-open-url]'); + if (!rowEl) { return; } - const url = getUrl(rowData, rowIndex); - if (url) { - window.location.href = url; + rowEl.focus({ preventScroll: true }); + }); + containerEl.addEventListener('keydown', (event) => { + if (event.key !== 'Enter' || event.defaultPrevented) { + return; + } + if (event.altKey || event.ctrlKey || event.metaKey || event.shiftKey) { + return; + } + const target = event.target instanceof Element ? event.target : null; + if (!target || target.closest(interactiveSelector)) { + return; + } + const rowEl = target.closest('.gridjs-tr'); + if (!rowEl) { + return; + } + if (navigateToRow(rowEl)) { + event.preventDefault(); } }); } @@ -581,13 +823,13 @@ export function createServerGrid(options) { if (!rowInfo) { return; } - const { rowData } = rowInfo; + const { rowData, rowIndex } = rowInfo; const actionKey = actionEl.getAttribute('data-grid-action') || ''; const actionItem = actionConfig.items.find((item) => item && item.key === actionKey); if (!actionItem) { return; } - const id = actionConfig.getRowId ? actionConfig.getRowId(rowData) : null; + const id = actionConfig.getRowId ? actionConfig.getRowId(rowData, rowIndex) : null; if (!id) { return; } @@ -614,6 +856,91 @@ export function createServerGrid(options) { }); } + let pageSizeSelect = null; + const syncPageSizeControl = () => { + if (!pageSizeSelect) { + return; + } + pageSizeSelect.value = String(currentLimit); + }; + + const resolvePageSizeToolbar = () => { + const selector = String(pageSizeConfig.toolbarSelector || '.app-list-toolbar').trim() || '.app-list-toolbar'; + const allToolbars = Array.from(document.querySelectorAll(selector)); + if (!allToolbars.length) { + return null; + } + const precedingToolbars = allToolbars.filter((toolbar) => ( + Boolean(toolbar.compareDocumentPosition(containerEl) & Node.DOCUMENT_POSITION_FOLLOWING) + )); + const pool = precedingToolbars.length ? precedingToolbars : allToolbars; + const withFilterButton = pool.filter((toolbar) => toolbar.querySelector('[data-filter-drawer-open]')); + const targetPool = withFilterButton.length ? withFilterButton : pool; + return targetPool[targetPool.length - 1] || null; + }; + + const mountPageSizeControl = () => { + if (!pageSizeEnabled) { + return; + } + const toolbar = resolvePageSizeToolbar(); + if (!toolbar) { + warnOnce( + 'UI_PAGE_SIZE_HOST_MISSING', + `Missing page-size toolbar near grid: ${containerKey || 'unknown'}`, + { module: 'grid-factory' } + ); + return; + } + const controlKey = encodeURIComponent(`${containerKey}:${dataUrlPath}`); + const existing = toolbar.querySelector(`.app-grid-page-size[data-grid-page-size-key="${controlKey}"]`); + if (existing) { + pageSizeSelect = existing.querySelector('select'); + syncPageSizeControl(); + return; + } + + const field = document.createElement('label'); + field.className = 'app-field app-grid-page-size'; + field.dataset.gridPageSizeKey = controlKey; + + const caption = document.createElement('span'); + caption.textContent = pageSizeLabel; + + const select = document.createElement('select'); + pageSizeOptions.forEach((option) => { + const optionEl = document.createElement('option'); + optionEl.value = String(option); + optionEl.textContent = String(option); + select.appendChild(optionEl); + }); + select.value = String(currentLimit); + select.addEventListener('change', () => { + const nextLimit = parseAllowedLimit(select.value); + if (nextLimit === null || nextLimit === currentLimit) { + syncPageSizeControl(); + return; + } + currentLimit = nextLimit; + currentPage = 1; + safeStorageSet(pageSizeStorageKey, String(currentLimit)); + syncPageSizeControl(); + updateGrid({ resetPage: true, urlHistoryMode: 'push' }); + }); + + field.appendChild(caption); + field.appendChild(select); + + const filterButton = toolbar.querySelector(':scope > [data-filter-drawer-open]'); + if (filterButton) { + filterButton.insertAdjacentElement('afterend', field); + } else { + toolbar.appendChild(field); + } + pageSizeSelect = select; + syncPageSizeControl(); + }; + const updateGrid = (options = {}) => { const resetPage = options && options.resetPage === true; const urlHistoryMode = options?.urlHistoryMode || 'replace'; @@ -621,12 +948,18 @@ export function createServerGrid(options) { currentPage = 1; } updateUrl({ historyMode: urlHistoryMode }); - const nextConfig = { - server: { ...serverConfig, url: baseUrl() } + const paginationState = grid?.config?.pagination && typeof grid.config.pagination === 'object' + ? grid.config.pagination + : paginationConfig; + const nextPagination = { + ...paginationState, + limit: currentLimit, + page: resetPage ? 0 : Math.max(currentPage - 1, 0) + }; + const nextConfig = { + server: { ...serverConfig, url: baseUrl() }, + pagination: nextPagination }; - if (resetPage && grid?.config?.pagination && typeof grid.config.pagination === 'object') { - nextConfig.pagination = { ...grid.config.pagination, page: 0 }; - } // Use Grid.js reconfiguration API so server pipeline is refreshed reliably on filter/search changes. if (typeof grid?.updateConfig === 'function') { @@ -636,13 +969,13 @@ export function createServerGrid(options) { if (grid?.config) { grid.config.server = nextConfig.server; - if (nextConfig.pagination) { - grid.config.pagination = nextConfig.pagination; - } + grid.config.pagination = nextConfig.pagination; } grid.forceRender(); }; + mountPageSizeControl(); + const debounce = (fn, delay = 250) => { let timer; return (...args) => { @@ -724,7 +1057,8 @@ export function createServerGrid(options) { search: searchValue, filters: filterValues, sort: currentSort ? { ...currentSort } : null, - page: currentPage + page: currentPage, + limit: currentLimit }; }; @@ -741,7 +1075,8 @@ export function createServerGrid(options) { search: draftSearch, filters: filterValues, sort: currentSort ? { ...currentSort } : null, - page: currentPage + page: currentPage, + limit: currentLimit }; }; @@ -783,6 +1118,12 @@ export function createServerGrid(options) { const parsedPage = parsePage(state.page); currentPage = parsedPage; } + const nextLimit = parseAllowedLimit(state?.limit); + if (nextLimit !== null) { + currentLimit = nextLimit; + safeStorageSet(pageSizeStorageKey, String(currentLimit)); + syncPageSizeControl(); + } }; const getSortParams = () => (currentSort ? { ...currentSort } : null); diff --git a/web/js/pages/admin-tenants-index.js b/web/js/pages/admin-tenants-index.js index a7407c0..13181b8 100644 --- a/web/js/pages/admin-tenants-index.js +++ b/web/js/pages/admin-tenants-index.js @@ -11,7 +11,17 @@ if (config) { const labels = config.labels ?? {}; const initTenantsGrid = () => { + const tenantUuidIndex = 8; + const tenantDescriptionIndex = 1; const formatBadge = (value) => badgeHtml(gridjs, value); + const initialsForRow = (row) => { + const descriptionCell = row?.cells?.[tenantDescriptionIndex]?.data; + const label = typeof descriptionCell === 'object' && descriptionCell !== null + ? String(descriptionCell.label ?? '') + : String(descriptionCell ?? ''); + const initial = label.trim().charAt(0).toUpperCase(); + return initial || '?'; + }; const formatStatus = (value) => { if (!value || typeof value !== 'object') { return gridjs.html('-'); @@ -50,15 +60,29 @@ if (config) { dataUrl: 'admin/tenants/data', appBase, columns: [ + { + name: labels.avatar || 'Avatar', + sort: false, + formatter: (cell, row) => { + if (!cell) { + const initials = escapeHtml(initialsForRow(row)); + return gridjs.html(`${initials}`); + } + const uuid = encodeURIComponent(String(cell)); + const src = new URL(`admin/tenants/avatar-file?uuid=${uuid}&size=64`, appBase).toString(); + const full = new URL(`admin/tenants/avatar-file?uuid=${uuid}&size=256`, appBase).toString(); + return gridjs.html(``); + }, + }, { name: labels.description || 'Description', sort: true, formatter: (cell) => { if (!cell || typeof cell !== 'object') { - return gridjs.html(String(cell ?? '')); + return gridjs.html(escapeHtml(String(cell ?? ''))); } - const label = cell.label ?? ''; - return gridjs.html(`${label}`); + const label = escapeHtml(cell.label ?? ''); + return gridjs.html(label); }, }, { @@ -96,10 +120,11 @@ if (config) { hidden: true, }, ], - sortColumns: ['description', 'status', 'theme', 'sso', 'active_users', 'inactive_users', 'created', null], + sortColumns: [null, 'description', 'status', 'theme', 'sso', 'active_users', 'inactive_users', 'created', null], paginationLimit: 10, language: config.gridLang ?? {}, mapData: (data) => data.data.map((row) => [ + row.has_avatar ? row.uuid : '', { label: row.description, is_default: row.is_default ? 1 : 0, @@ -117,11 +142,11 @@ if (config) { filterSchema, urlSync: true, rowDataset: (row) => ({ - uuid: row?.cells?.[7]?.data, + uuid: row?.cells?.[tenantUuidIndex]?.data, }), rowDblClick: { getUrl: (rowData) => { - const uuid = rowData?.cells?.[7]?.data; + const uuid = rowData?.cells?.[tenantUuidIndex]?.data; return uuid ? new URL(`admin/tenants/edit/${uuid}`, appBase).toString() : ''; }, },