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() : '';
},
},